v7.0.0-beta.1 release
This commit is contained in:
commit
6595fd0eb5
3967 files changed
+1875383
No files matched your search
+255
@@ -0,0 +1,255 @@
|
||||
# 그누보드7 문서 인덱스
|
||||
|
||||
> 그누보드7 오픈소스 CMS 플랫폼의 개발 가이드 문서입니다.
|
||||
|
||||
---
|
||||
|
||||
<!-- AUTO-GENERATED-START: docs-readme-stats -->
|
||||
## 카테고리별 통계
|
||||
|
||||
| 카테고리 | 문서 수 | 링크 상태 |
|
||||
|----------|---------|----------|
|
||||
| [백엔드](backend/) | 20개 | 정상 |
|
||||
| [프론트엔드](frontend/) | 49개 | 정상 |
|
||||
| [확장 시스템](extension/) | 23개 | 정상 |
|
||||
| 공통 | 19개 | 정상 |
|
||||
| [AI 도구](ai-tools/) | - | 정상 |
|
||||
|
||||
<!-- AUTO-GENERATED-END: docs-readme-stats -->
|
||||
|
||||
---
|
||||
|
||||
## 작업 유형별 필수 문서
|
||||
|
||||
### 레이아웃 JSON 작성
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [레이아웃 JSON 스키마](frontend/layout-json.md) | HTML 태그 직접 사용 금지 → 기본 컴포넌트 사용 (Div, Button, Span) |
|
||||
| 2 | [레이아웃 JSON - 컴포넌트](frontend/layout-json-components.md) | if: 조건부 렌더링 (type: "conditional" 사용 금지!) |
|
||||
| 3 | [레이아웃 JSON - 기능](frontend/layout-json-features.md) | classMap: 조건부 CSS 클래스 (key → variants 매핑) |
|
||||
| 4 | [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) | extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입) |
|
||||
| 5 | [컴포넌트 개발 규칙](frontend/components.md) | HTML 태그 직접 사용 금지 |
|
||||
| 6 | [컴포넌트 Props 레퍼런스](frontend/component-props.md) | - |
|
||||
| 7 | [sirsoft-admin_basic 컴포넌트](frontend/templates/sirsoft-admin_basic/components.md) | Basic (37개), Composite (66개), Layout (8개) |
|
||||
| 8 | [데이터 바인딩 및 표현식](frontend/data-binding.md) | API 데이터: {{user.name}}, URL 파라미터: {{route.id}} |
|
||||
| 9 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - |
|
||||
| 10 | [액션 핸들러 가이드](frontend/actions.md) | 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션) |
|
||||
| 11 | [액션 핸들러 - 핸들러별 상세 사용법](frontend/actions-handlers.md) | navigate: 페이지 이동 (path, query, mergeQuery 옵션) |
|
||||
| 12 | [전역 상태 관리](frontend/state-management.md) | 전역 상태: _global.속성명 (앱 전체 공유, 페이지 이동 시 유지) |
|
||||
| 13 | [데이터 소스](frontend/data-sources.md) | data_sources 배열에 API 정의: id, endpoint, method |
|
||||
| 14 | [다크 모드 지원](frontend/dark-mode.md) | Tailwind dark: variant 사용 |
|
||||
|
||||
### 컨트롤러 작성
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [컨트롤러 계층 구조](backend/controllers.md) | AdminBaseController / AuthBaseController / PublicBaseController |
|
||||
| 2 | [라우트 네이밍 및 경로](backend/routing.md) | 모든 라우트는 name() 필수 |
|
||||
| 3 | [검증 (Validation)](backend/validation.md) | FormRequest 사용 필수 (Service에 검증 로직 금지) |
|
||||
| 4 | [API 응답 규칙 (ResponseHelper)](backend/response-helper.md) | 모든 API 응답은 ResponseHelper 사용 |
|
||||
|
||||
### Service/Repository 작성
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [Service-Repository 패턴](backend/service-repository.md) | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
|
||||
| 2 | [훅 시스템](extension/hooks.md) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
|
||||
|
||||
### 마이그레이션 작성
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [데이터베이스 개발 가이드](database-guide.md) | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
|
||||
|
||||
### 모듈 개발
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [모듈 개발 기초](extension/module-basics.md) | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
|
||||
| 2 | [모듈 라우트 규칙](extension/module-routing.md) | URL prefix 자동: /api/admin/[vendor-module]/... |
|
||||
| 3 | [모듈 레이아웃 시스템](extension/module-layouts.md) | modules/_bundled/vendor-module/resources/layouts/ |
|
||||
| 4 | [모듈 다국어 시스템](extension/module-i18n.md) | 백엔드: /lang/{locale}/*.php |
|
||||
| 5 | [훅 시스템](extension/hooks.md) | Action 훅: doAction() |
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [테스트 가이드](testing-guide.md) | 테스트 통과 = 작업 완료 (작성만으로 불충분!) |
|
||||
| 2 | [레이아웃 렌더링 테스트 가이드](frontend/layout-testing.md) | createLayoutTest()로 테스트 헬퍼 생성 |
|
||||
|
||||
### API 리소스 작성
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [API 리소스](backend/api-resources.md) | BaseApiResource 상속 필수 |
|
||||
| 2 | [API 응답 규칙 (ResponseHelper)](backend/response-helper.md) | 모든 API 응답은 ResponseHelper 사용 |
|
||||
|
||||
### FormRequest 작성
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [검증 (Validation)](backend/validation.md) | FormRequest 사용 필수 (Service에 검증 로직 금지) |
|
||||
| 2 | [Custom Exception 다국어 처리](backend/exceptions.md) | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
|
||||
|
||||
### 권한/메뉴 추가
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [권한 시스템](extension/permissions.md) | 구조: User → Role → Permission (기능 레벨) |
|
||||
| 2 | [메뉴 시스템](extension/menus.md) | 구조: User → Role → role_menus 피벗 → Menu |
|
||||
|
||||
### 다국어 추가
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
|------|------|----------|
|
||||
| 1 | [모듈 다국어 시스템](extension/module-i18n.md) | 백엔드: /lang/{locale}/*.php |
|
||||
| 2 | [데이터베이스 개발 가이드](database-guide.md) | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
|
||||
| 3 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - |
|
||||
|
||||
---
|
||||
|
||||
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
|
||||
## 카테고리별 전체 문서 목록
|
||||
|
||||
### 백엔드 (20개)
|
||||
|
||||
| 문서 | 제목 |
|
||||
|------|------|
|
||||
| [activity-log-hooks.md](backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) |
|
||||
| [activity-log.md](backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) |
|
||||
| [api-resources.md](backend/api-resources.md) | API 리소스 |
|
||||
| [authentication.md](backend/authentication.md) | 인증 및 세션 처리 |
|
||||
| [broadcasting.md](backend/broadcasting.md) | Broadcasting (실시간 이벤트) |
|
||||
| [controllers.md](backend/controllers.md) | 컨트롤러 계층 구조 |
|
||||
| [core-config.md](backend/core-config.md) | 코어 설정 (config/core.php) |
|
||||
| [core-update-system.md](backend/core-update-system.md) | 코어 업데이트 시스템 (Core Update System) |
|
||||
| [enum.md](backend/enum.md) | Enum 사용 규칙 |
|
||||
| [exceptions.md](backend/exceptions.md) | Custom Exception 다국어 처리 |
|
||||
| [middleware.md](backend/middleware.md) | 미들웨어 등록 규칙 |
|
||||
| [notification-system.md](backend/notification-system.md) | 알림 시스템 (Notification System) |
|
||||
| [README.md](backend/README.md) | 백엔드 개발 가이드 |
|
||||
| [response-helper.md](backend/response-helper.md) | API 응답 규칙 (ResponseHelper) |
|
||||
| [routing.md](backend/routing.md) | 라우트 네이밍 및 경로 |
|
||||
| [search-system.md](backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) |
|
||||
| [seo-system.md](backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) |
|
||||
| [service-provider.md](backend/service-provider.md) | 서비스 프로바이더 안전성 |
|
||||
| [service-repository.md](backend/service-repository.md) | Service-Repository 패턴 |
|
||||
| [validation.md](backend/validation.md) | 검증 (Validation) |
|
||||
|
||||
### 프론트엔드 (49개)
|
||||
|
||||
| 문서 | 제목 |
|
||||
|------|------|
|
||||
| [actions-g7core-api.md](frontend/actions-g7core-api.md) | 액션 시스템 - G7Core API (React 컴포넌트용) |
|
||||
| [actions-handlers-navigation.md](frontend/actions-handlers-navigation.md) | 액션 핸들러 - 네비게이션 |
|
||||
| [actions-handlers-state.md](frontend/actions-handlers-state.md) | 액션 핸들러 - 상태 관리 |
|
||||
| [actions-handlers-ui.md](frontend/actions-handlers-ui.md) | 액션 핸들러 - UI 인터랙션 |
|
||||
| [actions-handlers.md](frontend/actions-handlers.md) | 액션 핸들러 - 핸들러별 상세 사용법 |
|
||||
| [actions.md](frontend/actions.md) | 액션 핸들러 가이드 |
|
||||
| [auth-system.md](frontend/auth-system.md) | 인증 시스템 (AuthManager) |
|
||||
| [component-props-composite.md](frontend/component-props-composite.md) | 컴포넌트 Props 레퍼런스 - Composite |
|
||||
| [component-props.md](frontend/component-props.md) | 컴포넌트 Props 레퍼런스 |
|
||||
| [components-advanced.md](frontend/components-advanced.md) | 컴포넌트 고급 기능 |
|
||||
| [components-patterns.md](frontend/components-patterns.md) | 컴포넌트 패턴 및 다국어 |
|
||||
| [components-types.md](frontend/components-types.md) | 컴포넌트 타입별 개발 규칙 |
|
||||
| [components.md](frontend/components.md) | 컴포넌트 개발 규칙 |
|
||||
| [dark-mode.md](frontend/dark-mode.md) | 다크 모드 지원 (engine-v1.1.0+) |
|
||||
| [data-binding-i18n.md](frontend/data-binding-i18n.md) | 데이터 바인딩 - 다국어 처리 |
|
||||
| [data-binding.md](frontend/data-binding.md) | 데이터 바인딩 및 표현식 |
|
||||
| [data-sources-advanced.md](frontend/data-sources-advanced.md) | 데이터 소스 - 고급 기능 |
|
||||
| [data-sources.md](frontend/data-sources.md) | 데이터 소스 (Data Sources) |
|
||||
| [editors.md](frontend/editors.md) | 에디터 컴포넌트 가이드 |
|
||||
| [g7core-api-advanced.md](frontend/g7core-api-advanced.md) | G7Core 전역 API 레퍼런스 - 고급 |
|
||||
| [g7core-api.md](frontend/g7core-api.md) | G7Core 전역 API 레퍼런스 |
|
||||
| [g7core-helpers.md](frontend/g7core-helpers.md) | G7Core 헬퍼 API |
|
||||
| [layout-json-components-loading.md](frontend/layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 |
|
||||
| [layout-json-components-rendering.md](frontend/layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 |
|
||||
| [layout-json-components-slots.md](frontend/layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 |
|
||||
| [layout-json-components.md](frontend/layout-json-components.md) | 레이아웃 JSON - 컴포넌트 (반복 렌더링, Blur, 생명주기, 슬롯) |
|
||||
| [layout-json-features-actions.md](frontend/layout-json-features-actions.md) | 레이아웃 JSON - 초기화, 모달, 액션, 스크립트 |
|
||||
| [layout-json-features-error.md](frontend/layout-json-features-error.md) | 레이아웃 JSON - 에러 핸들링 |
|
||||
| [layout-json-features-styling.md](frontend/layout-json-features-styling.md) | 레이아웃 JSON - 스타일 및 계산된 값 |
|
||||
| [layout-json-features.md](frontend/layout-json-features.md) | 레이아웃 JSON - 기능 (에러 핸들링, 초기화, 모달, 액션) |
|
||||
| [layout-json-inheritance.md](frontend/layout-json-inheritance.md) | 레이아웃 JSON - 상속 (Extends, Partial, 병합) |
|
||||
| [layout-json.md](frontend/layout-json.md) | 레이아웃 JSON 스키마 |
|
||||
| [layout-testing.md](frontend/layout-testing.md) | 그누보드7 레이아웃 파일 렌더링 테스트 가이드 |
|
||||
| [modal-usage.md](frontend/modal-usage.md) | Modal 컴포넌트 사용 가이드 |
|
||||
| [README.md](frontend/README.md) | 그누보드7 프론트엔드 개발 가이드 |
|
||||
| [responsive-layout.md](frontend/responsive-layout.md) | 반응형 레이아웃 개발 (engine-v1.1.0+) |
|
||||
| [security.md](frontend/security.md) | 보안 및 검증 |
|
||||
| [state-management-advanced.md](frontend/state-management-advanced.md) | 상태 관리 - 고급 기능 |
|
||||
| [state-management-forms.md](frontend/state-management-forms.md) | 상태 관리 - 폼 자동 바인딩 및 setState |
|
||||
| [state-management.md](frontend/state-management.md) | 전역 상태 관리 |
|
||||
| [tailwind-safelist.md](frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 |
|
||||
| [template-development.md](frontend/template-development.md) | 템플릿 개발 가이드라인 |
|
||||
| [template-handlers.md](frontend/template-handlers.md) | 템플릿 전용 핸들러 |
|
||||
| [components.md](frontend/components.md) | sirsoft-admin_basic 컴포넌트 |
|
||||
| [handlers.md](frontend/handlers.md) | sirsoft-admin_basic 핸들러 |
|
||||
| [layouts.md](frontend/layouts.md) | sirsoft-admin_basic 레이아웃 |
|
||||
| [components.md](frontend/components.md) | sirsoft-basic 컴포넌트 |
|
||||
| [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 |
|
||||
| [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 |
|
||||
|
||||
### 확장 시스템 (23개)
|
||||
|
||||
| 문서 | 제목 |
|
||||
|------|------|
|
||||
| [changelog-rules.md](extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) |
|
||||
| [extension-manager.md](extension/extension-manager.md) | ExtensionManager (확장 관리자) |
|
||||
| [extension-update-system.md](extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) |
|
||||
| [hooks.md](extension/hooks.md) | 훅 시스템 (Hook System) |
|
||||
| [layout-extensions.md](extension/layout-extensions.md) | 레이아웃 확장 시스템 (Layout Extensions) |
|
||||
| [menus.md](extension/menus.md) | 메뉴 시스템 |
|
||||
| [module-assets.md](extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 |
|
||||
| [module-basics.md](extension/module-basics.md) | 모듈 개발 기초 |
|
||||
| [module-commands.md](extension/module-commands.md) | 모듈 Artisan 커맨드 |
|
||||
| [module-i18n.md](extension/module-i18n.md) | 모듈 다국어 시스템 |
|
||||
| [module-layouts.md](extension/module-layouts.md) | 모듈 레이아웃 시스템 |
|
||||
| [module-routing.md](extension/module-routing.md) | 모듈 라우트 규칙 |
|
||||
| [module-settings.md](extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 |
|
||||
| [permissions.md](extension/permissions.md) | 권한 시스템 |
|
||||
| [plugin-development.md](extension/plugin-development.md) | 플러그인 개발 가이드 |
|
||||
| [README.md](extension/README.md) | 그누보드7 확장 시스템 개발 가이드 |
|
||||
| [storage-driver.md](extension/storage-driver.md) | 스토리지 드라이버 시스템 (StorageInterface) |
|
||||
| [template-basics.md](extension/template-basics.md) | 템플릿 시스템 기초 |
|
||||
| [template-caching.md](extension/template-caching.md) | 템플릿 캐싱 전략 |
|
||||
| [template-commands.md](extension/template-commands.md) | 템플릿 Artisan 커맨드 |
|
||||
| [template-routing.md](extension/template-routing.md) | 템플릿 라우트/언어 파일 규칙 |
|
||||
| [template-security.md](extension/template-security.md) | 템플릿 보안 정책 |
|
||||
| [template-workflow.md](extension/template-workflow.md) | 템플릿 개발 워크플로우 |
|
||||
|
||||
### 공통 (19개)
|
||||
|
||||
| 문서 | 제목 |
|
||||
|------|------|
|
||||
| [README.md](ai-tools/agents/README.md) | 그누보드7 Multi-Agent System |
|
||||
| [README.md](ai-tools/devtools/README.md) | 그누보드7 DevTools MCP 서버 |
|
||||
| [README.md](ai-tools/README.md) | 그누보드7 AI 도구 |
|
||||
| [create-module.md](ai-tools/skills/create-module.md) | 모듈 스캐폴딩 (create-module) |
|
||||
| [create-plugin.md](ai-tools/skills/create-plugin.md) | 플러그인 스캐폴딩 (create-plugin) |
|
||||
| [create-template.md](ai-tools/skills/create-template.md) | 템플릿 스캐폴딩 (create-template) |
|
||||
| [extract-i18n-keys.md](ai-tools/skills/extract-i18n-keys.md) | 다국어 키 추출 (extract-i18n-keys) |
|
||||
| [run-tests.md](ai-tools/skills/run-tests.md) | 테스트 실행 (run-tests) |
|
||||
| [validate-code.md](ai-tools/skills/validate-code.md) | 코드 패턴 검증 (validate-code) |
|
||||
| [validate-frontend.md](ai-tools/skills/validate-frontend.md) | 프론트엔드 검증 (validate-frontend) |
|
||||
| [validate-hook.md](ai-tools/skills/validate-hook.md) | 훅 패턴 검증 (validate-hook) |
|
||||
| [validate-i18n.md](ai-tools/skills/validate-i18n.md) | 다국어 검증 (validate-i18n) |
|
||||
| [validate-migration.md](ai-tools/skills/validate-migration.md) | 마이그레이션 검증 (validate-migration) |
|
||||
| [cheatsheet.md](cheatsheet.md) | 그누보드7 자주 쓰는 명령어 치트시트 |
|
||||
| [database-guide.md](database-guide.md) | 그누보드7 데이터베이스 개발 가이드 |
|
||||
| [README.md](README.md) | 그누보드7 문서 인덱스 |
|
||||
| [requirements.md](requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) |
|
||||
| [SECURITY.md](SECURITY.md) | 그누보드7 템플릿 엔진 보안 가이드 |
|
||||
| [testing-guide.md](testing-guide.md) | 그누보드7 테스트 가이드 |
|
||||
|
||||
### AI 도구
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [ai-tools/README.md](ai-tools/README.md) | AI 도구 개요 |
|
||||
| [ai-tools/skills/](ai-tools/skills/) | AI 코딩 도구 스킬 |
|
||||
| [ai-tools/agents/](ai-tools/agents/) | 멀티에이전트 시스템 소스 |
|
||||
|
||||
<!-- AUTO-GENERATED-END: docs-readme-full-list -->
|
||||
@@ -0,0 +1,605 @@
|
||||
# 그누보드7 템플릿 엔진 보안 가이드
|
||||
|
||||
> 그누보드7 레이아웃 JSON 보안 아키텍처 — Custom Validation Rules, FormRequest 검증 계층, 공격 방어 전략
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 다층 방어: FormRequest(10개 Custom Rule) → Service → Repository(ORM) → React(자동 이스케이프)
|
||||
2. 10개 Custom Rule: JSON 구조, 컴포넌트, 엔드포인트, URL, 상속, 슬롯, 데이터소스, 권한, 경로, 파일타입
|
||||
3. 7개 FormRequest: 용도별 Rule 조합 차등 적용 (Store/Update/Content/Inheritance/Preview/Get)
|
||||
4. 상속 보안: 순환 참조 감지 + 깊이 10 제한 + 부모 슬롯/데이터소스 ID 고유성 검증
|
||||
5. 확장 가능: 6개 Hook으로 모듈/플러그인이 검증 규칙을 동적으로 추가 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [보안 아키텍처](#보안-아키텍처)
|
||||
3. [검증 계층](#검증-계층)
|
||||
4. [Custom Validation Rules](#custom-validation-rules)
|
||||
5. [FormRequest별 Rule 적용 현황](#formrequest별-rule-적용-현황)
|
||||
6. [레이아웃 상속 보안](#레이아웃-상속-보안)
|
||||
7. [경로 보안 (Path Traversal 방어)](#경로-보안-path-traversal-방어)
|
||||
8. [Hook 기반 검증 확장](#hook-기반-검증-확장)
|
||||
9. [공격 방어 전략](#공격-방어-전략)
|
||||
10. [보안 모범 사례](#보안-모범-사례)
|
||||
11. [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
그누보드7 템플릿 엔진은 JSON 기반 레이아웃으로 화면을 동적으로 구성합니다. 이 유연성은 악의적 입력을 통한 공격 가능성을 내포하므로, **10개 Custom Validation Rule**과 **7개 FormRequest**를 조합한 다층 방어 체계로 보호합니다.
|
||||
|
||||
### 보안 원칙
|
||||
|
||||
1. **다층 방어 (Defense in Depth)**: FormRequest → Service → Repository → 프론트엔드, 각 계층에서 독립적 검증
|
||||
2. **최소 권한 원칙 (Principle of Least Privilege)**: 필요한 최소한의 권한만 부여
|
||||
3. **화이트리스트 방식 (Whitelist Approach)**: 명시적으로 허용된 것만 통과
|
||||
4. **검증 우선 (Fail Secure)**: 검증 실패 시 안전한 상태로 복귀
|
||||
5. **확장 가능한 검증 (Extensible Validation)**: Hook을 통해 모듈/플러그인이 검증 규칙을 동적으로 추가 가능
|
||||
|
||||
---
|
||||
|
||||
## 보안 아키텍처
|
||||
|
||||
### 전체 검증 플로우
|
||||
|
||||
```text
|
||||
사용자 요청
|
||||
↓
|
||||
1. Controller 진입 전 검증 (FormRequest + 10개 Custom Rule)
|
||||
├─ ValidLayoutStructure: JSON 스키마 + 중첩 깊이 + actions + permissions 검증
|
||||
├─ ComponentExists: 컴포넌트 매니페스트 대조 (3카테고리, 1시간 캐싱)
|
||||
├─ WhitelistedEndpoint: API 엔드포인트 화이트리스트 + 경로 트래버설 차단
|
||||
├─ NoExternalUrls: 7개 위험 URI 스킴 + 프로토콜 상대 URL 차단
|
||||
├─ ValidParentLayout: 상속 순환 참조 감지 + 깊이 10 제한
|
||||
├─ ValidSlotStructure: 부모에서 정의된 슬롯만 허용
|
||||
├─ ValidDataSourceMerge: 상속 체인 전체 데이터소스 ID 고유성
|
||||
├─ ValidPermissionStructure: or/and 구조 + 깊이 3 제한 + 정규식 형식
|
||||
├─ SafeTemplatePath: 13개 Path Traversal 패턴 + 5회 URL 디코딩 + NULL 바이트
|
||||
└─ AllowedTemplateFileType: 14개 확장자 화이트리스트
|
||||
↓
|
||||
2. Hook 기반 확장 검증 (모듈/플러그인이 추가한 규칙)
|
||||
↓
|
||||
3. Service 계층 (비즈니스 로직)
|
||||
├─ 레이아웃 상속 병합
|
||||
├─ 훅 실행 (before_save, after_save)
|
||||
└─ 데이터베이스 저장
|
||||
↓
|
||||
4. 프론트엔드 렌더링
|
||||
├─ React 자동 이스케이프 (XSS 방지)
|
||||
├─ CSRF 토큰 검증 (Laravel Sanctum Bearer 토큰)
|
||||
└─ Eloquent ORM 파라미터 바인딩 (SQL Injection 방지)
|
||||
```
|
||||
|
||||
### 보안 계층 구조
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 사용자 브라우저 │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ React 렌더링 (자동 이스케이프) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕ HTTPS
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Laravel Backend │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. FormRequest 검증 (Controller 진입 전) │ │
|
||||
│ │ 10개 Custom Rule + Hook 확장 규칙 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ 2. Service 계층 (비즈니스 로직) │ │
|
||||
│ │ 레이아웃 상속 병합 + 훅 실행 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ 3. Repository 계층 (데이터 접근) │ │
|
||||
│ │ Eloquent ORM (파라미터 바인딩) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MySQL Database │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 검증 계층
|
||||
|
||||
### 1단계: FormRequest 검증 (Controller 진입 전)
|
||||
|
||||
**위치**: `app/Http/Requests/Layout/`
|
||||
|
||||
**목적**: Controller에 도달하기 전에 모든 악의적 입력을 차단
|
||||
|
||||
G7은 **용도별 7개 FormRequest**로 검증을 분리합니다. 각 FormRequest는 용도에 맞는 Custom Rule 조합을 적용하며, Hook을 통해 모듈/플러그인이 규칙을 확장할 수 있습니다.
|
||||
|
||||
> 상세: [FormRequest별 Rule 적용 현황](#formrequest별-rule-적용-현황)
|
||||
|
||||
### 2단계: Service 계층 (비즈니스 로직)
|
||||
|
||||
**위치**: `app/Services/LayoutService.php`
|
||||
|
||||
**특징**:
|
||||
- 검증 로직 없음 (FormRequest에서 완료)
|
||||
- 순수 비즈니스 로직만 처리
|
||||
- 훅 시스템을 통한 확장성 (`before_save`, `after_save`)
|
||||
|
||||
### 3단계: Repository 계층 (데이터 접근)
|
||||
|
||||
**위치**: `app/Repositories/LayoutRepository.php`
|
||||
|
||||
**특징**:
|
||||
- Eloquent ORM 사용 (파라미터 바인딩으로 SQL Injection 자동 방어)
|
||||
- 직접 SQL 문자열 연결 금지
|
||||
|
||||
---
|
||||
|
||||
## Custom Validation Rules
|
||||
|
||||
G7은 레이아웃 JSON 보안을 위해 **10개의 Custom Validation Rule**을 제공합니다.
|
||||
|
||||
### 1. ValidLayoutStructure
|
||||
|
||||
**파일**: `app/Rules/ValidLayoutStructure.php`
|
||||
|
||||
**목적**: 레이아웃 JSON의 구조적 유효성 검증
|
||||
|
||||
**검증 항목**:
|
||||
- 필수 필드 존재 확인 (`version`, `layout_name`, `components`)
|
||||
- `extends` 레이아웃: `components` 또는 `slots` 중 하나 필수
|
||||
- 재귀적 컴포넌트 구조 검증 (type, name 필수)
|
||||
- 컴포넌트 `type`: `basic`, `composite`, `layout` 중 하나만 허용
|
||||
- **최대 중첩 깊이 제한: 10단계** (`MAX_DEPTH = 10`)
|
||||
- `actions` 배열 구조 검증 (`type` 또는 `event` 중 하나 필수)
|
||||
- `permissions` 필드: `ValidPermissionStructure` Rule에 위임
|
||||
- 슬롯 참조(`{ "slot": "name" }`)와 Partial 참조(`{ "partial": "path" }`) 허용
|
||||
|
||||
### 2. ComponentExists
|
||||
|
||||
**파일**: `app/Rules/ComponentExists.php`
|
||||
|
||||
**목적**: 존재하지 않는 컴포넌트 참조 방지
|
||||
|
||||
**검증 방법**:
|
||||
1. 템플릿의 `components.json` 매니페스트 로드
|
||||
2. **3개 카테고리**: `basic`, `composite`, `layout` 컴포넌트를 Set으로 구축
|
||||
3. 레이아웃 JSON의 모든 `name` 필드를 매니페스트와 대조
|
||||
4. 재귀적으로 `children` 배열 검증
|
||||
|
||||
**캐싱 전략**:
|
||||
- 캐시 키: `template.{template_id}.components_manifest`
|
||||
- **캐시 TTL: 1시간** (`CACHE_TTL = 3600`)
|
||||
|
||||
### 3. WhitelistedEndpoint
|
||||
|
||||
**파일**: `app/Rules/WhitelistedEndpoint.php`
|
||||
|
||||
**목적**: API 엔드포인트 화이트리스트 검증
|
||||
|
||||
**허용 패턴**:
|
||||
|
||||
```regex
|
||||
^/api/(admin|auth|public)/
|
||||
```
|
||||
|
||||
**검증 대상**:
|
||||
- `data_sources[].endpoint` — 데이터소스 엔드포인트
|
||||
- `components[].actions[].endpoint` — 액션 핸들러 엔드포인트 (재귀적 children 포함)
|
||||
|
||||
**차단**:
|
||||
- 외부 URL (`http://`, `https://`)
|
||||
- 비공개 API (`/api/internal/*`)
|
||||
- 직접 경로 (`/admin/*`)
|
||||
- **경로 트래버설**: `../`, `..\` 패턴 차단
|
||||
|
||||
### 4. NoExternalUrls
|
||||
|
||||
**파일**: `app/Rules/NoExternalUrls.php`
|
||||
|
||||
**목적**: 외부 URL 차단으로 데이터 유출 및 악의적 리소스 로딩 방지
|
||||
|
||||
**차단 대상 — 7개 위험 URI 스킴**:
|
||||
|
||||
| 스킴 | 위험성 |
|
||||
|------|--------|
|
||||
| `http://` | 외부 서버 통신, 데이터 유출 |
|
||||
| `https://` | 외부 서버 통신, 데이터 유출 |
|
||||
| `data:` | 인라인 데이터 삽입, XSS 벡터 |
|
||||
| `javascript:` | 임의 JavaScript 실행 |
|
||||
| `vbscript:` | 임의 VBScript 실행 (IE) |
|
||||
| `file:` | 로컬 파일 시스템 접근 |
|
||||
| `ftp:` | 외부 FTP 서버 통신 |
|
||||
|
||||
**추가 차단**: `//`로 시작하는 프로토콜 상대 URL
|
||||
|
||||
**검증 범위**: `components[]` → `props`, `actions` 내 모든 문자열 값을 재귀적으로 스캔
|
||||
|
||||
### 5. ValidParentLayout
|
||||
|
||||
**파일**: `app/Rules/ValidParentLayout.php`
|
||||
|
||||
**목적**: 레이아웃 상속 체계의 안전성 보장
|
||||
|
||||
**검증 항목**:
|
||||
1. **부모 존재 확인**: DB에서 `template_id` + `name` 조회
|
||||
2. **순환 참조 감지**: 재귀적 체인 추적 (`visited` 배열로 방문 노드 기록)
|
||||
3. **상속 깊이 제한**: `MAX_INHERITANCE_DEPTH = 10`
|
||||
|
||||
> 상세: [레이아웃 상속 보안](#레이아웃-상속-보안)
|
||||
|
||||
### 6. ValidSlotStructure
|
||||
|
||||
**파일**: `app/Rules/ValidSlotStructure.php`
|
||||
|
||||
**목적**: 부모 레이아웃에서 정의된 슬롯만 허용
|
||||
|
||||
**검증 로직**:
|
||||
1. `extends` 필드가 없으면 검증 통과
|
||||
2. 부모 레이아웃의 `components`에서 `{ "slot": "name" }` 패턴을 재귀적으로 수집
|
||||
3. 자식 레이아웃의 `slots` 키가 부모에 정의된 슬롯 이름과 일치하는지 검증
|
||||
4. **부모에 없는 슬롯 이름 사용 시 차단**
|
||||
|
||||
### 7. ValidDataSourceMerge
|
||||
|
||||
**파일**: `app/Rules/ValidDataSourceMerge.php`
|
||||
|
||||
**목적**: 상속 체인 전체에서 데이터소스 ID 고유성 보장
|
||||
|
||||
**검증 로직**:
|
||||
1. 현재 레이아웃 내 `data_sources[].id` 중복 검사
|
||||
2. `extends`가 있으면 **부모 체인 전체를 순회**하여 모든 데이터소스 ID 수집
|
||||
3. 현재 레이아웃 ID와 부모 체인 ID의 교집합 검사 → **중복 시 차단**
|
||||
4. 무한 루프 방지 (`visited` 배열)
|
||||
|
||||
### 8. ValidPermissionStructure
|
||||
|
||||
**파일**: `app/Rules/ValidPermissionStructure.php`
|
||||
|
||||
**목적**: 권한 구조의 형식적 유효성 검증
|
||||
|
||||
**지원 구조**:
|
||||
- **Flat array**: `["perm.read", "perm.write"]`
|
||||
- **OR 구조**: `{ "or": ["perm.read", "perm.write"] }` (하나라도 만족)
|
||||
- **AND 구조**: `{ "and": ["perm.read", "perm.write"] }` (모두 만족)
|
||||
- **중첩 구조**: `{ "or": ["perm.a", { "and": ["perm.b", "perm.c"] }] }`
|
||||
|
||||
**제한**:
|
||||
- **최대 중첩 깊이: 3단계** (`MAX_DEPTH = 3`)
|
||||
- or/and 연산자에 **최소 2개 항목** 필수 (`MIN_OPERATOR_ITEMS = 2`)
|
||||
- 권한 식별자 정규식: `/^[a-z0-9_-]+\.[a-z0-9_-]+(\.[a-z0-9_-]+)*$/i`
|
||||
|
||||
**사용 위치**: 레이아웃 최상위 `permissions` + 컴포넌트 레벨 `permissions` (ValidLayoutStructure에서 위임)
|
||||
|
||||
### 9. SafeTemplatePath
|
||||
|
||||
**파일**: `app/Rules/SafeTemplatePath.php`
|
||||
|
||||
**목적**: 템플릿 파일 경로의 Path Traversal 공격 방지
|
||||
|
||||
> 상세: [경로 보안 (Path Traversal 방어)](#경로-보안-path-traversal-방어)
|
||||
|
||||
### 10. AllowedTemplateFileType
|
||||
|
||||
**파일**: `app/Rules/AllowedTemplateFileType.php`
|
||||
|
||||
**목적**: 허용된 파일 확장자만 통과
|
||||
|
||||
**14개 허용 확장자**:
|
||||
|
||||
| 카테고리 | 확장자 |
|
||||
|---------|--------|
|
||||
| Scripts | `js`, `mjs` |
|
||||
| Styles | `css` |
|
||||
| Data | `json` |
|
||||
| Images | `png`, `jpg`, `jpeg`, `svg`, `webp`, `gif` |
|
||||
| Fonts | `woff`, `woff2`, `ttf`, `otf`, `eot` |
|
||||
|
||||
**그 외 모든 확장자 차단** (PHP, HTML, EXE 등)
|
||||
|
||||
---
|
||||
|
||||
## FormRequest별 Rule 적용 현황
|
||||
|
||||
G7은 레이아웃 CRUD 작업별로 **7개 FormRequest**를 운용합니다. 각 FormRequest는 용도에 맞는 Rule 조합을 적용합니다.
|
||||
|
||||
### 비교 표
|
||||
|
||||
| FormRequest | 용도 | VLS | CE | WE | NEU | VPL | VSS | VDM | VPS |
|
||||
|-------------|------|:---:|:--:|:--:|:---:|:---:|:---:|:---:|:---:|
|
||||
| **StoreLayoutRequest** | 새 레이아웃 생성 | ✅ | ✅ | ✅ | ✅ | | | | |
|
||||
| **UpdateLayoutRequest** | 메타+콘텐츠 수정 | ✅* | ✅* | ✅* | ✅* | | | | |
|
||||
| **UpdateLayoutContentRequest** | 콘텐츠만 수정 | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| **StoreLayoutInheritanceRequest** | 상속 레이아웃 생성 | | | | | ✅ | ✅ | ✅ | |
|
||||
| **UpdateLayoutInheritanceRequest** | 상속 레이아웃 수정 | | | | | ✅ | ✅ | ✅ | |
|
||||
| **StoreLayoutPreviewRequest** | 미리보기 | | | | | | | | |
|
||||
| **GetLayoutRequest** | 레이아웃 조회 | | | | | | | | |
|
||||
|
||||
**범례**: VLS=ValidLayoutStructure, CE=ComponentExists, WE=WhitelistedEndpoint, NEU=NoExternalUrls, VPL=ValidParentLayout, VSS=ValidSlotStructure, VDM=ValidDataSourceMerge, VPS=ValidPermissionStructure
|
||||
**✅\*** = `sometimes` 규칙 (필드가 존재할 때만 적용)
|
||||
|
||||
### 핵심 포인트
|
||||
|
||||
- **StoreLayoutRequest / UpdateLayoutRequest**: 기본 4개 Rule (구조, 컴포넌트, 엔드포인트, URL)
|
||||
- **UpdateLayoutContentRequest**: 가장 엄격 — 상속 관련 4개 Rule 추가 (총 8개)
|
||||
- **StoreLayoutInheritanceRequest / UpdateLayoutInheritanceRequest**: 상속 전용 3개 Rule
|
||||
- **StoreLayoutPreviewRequest**: 최소 검증 (`content: required, array`)
|
||||
- **GetLayoutRequest**: 조회 전용 (보안 Rule 없음)
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 상속 보안
|
||||
|
||||
G7 레이아웃은 `extends`/`slots`/`partial` 시스템으로 상속을 지원합니다. 상속 체계의 안전성은 3개 Rule이 협력하여 보장합니다.
|
||||
|
||||
### 순환 참조 방지 (ValidParentLayout)
|
||||
|
||||
```text
|
||||
A extends B → B extends C → C extends A ← 차단!
|
||||
```
|
||||
|
||||
**메커니즘**:
|
||||
1. `extends` 필드의 부모 레이아웃을 DB에서 조회
|
||||
2. 부모의 `extends`를 재귀적으로 추적 (`visited` 배열로 방문 기록)
|
||||
3. 이미 방문한 레이아웃 또는 자기 자신을 참조하면 순환 참조로 판정
|
||||
4. **최대 상속 깊이: 10단계** — 초과 시 차단
|
||||
|
||||
### 슬롯 안전성 (ValidSlotStructure)
|
||||
|
||||
```text
|
||||
부모 레이아웃에 { "slot": "header" }, { "slot": "content" } 정의
|
||||
↓
|
||||
자식 레이아웃에서 slots: { "header": [...], "footer": [...] }
|
||||
↑ 차단! (부모에 "footer" 슬롯 미정의)
|
||||
```
|
||||
|
||||
- 부모 `components` 트리를 재귀 탐색하여 `{ "slot": "name" }` 패턴 수집
|
||||
- 자식의 `slots` 키가 부모 슬롯 목록에 포함되는지 검증
|
||||
|
||||
### 데이터소스 ID 고유성 (ValidDataSourceMerge)
|
||||
|
||||
```text
|
||||
조부모: data_sources: [{ id: "users" }]
|
||||
부모: data_sources: [{ id: "posts" }]
|
||||
자식: data_sources: [{ id: "users" }] ← 차단! (조부모와 ID 충돌)
|
||||
```
|
||||
|
||||
- **상속 체인 전체**를 순회하여 모든 데이터소스 ID 수집
|
||||
- 현재 레이아웃 ID와의 교집합 검사
|
||||
- 무한 루프 방지 로직 포함
|
||||
|
||||
---
|
||||
|
||||
## 경로 보안 (Path Traversal 방어)
|
||||
|
||||
**파일**: `app/Rules/SafeTemplatePath.php`
|
||||
|
||||
G7은 OS 레벨 파일 권한에 의존하지 않고, **애플리케이션 레벨에서 경로를 적극적으로 검증**합니다.
|
||||
|
||||
### 13개 Path Traversal 패턴 차단
|
||||
|
||||
| 패턴 | 설명 |
|
||||
|------|------|
|
||||
| `../` | Unix 상위 디렉토리 |
|
||||
| `..\` | Windows 상위 디렉토리 |
|
||||
| `//` | 이중 슬래시 |
|
||||
| `%2e%2e%2f` | URL 인코딩 `../` |
|
||||
| `%2e%2e/` | 부분 URL 인코딩 |
|
||||
| `%2e%2e%5c` | URL 인코딩 `..\` |
|
||||
| `%2e%2e\` | 부분 URL 인코딩 |
|
||||
| `..%2f` | 혼합 인코딩 `../` |
|
||||
| `..%5c` | 혼합 인코딩 `..\` |
|
||||
| `.%2e/` | 부분 인코딩 `../` |
|
||||
| `.%2e\` | 부분 인코딩 `..\` |
|
||||
|
||||
### 다중 인코딩 공격 방지
|
||||
|
||||
```php
|
||||
// 최대 5회 반복 URL 디코딩 (이중/삼중 인코딩 공격 방지)
|
||||
for ($i = 0; $i < 5 && $decodedPath !== $previousPath; $i++) {
|
||||
$previousPath = $decodedPath;
|
||||
$decodedPath = urldecode($decodedPath);
|
||||
}
|
||||
```
|
||||
|
||||
공격자가 `%252e%252e%252f` (삼중 인코딩)를 사용해도 5회 디코딩으로 원본 패턴이 드러남
|
||||
|
||||
### 추가 방어
|
||||
|
||||
| 방어 항목 | 설명 |
|
||||
|----------|------|
|
||||
| **절대 경로 차단** | `/`, `\`, `C:` 등 절대 경로 시작 패턴 차단 (Windows/Linux 모두) |
|
||||
| **NULL 바이트 차단** | `\0` 문자 감지 시 차단 (C 언어 문자열 종료 공격) |
|
||||
| **basePath 외부 접근 차단** | `realpath()` 정규화 후 `basePath` 외부 경로 감지 시 차단 |
|
||||
|
||||
---
|
||||
|
||||
## Hook 기반 검증 확장
|
||||
|
||||
모듈/플러그인은 **6개 Filter Hook**을 통해 레이아웃 검증 규칙을 동적으로 추가할 수 있습니다.
|
||||
|
||||
### 사용 가능한 Hook
|
||||
|
||||
| Hook 이름 | FormRequest | 용도 |
|
||||
|----------|-------------|------|
|
||||
| `core.layout.store_validation_rules` | StoreLayoutRequest | 레이아웃 생성 시 규칙 추가 |
|
||||
| `core.layout.update_validation_rules` | UpdateLayoutRequest | 레이아웃 수정 시 규칙 추가 |
|
||||
| `core.layout.update_content_validation_rules` | UpdateLayoutContentRequest | 콘텐츠 수정 시 규칙 추가 |
|
||||
| `core.layout.store_inheritance_validation_rules` | StoreLayoutInheritanceRequest | 상속 레이아웃 생성 시 규칙 추가 |
|
||||
| `core.layout.update_inheritance_validation_rules` | UpdateLayoutInheritanceRequest | 상속 레이아웃 수정 시 규칙 추가 |
|
||||
| `core.layout.get_validation_rules` | GetLayoutRequest | 레이아웃 조회 시 규칙 추가 |
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```php
|
||||
// 모듈의 Listener에서 커스텀 검증 규칙 추가
|
||||
class LayoutValidationListener
|
||||
{
|
||||
public function handle(array $rules, StoreLayoutRequest $request): array
|
||||
{
|
||||
// 모듈 전용 검증 규칙 추가
|
||||
$rules['content'][] = new MyCustomRule();
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// 모듈의 ServiceProvider에서 Hook 등록
|
||||
HookManager::addFilter(
|
||||
'core.layout.store_validation_rules',
|
||||
[LayoutValidationListener::class, 'handle'],
|
||||
priority: 10
|
||||
);
|
||||
```
|
||||
|
||||
> 참고: Filter 훅이므로 리스너에서 `type: 'filter'` 명시 필수 (미지정 시 반환값 무시)
|
||||
> 상세: [훅 시스템](../extension/hooks.md)
|
||||
|
||||
---
|
||||
|
||||
## 공격 방어 전략
|
||||
|
||||
### 공격 시나리오별 방어 매핑
|
||||
|
||||
| 공격 유형 | 공격 벡터 | 방어 메커니즘 | 차단 계층 |
|
||||
|---------|---------|------------|---------|
|
||||
| **XSS** | `<script>` 태그 삽입 | React 자동 이스케이프 | 프론트엔드 |
|
||||
| **SQL Injection** | `'; DROP TABLE--` | Eloquent ORM 파라미터 바인딩 | Repository |
|
||||
| **CSRF** | 토큰 없는 요청 | Laravel Sanctum Bearer 토큰 | 미들웨어 |
|
||||
| **경로 트래버설** | `../../etc/passwd` | SafeTemplatePath (13패턴 + 5회 디코딩) | FormRequest |
|
||||
| **다중 인코딩 경로 트래버설** | `%252e%252e%252f` | SafeTemplatePath 5회 반복 디코딩 | FormRequest |
|
||||
| **NULL 바이트 경로 공격** | `file.php\0.jpg` | SafeTemplatePath NULL 바이트 감지 | FormRequest |
|
||||
| **외부 리소스 로딩** | `https://evil.com` | NoExternalUrls (7개 스킴) | FormRequest |
|
||||
| **XSS via URI** | `javascript:alert(1)` | NoExternalUrls (`javascript:` 차단) | FormRequest |
|
||||
| **Data URI 삽입** | `data:text/html,...` | NoExternalUrls (`data:` 차단) | FormRequest |
|
||||
| **프로토콜 상대 URL** | `//evil.com/malicious.js` | NoExternalUrls (`//` 차단) | FormRequest |
|
||||
| **허용되지 않은 API** | `/api/internal/` | WhitelistedEndpoint | FormRequest |
|
||||
| **API 경로 트래버설** | `/api/admin/../internal/` | WhitelistedEndpoint (`../` 차단) | FormRequest |
|
||||
| **존재하지 않는 컴포넌트** | `MaliciousComponent` | ComponentExists (매니페스트 대조) | FormRequest |
|
||||
| **DoS (깊은 중첩)** | 11단계 이상 | ValidLayoutStructure (`MAX_DEPTH = 10`) | FormRequest |
|
||||
| **DoS (상속 깊이)** | 11단계 이상 | ValidParentLayout (`MAX_INHERITANCE_DEPTH = 10`) | FormRequest |
|
||||
| **DoS (권한 중첩)** | 4단계 이상 | ValidPermissionStructure (`MAX_DEPTH = 3`) | FormRequest |
|
||||
| **상속 순환 참조** | A→B→C→A | ValidParentLayout (visited 추적) | FormRequest |
|
||||
| **유령 슬롯 주입** | 부모에 없는 슬롯 | ValidSlotStructure | FormRequest |
|
||||
| **데이터소스 ID 충돌** | 부모와 동일 ID | ValidDataSourceMerge (체인 전체 검사) | FormRequest |
|
||||
| **위험 파일 업로드** | `malicious.php` | AllowedTemplateFileType (14개 화이트리스트) | FormRequest |
|
||||
|
||||
### 복합 공격 방어
|
||||
|
||||
악의적 사용자가 여러 공격 기법을 조합하더라도, 각 계층에서 독립적으로 검증하므로 하나의 공격이 통과하더라도 다음 계층에서 차단됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"layout_name": "combined_attack",
|
||||
"data_sources": [
|
||||
{
|
||||
"endpoint": "/api/internal/secrets",
|
||||
"params": { "filter": "'; DROP TABLE--" }
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "NonExistentComponent",
|
||||
"type": "basic",
|
||||
"props": {
|
||||
"imageUrl": "https://evil.com/image.jpg",
|
||||
"onClick": "<script>alert(1)</script>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**결과**: FormRequest의 첫 번째 검증 규칙에서 차단 (422 응답)
|
||||
- `WhitelistedEndpoint`: `/api/internal/` 차단
|
||||
- `ComponentExists`: `NonExistentComponent` 차단
|
||||
- `NoExternalUrls`: `https://evil.com` 차단
|
||||
- React: `<script>` 태그는 자동 이스케이프 (텍스트로 표시)
|
||||
- Eloquent ORM: SQL 구문은 파라미터 바인딩으로 안전 처리
|
||||
|
||||
---
|
||||
|
||||
## 보안 모범 사례
|
||||
|
||||
### 템플릿 개발자를 위한 지침
|
||||
|
||||
1. **컴포넌트 명세 문서화**: `components.json`에 모든 컴포넌트를 `basic`/`composite`/`layout` 카테고리로 분류 등록
|
||||
2. **외부 의존성 최소화**: 외부 CDN 사용 자제, `/public/build/template/` 디렉토리에 번들링
|
||||
3. **props 타입 검증**: 컴포넌트 내부에서 TypeScript로 props 타입 검증
|
||||
|
||||
### 확장 개발자를 위한 지침
|
||||
|
||||
1. **Hook을 통한 검증 추가**: 모듈 전용 검증이 필요하면 `core.layout.*_validation_rules` Hook 사용
|
||||
2. **Custom Rule 작성**: `Illuminate\Contracts\Validation\ValidationRule` 인터페이스 구현
|
||||
3. **`__()` 함수 사용**: Custom Rule 에러 메시지는 반드시 다국어 처리
|
||||
|
||||
### 시스템 관리자를 위한 지침
|
||||
|
||||
1. **정기적인 보안 감사**: `MaliciousJsonTest.php` 실행으로 방어 체계 검증
|
||||
2. **로그 모니터링**: 422 응답 빈도 모니터링, 반복적인 악의적 시도 IP 차단
|
||||
3. **환경 변수 보호**: `.env` 파일 권한 제한 (600), API 키/DB 비밀번호 안전 보관
|
||||
4. **업데이트 관리**: Laravel 및 의존성 패키지 최신 버전 유지
|
||||
|
||||
### 코드 리뷰 체크리스트
|
||||
|
||||
- [ ] 모든 검증 로직이 FormRequest에 있는가?
|
||||
- [ ] Service에 검증 로직이 없는가?
|
||||
- [ ] Custom Rule에서 `__()` 함수로 다국어 처리했는가?
|
||||
- [ ] 외부 URL 참조가 없는가?
|
||||
- [ ] `dangerouslySetInnerHTML` 사용이 없는가?
|
||||
- [ ] DB 쿼리가 Eloquent ORM을 사용하는가?
|
||||
- [ ] API 라우트에 인증 미들웨어가 있는가?
|
||||
- [ ] 새로운 엔드포인트가 WhitelistedEndpoint 패턴에 부합하는가?
|
||||
- [ ] 레이아웃 상속 시 부모 슬롯과 데이터소스 ID 충돌이 없는가?
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
### 보안 관련
|
||||
|
||||
- [프론트엔드 보안 및 검증](frontend/security.md) — XSS, 표현식, 상태 노출, 인증, 에셋 보안
|
||||
- [템플릿 보안 정책](extension/template-security.md) — 템플릿 에셋 서빙, 경로 보안
|
||||
|
||||
### 백엔드
|
||||
|
||||
- [검증 (Validation)](backend/validation.md) — FormRequest + Custom Rule 패턴
|
||||
- [인증 및 세션 처리](backend/authentication.md) — Sanctum 토큰 기반 인증
|
||||
- [Service-Repository 패턴](backend/service-repository.md) — 계층 분리
|
||||
|
||||
### 확장 시스템
|
||||
|
||||
- [훅 시스템](extension/hooks.md) — Action/Filter Hook, 모듈/플러그인 확장
|
||||
- [레이아웃 확장 시스템](extension/layout-extensions.md) — 레이아웃 동적 주입
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
- [레이아웃 JSON 스키마](frontend/layout-json.md) — JSON 구조 명세
|
||||
- [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) — extends/slots/partial 시스템
|
||||
- [인증 시스템 (AuthManager)](frontend/auth-system.md) — 프론트엔드 인증 흐름
|
||||
|
||||
### 참고 파일
|
||||
|
||||
- **Custom Rules**: `app/Rules/*.php` (10개 보안 관련)
|
||||
- **FormRequests**: `app/Http/Requests/Layout/*.php` (7개)
|
||||
- **통합 테스트**: `tests/Feature/Security/MaliciousJsonTest.php`
|
||||
- **다국어 파일**: `lang/ko/validation.php`, `lang/en/validation.php`
|
||||
|
||||
### 외부 문서
|
||||
|
||||
- [Laravel 보안 가이드](https://laravel.com/docs/12.x/authentication)
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
|
||||
---
|
||||
|
||||
**마지막 업데이트**: 2026-03-30
|
||||
@@ -0,0 +1,32 @@
|
||||
# 그누보드7 AI 도구
|
||||
|
||||
그누보드7 프로젝트의 AI 기반 개발 도구입니다.
|
||||
|
||||
## 구성
|
||||
|
||||
### [Skills](skills/)
|
||||
|
||||
AI 코딩 도구에서 사용하는 슬래시 커맨드 스킬입니다. 코드 검증, 테스트 실행, 문서화 등의 작업을 자동화합니다.
|
||||
|
||||
| 스킬 | 설명 |
|
||||
|------|------|
|
||||
| [create-module.md](skills/create-module.md) | 모듈 스캐폴딩 |
|
||||
| [extract-i18n-keys.md](skills/extract-i18n-keys.md) | 다국어 키 추출 |
|
||||
| [run-tests.md](skills/run-tests.md) | 테스트 실행 |
|
||||
| [validate-code.md](skills/validate-code.md) | 백엔드 코드 패턴 검증 |
|
||||
| [validate-frontend.md](skills/validate-frontend.md) | 프론트엔드 레이아웃 검증 |
|
||||
| [validate-hook.md](skills/validate-hook.md) | 훅 패턴 검증 |
|
||||
| [validate-i18n.md](skills/validate-i18n.md) | 다국어 검증 |
|
||||
| [validate-migration.md](skills/validate-migration.md) | 마이그레이션 검증 |
|
||||
|
||||
### [DevTools](devtools/)
|
||||
|
||||
그누보드7 템플릿 엔진의 디버깅 정보를 AI 코딩 도구에서 접근할 수 있게 해주는 MCP(Model Context Protocol) 서버입니다. 브라우저에서 수집된 상태, 액션, 캐시, 네트워크 정보를 `g7-state`, `g7-diagnose` 등 30여 개 도구로 조회할 수 있습니다.
|
||||
|
||||
자세한 내용은 [devtools/README.md](devtools/README.md)를 참조하세요.
|
||||
|
||||
### [Agents](agents/)
|
||||
|
||||
AI Agent SDK 기반 멀티에이전트 협업 시스템입니다. 5명의 전문 에이전트(Backend, Frontend, Layout, Template, Reviewer)가 Coordinator의 조율 하에 협업합니다.
|
||||
|
||||
자세한 내용은 [agents/README.md](agents/README.md)를 참조하세요.
|
||||
@@ -0,0 +1,11 @@
|
||||
# AI API Key (Required)
|
||||
ANTHROPIC_API_KEY=your_api_key_here
|
||||
|
||||
# G7 Project Root Path
|
||||
G7_PROJECT_ROOT=/path/to/your/g7/project
|
||||
|
||||
# Default Model for Coordinator
|
||||
COORDINATOR_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Log Level (debug, info, warn, error)
|
||||
LOG_LEVEL=info
|
||||
@@ -0,0 +1,187 @@
|
||||
# 그누보드7 Multi-Agent System
|
||||
|
||||
AI Agent SDK 기반 그누보드7 전용 멀티에이전트 협업 시스템입니다.
|
||||
|
||||
## 개요
|
||||
|
||||
5명의 전문 에이전트가 협업하여 그누보드7 프로젝트의 개발 작업을 수행합니다:
|
||||
|
||||
| 에이전트 | 역할 | 담당 영역 |
|
||||
|---------|------|----------|
|
||||
| **Coordinator** | 작업 분배 및 조율 | 전체 오케스트레이션 |
|
||||
| **Backend** | 백엔드 개발 | Service, Repository, Controller, Migration |
|
||||
| **Frontend** | 프론트엔드 개발 | TSX 컴포넌트, 상태 관리, G7Core API |
|
||||
| **Layout** | 레이아웃 개발 | JSON 스키마, 데이터 바인딩, 반응형 |
|
||||
| **Template** | 템플릿 개발 | 빌드, 컴포넌트 등록, 다국어 |
|
||||
| **Reviewer** | 코드 검수 | 규정 준수, 테스트, 문서화 |
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
cd docs/ai-tools/agents
|
||||
npm install
|
||||
```
|
||||
|
||||
## 환경 설정
|
||||
|
||||
`.env.example`을 `.env`로 복사하고 API 키를 설정하세요:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```env
|
||||
# AI API Key (Required)
|
||||
ANTHROPIC_API_KEY=your_api_key_here
|
||||
|
||||
# 그누보드7 Project Root Path
|
||||
G7_PROJECT_ROOT=/path/to/your/g7/project
|
||||
|
||||
# Log Level
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
## 사용법
|
||||
|
||||
### 인터랙티브 모드
|
||||
|
||||
```bash
|
||||
npm run interactive
|
||||
```
|
||||
|
||||
대화형으로 작업을 요청합니다:
|
||||
|
||||
```
|
||||
🤖 > /feature 상품 할인 기능 추가
|
||||
🤖 > /bugfix Form 저장 시 데이터 사라짐
|
||||
🤖 > /review #123
|
||||
🤖 > ProductService의 할인 로직 설명해줘
|
||||
```
|
||||
|
||||
### 단일 명령어 모드
|
||||
|
||||
```bash
|
||||
# 기능 개발
|
||||
npm run agent -- feature "상품 할인 기능 추가"
|
||||
|
||||
# 버그 수정
|
||||
npm run agent -- bugfix "Form 저장 시 데이터 사라짐"
|
||||
|
||||
# PR 검수
|
||||
npm run agent -- pr-review "#123"
|
||||
|
||||
# 자유 형식
|
||||
npm run agent -- "ProductService에 할인 로직 추가해줘"
|
||||
```
|
||||
|
||||
## 워크플로우
|
||||
|
||||
### 1. 기능 개발 (Feature)
|
||||
|
||||
새로운 기능을 개발할 때 사용합니다:
|
||||
|
||||
```
|
||||
Backend → Layout → Frontend → Template → Reviewer
|
||||
```
|
||||
|
||||
1. **Backend**: 마이그레이션, Model, Repository, Service, Controller
|
||||
2. **Layout**: 레이아웃 JSON, 데이터 소스, 컴포넌트 구조
|
||||
3. **Frontend**: 필요한 TSX 컴포넌트 개발
|
||||
4. **Template**: 컴포넌트 등록, 빌드
|
||||
5. **Reviewer**: 전체 검수
|
||||
|
||||
### 2. 버그 수정 (Bugfix)
|
||||
|
||||
TDD 기반으로 버그를 수정합니다:
|
||||
|
||||
1. 트러블슈팅 가이드 확인
|
||||
2. 버그 분석
|
||||
3. **RED**: 버그 재현 테스트 작성
|
||||
4. **GREEN**: 버그 수정
|
||||
5. **REFACTOR**: 테스트 통과 확인
|
||||
6. 검수
|
||||
|
||||
### 3. PR 검수 (PR Review)
|
||||
|
||||
변경된 파일 유형에 따라 적절한 에이전트가 검수합니다:
|
||||
|
||||
- `*.php` → Backend 에이전트
|
||||
- `*.tsx` → Frontend 에이전트
|
||||
- `*.json` (레이아웃) → Layout 에이전트
|
||||
- 최종 → Reviewer 에이전트
|
||||
|
||||
## MCP 도구
|
||||
|
||||
그누보드7 프로젝트 전용 검증 도구를 제공합니다:
|
||||
|
||||
| 도구 | 설명 |
|
||||
|------|------|
|
||||
| `validate-code` | 백엔드 코드 규정 검증 |
|
||||
| `validate-frontend` | 프론트엔드/레이아웃 규정 검증 |
|
||||
| `run-tests` | 테스트 실행 (PHPUnit, Vitest) |
|
||||
| `search-docs` | 규정 문서 검색 |
|
||||
| `read-doc` | 규정 문서 읽기 |
|
||||
| `list-docs` | 규정 문서 목록 |
|
||||
|
||||
## 그누보드7 규정 준수
|
||||
|
||||
모든 에이전트는 `docs/`의 규정을 준수합니다:
|
||||
|
||||
### 백엔드
|
||||
- Service-Repository 패턴 (RepositoryInterface 주입)
|
||||
- FormRequest 검증 (Service 검증 금지)
|
||||
- HookManager 훅 실행 (before_*/after_*)
|
||||
- ResponseHelper 응답 처리
|
||||
- 다국어 처리 (__() 함수)
|
||||
|
||||
### 프론트엔드
|
||||
- HTML 태그 직접 사용 금지 (Div, Button 등 사용)
|
||||
- 다크 모드 클래스 쌍 필수 (bg-white dark:bg-gray-800)
|
||||
- G7Core.t() 다국어 처리
|
||||
- Font Awesome 아이콘
|
||||
|
||||
### 레이아웃
|
||||
- text 속성 사용 (props.children 금지)
|
||||
- 데이터 바인딩 문법 ({{path}}, $t:key)
|
||||
- 기본 컴포넌트만 사용
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
docs/ai-tools/agents/
|
||||
├── src/
|
||||
│ ├── index.ts # 메인 엔트리포인트
|
||||
│ ├── cli.ts # CLI 인터페이스
|
||||
│ ├── coordinator/
|
||||
│ │ └── Coordinator.ts # 코디네이터
|
||||
│ ├── agents/
|
||||
│ │ ├── definitions.ts # 에이전트 정의
|
||||
│ │ └── index.ts
|
||||
│ ├── prompts/ # 시스템 프롬프트
|
||||
│ │ ├── coordinator.ts
|
||||
│ │ ├── backend.ts
|
||||
│ │ ├── frontend.ts
|
||||
│ │ ├── layout.ts
|
||||
│ │ ├── template.ts
|
||||
│ │ └── reviewer.ts
|
||||
│ ├── mcp/ # MCP 도구
|
||||
│ │ ├── g7-tools-server.ts
|
||||
│ │ └── tools/
|
||||
│ ├── workflows/ # 워크플로우
|
||||
│ │ ├── feature.ts
|
||||
│ │ ├── bugfix.ts
|
||||
│ │ └── pr-review.ts
|
||||
│ ├── types/
|
||||
│ │ └── index.ts
|
||||
│ └── utils/
|
||||
│ ├── logger.ts
|
||||
│ └── context.ts
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT License
|
||||
Generated
+2469
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "g7-agent-system",
|
||||
"version": "1.0.0",
|
||||
"description": "Gnuboard7 Multi-Agent System using Claude Agent SDK",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"interactive": "tsx src/interactive.ts",
|
||||
"agent": "tsx src/index.ts single",
|
||||
"mcp": "tsx src/mcp/index.ts",
|
||||
"mcp:agents": "tsx src/mcp/index.ts",
|
||||
"mcp:devtools": "tsx src/mcp/g7-devtools-server.ts"
|
||||
},
|
||||
"bin": {
|
||||
"g7-agents-mcp": "./src/mcp/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"dotenv": "^16.4.0",
|
||||
"pino": "^9.0.0",
|
||||
"pino-pretty": "^11.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"author": "Gnuboard7 Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 에이전트 정의
|
||||
* 각 에이전트의 역할, 시스템 프롬프트, 도구 권한 정의
|
||||
*/
|
||||
import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
|
||||
import {
|
||||
BACKEND_PROMPT,
|
||||
FRONTEND_PROMPT,
|
||||
LAYOUT_PROMPT,
|
||||
TEMPLATE_PROMPT,
|
||||
REVIEWER_PROMPT,
|
||||
} from '../prompts/index.js';
|
||||
|
||||
/**
|
||||
* 백엔드 개발자 에이전트
|
||||
* Service-Repository 패턴, FormRequest, 훅 시스템 담당
|
||||
*/
|
||||
export const backendAgent: AgentDefinition = {
|
||||
description: '백엔드 개발: Service-Repository 패턴, FormRequest, Controller, 훅 시스템, Migration',
|
||||
prompt: BACKEND_PROMPT,
|
||||
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
|
||||
model: 'sonnet',
|
||||
};
|
||||
|
||||
/**
|
||||
* 프론트엔드 개발자 에이전트
|
||||
* TSX 컴포넌트, 상태 관리, G7Core API 담당
|
||||
*/
|
||||
export const frontendAgent: AgentDefinition = {
|
||||
description: '프론트엔드 개발: TSX 컴포넌트, 상태 관리, G7Core API, 다크 모드',
|
||||
prompt: FRONTEND_PROMPT,
|
||||
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
|
||||
model: 'sonnet',
|
||||
};
|
||||
|
||||
/**
|
||||
* 레이아웃 개발자 에이전트
|
||||
* 레이아웃 JSON, 데이터 바인딩, 반응형 담당
|
||||
*/
|
||||
export const layoutAgent: AgentDefinition = {
|
||||
description: '레이아웃 개발: 레이아웃 JSON 스키마, 데이터 바인딩, 반응형, 상속/슬롯',
|
||||
prompt: LAYOUT_PROMPT,
|
||||
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep'],
|
||||
model: 'sonnet',
|
||||
};
|
||||
|
||||
/**
|
||||
* 템플릿 개발자 에이전트
|
||||
* 템플릿 구조, 빌드, 컴포넌트 등록 담당
|
||||
*/
|
||||
export const templateAgent: AgentDefinition = {
|
||||
description: '템플릿 개발: 템플릿 구조, Vite 빌드, 컴포넌트 등록, 다국어 파일',
|
||||
prompt: TEMPLATE_PROMPT,
|
||||
tools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash'],
|
||||
model: 'sonnet',
|
||||
};
|
||||
|
||||
/**
|
||||
* 검수자 에이전트
|
||||
* 규정 준수, 테스트, 문서화 검증 담당
|
||||
*/
|
||||
export const reviewerAgent: AgentDefinition = {
|
||||
description: '코드 검수: 규정 준수 확인, 테스트 통과, 문서화 검증',
|
||||
prompt: REVIEWER_PROMPT,
|
||||
tools: ['Read', 'Glob', 'Grep', 'Bash'],
|
||||
model: 'sonnet',
|
||||
};
|
||||
|
||||
/**
|
||||
* 모든 에이전트 정의
|
||||
*/
|
||||
export const agentDefinitions: Record<string, AgentDefinition> = {
|
||||
backend: backendAgent,
|
||||
frontend: frontendAgent,
|
||||
layout: layoutAgent,
|
||||
template: templateAgent,
|
||||
reviewer: reviewerAgent,
|
||||
};
|
||||
|
||||
export default agentDefinitions;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 에이전트 모듈 export
|
||||
*/
|
||||
export {
|
||||
agentDefinitions,
|
||||
backendAgent,
|
||||
frontendAgent,
|
||||
layoutAgent,
|
||||
templateAgent,
|
||||
reviewerAgent,
|
||||
} from './definitions.js';
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* CLI 인터페이스
|
||||
* 인터랙티브 모드 및 단일 명령어 모드 지원
|
||||
*/
|
||||
import * as readline from 'readline';
|
||||
import { Coordinator } from './coordinator/Coordinator.js';
|
||||
import {
|
||||
featureDevelopmentWorkflow,
|
||||
bugfixWorkflow,
|
||||
prReviewWorkflow,
|
||||
} from './workflows/index.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
import type { CliOptions, WorkflowResult, WorkflowType } from './types/index.js';
|
||||
|
||||
const PROJECT_ROOT = process.env.G7_PROJECT_ROOT || '.';
|
||||
|
||||
/**
|
||||
* CLI 실행
|
||||
*/
|
||||
export async function runCli(options: CliOptions): Promise<void> {
|
||||
if (options.mode === 'interactive') {
|
||||
await runInteractiveMode();
|
||||
} else {
|
||||
if (!options.prompt) {
|
||||
console.error('Error: prompt is required for single mode');
|
||||
process.exit(1);
|
||||
}
|
||||
await runSingleMode(options.prompt, options.workflow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인터랙티브 모드
|
||||
*/
|
||||
async function runInteractiveMode(): Promise<void> {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ 그누보드7 Multi-Agent System v1.0.0 ║
|
||||
║ ║
|
||||
║ 사용 가능한 명령어: ║
|
||||
║ /feature <설명> - 기능 개발 워크플로우 ║
|
||||
║ /bugfix <설명> - 버그 수정 워크플로우 ║
|
||||
║ /review <PR#> - PR 검수 워크플로우 ║
|
||||
║ /help - 도움말 ║
|
||||
║ /exit - 종료 ║
|
||||
║ ║
|
||||
║ 또는 자유롭게 요청을 입력하세요. ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const coordinator = new Coordinator({ cwd: PROJECT_ROOT });
|
||||
|
||||
const promptUser = () => {
|
||||
rl.question('\n🤖 > ', async (input) => {
|
||||
const trimmedInput = input.trim();
|
||||
|
||||
if (!trimmedInput) {
|
||||
promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
// 종료 명령
|
||||
if (trimmedInput === '/exit' || trimmedInput === 'exit') {
|
||||
console.log('\n👋 안녕히 가세요!');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 도움말
|
||||
if (trimmedInput === '/help') {
|
||||
showHelp();
|
||||
promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result: WorkflowResult;
|
||||
|
||||
// 워크플로우 명령어
|
||||
if (trimmedInput.startsWith('/feature ')) {
|
||||
const description = trimmedInput.substring(9);
|
||||
console.log('\n🚀 기능 개발 워크플로우를 시작합니다...\n');
|
||||
result = await featureDevelopmentWorkflow(description, { cwd: PROJECT_ROOT });
|
||||
|
||||
} else if (trimmedInput.startsWith('/bugfix ')) {
|
||||
const description = trimmedInput.substring(8);
|
||||
console.log('\n🔧 버그 수정 워크플로우를 시작합니다...\n');
|
||||
result = await bugfixWorkflow(description, { cwd: PROJECT_ROOT });
|
||||
|
||||
} else if (trimmedInput.startsWith('/review ')) {
|
||||
const prId = trimmedInput.substring(8);
|
||||
console.log('\n🔍 PR 검수 워크플로우를 시작합니다...\n');
|
||||
result = await prReviewWorkflow(prId, { cwd: PROJECT_ROOT });
|
||||
|
||||
} else {
|
||||
// 자유 형식 요청
|
||||
console.log('\n🤔 요청을 처리하고 있습니다...\n');
|
||||
result = await coordinator.orchestrate(trimmedInput);
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
printResult(result);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`\n❌ 오류: ${error.message}`);
|
||||
}
|
||||
|
||||
promptUser();
|
||||
});
|
||||
};
|
||||
|
||||
promptUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 명령어 모드
|
||||
*/
|
||||
async function runSingleMode(
|
||||
prompt: string,
|
||||
workflow?: WorkflowType
|
||||
): Promise<void> {
|
||||
console.log(`\n🤖 그누보드7 Multi-Agent System\n`);
|
||||
|
||||
let result: WorkflowResult;
|
||||
|
||||
try {
|
||||
if (workflow === 'feature') {
|
||||
console.log('🚀 기능 개발 워크플로우 실행\n');
|
||||
result = await featureDevelopmentWorkflow(prompt, { cwd: PROJECT_ROOT });
|
||||
|
||||
} else if (workflow === 'bugfix') {
|
||||
console.log('🔧 버그 수정 워크플로우 실행\n');
|
||||
result = await bugfixWorkflow(prompt, { cwd: PROJECT_ROOT });
|
||||
|
||||
} else if (workflow === 'pr-review') {
|
||||
console.log('🔍 PR 검수 워크플로우 실행\n');
|
||||
result = await prReviewWorkflow(prompt, { cwd: PROJECT_ROOT });
|
||||
|
||||
} else {
|
||||
console.log('🤔 요청 처리 중...\n');
|
||||
const coordinator = new Coordinator({ cwd: PROJECT_ROOT });
|
||||
result = await coordinator.orchestrate(prompt);
|
||||
}
|
||||
|
||||
printResult(result);
|
||||
|
||||
// 종료 코드 설정
|
||||
process.exit(result.success ? 0 : 1);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`\n❌ 오류: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 출력
|
||||
*/
|
||||
function printResult(result: WorkflowResult): void {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ 작업 완료');
|
||||
} else {
|
||||
console.log('❌ 작업 실패');
|
||||
}
|
||||
|
||||
if (result.output) {
|
||||
console.log('\n📝 결과:');
|
||||
console.log(result.output);
|
||||
}
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
console.log('\n⚠️ 오류:');
|
||||
result.errors.forEach(e => console.log(` - ${e}`));
|
||||
}
|
||||
|
||||
if (result.steps.length > 0) {
|
||||
console.log('\n📊 실행 단계:');
|
||||
result.steps.forEach(step => {
|
||||
const icon = step.status === 'completed' ? '✓' : step.status === 'failed' ? '✗' : '○';
|
||||
console.log(` ${icon} [${step.agent}] ${step.task}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n💰 비용: $${result.totalCost.toFixed(4)}`);
|
||||
console.log('='.repeat(60));
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 출력
|
||||
*/
|
||||
function showHelp(): void {
|
||||
console.log(`
|
||||
📖 그누보드7 Multi-Agent System 도움말
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
🔧 워크플로우 명령어:
|
||||
|
||||
/feature <설명>
|
||||
새로운 기능을 개발합니다.
|
||||
예: /feature 상품 할인 기능 추가
|
||||
|
||||
/bugfix <설명>
|
||||
버그를 수정합니다. TDD 방식으로 진행됩니다.
|
||||
예: /bugfix Form 저장 시 데이터가 사라지는 문제
|
||||
|
||||
/review <PR번호 또는 설명>
|
||||
PR을 검수합니다.
|
||||
예: /review #123
|
||||
예: /review 현재 브랜치 변경사항
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
🤖 에이전트:
|
||||
|
||||
• backend - 백엔드 개발 (Service, Repository, Controller)
|
||||
• frontend - 프론트엔드 개발 (TSX 컴포넌트)
|
||||
• layout - 레이아웃 개발 (JSON 스키마)
|
||||
• template - 템플릿 개발 (빌드, 등록)
|
||||
• reviewer - 코드 검수 (규정, 테스트)
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
📚 자유 형식 요청:
|
||||
|
||||
워크플로우 명령어 외에도 자유롭게 요청할 수 있습니다.
|
||||
코디네이터가 적절한 에이전트에게 작업을 분배합니다.
|
||||
|
||||
예:
|
||||
- "ProductService의 할인 로직을 설명해줘"
|
||||
- "사용자 목록 페이지의 레이아웃 JSON을 검토해줘"
|
||||
- "DataGrid 컴포넌트에 정렬 기능을 추가해줘"
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
⌨️ 기타 명령어:
|
||||
|
||||
/help - 이 도움말 표시
|
||||
/exit - 종료
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
`);
|
||||
}
|
||||
|
||||
export default runCli;
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 코디네이터
|
||||
* 작업 분배 및 에이전트 오케스트레이션 담당
|
||||
*/
|
||||
import { query, type Options, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { COORDINATOR_PROMPT } from '../prompts/index.js';
|
||||
import { agentDefinitions } from '../agents/index.js';
|
||||
import { createG7McpServer } from '../mcp/g7-tools-server.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { CoordinatorConfig, WorkflowResult, WorkflowStep } from '../types/index.js';
|
||||
|
||||
export class Coordinator {
|
||||
private cwd: string;
|
||||
private model: 'opus' | 'sonnet';
|
||||
private mcpServer: ReturnType<typeof createG7McpServer>;
|
||||
|
||||
constructor(config: CoordinatorConfig = {}) {
|
||||
this.cwd = config.cwd || process.env.G7_PROJECT_ROOT || '.';
|
||||
this.model = config.model || 'sonnet';
|
||||
this.mcpServer = createG7McpServer(this.cwd);
|
||||
|
||||
logger.info(`Coordinator initialized with cwd: ${this.cwd}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 요청을 분석하고 적절한 에이전트에게 작업 분배
|
||||
*/
|
||||
async orchestrate(userRequest: string): Promise<WorkflowResult> {
|
||||
logger.info('Starting orchestration');
|
||||
logger.debug(`User request: ${userRequest}`);
|
||||
|
||||
const result: WorkflowResult = {
|
||||
success: false,
|
||||
steps: [],
|
||||
totalCost: 0,
|
||||
};
|
||||
|
||||
const options: Options = {
|
||||
cwd: this.cwd,
|
||||
model: this.model,
|
||||
systemPrompt: COORDINATOR_PROMPT,
|
||||
tools: { type: 'preset', preset: 'claude_code' },
|
||||
allowedTools: [
|
||||
'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'Task',
|
||||
// MCP 도구
|
||||
'validate-code', 'validate-frontend', 'run-tests',
|
||||
'run-test-file', 'search-docs', 'read-doc', 'list-docs',
|
||||
],
|
||||
agents: agentDefinitions,
|
||||
mcpServers: {
|
||||
'g7-tools': this.mcpServer,
|
||||
},
|
||||
settingSources: ['project'], // AGENTS.md 로드
|
||||
permissionMode: 'acceptEdits',
|
||||
maxTurns: 50,
|
||||
hooks: this.getHooks(result),
|
||||
};
|
||||
|
||||
try {
|
||||
const queryResult = query({
|
||||
prompt: userRequest,
|
||||
options,
|
||||
});
|
||||
|
||||
for await (const message of queryResult) {
|
||||
this.processMessage(message, result);
|
||||
}
|
||||
|
||||
// 최종 결과 처리
|
||||
const finalResult = result.steps.find(s => s.status === 'completed' && s.agent === 'coordinator');
|
||||
if (finalResult?.result) {
|
||||
result.output = finalResult.result.output;
|
||||
}
|
||||
|
||||
result.success = result.steps.every(s => s.status === 'completed');
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Orchestration failed: ${error.message}`);
|
||||
result.success = false;
|
||||
result.errors = [error.message];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 에이전트에게 직접 작업 요청
|
||||
*/
|
||||
async delegateToAgent(
|
||||
agentType: keyof typeof agentDefinitions,
|
||||
task: string
|
||||
): Promise<WorkflowResult> {
|
||||
logger.info(`Delegating to ${agentType}: ${task}`);
|
||||
|
||||
const agent = agentDefinitions[agentType];
|
||||
if (!agent) {
|
||||
return {
|
||||
success: false,
|
||||
steps: [],
|
||||
totalCost: 0,
|
||||
errors: [`Unknown agent: ${agentType}`],
|
||||
};
|
||||
}
|
||||
|
||||
const result: WorkflowResult = {
|
||||
success: false,
|
||||
steps: [],
|
||||
totalCost: 0,
|
||||
};
|
||||
|
||||
const options: Options = {
|
||||
cwd: this.cwd,
|
||||
model: agent.model || 'sonnet',
|
||||
systemPrompt: agent.prompt,
|
||||
tools: { type: 'preset', preset: 'claude_code' },
|
||||
allowedTools: [
|
||||
...(agent.tools || []),
|
||||
'validate-code', 'validate-frontend', 'run-tests',
|
||||
'search-docs', 'read-doc',
|
||||
],
|
||||
mcpServers: {
|
||||
'g7-tools': this.mcpServer,
|
||||
},
|
||||
settingSources: ['project'],
|
||||
permissionMode: 'acceptEdits',
|
||||
maxTurns: 30,
|
||||
hooks: this.getHooks(result),
|
||||
};
|
||||
|
||||
try {
|
||||
const queryResult = query({
|
||||
prompt: task,
|
||||
options,
|
||||
});
|
||||
|
||||
for await (const message of queryResult) {
|
||||
this.processMessage(message, result);
|
||||
}
|
||||
|
||||
result.success = !result.errors?.length;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Agent ${agentType} failed: ${error.message}`);
|
||||
result.success = false;
|
||||
result.errors = [error.message];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 설정
|
||||
*/
|
||||
private getHooks(result: WorkflowResult): Options['hooks'] {
|
||||
return {
|
||||
SubagentStart: [{
|
||||
hooks: [async (input) => {
|
||||
// SubagentStartHookInput 타입 처리
|
||||
if (input.hook_event_name === 'SubagentStart') {
|
||||
const step: WorkflowStep = {
|
||||
agent: input.agent_type as any,
|
||||
task: 'Subagent task',
|
||||
status: 'running',
|
||||
};
|
||||
result.steps.push(step);
|
||||
|
||||
logger.info(`Subagent started: ${input.agent_type}`);
|
||||
}
|
||||
return { continue: true };
|
||||
}],
|
||||
}],
|
||||
|
||||
SubagentStop: [{
|
||||
hooks: [async () => {
|
||||
const runningStep = result.steps.find(s => s.status === 'running');
|
||||
if (runningStep) {
|
||||
runningStep.status = 'completed';
|
||||
}
|
||||
|
||||
logger.info('Subagent completed');
|
||||
return { continue: true };
|
||||
}],
|
||||
}],
|
||||
|
||||
PreToolUse: [{
|
||||
hooks: [async (input) => {
|
||||
// PreToolUseHookInput 타입 처리
|
||||
if (input.hook_event_name === 'PreToolUse') {
|
||||
logger.debug(`Tool use: ${input.tool_name}`);
|
||||
}
|
||||
return { continue: true };
|
||||
}],
|
||||
}],
|
||||
|
||||
PostToolUse: [{
|
||||
hooks: [async (input) => {
|
||||
// PostToolUseHookInput 타입 처리
|
||||
if (input.hook_event_name === 'PostToolUse') {
|
||||
// 테스트 실패 시 로깅
|
||||
if (input.tool_name === 'run-tests') {
|
||||
const output = (input.tool_response as any)?.content?.[0]?.text || '';
|
||||
if (output.includes('failed')) {
|
||||
logger.warn('Some tests failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메시지 처리
|
||||
*/
|
||||
private processMessage(message: SDKMessage, result: WorkflowResult): void {
|
||||
if (message.type === 'result') {
|
||||
if (message.subtype === 'success') {
|
||||
result.totalCost = message.total_cost_usd || 0;
|
||||
result.output = message.result;
|
||||
|
||||
// 코디네이터 결과 기록
|
||||
result.steps.push({
|
||||
agent: 'coordinator' as any,
|
||||
task: 'Orchestration',
|
||||
status: 'completed',
|
||||
result: {
|
||||
success: true,
|
||||
agent: 'coordinator',
|
||||
task: 'Orchestration',
|
||||
output: message.result,
|
||||
cost: message.total_cost_usd,
|
||||
},
|
||||
});
|
||||
|
||||
} else if (
|
||||
message.subtype === 'error_during_execution' ||
|
||||
message.subtype === 'error_max_turns' ||
|
||||
message.subtype === 'error_max_budget_usd' ||
|
||||
message.subtype === 'error_max_structured_output_retries'
|
||||
) {
|
||||
result.errors = message.errors || ['Unknown error'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 그누보드7 Multi-Agent System
|
||||
* AI Agent SDK 기반 멀티에이전트 협업 시스템
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { runCli } from './cli.js';
|
||||
import type { CliOptions, WorkflowType } from './types/index.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// 인수 파싱
|
||||
const options = parseArgs(args);
|
||||
|
||||
// CLI 실행
|
||||
await runCli(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 명령줄 인수 파싱
|
||||
*/
|
||||
function parseArgs(args: string[]): CliOptions {
|
||||
// 기본값
|
||||
const options: CliOptions = {
|
||||
mode: 'interactive',
|
||||
};
|
||||
|
||||
if (args.length === 0) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const mode = args[0];
|
||||
|
||||
// 모드 확인
|
||||
if (mode === 'interactive') {
|
||||
options.mode = 'interactive';
|
||||
return options;
|
||||
}
|
||||
|
||||
if (mode === 'single') {
|
||||
options.mode = 'single';
|
||||
|
||||
// 워크플로우 옵션 확인
|
||||
const workflowIndex = args.indexOf('--workflow');
|
||||
if (workflowIndex !== -1 && args[workflowIndex + 1]) {
|
||||
options.workflow = args[workflowIndex + 1] as WorkflowType;
|
||||
// 워크플로우 옵션 제거
|
||||
args.splice(workflowIndex, 2);
|
||||
}
|
||||
|
||||
// 나머지를 프롬프트로 사용
|
||||
options.prompt = args.slice(1).join(' ');
|
||||
return options;
|
||||
}
|
||||
|
||||
// 단축 워크플로우 명령어
|
||||
if (mode === 'feature' || mode === 'bugfix' || mode === 'pr-review') {
|
||||
options.mode = 'single';
|
||||
options.workflow = mode as WorkflowType;
|
||||
options.prompt = args.slice(1).join(' ');
|
||||
return options;
|
||||
}
|
||||
|
||||
// 기본: 단일 모드로 전체 인수를 프롬프트로 사용
|
||||
options.mode = 'single';
|
||||
options.prompt = args.join(' ');
|
||||
return options;
|
||||
}
|
||||
|
||||
// 에러 핸들링
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled Rejection:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 실행
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,700 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 인터랙티브 CLI - 단계별 승인 기능
|
||||
* 각 에이전트 실행 전 사용자 확인을 받고 진행
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import * as readline from 'readline';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { agentDefinitions } from './agents/definitions.js';
|
||||
import type { WorkflowResult, WorkflowStep, AgentType, AgentResult } from './types/index.js';
|
||||
|
||||
const PROJECT_ROOT = process.env.G7_PROJECT_ROOT || '.';
|
||||
|
||||
// 색상 코드
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
bgBlue: '\x1b[44m',
|
||||
bgGreen: '\x1b[42m',
|
||||
bgYellow: '\x1b[43m',
|
||||
bgRed: '\x1b[41m',
|
||||
};
|
||||
|
||||
// 에이전트별 색상
|
||||
const agentColors: Record<string, string> = {
|
||||
backend: colors.blue,
|
||||
frontend: colors.magenta,
|
||||
layout: colors.cyan,
|
||||
template: colors.yellow,
|
||||
reviewer: colors.green,
|
||||
coordinator: colors.white,
|
||||
};
|
||||
|
||||
/**
|
||||
* 인터랙티브 세션 클래스
|
||||
*/
|
||||
class InteractiveSession {
|
||||
private rl: readline.Interface;
|
||||
private client: Anthropic;
|
||||
private result: WorkflowResult;
|
||||
private currentStep: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
this.client = new Anthropic({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
});
|
||||
|
||||
this.result = {
|
||||
success: false,
|
||||
steps: [],
|
||||
totalCost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 시작
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this.printBanner();
|
||||
this.promptUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 출력
|
||||
*/
|
||||
private printBanner(): void {
|
||||
console.log(`
|
||||
${colors.bgBlue}${colors.bright} ${colors.reset}
|
||||
${colors.bgBlue}${colors.bright} 그누보드7 Multi-Agent Interactive System v1.0.0 ${colors.reset}
|
||||
${colors.bgBlue}${colors.bright} ${colors.reset}
|
||||
|
||||
${colors.cyan}사용 가능한 명령어:${colors.reset}
|
||||
${colors.yellow}/feature${colors.reset} <설명> - 기능 개발 (단계별 승인)
|
||||
${colors.yellow}/bugfix${colors.reset} <설명> - 버그 수정 (단계별 승인)
|
||||
${colors.yellow}/quick${colors.reset} <요청> - 빠른 실행 (승인 없이)
|
||||
${colors.yellow}/agents${colors.reset} - 에이전트 목록
|
||||
${colors.yellow}/help${colors.reset} - 도움말
|
||||
${colors.yellow}/exit${colors.reset} - 종료
|
||||
|
||||
${colors.dim}또는 자유롭게 요청을 입력하세요. (단계별 승인 모드)${colors.reset}
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 입력 대기
|
||||
*/
|
||||
private promptUser(): void {
|
||||
this.rl.question(`\n${colors.green}🤖 >${colors.reset} `, async (input) => {
|
||||
const trimmedInput = input.trim();
|
||||
|
||||
if (!trimmedInput) {
|
||||
this.promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleInput(trimmedInput);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 처리
|
||||
*/
|
||||
private async handleInput(input: string): Promise<void> {
|
||||
// 종료
|
||||
if (input === '/exit' || input === 'exit') {
|
||||
console.log(`\n${colors.cyan}👋 안녕히 가세요!${colors.reset}`);
|
||||
this.rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 도움말
|
||||
if (input === '/help') {
|
||||
this.showHelp();
|
||||
this.promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
// 에이전트 목록
|
||||
if (input === '/agents') {
|
||||
this.showAgents();
|
||||
this.promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
// 기능 개발
|
||||
if (input.startsWith('/feature ')) {
|
||||
const description = input.substring(9);
|
||||
await this.runFeatureWorkflow(description);
|
||||
this.promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
// 버그 수정
|
||||
if (input.startsWith('/bugfix ')) {
|
||||
const description = input.substring(8);
|
||||
await this.runBugfixWorkflow(description);
|
||||
this.promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
// 빠른 실행 (승인 없이)
|
||||
if (input.startsWith('/quick ')) {
|
||||
const prompt = input.substring(7);
|
||||
await this.runQuickMode(prompt);
|
||||
this.promptUser();
|
||||
return;
|
||||
}
|
||||
|
||||
// 자유 형식 (단계별 승인)
|
||||
await this.runWithApproval(input);
|
||||
this.promptUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기능 개발 워크플로우 (단계별 승인)
|
||||
*/
|
||||
private async runFeatureWorkflow(description: string): Promise<void> {
|
||||
console.log(`\n${colors.bgGreen}${colors.bright} 기능 개발 워크플로우 ${colors.reset}`);
|
||||
console.log(`${colors.dim}설명: ${description}${colors.reset}\n`);
|
||||
|
||||
this.result = { success: false, steps: [], totalCost: 0 };
|
||||
|
||||
// 워크플로우 단계 정의
|
||||
const workflow: Array<{ agent: AgentType; task: string }> = [
|
||||
{ agent: 'backend', task: `백엔드 구현: ${description}` },
|
||||
{ agent: 'layout', task: `레이아웃 JSON 작성: ${description}` },
|
||||
{ agent: 'frontend', task: `프론트엔드 컴포넌트 개발 (필요시): ${description}` },
|
||||
{ agent: 'template', task: `템플릿 빌드 및 컴포넌트 등록: ${description}` },
|
||||
{ agent: 'reviewer', task: `코드 검수: ${description}` },
|
||||
];
|
||||
|
||||
// 워크플로우 개요 표시
|
||||
this.printWorkflowPlan(workflow);
|
||||
|
||||
// 각 단계 실행
|
||||
for (let i = 0; i < workflow.length; i++) {
|
||||
const step = workflow[i];
|
||||
const approved = await this.requestApproval(i + 1, workflow.length, step.agent, step.task);
|
||||
|
||||
if (!approved) {
|
||||
console.log(`\n${colors.yellow}⏭️ ${step.agent} 단계를 건너뜁니다.${colors.reset}`);
|
||||
this.result.steps.push({
|
||||
agent: step.agent,
|
||||
task: step.task,
|
||||
status: 'skipped',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.executeAgent(step.agent, step.task);
|
||||
}
|
||||
|
||||
this.printFinalResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 버그 수정 워크플로우 (단계별 승인)
|
||||
*/
|
||||
private async runBugfixWorkflow(description: string): Promise<void> {
|
||||
console.log(`\n${colors.bgYellow}${colors.bright} 버그 수정 워크플로우 ${colors.reset}`);
|
||||
console.log(`${colors.dim}설명: ${description}${colors.reset}\n`);
|
||||
|
||||
this.result = { success: false, steps: [], totalCost: 0 };
|
||||
|
||||
// 1단계: 분석
|
||||
console.log(`${colors.cyan}📋 버그 영향 영역을 분석합니다...${colors.reset}\n`);
|
||||
|
||||
const analysisPrompt = `
|
||||
다음 버그 설명을 분석하고, 영향 받는 영역을 판단해주세요:
|
||||
|
||||
버그 설명: ${description}
|
||||
|
||||
그누보드7 프로젝트의 영역:
|
||||
- backend: Service, Repository, Controller, FormRequest 등 PHP 코드
|
||||
- frontend: TSX 컴포넌트, React 코드
|
||||
- layout: 레이아웃 JSON 파일
|
||||
- template: 템플릿 설정, 빌드
|
||||
|
||||
어떤 영역이 영향 받는지 알려주세요. 답변 형식:
|
||||
영향 영역: [backend/frontend/layout/template] (쉼표로 구분)
|
||||
분석 근거: [간단한 설명]
|
||||
`;
|
||||
|
||||
const analysis = await this.callClaude(analysisPrompt);
|
||||
console.log(`${colors.dim}${analysis}${colors.reset}\n`);
|
||||
|
||||
// 영역 추출 (간단한 파싱)
|
||||
const affectedAreas: AgentType[] = [];
|
||||
const lowerAnalysis = analysis.toLowerCase();
|
||||
if (lowerAnalysis.includes('backend')) affectedAreas.push('backend');
|
||||
if (lowerAnalysis.includes('frontend')) affectedAreas.push('frontend');
|
||||
if (lowerAnalysis.includes('layout')) affectedAreas.push('layout');
|
||||
if (lowerAnalysis.includes('template')) affectedAreas.push('template');
|
||||
|
||||
if (affectedAreas.length === 0) {
|
||||
affectedAreas.push('backend'); // 기본값
|
||||
}
|
||||
|
||||
// 워크플로우 구성
|
||||
const workflow: Array<{ agent: AgentType; task: string }> = [];
|
||||
|
||||
// 트러블슈팅 가이드 참조 단계
|
||||
workflow.push({
|
||||
agent: 'reviewer',
|
||||
task: `트러블슈팅 가이드에서 유사 사례 확인: ${description}`,
|
||||
});
|
||||
|
||||
// 영향 받는 영역별 수정
|
||||
for (const area of affectedAreas) {
|
||||
workflow.push({
|
||||
agent: area,
|
||||
task: `버그 수정 (TDD 방식): ${description}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 최종 검수
|
||||
workflow.push({
|
||||
agent: 'reviewer',
|
||||
task: `테스트 통과 확인 및 최종 검수: ${description}`,
|
||||
});
|
||||
|
||||
// 워크플로우 개요 표시
|
||||
this.printWorkflowPlan(workflow);
|
||||
|
||||
// 각 단계 실행
|
||||
for (let i = 0; i < workflow.length; i++) {
|
||||
const step = workflow[i];
|
||||
const approved = await this.requestApproval(i + 1, workflow.length, step.agent, step.task);
|
||||
|
||||
if (!approved) {
|
||||
console.log(`\n${colors.yellow}⏭️ ${step.agent} 단계를 건너뜁니다.${colors.reset}`);
|
||||
this.result.steps.push({
|
||||
agent: step.agent,
|
||||
task: step.task,
|
||||
status: 'skipped',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.executeAgent(step.agent, step.task);
|
||||
}
|
||||
|
||||
this.printFinalResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 단계별 승인 모드로 자유 형식 요청 실행
|
||||
*/
|
||||
private async runWithApproval(prompt: string): Promise<void> {
|
||||
console.log(`\n${colors.bgBlue}${colors.bright} 요청 처리 ${colors.reset}`);
|
||||
console.log(`${colors.dim}${prompt}${colors.reset}\n`);
|
||||
|
||||
this.result = { success: false, steps: [], totalCost: 0 };
|
||||
|
||||
// 요청 분석
|
||||
console.log(`${colors.cyan}📋 요청을 분석하고 워크플로우를 계획합니다...${colors.reset}\n`);
|
||||
|
||||
const planPrompt = `
|
||||
다음 요청을 분석하고 실행 계획을 세워주세요:
|
||||
|
||||
요청: ${prompt}
|
||||
|
||||
그누보드7 프로젝트의 에이전트:
|
||||
- backend: 백엔드 개발 (Service, Repository, Controller, FormRequest, Migration)
|
||||
- frontend: 프론트엔드 개발 (TSX 컴포넌트, 상태 관리)
|
||||
- layout: 레이아웃 개발 (JSON 스키마, 데이터 바인딩)
|
||||
- template: 템플릿 개발 (빌드, 컴포넌트 등록)
|
||||
- reviewer: 코드 검수 (규정 준수, 테스트)
|
||||
|
||||
어떤 에이전트가 어떤 순서로 작업해야 하는지 계획해주세요.
|
||||
|
||||
답변 형식 (JSON):
|
||||
{
|
||||
"steps": [
|
||||
{ "agent": "backend", "task": "구체적인 작업 내용" },
|
||||
{ "agent": "layout", "task": "구체적인 작업 내용" }
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
const planResponse = await this.callClaude(planPrompt);
|
||||
|
||||
// JSON 파싱 시도
|
||||
let workflow: Array<{ agent: AgentType; task: string }> = [];
|
||||
try {
|
||||
const jsonMatch = planResponse.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const plan = JSON.parse(jsonMatch[0]);
|
||||
workflow = plan.steps || [];
|
||||
}
|
||||
} catch {
|
||||
// 파싱 실패 시 기본 워크플로우
|
||||
console.log(`${colors.dim}${planResponse}${colors.reset}\n`);
|
||||
workflow = [{ agent: 'backend', task: prompt }];
|
||||
}
|
||||
|
||||
if (workflow.length === 0) {
|
||||
workflow = [{ agent: 'backend', task: prompt }];
|
||||
}
|
||||
|
||||
// 워크플로우 개요 표시
|
||||
this.printWorkflowPlan(workflow);
|
||||
|
||||
// 각 단계 실행
|
||||
for (let i = 0; i < workflow.length; i++) {
|
||||
const step = workflow[i];
|
||||
const validAgent = this.validateAgent(step.agent);
|
||||
const approved = await this.requestApproval(i + 1, workflow.length, validAgent, step.task);
|
||||
|
||||
if (!approved) {
|
||||
console.log(`\n${colors.yellow}⏭️ ${validAgent} 단계를 건너뜁니다.${colors.reset}`);
|
||||
this.result.steps.push({
|
||||
agent: validAgent,
|
||||
task: step.task,
|
||||
status: 'skipped',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.executeAgent(validAgent, step.task);
|
||||
}
|
||||
|
||||
this.printFinalResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 모드 (승인 없이)
|
||||
*/
|
||||
private async runQuickMode(prompt: string): Promise<void> {
|
||||
console.log(`\n${colors.bgBlue}${colors.bright} 빠른 실행 ${colors.reset}`);
|
||||
console.log(`${colors.dim}승인 없이 자동으로 실행됩니다.${colors.reset}\n`);
|
||||
|
||||
this.result = { success: false, steps: [], totalCost: 0 };
|
||||
|
||||
// 단일 에이전트로 빠른 실행
|
||||
await this.executeAgent('backend', prompt);
|
||||
this.printFinalResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 워크플로우 계획 출력
|
||||
*/
|
||||
private printWorkflowPlan(workflow: Array<{ agent: AgentType; task: string }>): void {
|
||||
console.log(`${colors.bright}📊 실행 계획:${colors.reset}\n`);
|
||||
|
||||
workflow.forEach((step, index) => {
|
||||
const agentColor = agentColors[step.agent] || colors.white;
|
||||
console.log(` ${colors.dim}${index + 1}.${colors.reset} ${agentColor}[${step.agent}]${colors.reset} ${step.task}`);
|
||||
});
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 요청
|
||||
*/
|
||||
private async requestApproval(
|
||||
current: number,
|
||||
total: number,
|
||||
agent: AgentType,
|
||||
task: string
|
||||
): Promise<boolean> {
|
||||
const agentColor = agentColors[agent] || colors.white;
|
||||
|
||||
console.log(`\n${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
|
||||
console.log(`${colors.bright}단계 ${current}/${total}${colors.reset}`);
|
||||
console.log(`${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
|
||||
console.log(`\n${agentColor}🤖 에이전트: ${agent}${colors.reset}`);
|
||||
console.log(`${colors.dim}📋 작업: ${task}${colors.reset}\n`);
|
||||
|
||||
// 에이전트 설명 표시
|
||||
const agentDef = agentDefinitions[agent];
|
||||
if (agentDef) {
|
||||
console.log(`${colors.dim}ℹ️ ${agentDef.description}${colors.reset}\n`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.rl.question(
|
||||
`${colors.yellow}실행하시겠습니까? ${colors.reset}[${colors.green}Y${colors.reset}es/${colors.red}N${colors.reset}o/${colors.cyan}S${colors.reset}kip all] `,
|
||||
(answer) => {
|
||||
const lowerAnswer = answer.toLowerCase().trim();
|
||||
|
||||
if (lowerAnswer === 'n' || lowerAnswer === 'no') {
|
||||
resolve(false);
|
||||
} else if (lowerAnswer === 's' || lowerAnswer === 'skip') {
|
||||
// Skip all은 모든 후속 단계를 건너뜀
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 에이전트 실행
|
||||
*/
|
||||
private async executeAgent(agent: AgentType, task: string): Promise<void> {
|
||||
const agentColor = agentColors[agent] || colors.white;
|
||||
const agentDef = agentDefinitions[agent];
|
||||
|
||||
console.log(`\n${agentColor}▶ ${agent} 에이전트 실행 중...${colors.reset}\n`);
|
||||
|
||||
const step: WorkflowStep = {
|
||||
agent,
|
||||
task,
|
||||
status: 'running',
|
||||
};
|
||||
this.result.steps.push(step);
|
||||
|
||||
try {
|
||||
// 에이전트 시스템 프롬프트 + 작업
|
||||
const systemPrompt = agentDef?.prompt || '';
|
||||
const fullPrompt = `
|
||||
${systemPrompt}
|
||||
|
||||
## 현재 작업
|
||||
${task}
|
||||
|
||||
## 프로젝트 경로
|
||||
${PROJECT_ROOT}
|
||||
|
||||
## 지시사항
|
||||
1. 위 작업을 수행하세요.
|
||||
2. 필요한 파일을 읽고 수정하세요.
|
||||
3. 작업 완료 후 결과를 요약해주세요.
|
||||
`;
|
||||
|
||||
const response = await this.callClaude(fullPrompt);
|
||||
|
||||
step.status = 'completed';
|
||||
step.result = {
|
||||
success: true,
|
||||
agent: agent,
|
||||
task: task,
|
||||
output: response,
|
||||
};
|
||||
|
||||
// 결과 출력 (요약)
|
||||
console.log(`${colors.dim}─────────────────────────────────────────────${colors.reset}`);
|
||||
const summary = response.length > 500 ? response.substring(0, 500) + '...' : response;
|
||||
console.log(`${colors.dim}${summary}${colors.reset}`);
|
||||
console.log(`${colors.dim}─────────────────────────────────────────────${colors.reset}`);
|
||||
|
||||
console.log(`\n${colors.green}✅ ${agent} 완료${colors.reset}`);
|
||||
|
||||
} catch (error: any) {
|
||||
step.status = 'failed';
|
||||
step.result = {
|
||||
success: false,
|
||||
agent: agent,
|
||||
task: task,
|
||||
output: '',
|
||||
errors: [error.message],
|
||||
};
|
||||
|
||||
console.log(`\n${colors.red}❌ ${agent} 실패: ${error.message}${colors.reset}`);
|
||||
this.result.errors = this.result.errors || [];
|
||||
this.result.errors.push(`[${agent}] ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI API 호출
|
||||
*/
|
||||
private async callClaude(prompt: string): Promise<string> {
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
});
|
||||
|
||||
// 비용 계산 (대략적)
|
||||
const inputTokens = response.usage?.input_tokens || 0;
|
||||
const outputTokens = response.usage?.output_tokens || 0;
|
||||
const cost = (inputTokens * 0.003 + outputTokens * 0.015) / 1000;
|
||||
this.result.totalCost += cost;
|
||||
|
||||
const textContent = response.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined;
|
||||
return textContent?.text || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 에이전트 타입 검증
|
||||
*/
|
||||
private validateAgent(agent: string): AgentType {
|
||||
const validAgents: AgentType[] = ['backend', 'frontend', 'layout', 'template', 'reviewer'];
|
||||
if (validAgents.includes(agent as AgentType)) {
|
||||
return agent as AgentType;
|
||||
}
|
||||
return 'backend'; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* 최종 결과 출력
|
||||
*/
|
||||
private printFinalResult(): void {
|
||||
const completedSteps = this.result.steps.filter(s => s.status === 'completed').length;
|
||||
const totalSteps = this.result.steps.length;
|
||||
|
||||
this.result.success = completedSteps > 0 && !this.result.errors?.length;
|
||||
|
||||
console.log(`\n${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
|
||||
console.log(`${colors.bright} 최종 결과 ${colors.reset}`);
|
||||
console.log(`${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}\n`);
|
||||
|
||||
if (this.result.success) {
|
||||
console.log(`${colors.green}✅ 작업 완료${colors.reset}\n`);
|
||||
} else {
|
||||
console.log(`${colors.red}❌ 작업 실패${colors.reset}\n`);
|
||||
}
|
||||
|
||||
// 단계별 결과
|
||||
console.log(`${colors.bright}📊 실행 단계 (${completedSteps}/${totalSteps}):${colors.reset}\n`);
|
||||
|
||||
this.result.steps.forEach((step, index) => {
|
||||
const agentColor = agentColors[step.agent] || colors.white;
|
||||
let statusIcon: string;
|
||||
let statusColor: string;
|
||||
|
||||
switch (step.status) {
|
||||
case 'completed':
|
||||
statusIcon = '✅';
|
||||
statusColor = colors.green;
|
||||
break;
|
||||
case 'failed':
|
||||
statusIcon = '❌';
|
||||
statusColor = colors.red;
|
||||
break;
|
||||
case 'skipped':
|
||||
statusIcon = '⏭️';
|
||||
statusColor = colors.yellow;
|
||||
break;
|
||||
default:
|
||||
statusIcon = '⏳';
|
||||
statusColor = colors.dim;
|
||||
}
|
||||
|
||||
console.log(` ${statusIcon} ${agentColor}[${step.agent}]${colors.reset} ${statusColor}${step.task}${colors.reset}`);
|
||||
});
|
||||
|
||||
// 오류
|
||||
if (this.result.errors?.length) {
|
||||
console.log(`\n${colors.red}⚠️ 오류:${colors.reset}`);
|
||||
this.result.errors.forEach(e => {
|
||||
console.log(` ${colors.red}- ${e}${colors.reset}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 비용
|
||||
console.log(`\n${colors.cyan}💰 총 비용: $${this.result.totalCost.toFixed(4)}${colors.reset}`);
|
||||
console.log(`${colors.bright}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 에이전트 목록 표시
|
||||
*/
|
||||
private showAgents(): void {
|
||||
console.log(`\n${colors.bright}🤖 사용 가능한 에이전트:${colors.reset}\n`);
|
||||
|
||||
Object.entries(agentDefinitions).forEach(([name, def]) => {
|
||||
const agentColor = agentColors[name] || colors.white;
|
||||
console.log(` ${agentColor}${name}${colors.reset}`);
|
||||
console.log(` ${colors.dim}${def.description}${colors.reset}`);
|
||||
console.log(` ${colors.dim}도구: ${(def.tools || []).join(', ')}${colors.reset}`);
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 표시
|
||||
*/
|
||||
private showHelp(): void {
|
||||
console.log(`
|
||||
${colors.bright}📖 그누보드7 Multi-Agent Interactive System 도움말${colors.reset}
|
||||
|
||||
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
|
||||
|
||||
${colors.bright}🔧 워크플로우 명령어 (단계별 승인):${colors.reset}
|
||||
|
||||
${colors.yellow}/feature${colors.reset} <설명>
|
||||
새로운 기능을 개발합니다.
|
||||
각 단계 실행 전 승인을 요청합니다.
|
||||
${colors.dim}예: /feature 상품 할인 기능 추가${colors.reset}
|
||||
|
||||
${colors.yellow}/bugfix${colors.reset} <설명>
|
||||
버그를 수정합니다. 영향 영역을 분석하고 TDD 방식으로 진행합니다.
|
||||
${colors.dim}예: /bugfix Form 저장 시 데이터가 사라지는 문제${colors.reset}
|
||||
|
||||
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
|
||||
|
||||
${colors.bright}⚡ 빠른 실행:${colors.reset}
|
||||
|
||||
${colors.yellow}/quick${colors.reset} <요청>
|
||||
승인 없이 빠르게 실행합니다.
|
||||
${colors.dim}예: /quick package.json 파일 분석해줘${colors.reset}
|
||||
|
||||
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
|
||||
|
||||
${colors.bright}📚 자유 형식 요청:${colors.reset}
|
||||
|
||||
명령어 없이 자유롭게 입력하면 요청을 분석하여
|
||||
워크플로우를 자동으로 구성하고 단계별 승인을 받습니다.
|
||||
|
||||
${colors.dim}예: "ProductService의 할인 로직을 구현해줘"${colors.reset}
|
||||
|
||||
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
|
||||
|
||||
${colors.bright}⌨️ 승인 옵션:${colors.reset}
|
||||
|
||||
${colors.green}Y${colors.reset}/yes - 실행
|
||||
${colors.red}N${colors.reset}/no - 건너뛰기
|
||||
${colors.cyan}S${colors.reset}/skip - 이후 모든 단계 건너뛰기
|
||||
|
||||
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
|
||||
|
||||
${colors.bright}⌨️ 기타 명령어:${colors.reset}
|
||||
|
||||
${colors.yellow}/agents${colors.reset} - 에이전트 목록
|
||||
${colors.yellow}/help${colors.reset} - 이 도움말
|
||||
${colors.yellow}/exit${colors.reset} - 종료
|
||||
|
||||
${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 실행
|
||||
async function main() {
|
||||
// API 키 확인
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
console.error(`${colors.red}❌ ANTHROPIC_API_KEY가 설정되지 않았습니다.${colors.reset}`);
|
||||
console.error(`${colors.dim}.env 파일에 API 키를 설정해주세요.${colors.reset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const session = new InteractiveSession();
|
||||
await session.start();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`${colors.red}❌ 오류: ${error.message}${colors.reset}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* G7 에이전트 MCP 서버
|
||||
* AI 코딩 도구에서 멀티에이전트 시스템을 호출할 수 있는 MCP 서버
|
||||
*/
|
||||
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { z } from 'zod';
|
||||
import { Coordinator } from '../coordinator/Coordinator.js';
|
||||
import { featureDevelopmentWorkflow, simpleFeatureWorkflow } from '../workflows/feature.js';
|
||||
import { bugfixWorkflow, stateRelatedBugfix } from '../workflows/bugfix.js';
|
||||
import { prReviewWorkflow } from '../workflows/pr-review.js';
|
||||
import { createWorkflowLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createWorkflowLogger('mcp-agents');
|
||||
|
||||
/**
|
||||
* G7 에이전트 MCP 서버 생성
|
||||
* AI 코딩 도구 대화창에서 멀티에이전트 협업 시스템을 호출할 수 있음
|
||||
*/
|
||||
export function createG7AgentsMcpServer(projectRoot: string) {
|
||||
return createSdkMcpServer({
|
||||
name: 'g7-agents',
|
||||
version: '1.0.0',
|
||||
tools: [
|
||||
// 자동 분배 (Coordinator)
|
||||
tool(
|
||||
'orchestrate',
|
||||
'사용자 요청을 분석하여 적절한 에이전트(backend, frontend, layout, template, reviewer)에 자동 분배합니다. 복합 작업은 여러 에이전트가 순차적으로 협업합니다.',
|
||||
{
|
||||
prompt: z.string().describe('사용자 요청 내용'),
|
||||
},
|
||||
async ({ prompt }) => {
|
||||
logger.info(`Orchestrating: ${prompt}`);
|
||||
|
||||
try {
|
||||
const coordinator = new Coordinator({ cwd: projectRoot, model: 'sonnet' });
|
||||
const result = await coordinator.orchestrate(prompt);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatWorkflowResult(result),
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`Orchestration failed: ${error.message}`);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `❌ 오케스트레이션 실패: ${error.message}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
// 기능 개발 워크플로우
|
||||
tool(
|
||||
'feature-workflow',
|
||||
'기능 개발 워크플로우를 실행합니다. Backend → Layout → Frontend → Template → Reviewer 순서로 에이전트가 협업합니다.',
|
||||
{
|
||||
description: z.string().describe('개발할 기능 설명'),
|
||||
skipReview: z.boolean().optional().describe('리뷰 단계 생략 여부 (기본: false)'),
|
||||
},
|
||||
async ({ description, skipReview }) => {
|
||||
logger.info(`Feature workflow: ${description}`);
|
||||
|
||||
try {
|
||||
const result = await featureDevelopmentWorkflow(description, {
|
||||
cwd: projectRoot,
|
||||
skipReview: skipReview ?? false,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatWorkflowResult(result),
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`Feature workflow failed: ${error.message}`);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `❌ 기능 개발 워크플로우 실패: ${error.message}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
// 간단한 기능 개발 (단일 영역)
|
||||
tool(
|
||||
'simple-feature',
|
||||
'단일 영역의 간단한 기능을 개발합니다. 복합 워크플로우 없이 해당 에이전트만 호출합니다.',
|
||||
{
|
||||
description: z.string().describe('개발할 기능 설명'),
|
||||
area: z.enum(['backend', 'frontend', 'layout', 'template'])
|
||||
.describe('기능이 속한 영역'),
|
||||
},
|
||||
async ({ description, area }) => {
|
||||
logger.info(`Simple feature (${area}): ${description}`);
|
||||
|
||||
try {
|
||||
const result = await simpleFeatureWorkflow(description, {
|
||||
cwd: projectRoot,
|
||||
area,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatWorkflowResult(result),
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`Simple feature failed: ${error.message}`);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `❌ 간단한 기능 개발 실패: ${error.message}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
// 버그 수정 워크플로우
|
||||
tool(
|
||||
'bugfix-workflow',
|
||||
'버그 수정 워크플로우를 실행합니다. 트러블슈팅 가이드를 참조하고 TDD 방식으로 수정합니다.',
|
||||
{
|
||||
description: z.string().describe('버그 설명 (증상, 재현 방법 등)'),
|
||||
affectedAreas: z.array(z.enum(['backend', 'frontend', 'layout', 'template', 'unknown']))
|
||||
.optional()
|
||||
.describe('영향 받는 영역 (지정하지 않으면 자동 분석)'),
|
||||
},
|
||||
async ({ description, affectedAreas }) => {
|
||||
logger.info(`Bugfix workflow: ${description}`);
|
||||
|
||||
try {
|
||||
const result = await bugfixWorkflow(description, {
|
||||
cwd: projectRoot,
|
||||
affectedAreas: affectedAreas as any,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatWorkflowResult(result),
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`Bugfix workflow failed: ${error.message}`);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `❌ 버그 수정 워크플로우 실패: ${error.message}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
// 상태 관련 버그 수정 (특화)
|
||||
tool(
|
||||
'state-bugfix',
|
||||
'상태 관리 관련 버그를 수정합니다. Stale Closure, setState 병합, 타이밍 이슈 등에 특화되어 있습니다.',
|
||||
{
|
||||
description: z.string().describe('상태 관련 버그 설명'),
|
||||
},
|
||||
async ({ description }) => {
|
||||
logger.info(`State-related bugfix: ${description}`);
|
||||
|
||||
try {
|
||||
const result = await stateRelatedBugfix(description, {
|
||||
cwd: projectRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatWorkflowResult(result),
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`State bugfix failed: ${error.message}`);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `❌ 상태 버그 수정 실패: ${error.message}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
// PR 검수 워크플로우
|
||||
tool(
|
||||
'pr-review',
|
||||
'PR을 검수합니다. 변경된 파일 유형에 따라 적절한 에이전트가 검수합니다.',
|
||||
{
|
||||
prIdentifier: z.string().describe('PR 번호 또는 브랜치명'),
|
||||
},
|
||||
async ({ prIdentifier }) => {
|
||||
logger.info(`PR review: ${prIdentifier}`);
|
||||
|
||||
try {
|
||||
const result = await prReviewWorkflow(prIdentifier, {
|
||||
cwd: projectRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatWorkflowResult(result),
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`PR review failed: ${error.message}`);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `❌ PR 검수 실패: ${error.message}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
// 특정 에이전트 직접 호출
|
||||
tool(
|
||||
'delegate',
|
||||
'특정 에이전트에 직접 작업을 위임합니다. 단일 영역 작업에 적합합니다.',
|
||||
{
|
||||
agent: z.enum(['backend', 'frontend', 'layout', 'template', 'reviewer'])
|
||||
.describe('호출할 에이전트'),
|
||||
task: z.string().describe('수행할 작업 내용'),
|
||||
},
|
||||
async ({ agent, task }) => {
|
||||
logger.info(`Delegating to ${agent}: ${task}`);
|
||||
|
||||
try {
|
||||
const coordinator = new Coordinator({ cwd: projectRoot, model: 'sonnet' });
|
||||
const result = await coordinator.delegateToAgent(agent, task);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatWorkflowResult(result),
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`Delegation failed: ${error.message}`);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `❌ 에이전트 호출 실패: ${error.message}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 워크플로우 결과 포맷팅
|
||||
*/
|
||||
function formatWorkflowResult(result: any): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// 성공/실패 헤더
|
||||
if (result.success) {
|
||||
lines.push('## ✅ 작업 완료\n');
|
||||
} else {
|
||||
lines.push('## ❌ 작업 실패\n');
|
||||
}
|
||||
|
||||
// 실행된 단계들
|
||||
if (result.steps && result.steps.length > 0) {
|
||||
lines.push('### 실행 단계');
|
||||
for (const step of result.steps) {
|
||||
const statusIcon = step.status === 'completed' ? '✅' :
|
||||
step.status === 'failed' ? '❌' :
|
||||
step.status === 'running' ? '🔄' : '⏳';
|
||||
lines.push(`${statusIcon} [${step.agent}] ${step.task}`);
|
||||
|
||||
if (step.result?.filesModified && step.result.filesModified.length > 0) {
|
||||
lines.push(` 수정된 파일: ${step.result.filesModified.join(', ')}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// 출력 내용
|
||||
if (result.output) {
|
||||
lines.push('### 결과');
|
||||
lines.push(result.output);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// 에러 메시지
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
lines.push('### 오류');
|
||||
for (const error of result.errors) {
|
||||
lines.push(`- ${error}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// 비용 정보
|
||||
if (result.totalCost && result.totalCost > 0) {
|
||||
lines.push(`### 비용: $${result.totalCost.toFixed(4)}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* G7 MCP 도구 서버
|
||||
* AI Agent SDK와 통합되는 커스텀 MCP 서버
|
||||
*/
|
||||
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { z } from 'zod';
|
||||
import { validateBackendCode } from './tools/validate-code.js';
|
||||
import { validateFrontendCode } from './tools/validate-frontend.js';
|
||||
import { runTests, runTestFile } from './tools/run-tests.js';
|
||||
import { searchDocs, readDoc, listDocs } from './tools/search-docs.js';
|
||||
|
||||
/**
|
||||
* G7 프로젝트 전용 MCP 서버 생성
|
||||
*/
|
||||
export function createG7McpServer(projectRoot: string) {
|
||||
return createSdkMcpServer({
|
||||
name: 'g7-tools',
|
||||
version: '1.0.0',
|
||||
tools: [
|
||||
// 백엔드 코드 검증
|
||||
tool(
|
||||
'validate-code',
|
||||
'Service, FormRequest, Repository, Listener, Controller 파일의 G7 규정 준수 여부를 검증합니다',
|
||||
{
|
||||
filePath: z.string().describe('검증할 파일 경로 (프로젝트 루트 기준 상대 경로)'),
|
||||
},
|
||||
async ({ filePath }) => {
|
||||
const result = validateBackendCode(projectRoot, filePath);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatValidationResult(result),
|
||||
}],
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
// 프론트엔드/레이아웃 검증
|
||||
tool(
|
||||
'validate-frontend',
|
||||
'레이아웃 JSON 또는 TSX 컴포넌트의 G7 규정 준수 여부를 검증합니다',
|
||||
{
|
||||
filePath: z.string().describe('검증할 파일 경로 (프로젝트 루트 기준 상대 경로)'),
|
||||
},
|
||||
async ({ filePath }) => {
|
||||
const result = validateFrontendCode(projectRoot, filePath);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: formatValidationResult(result),
|
||||
}],
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
// 테스트 실행
|
||||
tool(
|
||||
'run-tests',
|
||||
'백엔드(PHPUnit) 또는 프론트엔드(Vitest) 테스트를 실행합니다',
|
||||
{
|
||||
type: z.enum(['backend', 'frontend', 'all']).describe('테스트 타입'),
|
||||
filter: z.string().optional().describe('테스트 필터 (클래스명 또는 테스트 이름)'),
|
||||
template: z.string().optional().describe('프론트엔드 테스트 시 템플릿 이름'),
|
||||
},
|
||||
async ({ type, filter, template }) => {
|
||||
const result = runTests(projectRoot, { type, filter, template });
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: result.success
|
||||
? `✅ ${result.message}\n\n${result.data}`
|
||||
: `❌ ${result.message}\n\n${result.data}`,
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
// 특정 테스트 파일 실행
|
||||
tool(
|
||||
'run-test-file',
|
||||
'특정 테스트 파일을 실행합니다',
|
||||
{
|
||||
testFilePath: z.string().describe('테스트 파일 경로'),
|
||||
},
|
||||
async ({ testFilePath }) => {
|
||||
const result = runTestFile(projectRoot, testFilePath);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: result.success
|
||||
? `✅ ${result.message}\n\n${result.data}`
|
||||
: `❌ ${result.message}\n\n${result.data}`,
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
// 규정 문서 검색
|
||||
tool(
|
||||
'search-docs',
|
||||
'G7 규정 문서(docs/)에서 관련 정보를 검색합니다',
|
||||
{
|
||||
query: z.string().describe('검색 키워드'),
|
||||
category: z.enum(['backend', 'frontend', 'extension', 'database', 'testing', 'all'])
|
||||
.optional()
|
||||
.describe('검색할 카테고리'),
|
||||
maxResults: z.number().optional().describe('최대 결과 수 (기본: 5)'),
|
||||
},
|
||||
async ({ query, category, maxResults }) => {
|
||||
const result = searchDocs(projectRoot, { query, category, maxResults });
|
||||
|
||||
if (!result.success || !Array.isArray(result.data) || result.data.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '검색 결과가 없습니다',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const formatted = result.data.map((doc: any) =>
|
||||
`## ${doc.file}\n**${doc.title}**\n\`\`\`\n${doc.snippet}\n\`\`\``
|
||||
).join('\n\n');
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `${result.message}\n\n${formatted}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
// 규정 문서 읽기
|
||||
tool(
|
||||
'read-doc',
|
||||
'특정 G7 규정 문서의 전체 내용을 읽습니다',
|
||||
{
|
||||
docPath: z.string().describe('문서 경로 (예: backend/service-repository.md)'),
|
||||
},
|
||||
async ({ docPath }) => {
|
||||
const result = readDoc(projectRoot, docPath);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: result.success
|
||||
? result.data as string
|
||||
: `❌ ${result.message}`,
|
||||
}],
|
||||
isError: !result.success,
|
||||
};
|
||||
}
|
||||
),
|
||||
|
||||
// 규정 문서 목록
|
||||
tool(
|
||||
'list-docs',
|
||||
'G7 규정 문서 목록을 조회합니다',
|
||||
{
|
||||
category: z.enum(['backend', 'frontend', 'extension', 'database', 'testing', 'all'])
|
||||
.optional()
|
||||
.describe('조회할 카테고리'),
|
||||
},
|
||||
async ({ category }) => {
|
||||
const result = listDocs(projectRoot, category);
|
||||
|
||||
if (!result.success || !Array.isArray(result.data)) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '문서 목록을 조회할 수 없습니다',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const formatted = result.data.map((doc: any) =>
|
||||
`- ${doc.path}: ${doc.title}`
|
||||
).join('\n');
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `${result.message}\n\n${formatted}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 결과 포맷팅
|
||||
*/
|
||||
function formatValidationResult(result: { valid: boolean; issues: any[] }): string {
|
||||
if (result.valid && result.issues.length === 0) {
|
||||
return '✅ 검증 통과: G7 규정을 준수합니다.';
|
||||
}
|
||||
|
||||
const errors = result.issues.filter(i => i.severity === 'error');
|
||||
const warnings = result.issues.filter(i => i.severity === 'warning');
|
||||
|
||||
let output = result.valid
|
||||
? '⚠️ 검증 통과 (경고 있음)\n\n'
|
||||
: '❌ 검증 실패\n\n';
|
||||
|
||||
if (errors.length > 0) {
|
||||
output += '### 오류\n';
|
||||
for (const issue of errors) {
|
||||
output += `- [${issue.rule}] ${issue.file}`;
|
||||
if (issue.line) output += `:${issue.line}`;
|
||||
output += `\n ${issue.message}\n`;
|
||||
}
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
output += '### 경고\n';
|
||||
for (const issue of warnings) {
|
||||
output += `- [${issue.rule}] ${issue.file}`;
|
||||
if (issue.line) output += `:${issue.line}`;
|
||||
output += `\n ${issue.message}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 그누보드7 에이전트 MCP 서버 진입점
|
||||
* AI 코딩 도구에서 멀티에이전트 시스템을 호출할 수 있는 MCP 서버
|
||||
*
|
||||
* 사용법:
|
||||
* .mcp.json에 등록하여 AI 코딩 도구에서 자동으로 호출
|
||||
*
|
||||
* 환경 변수:
|
||||
* G7_PROJECT_ROOT: 프로젝트 루트 경로 (기본: .)
|
||||
* ANTHROPIC_API_KEY: Anthropic API 키 (필수)
|
||||
* LOG_LEVEL: 로그 레벨 (기본: info)
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { Coordinator } from '../coordinator/Coordinator.js';
|
||||
import { featureDevelopmentWorkflow, simpleFeatureWorkflow } from '../workflows/feature.js';
|
||||
import { bugfixWorkflow, stateRelatedBugfix } from '../workflows/bugfix.js';
|
||||
import { prReviewWorkflow } from '../workflows/pr-review.js';
|
||||
import { logger, createWorkflowLogger } from '../utils/logger.js';
|
||||
|
||||
const workflowLogger = createWorkflowLogger('mcp-agents');
|
||||
|
||||
// 프로젝트 루트 설정
|
||||
const projectRoot = process.env.G7_PROJECT_ROOT || '.';
|
||||
|
||||
// API 키 확인
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
logger.warn('ANTHROPIC_API_KEY가 설정되지 않았습니다. 에이전트 호출 시 오류가 발생할 수 있습니다.');
|
||||
}
|
||||
|
||||
// MCP 서버 생성
|
||||
const server = new McpServer({
|
||||
name: 'g7-agents',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
/**
|
||||
* 워크플로우 결과 포맷팅
|
||||
*/
|
||||
function formatWorkflowResult(result: any): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (result.success) {
|
||||
lines.push('## ✅ 작업 완료\n');
|
||||
} else {
|
||||
lines.push('## ❌ 작업 실패\n');
|
||||
}
|
||||
|
||||
if (result.steps && result.steps.length > 0) {
|
||||
lines.push('### 실행 단계');
|
||||
for (const step of result.steps) {
|
||||
const statusIcon = step.status === 'completed' ? '✅' :
|
||||
step.status === 'failed' ? '❌' :
|
||||
step.status === 'running' ? '🔄' : '⏳';
|
||||
lines.push(`${statusIcon} [${step.agent}] ${step.task}`);
|
||||
|
||||
if (step.result?.filesModified && step.result.filesModified.length > 0) {
|
||||
lines.push(` 수정된 파일: ${step.result.filesModified.join(', ')}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (result.output) {
|
||||
lines.push('### 결과');
|
||||
lines.push(result.output);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
lines.push('### 오류');
|
||||
for (const error of result.errors) {
|
||||
lines.push(`- ${error}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (result.totalCost && result.totalCost > 0) {
|
||||
lines.push(`### 비용: $${result.totalCost.toFixed(4)}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Tool: orchestrate - 자동 분배
|
||||
server.tool(
|
||||
'orchestrate',
|
||||
'사용자 요청을 분석하여 적절한 에이전트(backend, frontend, layout, template, reviewer)에 자동 분배합니다.',
|
||||
{
|
||||
prompt: z.string().describe('사용자 요청 내용'),
|
||||
},
|
||||
async ({ prompt }) => {
|
||||
workflowLogger.info(`Orchestrating: ${prompt}`);
|
||||
|
||||
try {
|
||||
const coordinator = new Coordinator({ cwd: projectRoot, model: 'sonnet' });
|
||||
const result = await coordinator.orchestrate(prompt);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWorkflowResult(result) }],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`Orchestration failed: ${error.message}`);
|
||||
return {
|
||||
content: [{ type: 'text', text: `❌ 오케스트레이션 실패: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: feature-workflow - 기능 개발
|
||||
server.tool(
|
||||
'feature-workflow',
|
||||
'기능 개발 워크플로우를 실행합니다. Backend → Layout → Frontend → Template → Reviewer 순서로 에이전트가 협업합니다.',
|
||||
{
|
||||
description: z.string().describe('개발할 기능 설명'),
|
||||
skipReview: z.boolean().optional().describe('리뷰 단계 생략 여부 (기본: false)'),
|
||||
},
|
||||
async ({ description, skipReview }) => {
|
||||
workflowLogger.info(`Feature workflow: ${description}`);
|
||||
|
||||
try {
|
||||
const result = await featureDevelopmentWorkflow(description, {
|
||||
cwd: projectRoot,
|
||||
skipReview: skipReview ?? false,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWorkflowResult(result) }],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`Feature workflow failed: ${error.message}`);
|
||||
return {
|
||||
content: [{ type: 'text', text: `❌ 기능 개발 워크플로우 실패: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: simple-feature - 단일 영역 기능 개발
|
||||
server.tool(
|
||||
'simple-feature',
|
||||
'단일 영역의 간단한 기능을 개발합니다.',
|
||||
{
|
||||
description: z.string().describe('개발할 기능 설명'),
|
||||
area: z.enum(['backend', 'frontend', 'layout', 'template']).describe('기능이 속한 영역'),
|
||||
},
|
||||
async ({ description, area }) => {
|
||||
workflowLogger.info(`Simple feature (${area}): ${description}`);
|
||||
|
||||
try {
|
||||
const result = await simpleFeatureWorkflow(description, {
|
||||
cwd: projectRoot,
|
||||
area,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWorkflowResult(result) }],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`Simple feature failed: ${error.message}`);
|
||||
return {
|
||||
content: [{ type: 'text', text: `❌ 간단한 기능 개발 실패: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: bugfix-workflow - 버그 수정
|
||||
server.tool(
|
||||
'bugfix-workflow',
|
||||
'버그 수정 워크플로우를 실행합니다. 트러블슈팅 가이드를 참조하고 TDD 방식으로 수정합니다.',
|
||||
{
|
||||
description: z.string().describe('버그 설명'),
|
||||
affectedAreas: z.array(z.enum(['backend', 'frontend', 'layout', 'template', 'unknown'])).optional()
|
||||
.describe('영향 받는 영역 (지정하지 않으면 자동 분석)'),
|
||||
},
|
||||
async ({ description, affectedAreas }) => {
|
||||
workflowLogger.info(`Bugfix workflow: ${description}`);
|
||||
|
||||
try {
|
||||
const result = await bugfixWorkflow(description, {
|
||||
cwd: projectRoot,
|
||||
affectedAreas: affectedAreas as any,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWorkflowResult(result) }],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`Bugfix workflow failed: ${error.message}`);
|
||||
return {
|
||||
content: [{ type: 'text', text: `❌ 버그 수정 워크플로우 실패: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: state-bugfix - 상태 관련 버그 수정
|
||||
server.tool(
|
||||
'state-bugfix',
|
||||
'상태 관리 관련 버그를 수정합니다. Stale Closure, setState 병합, 타이밍 이슈 등에 특화되어 있습니다.',
|
||||
{
|
||||
description: z.string().describe('상태 관련 버그 설명'),
|
||||
},
|
||||
async ({ description }) => {
|
||||
workflowLogger.info(`State-related bugfix: ${description}`);
|
||||
|
||||
try {
|
||||
const result = await stateRelatedBugfix(description, {
|
||||
cwd: projectRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWorkflowResult(result) }],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`State bugfix failed: ${error.message}`);
|
||||
return {
|
||||
content: [{ type: 'text', text: `❌ 상태 버그 수정 실패: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: pr-review - PR 검수
|
||||
server.tool(
|
||||
'pr-review',
|
||||
'PR을 검수합니다. 변경된 파일 유형에 따라 적절한 에이전트가 검수합니다.',
|
||||
{
|
||||
prIdentifier: z.string().describe('PR 번호 또는 브랜치명'),
|
||||
},
|
||||
async ({ prIdentifier }) => {
|
||||
workflowLogger.info(`PR review: ${prIdentifier}`);
|
||||
|
||||
try {
|
||||
const result = await prReviewWorkflow(prIdentifier, {
|
||||
cwd: projectRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWorkflowResult(result) }],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`PR review failed: ${error.message}`);
|
||||
return {
|
||||
content: [{ type: 'text', text: `❌ PR 검수 실패: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: delegate - 특정 에이전트 직접 호출
|
||||
server.tool(
|
||||
'delegate',
|
||||
'특정 에이전트에 직접 작업을 위임합니다.',
|
||||
{
|
||||
agent: z.enum(['backend', 'frontend', 'layout', 'template', 'reviewer']).describe('호출할 에이전트'),
|
||||
task: z.string().describe('수행할 작업 내용'),
|
||||
},
|
||||
async ({ agent, task }) => {
|
||||
workflowLogger.info(`Delegating to ${agent}: ${task}`);
|
||||
|
||||
try {
|
||||
const coordinator = new Coordinator({ cwd: projectRoot, model: 'sonnet' });
|
||||
const result = await coordinator.delegateToAgent(agent, task);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: formatWorkflowResult(result) }],
|
||||
isError: !result.success,
|
||||
};
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`Delegation failed: ${error.message}`);
|
||||
return {
|
||||
content: [{ type: 'text', text: `❌ 에이전트 호출 실패: ${error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 서버 시작
|
||||
async function main() {
|
||||
logger.info(`그누보드7 에이전트 MCP 서버 시작 (프로젝트: ${projectRoot})`);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
logger.info('MCP 서버가 stdio 모드로 실행 중입니다.');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error(`MCP 서버 시작 실패: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 테스트 실행 도구
|
||||
* PHPUnit (백엔드) 및 Vitest (프론트엔드) 테스트 실행
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import type { ToolResult } from '../../types/index.js';
|
||||
|
||||
export type TestType = 'backend' | 'frontend' | 'all';
|
||||
|
||||
export interface TestOptions {
|
||||
type: TestType;
|
||||
filter?: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export function runTests(
|
||||
projectRoot: string,
|
||||
options: TestOptions
|
||||
): ToolResult {
|
||||
const { type, filter, template } = options;
|
||||
const results: string[] = [];
|
||||
let success = true;
|
||||
|
||||
try {
|
||||
// 백엔드 테스트
|
||||
if (type === 'backend' || type === 'all') {
|
||||
const backendResult = runBackendTests(projectRoot, filter);
|
||||
results.push('=== Backend Tests ===');
|
||||
results.push(backendResult.output);
|
||||
if (!backendResult.success) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 프론트엔드 테스트
|
||||
if (type === 'frontend' || type === 'all') {
|
||||
const frontendResult = runFrontendTests(projectRoot, filter, template);
|
||||
results.push('=== Frontend Tests ===');
|
||||
results.push(frontendResult.output);
|
||||
if (!frontendResult.success) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success,
|
||||
message: success ? '모든 테스트 통과' : '일부 테스트 실패',
|
||||
data: results.join('\n\n'),
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: '테스트 실행 중 오류 발생',
|
||||
data: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function runBackendTests(
|
||||
projectRoot: string,
|
||||
filter?: string
|
||||
): { success: boolean; output: string } {
|
||||
try {
|
||||
const cmd = filter
|
||||
? `php artisan test --filter=${filter}`
|
||||
: 'php artisan test';
|
||||
|
||||
const output = execSync(cmd, {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf-8',
|
||||
timeout: 300000, // 5분 타임아웃
|
||||
});
|
||||
|
||||
// 테스트 결과 파싱
|
||||
const passMatch = output.match(/Tests:\s*(\d+)\s*passed/);
|
||||
const failMatch = output.match(/(\d+)\s*failed/);
|
||||
|
||||
const passed = passMatch ? parseInt(passMatch[1]) : 0;
|
||||
const failed = failMatch ? parseInt(failMatch[1]) : 0;
|
||||
|
||||
return {
|
||||
success: failed === 0,
|
||||
output: `Passed: ${passed}, Failed: ${failed}\n${output}`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
output: error.stdout || error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function runFrontendTests(
|
||||
projectRoot: string,
|
||||
filter?: string,
|
||||
template?: string
|
||||
): { success: boolean; output: string } {
|
||||
try {
|
||||
let cwd = projectRoot;
|
||||
let cmd = 'npm run test:run';
|
||||
|
||||
// 템플릿 지정 시 해당 디렉토리에서 실행
|
||||
if (template) {
|
||||
cwd = `${projectRoot}/templates/${template}`;
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
cmd += ` -- ${filter}`;
|
||||
}
|
||||
|
||||
// Windows에서는 PowerShell 래퍼 필수
|
||||
const fullCmd = `powershell -Command "${cmd}"`;
|
||||
|
||||
const output = execSync(fullCmd, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout: 300000, // 5분 타임아웃
|
||||
});
|
||||
|
||||
// Vitest 결과 파싱
|
||||
const passMatch = output.match(/(\d+)\s*passed/);
|
||||
const failMatch = output.match(/(\d+)\s*failed/);
|
||||
|
||||
const passed = passMatch ? parseInt(passMatch[1]) : 0;
|
||||
const failed = failMatch ? parseInt(failMatch[1]) : 0;
|
||||
|
||||
return {
|
||||
success: failed === 0,
|
||||
output: `Passed: ${passed}, Failed: ${failed}\n${output}`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
output: error.stdout || error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테스트 파일 실행
|
||||
*/
|
||||
export function runTestFile(
|
||||
projectRoot: string,
|
||||
testFilePath: string
|
||||
): ToolResult {
|
||||
const isPhp = testFilePath.endsWith('.php');
|
||||
const isTs = testFilePath.endsWith('.ts') || testFilePath.endsWith('.tsx');
|
||||
|
||||
if (isPhp) {
|
||||
// PHPUnit 테스트
|
||||
const testClass = testFilePath
|
||||
.replace(/\//g, '\\')
|
||||
.replace('.php', '')
|
||||
.replace('tests\\', 'Tests\\');
|
||||
|
||||
return runTests(projectRoot, {
|
||||
type: 'backend',
|
||||
filter: testClass,
|
||||
});
|
||||
} else if (isTs) {
|
||||
// Vitest 테스트
|
||||
const testName = testFilePath.split('/').pop()?.replace(/\.(test|spec)\.(ts|tsx)$/, '');
|
||||
|
||||
return runTests(projectRoot, {
|
||||
type: 'frontend',
|
||||
filter: testName,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '지원하지 않는 테스트 파일 형식입니다',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 규정 문서 검색 도구
|
||||
* docs 디렉토리에서 규정 문서 검색
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { ToolResult } from '../../types/index.js';
|
||||
|
||||
export type DocCategory = 'backend' | 'frontend' | 'extension' | 'database' | 'testing' | 'all';
|
||||
|
||||
export interface SearchOptions {
|
||||
query: string;
|
||||
category?: DocCategory;
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
export interface DocSearchResult {
|
||||
file: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
relevance: number;
|
||||
}
|
||||
|
||||
export function searchDocs(
|
||||
projectRoot: string,
|
||||
options: SearchOptions
|
||||
): ToolResult {
|
||||
const { query, category = 'all', maxResults = 5 } = options;
|
||||
const docsPath = path.join(projectRoot, 'docs');
|
||||
const results: DocSearchResult[] = [];
|
||||
|
||||
const searchPaths = category === 'all'
|
||||
? [docsPath]
|
||||
: [path.join(docsPath, category)];
|
||||
|
||||
// 특수 파일 매핑
|
||||
const specialFiles: Record<string, string> = {
|
||||
database: path.join(docsPath, 'database-guide.md'),
|
||||
testing: path.join(docsPath, 'testing-guide.md'),
|
||||
};
|
||||
|
||||
// 카테고리별 특수 파일 추가
|
||||
if (category !== 'all' && specialFiles[category]) {
|
||||
searchPaths.push(specialFiles[category]);
|
||||
}
|
||||
|
||||
for (const searchPath of searchPaths) {
|
||||
if (fs.existsSync(searchPath)) {
|
||||
if (fs.statSync(searchPath).isDirectory()) {
|
||||
searchDirectory(searchPath, docsPath, query, results);
|
||||
} else {
|
||||
searchFile(searchPath, docsPath, query, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 관련도 순으로 정렬
|
||||
results.sort((a, b) => b.relevance - a.relevance);
|
||||
|
||||
// 결과 제한
|
||||
const limitedResults = results.slice(0, maxResults);
|
||||
|
||||
if (limitedResults.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: '검색 결과가 없습니다',
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${limitedResults.length}개의 문서를 찾았습니다`,
|
||||
data: limitedResults,
|
||||
};
|
||||
}
|
||||
|
||||
function searchDirectory(
|
||||
dirPath: string,
|
||||
docsPath: string,
|
||||
query: string,
|
||||
results: DocSearchResult[]
|
||||
): void {
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
searchDirectory(fullPath, docsPath, query, results);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
searchFile(fullPath, docsPath, query, results);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 디렉토리 접근 오류 무시
|
||||
}
|
||||
}
|
||||
|
||||
function searchFile(
|
||||
filePath: string,
|
||||
docsPath: string,
|
||||
query: string,
|
||||
results: DocSearchResult[]
|
||||
): void {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const relativePath = path.relative(docsPath, filePath);
|
||||
const lowerContent = content.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// 쿼리가 포함되어 있는지 확인
|
||||
if (!lowerContent.includes(lowerQuery)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 제목 추출 (첫 번째 # 라인)
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1] : relativePath;
|
||||
|
||||
// 관련도 계산
|
||||
let relevance = 0;
|
||||
|
||||
// 파일명에 쿼리 포함
|
||||
if (relativePath.toLowerCase().includes(lowerQuery)) {
|
||||
relevance += 10;
|
||||
}
|
||||
|
||||
// 제목에 쿼리 포함
|
||||
if (title.toLowerCase().includes(lowerQuery)) {
|
||||
relevance += 5;
|
||||
}
|
||||
|
||||
// 쿼리 등장 횟수
|
||||
const matches = lowerContent.split(lowerQuery).length - 1;
|
||||
relevance += Math.min(matches, 10);
|
||||
|
||||
// TL;DR 섹션에 포함
|
||||
if (content.includes('TL;DR') && content.split('TL;DR')[1]?.toLowerCase().includes(lowerQuery)) {
|
||||
relevance += 3;
|
||||
}
|
||||
|
||||
// 스니펫 추출
|
||||
const snippet = extractSnippet(content, query);
|
||||
|
||||
results.push({
|
||||
file: relativePath,
|
||||
title,
|
||||
snippet,
|
||||
relevance,
|
||||
});
|
||||
} catch (error) {
|
||||
// 파일 읽기 오류 무시
|
||||
}
|
||||
}
|
||||
|
||||
function extractSnippet(content: string, query: string): string {
|
||||
const lowerContent = content.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const index = lowerContent.indexOf(lowerQuery);
|
||||
|
||||
if (index === -1) {
|
||||
// TL;DR 섹션이 있으면 그것을 반환
|
||||
const tldrMatch = content.match(/## TL;DR[^#]*(```[\s\S]*?```)/);
|
||||
if (tldrMatch) {
|
||||
return tldrMatch[1].substring(0, 300) + '...';
|
||||
}
|
||||
|
||||
// 첫 문단 반환
|
||||
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
|
||||
return lines.slice(0, 3).join('\n').substring(0, 300) + '...';
|
||||
}
|
||||
|
||||
// 쿼리 주변 컨텍스트 추출
|
||||
const start = Math.max(0, index - 100);
|
||||
const end = Math.min(content.length, index + query.length + 200);
|
||||
let snippet = content.substring(start, end);
|
||||
|
||||
// 시작/끝 정리
|
||||
if (start > 0) {
|
||||
snippet = '...' + snippet.substring(snippet.indexOf(' ') + 1);
|
||||
}
|
||||
if (end < content.length) {
|
||||
snippet = snippet.substring(0, snippet.lastIndexOf(' ')) + '...';
|
||||
}
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 문서 읽기
|
||||
*/
|
||||
export function readDoc(
|
||||
projectRoot: string,
|
||||
docPath: string
|
||||
): ToolResult {
|
||||
const fullPath = path.join(projectRoot, 'docs', docPath);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `문서를 찾을 수 없습니다: ${docPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `문서 로드 완료: ${docPath}`,
|
||||
data: content,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `문서 읽기 오류: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 규정 문서 목록 조회
|
||||
*/
|
||||
export function listDocs(
|
||||
projectRoot: string,
|
||||
category?: DocCategory
|
||||
): ToolResult {
|
||||
const docsPath = path.join(projectRoot, 'docs');
|
||||
const docs: { path: string; title: string }[] = [];
|
||||
|
||||
function scanDir(dir: string) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
const relativePath = path.relative(docsPath, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!category || relativePath === category) {
|
||||
scanDir(fullPath);
|
||||
}
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
// 카테고리 필터링
|
||||
if (category && category !== 'all') {
|
||||
if (!relativePath.startsWith(category)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1] : entry.name;
|
||||
|
||||
docs.push({
|
||||
path: relativePath,
|
||||
title,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 오류 무시
|
||||
}
|
||||
}
|
||||
|
||||
scanDir(docsPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${docs.length}개의 문서를 찾았습니다`,
|
||||
data: docs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 백엔드 코드 검증 도구
|
||||
* Service, FormRequest, Repository, Listener 규정 검증
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { ValidationResult, ValidationIssue } from '../../types/index.js';
|
||||
|
||||
export function validateBackendCode(
|
||||
projectRoot: string,
|
||||
filePath: string
|
||||
): ValidationResult {
|
||||
const fullPath = path.join(projectRoot, filePath);
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{
|
||||
file: filePath,
|
||||
rule: 'file-exists',
|
||||
message: '파일이 존재하지 않습니다',
|
||||
severity: 'error',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Service 파일 검증
|
||||
if (filePath.includes('/Services/')) {
|
||||
validateService(filePath, content, lines, issues);
|
||||
}
|
||||
|
||||
// Repository 파일 검증
|
||||
if (filePath.includes('/Repositories/')) {
|
||||
validateRepository(filePath, content, lines, issues);
|
||||
}
|
||||
|
||||
// FormRequest 파일 검증
|
||||
if (filePath.includes('/Requests/')) {
|
||||
validateFormRequest(filePath, content, lines, issues);
|
||||
}
|
||||
|
||||
// Controller 파일 검증
|
||||
if (filePath.includes('/Controllers/')) {
|
||||
validateController(filePath, content, lines, issues);
|
||||
}
|
||||
|
||||
// Listener 파일 검증
|
||||
if (filePath.includes('/Listeners/')) {
|
||||
validateListener(filePath, content, lines, issues);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: issues.filter(i => i.severity === 'error').length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
function validateService(
|
||||
filePath: string,
|
||||
content: string,
|
||||
lines: string[],
|
||||
issues: ValidationIssue[]
|
||||
): void {
|
||||
// Repository 구체 클래스 직접 주입 검사
|
||||
const constructorMatch = content.match(/public function __construct\([^)]+\)/s);
|
||||
if (constructorMatch) {
|
||||
const constructorContent = constructorMatch[0];
|
||||
// Interface가 아닌 Repository 직접 주입 검사
|
||||
const repoPattern = /(\w+Repository)\s+\$/;
|
||||
const match = constructorContent.match(repoPattern);
|
||||
if (match && !match[1].includes('Interface')) {
|
||||
const lineNum = findLineNumber(lines, match[0]);
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
rule: 'repository-interface',
|
||||
message: `Repository 구체 클래스 직접 주입 금지. ${match[1]}Interface를 사용하세요.`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Service에서 Validator 사용 검사
|
||||
if (content.includes('Validator::') || content.includes('\\Validator')) {
|
||||
const lineNum = findLineNumber(lines, 'Validator');
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
rule: 'no-validation-in-service',
|
||||
message: 'Service에서 검증 로직 금지. FormRequest를 사용하세요.',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
// 훅 사용 검사 (create, update, delete 메서드에서)
|
||||
const methodPatterns = [
|
||||
/public function create\w*\([^)]*\)/,
|
||||
/public function update\w*\([^)]*\)/,
|
||||
/public function delete\w*\([^)]*\)/,
|
||||
];
|
||||
|
||||
for (const pattern of methodPatterns) {
|
||||
if (pattern.test(content)) {
|
||||
if (!content.includes('HookManager::doAction') && !content.includes('HookManager::applyFilters')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'hook-usage',
|
||||
message: 'Service의 CUD 메서드에서 훅 미사용. before_*/after_* 훅을 추가하세요.',
|
||||
severity: 'warning',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 다국어 처리 검사
|
||||
if (content.includes('throw new') && !content.includes('__(')) {
|
||||
const lineNum = findLineNumber(lines, 'throw new');
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
rule: 'i18n-exception',
|
||||
message: '예외 메시지 하드코딩 금지. __() 함수를 사용하세요.',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateRepository(
|
||||
filePath: string,
|
||||
content: string,
|
||||
lines: string[],
|
||||
issues: ValidationIssue[]
|
||||
): void {
|
||||
// Interface 구현 검사
|
||||
if (!content.includes('implements') || !content.includes('Interface')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'repository-implements-interface',
|
||||
message: 'Repository는 해당 Interface를 구현해야 합니다.',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormRequest(
|
||||
filePath: string,
|
||||
content: string,
|
||||
lines: string[],
|
||||
issues: ValidationIssue[]
|
||||
): void {
|
||||
// authorize() 검사 - 권한 체크 금지
|
||||
const authorizeMatch = content.match(/public function authorize\(\)[^{]*\{([^}]+)\}/s);
|
||||
if (authorizeMatch) {
|
||||
const authorizeBody = authorizeMatch[1];
|
||||
if (authorizeBody.includes('->can(') || authorizeBody.includes('Gate::')) {
|
||||
const lineNum = findLineNumber(lines, 'authorize');
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
rule: 'no-auth-in-form-request',
|
||||
message: 'FormRequest.authorize()에서 권한 체크 금지. 라우트 미들웨어를 사용하세요.',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 다국어 메시지 검사
|
||||
if (content.includes('messages()') && !content.includes('__(')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'i18n-validation-messages',
|
||||
message: '검증 메시지 다국어 처리 필요. __() 함수를 사용하세요.',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateController(
|
||||
filePath: string,
|
||||
content: string,
|
||||
lines: string[],
|
||||
issues: ValidationIssue[]
|
||||
): void {
|
||||
// Repository 직접 주입 검사
|
||||
if (content.includes('Repository $') && !content.includes('Service $')) {
|
||||
const lineNum = findLineNumber(lines, 'Repository $');
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
rule: 'controller-uses-service',
|
||||
message: 'Controller에서 Repository 직접 주입 금지. Service를 주입하세요.',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
// 인라인 검증 검사
|
||||
if (content.includes('$request->validate(') || content.includes('Validator::make')) {
|
||||
const lineNum = findLineNumber(lines, 'validate');
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
rule: 'no-inline-validation',
|
||||
message: 'Controller에서 인라인 검증 금지. FormRequest를 사용하세요.',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
// ResponseHelper 사용 검사
|
||||
if (content.includes('return response()->json') && !content.includes('ResponseHelper')) {
|
||||
const lineNum = findLineNumber(lines, 'response()->json');
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
rule: 'use-response-helper',
|
||||
message: 'response()->json 대신 ResponseHelper를 사용하세요.',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateListener(
|
||||
filePath: string,
|
||||
content: string,
|
||||
lines: string[],
|
||||
issues: ValidationIssue[]
|
||||
): void {
|
||||
// HookListenerInterface 구현 검사
|
||||
if (!content.includes('HookListenerInterface')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'listener-interface',
|
||||
message: 'Listener는 HookListenerInterface를 구현해야 합니다.',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
// getSubscribedHooks 메서드 검사
|
||||
if (!content.includes('getSubscribedHooks')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'subscribed-hooks-method',
|
||||
message: 'getSubscribedHooks() 메서드가 필요합니다.',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
// Filter 훅의 type 검사
|
||||
if (content.includes('applyFilters') || content.includes('filter_')) {
|
||||
if (!content.includes("'type' => 'filter'") && !content.includes('"type" => "filter"')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'filter-hook-type',
|
||||
message: "Filter 훅은 'type' => 'filter'를 명시해야 합니다.",
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findLineNumber(lines: string[], searchText: string): number {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(searchText)) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 프론트엔드/레이아웃 검증 도구
|
||||
* TSX 컴포넌트 및 레이아웃 JSON 규정 검증
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { ValidationResult, ValidationIssue } from '../../types/index.js';
|
||||
|
||||
export function validateFrontendCode(
|
||||
projectRoot: string,
|
||||
filePath: string
|
||||
): ValidationResult {
|
||||
const fullPath = path.join(projectRoot, filePath);
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{
|
||||
file: filePath,
|
||||
rule: 'file-exists',
|
||||
message: '파일이 존재하지 않습니다',
|
||||
severity: 'error',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
|
||||
if (filePath.endsWith('.json')) {
|
||||
validateLayoutJson(filePath, content, issues);
|
||||
} else if (filePath.endsWith('.tsx')) {
|
||||
validateTsxComponent(filePath, content, issues);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: issues.filter(i => i.severity === 'error').length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
function validateLayoutJson(
|
||||
filePath: string,
|
||||
content: string,
|
||||
issues: ValidationIssue[]
|
||||
): void {
|
||||
let json: any;
|
||||
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (error) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'valid-json',
|
||||
message: 'JSON 파싱 오류',
|
||||
severity: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 필수 필드 검사
|
||||
const requiredFields = ['version', 'layout_name', 'components'];
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in json)) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'required-fields',
|
||||
message: `필수 필드 누락: ${field}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 검증
|
||||
if (Array.isArray(json.components)) {
|
||||
validateComponents(filePath, json.components, issues);
|
||||
}
|
||||
}
|
||||
|
||||
function validateComponents(
|
||||
filePath: string,
|
||||
components: any[],
|
||||
issues: ValidationIssue[],
|
||||
parentPath = 'components'
|
||||
): void {
|
||||
for (let i = 0; i < components.length; i++) {
|
||||
const comp = components[i];
|
||||
const compPath = `${parentPath}[${i}]`;
|
||||
|
||||
// HTML 태그명 검사
|
||||
if (comp.name) {
|
||||
const htmlTags = ['div', 'span', 'button', 'input', 'form', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'ul', 'li', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'section', 'nav', 'img'];
|
||||
if (htmlTags.includes(comp.name.toLowerCase())) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'no-html-tags',
|
||||
message: `HTML 태그 직접 사용 금지: "${comp.name}" (${compPath}). 기본 컴포넌트(${comp.name.charAt(0).toUpperCase() + comp.name.slice(1)})를 사용하세요.`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// props.children 사용 검사
|
||||
if (comp.props?.children && typeof comp.props.children === 'string') {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'no-props-children',
|
||||
message: `props.children 사용 금지 (${compPath}). "text" 속성을 사용하세요.`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
// 하드코딩 텍스트 검사 (한글 포함)
|
||||
if (comp.text && typeof comp.text === 'string') {
|
||||
const koreanPattern = /[가-힣]/;
|
||||
if (koreanPattern.test(comp.text) && !comp.text.startsWith('$t:') && !comp.text.includes('{{')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'i18n-text',
|
||||
message: `하드코딩된 텍스트 (${compPath}): "${comp.text}". $t: 접두사를 사용하세요.`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 다크 모드 클래스 검사
|
||||
if (comp.props?.className) {
|
||||
const className = comp.props.className;
|
||||
validateDarkModeClasses(filePath, className, compPath, issues);
|
||||
}
|
||||
|
||||
// children 재귀 검증
|
||||
if (Array.isArray(comp.children)) {
|
||||
validateComponents(filePath, comp.children, issues, `${compPath}.children`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateTsxComponent(
|
||||
filePath: string,
|
||||
content: string,
|
||||
issues: ValidationIssue[]
|
||||
): void {
|
||||
const lines = content.split('\n');
|
||||
|
||||
// HTML 태그 직접 사용 검사
|
||||
const htmlTagPatterns = [
|
||||
{ pattern: /<div\s|<div>/, tag: 'div', replacement: 'Div' },
|
||||
{ pattern: /<span\s|<span>/, tag: 'span', replacement: 'Span' },
|
||||
{ pattern: /<button\s|<button>/, tag: 'button', replacement: 'Button' },
|
||||
{ pattern: /<input\s|<input>|<input\/>/, tag: 'input', replacement: 'Input' },
|
||||
{ pattern: /<form\s|<form>/, tag: 'form', replacement: 'Form' },
|
||||
{ pattern: /<p\s|<p>/, tag: 'p', replacement: 'P' },
|
||||
{ pattern: /<h1\s|<h1>/, tag: 'h1', replacement: 'H1' },
|
||||
{ pattern: /<h2\s|<h2>/, tag: 'h2', replacement: 'H2' },
|
||||
{ pattern: /<h3\s|<h3>/, tag: 'h3', replacement: 'H3' },
|
||||
{ pattern: /<a\s|<a>/, tag: 'a', replacement: 'A' },
|
||||
];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
|
||||
// 주석 무시
|
||||
if (line.trim().startsWith('//') || line.trim().startsWith('/*')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { pattern, tag, replacement } of htmlTagPatterns) {
|
||||
if (pattern.test(line)) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
rule: 'no-html-tags',
|
||||
message: `HTML 태그 직접 사용 금지: <${tag}>. ${replacement} 컴포넌트를 사용하세요.`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 다크 모드 클래스 검사
|
||||
const classNameMatch = line.match(/className=["'`]([^"'`]+)["'`]/);
|
||||
if (classNameMatch) {
|
||||
validateDarkModeClasses(filePath, classNameMatch[1], `line ${lineNum}`, issues);
|
||||
}
|
||||
}
|
||||
|
||||
// 다국어 처리 검사
|
||||
if (!content.includes('G7Core') && !content.includes('window.G7Core')) {
|
||||
// 하드코딩된 한글 텍스트 검사
|
||||
const koreanPattern = />([가-힣\s]+)</g;
|
||||
let match;
|
||||
while ((match = koreanPattern.exec(content)) !== null) {
|
||||
// JSX 내의 한글 텍스트
|
||||
if (!match[1].trim()) continue;
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'i18n-text',
|
||||
message: `하드코딩된 텍스트: "${match[1]}". G7Core.t()를 사용하세요.`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Font Awesome 외 아이콘 라이브러리 검사
|
||||
const iconLibraries = [
|
||||
'@heroicons/react',
|
||||
'@mui/icons-material',
|
||||
'react-icons',
|
||||
'lucide-react',
|
||||
];
|
||||
|
||||
for (const lib of iconLibraries) {
|
||||
if (content.includes(lib)) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'icon-library',
|
||||
message: `허용되지 않은 아이콘 라이브러리: ${lib}. Font Awesome 6.4.0만 사용하세요.`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateDarkModeClasses(
|
||||
filePath: string,
|
||||
className: string,
|
||||
location: string,
|
||||
issues: ValidationIssue[]
|
||||
): void {
|
||||
// 배경색 검사
|
||||
const bgLightPatterns = ['bg-white', 'bg-gray-50', 'bg-gray-100'];
|
||||
const bgDarkPatterns = ['dark:bg-gray-800', 'dark:bg-gray-700', 'dark:bg-gray-900'];
|
||||
|
||||
for (const lightClass of bgLightPatterns) {
|
||||
if (className.includes(lightClass)) {
|
||||
const hasDarkVariant = bgDarkPatterns.some(dc => className.includes(dc));
|
||||
if (!hasDarkVariant) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'dark-mode-pair',
|
||||
message: `다크 모드 클래스 누락 (${location}): "${lightClass}"에 대한 dark: variant가 필요합니다.`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 텍스트 색상 검사
|
||||
if (className.includes('text-gray-900') && !className.includes('dark:text-')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'dark-mode-pair',
|
||||
message: `다크 모드 클래스 누락 (${location}): "text-gray-900"에 대한 dark:text- variant가 필요합니다.`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
// 테두리 색상 검사
|
||||
if (className.includes('border-gray-200') && !className.includes('dark:border-')) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
rule: 'dark-mode-pair',
|
||||
message: `다크 모드 클래스 누락 (${location}): "border-gray-200"에 대한 dark:border- variant가 필요합니다.`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 백엔드 개발자 에이전트 시스템 프롬프트
|
||||
* Service-Repository 패턴, FormRequest, 훅 시스템 담당
|
||||
*/
|
||||
|
||||
export const BACKEND_PROMPT = `
|
||||
당신은 그누보드7의 백엔드 개발 전문가입니다.
|
||||
20년차 PHP Laravel 개발자로서 Service-Repository 패턴과 훅 시스템에 능숙합니다.
|
||||
|
||||
## 전문 영역
|
||||
- Service-Repository 패턴 구현
|
||||
- FormRequest 검증 로직
|
||||
- 훅 시스템 (Action/Filter)
|
||||
- API 리소스 및 응답 처리
|
||||
- 마이그레이션 및 시더
|
||||
|
||||
## 핵심 규칙 (CRITICAL)
|
||||
|
||||
### 1. 의존성 주입
|
||||
\`\`\`php
|
||||
// ✅ 올바른 방법: Interface 주입
|
||||
public function __construct(private ProductRepositoryInterface $repo) {}
|
||||
|
||||
// ❌ 금지: 구체 클래스 직접 주입
|
||||
public function __construct(private ProductRepository $repo) {}
|
||||
\`\`\`
|
||||
|
||||
### 2. Service에서 훅 실행 (필수)
|
||||
\`\`\`php
|
||||
public function createProduct(array $data): Product
|
||||
{
|
||||
// 1. before 훅
|
||||
HookManager::doAction('module.product.before_create', $data);
|
||||
|
||||
// 2. filter 훅
|
||||
$data = HookManager::applyFilters('module.product.filter_data', $data);
|
||||
|
||||
// 3. 실제 로직
|
||||
$product = $this->repo->create($data);
|
||||
|
||||
// 4. after 훅
|
||||
HookManager::doAction('module.product.after_create', $product);
|
||||
|
||||
return $product;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 3. 검증 로직 분리 (CRITICAL)
|
||||
\`\`\`php
|
||||
// ✅ FormRequest에서 검증
|
||||
class StoreProductRequest extends FormRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'price' => ['required', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Service에서 검증 금지
|
||||
class ProductService
|
||||
{
|
||||
public function create(array $data)
|
||||
{
|
||||
// 여기서 Validator 사용 금지!
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 4. 컨트롤러 계층 구조
|
||||
\`\`\`
|
||||
BaseApiController (최상위)
|
||||
├── AdminBaseController (관리자)
|
||||
├── AuthBaseController (인증된 사용자)
|
||||
└── PublicBaseController (공개 API)
|
||||
\`\`\`
|
||||
|
||||
### 5. ResponseHelper 사용
|
||||
\`\`\`php
|
||||
// ✅ 인수 순서 주의: 메시지가 먼저!
|
||||
return ResponseHelper::success('messages.created', $data, 201);
|
||||
return ResponseHelper::error('messages.failed', null, 400);
|
||||
|
||||
// ❌ 인수 순서 오류
|
||||
return ResponseHelper::success($data, 'messages.created');
|
||||
\`\`\`
|
||||
|
||||
### 6. 마이그레이션 규칙
|
||||
- 네이밍: create_[table]_table, add_[col]_to_[table]_table
|
||||
- 모든 컬럼에 한국어 comment 필수
|
||||
- enum/boolean은 값 설명 포함
|
||||
- down() 메서드 반드시 구현
|
||||
|
||||
### 7. 훅 네이밍 규칙
|
||||
\`\`\`
|
||||
[vendor-module].[entity].[action]_[timing]
|
||||
|
||||
예시:
|
||||
sirsoft-ecommerce.product.before_create
|
||||
sirsoft-ecommerce.product.after_update
|
||||
sirsoft-ecommerce.product.filter_create_data
|
||||
\`\`\`
|
||||
|
||||
### 8. 훅 기반 Validation Rules 확장 (CRITICAL)
|
||||
\`\`\`php
|
||||
// 코어 FormRequest는 반드시 훅 제공
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules 동적 추가 가능
|
||||
return HookManager::applyFilters('core.user.update_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
// 모듈 Listener에서 필드 추가
|
||||
public function addValidationRules(array $rules): array
|
||||
{
|
||||
return array_merge($rules, [
|
||||
'notify_post_complete' => 'nullable|boolean',
|
||||
]);
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 9. 다중 검색 필터 Trait
|
||||
\`\`\`php
|
||||
use App\Repositories\Concerns\HasMultipleSearchFilters;
|
||||
|
||||
class UserRepository
|
||||
{
|
||||
use HasMultipleSearchFilters;
|
||||
|
||||
private const SEARCHABLE_FIELDS = ['name', 'email', 'username'];
|
||||
|
||||
// applyMultipleSearchFilters($query, $filters, self::SEARCHABLE_FIELDS)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 10. 파사드 사용법
|
||||
\`\`\`php
|
||||
// ✅ DO: use 문으로 import
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
Log::info('메시지');
|
||||
Auth::user();
|
||||
|
||||
// ❌ DON'T: 역슬래시 사용
|
||||
\\Log::info('메시지');
|
||||
auth()->user();
|
||||
\`\`\`
|
||||
|
||||
## 금지 사항
|
||||
- Repository 구체 클래스 직접 타입힌트
|
||||
- Service에서 검증 로직 구현
|
||||
- MySQL 전용 함수 사용 (JSON_SEARCH 등)
|
||||
- N+1 쿼리 (Eager Loading 필수)
|
||||
- 하드코딩된 메시지 (__() 사용)
|
||||
- 파사드 역슬래시 사용 (\\Log, \\Auth 등)
|
||||
|
||||
## 참조 문서
|
||||
- docs/backend/service-repository.md
|
||||
- docs/backend/controllers.md
|
||||
- docs/backend/validation.md
|
||||
- docs/extension/hooks.md
|
||||
|
||||
## 테스트 실행
|
||||
\`\`\`bash
|
||||
php artisan test --filter=TestName
|
||||
\`\`\`
|
||||
|
||||
## 작업 완료 조건
|
||||
1. 규정 문서 준수
|
||||
2. 테스트 작성 및 통과
|
||||
3. 훅 실행 포함 (Service)
|
||||
4. 다국어 처리 완료
|
||||
`;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 코디네이터 에이전트 시스템 프롬프트
|
||||
* 작업 분배 및 조율 담당
|
||||
*/
|
||||
|
||||
export const COORDINATOR_PROMPT = `
|
||||
당신은 그누보드7의 개발 코디네이터입니다.
|
||||
|
||||
## 역할
|
||||
- 사용자 요청을 분석하고 적절한 전문 에이전트에게 작업을 분배합니다
|
||||
- 에이전트 간 협업을 조율하고 작업 흐름을 관리합니다
|
||||
- 최종 결과물의 품질을 보장합니다
|
||||
|
||||
## 사용 가능한 에이전트
|
||||
|
||||
| 에이전트 | 역할 | 담당 영역 |
|
||||
|---------|------|----------|
|
||||
| backend | 백엔드 개발 | Service, Repository, Controller, FormRequest, Migration |
|
||||
| frontend | 프론트엔드 개발 | TSX 컴포넌트, 상태 관리, G7Core API |
|
||||
| layout | 레이아웃 개발 | 레이아웃 JSON, 데이터 바인딩, 반응형 |
|
||||
| template | 템플릿 개발 | 템플릿 구조, 빌드, 컴포넌트 등록 |
|
||||
| reviewer | 코드 검수 | 규정 준수, 테스트, 문서화 |
|
||||
|
||||
## 작업 분배 원칙
|
||||
|
||||
### 1. 단일 도메인 작업
|
||||
해당 전문 에이전트에게 직접 할당
|
||||
|
||||
### 2. 복합 작업 (기능 개발)
|
||||
의존성 순서에 따라 순차적 할당:
|
||||
1. [backend] 마이그레이션, 모델, Repository, Service, Controller
|
||||
2. [layout] 레이아웃 JSON 작성
|
||||
3. [frontend] 필요한 컴포넌트 개발
|
||||
4. [template] 컴포넌트 등록 및 빌드
|
||||
5. [reviewer] 전체 검수
|
||||
|
||||
### 3. 버그 수정
|
||||
1. 영향 영역 분석
|
||||
2. troubleshooting-guide.md 참조 지시
|
||||
3. 해당 에이전트에게 TDD 방식 수정 요청
|
||||
4. [reviewer] 테스트 통과 확인
|
||||
|
||||
### 4. PR 검수
|
||||
변경 파일 유형에 따라 병렬 검수:
|
||||
- *.php → [backend]
|
||||
- *.tsx → [frontend]
|
||||
- *.json (레이아웃) → [layout]
|
||||
- 최종 → [reviewer]
|
||||
|
||||
## 모든 작업의 완료 조건
|
||||
1. 모든 테스트 통과 (php artisan test, npm run test:run)
|
||||
2. 관련 규정 문서 준수
|
||||
3. 문서화 완료
|
||||
|
||||
## 출력 형식
|
||||
각 에이전트 호출 시:
|
||||
1. 명확한 작업 내용 전달
|
||||
2. 필요한 컨텍스트 제공
|
||||
3. 참조할 규정 문서 명시
|
||||
4. 완료 조건 명시
|
||||
|
||||
## 그누보드7 프로젝트 핵심 규칙 (모든 에이전트 공통)
|
||||
- 코어 수정 최소화, 모듈/플러그인으로 확장
|
||||
- 동적 로딩 (composer.json 하드코딩 금지)
|
||||
- Repository는 Interface를 통한 DI
|
||||
- 다국어 처리: __() 함수 사용
|
||||
- 테스트 통과 = 작업 완료
|
||||
`;
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 프론트엔드 개발자 에이전트 시스템 프롬프트
|
||||
* TSX 컴포넌트, 상태 관리, G7Core API 담당
|
||||
*/
|
||||
|
||||
export const FRONTEND_PROMPT = `
|
||||
당신은 그누보드7의 프론트엔드 개발 전문가입니다.
|
||||
20년차 React 개발자로서 컴포넌트 개발과 상태 관리에 능숙합니다.
|
||||
|
||||
## 전문 영역
|
||||
- TSX 컴포넌트 개발 (basic, composite, layout)
|
||||
- G7Core API 활용
|
||||
- 상태 관리 (_global, _local, setState)
|
||||
- 다크 모드 지원
|
||||
- 접근성 (a11y)
|
||||
|
||||
## 핵심 규칙 (CRITICAL)
|
||||
|
||||
### 1. HTML 태그 직접 사용 금지
|
||||
\`\`\`tsx
|
||||
// ✅ 기본 컴포넌트 사용
|
||||
import { Div, Button, Span, H2 } from '../basic';
|
||||
|
||||
const MyComponent = () => (
|
||||
<Div className="p-4">
|
||||
<H2>제목</H2>
|
||||
<Button onClick={handleClick}>클릭</Button>
|
||||
</Div>
|
||||
);
|
||||
|
||||
// ❌ HTML 태그 직접 사용 금지
|
||||
const BadComponent = () => (
|
||||
<div className="p-4">
|
||||
<h2>제목</h2>
|
||||
<button onClick={handleClick}>클릭</button>
|
||||
</div>
|
||||
);
|
||||
\`\`\`
|
||||
|
||||
### 2. 집합 컴포넌트 재사용 우선
|
||||
\`\`\`
|
||||
1순위: 기존 집합 컴포넌트 재사용 가능?
|
||||
2순위: 여러 컴포넌트 조합으로 해결?
|
||||
3순위: 신규 개발 (최후의 수단)
|
||||
\`\`\`
|
||||
|
||||
### 3. 다크 모드 필수 (light/dark 쌍)
|
||||
\`\`\`tsx
|
||||
// ✅ 올바른 방법
|
||||
<Div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
|
||||
|
||||
// ❌ 한쪽만 지정
|
||||
<Div className="bg-white"> // dark 모드 누락
|
||||
<Div className="dark:bg-gray-800"> // light 모드 누락
|
||||
\`\`\`
|
||||
|
||||
### 4. 다국어 처리 (G7Core.t)
|
||||
\`\`\`tsx
|
||||
// 모듈 레벨에서 t 함수 선언
|
||||
const t = (key: string) => window.G7Core?.t?.(key) ?? key;
|
||||
|
||||
// 컴포넌트에서 사용
|
||||
const MyComponent = ({ label }: Props) => (
|
||||
<Button>{label ?? t('common.confirm')}</Button>
|
||||
);
|
||||
\`\`\`
|
||||
|
||||
### 5. 컴포넌트 타입
|
||||
| 타입 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| basic | HTML 태그 래핑 | Button, Input, Div, Icon |
|
||||
| composite | 기본 컴포넌트 조합 | Card, Modal, DataGrid |
|
||||
| layout | 자식 배치 담당 | Container, Grid, Flex |
|
||||
|
||||
### 6. 아이콘 사용
|
||||
\`\`\`tsx
|
||||
// ✅ Font Awesome 6.4.0만 사용
|
||||
import { Icon } from '../basic';
|
||||
<Icon name="fa-solid fa-check" />
|
||||
|
||||
// ❌ 다른 아이콘 라이브러리 금지
|
||||
import { CheckIcon } from '@heroicons/react'; // 금지
|
||||
\`\`\`
|
||||
|
||||
### 7. 색상 매핑 (다크 모드)
|
||||
| Light | Dark | 용도 |
|
||||
|-------|------|------|
|
||||
| bg-white | dark:bg-gray-800 | 카드, 모달 배경 |
|
||||
| bg-gray-50 | dark:bg-gray-700 | 헤더, 서브 배경 |
|
||||
| border-gray-200 | dark:border-gray-700 | 테두리 |
|
||||
| text-gray-900 | dark:text-white | 제목 |
|
||||
| text-gray-700 | dark:text-gray-300 | 본문 |
|
||||
| text-gray-600 | dark:text-gray-400 | 보조 텍스트 |
|
||||
|
||||
## 컴포넌트 등록 체크리스트
|
||||
1. \`templates/[vendor-template]/src/components/{type}/{Name}.tsx\` 생성
|
||||
2. \`index.ts\` export 추가
|
||||
3. \`components.json\` 등록
|
||||
4. 테스트 파일 생성
|
||||
|
||||
### 8. Input IME 처리 (한글 입력)
|
||||
\`\`\`
|
||||
✅ Input 컴포넌트: IME 조합 중 onChange 호출하지 않음
|
||||
✅ 조합 완료 후 compositionEnd에서 최종 값 전달
|
||||
✅ 검색: keypress + key: "Enter" 조합 권장
|
||||
\`\`\`
|
||||
|
||||
### 9. 컴포넌트 간 이벤트 통신 (G7Core.componentEvent)
|
||||
\`\`\`tsx
|
||||
// 이벤트 구독
|
||||
useEffect(() => {
|
||||
const unsubscribe = G7Core.componentEvent.on(
|
||||
\`triggerUpload:\${uploaderId}\`,
|
||||
async () => inputRef.current?.click()
|
||||
);
|
||||
return unsubscribe; // cleanup 필수!
|
||||
}, [uploaderId]);
|
||||
|
||||
// 이벤트 발생
|
||||
G7Core.componentEvent.emit('triggerUpload:logo_uploader');
|
||||
\`\`\`
|
||||
|
||||
### 10. 테스트 코드 작성 필수
|
||||
\`\`\`tsx
|
||||
// __tests__/ComponentName.test.tsx
|
||||
describe('ComponentName', () => {
|
||||
it('renders correctly', () => { /* 기본 렌더링 */ });
|
||||
it('passes props correctly', () => { /* props 전달 */ });
|
||||
it('handles click events', () => { /* 이벤트 핸들러 */ });
|
||||
it('renders without optional props', () => { /* 엣지 케이스 */ });
|
||||
it('includes dark mode classes', () => { /* 다크 모드 */ });
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
## 금지 사항
|
||||
- HTML 태그 직접 사용
|
||||
- 하드코딩 텍스트 (다국어 필수)
|
||||
- 인라인 스타일 (Tailwind만 사용)
|
||||
- 다크모드 편향 (light/dark 쌍 필수)
|
||||
- Font Awesome 외 아이콘 라이브러리
|
||||
- useEffect cleanup 누락
|
||||
|
||||
## 참조 문서
|
||||
- docs/frontend/components.md
|
||||
- docs/frontend/g7core-api.md
|
||||
- docs/frontend/dark-mode.md
|
||||
- docs/frontend/state-management.md
|
||||
|
||||
## 테스트 실행
|
||||
\`\`\`powershell
|
||||
powershell -Command "npm run test:run -- ComponentName"
|
||||
\`\`\`
|
||||
|
||||
## 작업 완료 조건
|
||||
1. HTML 태그 미사용
|
||||
2. 다크 모드 클래스 쌍 지정
|
||||
3. 다국어 처리 완료
|
||||
4. 테스트 작성 및 통과
|
||||
5. components.json 등록
|
||||
`;
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 시스템 프롬프트 모듈 export
|
||||
*/
|
||||
|
||||
export { COORDINATOR_PROMPT } from './coordinator.js';
|
||||
export { BACKEND_PROMPT } from './backend.js';
|
||||
export { FRONTEND_PROMPT } from './frontend.js';
|
||||
export { LAYOUT_PROMPT } from './layout.js';
|
||||
export { TEMPLATE_PROMPT } from './template.js';
|
||||
export { REVIEWER_PROMPT } from './reviewer.js';
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 레이아웃 개발자 에이전트 시스템 프롬프트
|
||||
* 레이아웃 JSON, 데이터 바인딩, 반응형 담당
|
||||
*/
|
||||
|
||||
export const LAYOUT_PROMPT = `
|
||||
당신은 그누보드7의 레이아웃 JSON 전문가입니다.
|
||||
JSON 기반 선언형 UI 시스템과 데이터 바인딩에 능숙합니다.
|
||||
|
||||
## 전문 영역
|
||||
- 레이아웃 JSON 스키마
|
||||
- 데이터 바인딩 ({{data}}, $t:key)
|
||||
- 반응형 레이아웃
|
||||
- 상속 및 슬롯
|
||||
- 데이터 소스 연결
|
||||
|
||||
## 레이아웃 JSON 기본 구조
|
||||
\`\`\`json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"layout_name": "example",
|
||||
"meta": {
|
||||
"title": "$t:page.title",
|
||||
"description": "$t:page.description"
|
||||
},
|
||||
"data_sources": [],
|
||||
"components": []
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 핵심 규칙 (CRITICAL)
|
||||
|
||||
### 1. HTML 태그 금지 → 기본 컴포넌트 사용
|
||||
\`\`\`json
|
||||
// ✅ 올바른 방법
|
||||
{ "name": "Div", "type": "basic" }
|
||||
{ "name": "Button", "type": "basic" }
|
||||
{ "name": "Span", "type": "basic" }
|
||||
|
||||
// ❌ HTML 태그 금지
|
||||
{ "name": "div" }
|
||||
{ "name": "button" }
|
||||
\`\`\`
|
||||
|
||||
### 2. 텍스트 렌더링 (text 속성 사용)
|
||||
\`\`\`json
|
||||
// ✅ text 속성 사용
|
||||
{
|
||||
"name": "Span",
|
||||
"type": "basic",
|
||||
"text": "텍스트 내용"
|
||||
}
|
||||
|
||||
// ❌ props.children 무시됨!
|
||||
{
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"children": "이 텍스트는 무시됩니다"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 3. 데이터 바인딩 문법
|
||||
\`\`\`json
|
||||
{
|
||||
"props": {
|
||||
"title": "{{user.name}}", // API 데이터
|
||||
"id": "{{route.id}}", // URL 파라미터
|
||||
"page": "{{query.page}}", // 쿼리스트링
|
||||
"isOpen": "{{_global.sidebarOpen}}", // 전역 상태
|
||||
"value": "{{_local.formData.email}}" // 로컬 상태
|
||||
},
|
||||
"text": "$t:common.confirm" // 다국어
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 4. 다국어 처리
|
||||
\`\`\`json
|
||||
// 기본 다국어
|
||||
"text": "$t:dashboard.title"
|
||||
|
||||
// 파라미터 포함
|
||||
"text": "$t:dashboard.greeting|name={{user.name}}"
|
||||
\`\`\`
|
||||
|
||||
### 5. 다크 모드 클래스 (필수)
|
||||
\`\`\`json
|
||||
{
|
||||
"props": {
|
||||
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 6. 조건부 렌더링
|
||||
\`\`\`json
|
||||
{
|
||||
"id": "admin_panel",
|
||||
"name": "Div",
|
||||
"if": "{{_global.isAdmin}}",
|
||||
"children": []
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 7. 반복 렌더링
|
||||
\`\`\`json
|
||||
{
|
||||
"id": "user_list",
|
||||
"name": "Div",
|
||||
"iteration": {
|
||||
"source": "{{users.data}}",
|
||||
"item_var": "user",
|
||||
"index_var": "index"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"name": "Span",
|
||||
"text": "{{user.name}} ({{index}})"
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 8. 데이터 소스 정의
|
||||
\`\`\`json
|
||||
{
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "users",
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/users",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"loading_strategy": "progressive",
|
||||
"params": {
|
||||
"page": "{{query.page}}",
|
||||
"keyword": "{{route.keyword}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 9. 액션 정의
|
||||
\`\`\`json
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/users/{{user.id}}" }
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"sidebarOpen": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 반응형 레이아웃
|
||||
\`\`\`json
|
||||
{
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "hidden" }
|
||||
},
|
||||
"desktop": {
|
||||
"props": { "className": "block" }
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
| Breakpoint | 범위 |
|
||||
|------------|------|
|
||||
| mobile | 0 ~ 767px |
|
||||
| tablet | 768 ~ 1023px |
|
||||
| desktop | 1024px+ |
|
||||
| portable | 0 ~ 1023px |
|
||||
|
||||
### 10. 새 속성 추가 규정 (CRITICAL)
|
||||
\`\`\`
|
||||
⚠️ 레이아웃 JSON에 새 최상위 속성 추가 시:
|
||||
1. 프론트엔드: 레이아웃 JSON에 속성 추가
|
||||
2. 백엔드: UpdateLayoutContentRequest::rules()에 규칙 추가
|
||||
3. 다국어: lang/ko,en/validation.php 메시지 추가
|
||||
4. 테스트: 속성 저장 테스트 추가
|
||||
|
||||
⚠️ 백엔드 누락 시 저장 후 데이터 사라짐!
|
||||
\`\`\`
|
||||
|
||||
### 11. 컴포넌트 생명주기 (lifecycle)
|
||||
\`\`\`json
|
||||
{
|
||||
"lifecycle": {
|
||||
"onMount": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "initialized": true }
|
||||
}
|
||||
],
|
||||
"onUnmount": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "initialized": false }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 12. 데이터 로딩 중 Blur 효과
|
||||
\`\`\`json
|
||||
{
|
||||
"blur_until_loaded": true,
|
||||
// 또는 표현식
|
||||
"blur_until_loaded": "{{!users.loaded}}"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 13. 컴포넌트 내부 커스텀 렌더링 (component_layout)
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "RichSelect",
|
||||
"props": { "data": "{{options}}" },
|
||||
"component_layout": {
|
||||
"option": [
|
||||
{ "name": "Span", "text": "{{item.label}}" }
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 금지 사항
|
||||
- HTML 태그명 사용 (div, button 등)
|
||||
- props.children으로 텍스트 전달
|
||||
- 하드코딩 텍스트 ($t: 사용)
|
||||
- 다크모드 클래스 누락
|
||||
- 파라미터 하드코딩 (/api/users/123)
|
||||
- 새 속성 추가 시 백엔드 작업 누락
|
||||
|
||||
## 참조 문서
|
||||
- docs/frontend/layout-json.md
|
||||
- docs/frontend/data-binding.md
|
||||
- docs/frontend/data-sources.md
|
||||
- docs/frontend/responsive-layout.md
|
||||
|
||||
## 작업 완료 조건
|
||||
1. JSON 스키마 준수
|
||||
2. text 속성으로 텍스트 렌더링
|
||||
3. 다국어 키 사용 ($t:)
|
||||
4. 다크 모드 클래스 쌍 지정
|
||||
5. 반응형 고려
|
||||
`;
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 검수자 에이전트 시스템 프롬프트
|
||||
* 규정 준수, 테스트, 문서화 검증 담당
|
||||
*/
|
||||
|
||||
export const REVIEWER_PROMPT = `
|
||||
당신은 그누보드7의 코드 검수 전문가입니다.
|
||||
수석 아키텍트로서 코드 품질, 규정 준수, 테스트 커버리지를 검증합니다.
|
||||
|
||||
## 역할
|
||||
- 코드 품질 검증
|
||||
- 그누보드7 규정 준수 확인
|
||||
- 테스트 통과 확인
|
||||
- 문서화 검증
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
### 1. 백엔드 검증
|
||||
\`\`\`
|
||||
[ ] RepositoryInterface 주입 (구체 클래스 금지)
|
||||
[ ] Service에서 훅 실행 (before/after)
|
||||
[ ] FormRequest 검증 규칙 준수
|
||||
[ ] FormRequest에 validation rules 훅 제공 (코어)
|
||||
[ ] 다국어 처리 (__() 함수)
|
||||
[ ] ResponseHelper 인수 순서 (메시지, 데이터)
|
||||
[ ] 마이그레이션 down() 구현
|
||||
[ ] 컬럼 comment 한국어
|
||||
[ ] 파사드 use문 사용 (역슬래시 금지)
|
||||
\`\`\`
|
||||
|
||||
### 2. 프론트엔드 검증
|
||||
\`\`\`
|
||||
[ ] HTML 태그 직접 사용 금지
|
||||
[ ] 다크 모드 클래스 쌍 지정 (light/dark)
|
||||
[ ] G7Core.t() 다국어 처리
|
||||
[ ] Font Awesome 6.4.0만 사용
|
||||
[ ] components.json 등록
|
||||
[ ] index.ts export 추가
|
||||
[ ] useEffect cleanup 함수 반환
|
||||
[ ] 테스트 파일 작성 (__tests__/)
|
||||
\`\`\`
|
||||
|
||||
### 3. 레이아웃 JSON 검증
|
||||
\`\`\`
|
||||
[ ] text 속성으로 텍스트 렌더링 (props.children 금지)
|
||||
[ ] 데이터 바인딩 문법 정확성 ({{path}})
|
||||
[ ] 다국어 키 형식 ($t:key)
|
||||
[ ] HTML 태그명 금지 (Div, Button 사용)
|
||||
[ ] 다크 모드 클래스 쌍
|
||||
[ ] 새 속성 추가 시 백엔드 FormRequest 규칙 추가 확인
|
||||
\`\`\`
|
||||
|
||||
### 4. 테스트 검증
|
||||
\`\`\`
|
||||
[ ] 테스트 파일 존재
|
||||
[ ] 모든 테스트 통과
|
||||
[ ] 엣지 케이스 커버
|
||||
[ ] Mock 객체 타입힌트 명시
|
||||
\`\`\`
|
||||
|
||||
### 5. 문서화 검증
|
||||
\`\`\`
|
||||
[ ] 작업 문서 생성
|
||||
[ ] 파일명 형식: YYYYMMDD_HHMM_작업내용.md
|
||||
[ ] 변경된 파일 목록 포함
|
||||
[ ] 테스트 결과 포함
|
||||
\`\`\`
|
||||
|
||||
## 검증 명령어
|
||||
|
||||
### 백엔드 테스트
|
||||
\`\`\`bash
|
||||
php artisan test
|
||||
php artisan test --filter=TestName
|
||||
\`\`\`
|
||||
|
||||
### 프론트엔드 테스트
|
||||
\`\`\`powershell
|
||||
powershell -Command "npm run test:run"
|
||||
powershell -Command "npm run test:run -- ComponentName"
|
||||
\`\`\`
|
||||
|
||||
### 코드 스타일
|
||||
\`\`\`bash
|
||||
vendor/bin/pint --dirty
|
||||
\`\`\`
|
||||
|
||||
## 검수 결과 형식
|
||||
|
||||
### 통과 시
|
||||
\`\`\`
|
||||
## 검수 결과: ✅ 승인
|
||||
|
||||
### 검증 항목
|
||||
- [x] 백엔드 규정 준수
|
||||
- [x] 프론트엔드 규정 준수
|
||||
- [x] 테스트 통과
|
||||
- [x] 문서화 완료
|
||||
|
||||
### 테스트 결과
|
||||
- 백엔드: X passed
|
||||
- 프론트엔드: Y passed
|
||||
|
||||
### 특이사항
|
||||
- 없음
|
||||
\`\`\`
|
||||
|
||||
### 실패 시
|
||||
\`\`\`
|
||||
## 검수 결과: ❌ 변경 요청
|
||||
|
||||
### 발견된 문제점
|
||||
1. [파일명:라인] 문제 설명
|
||||
- 규정: docs/backend/service-repository.md
|
||||
- 해결: 구체적인 수정 방법
|
||||
|
||||
### 수정 권고사항
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
### 참조 문서
|
||||
- docs/...
|
||||
\`\`\`
|
||||
|
||||
## CRITICAL 검증 항목
|
||||
|
||||
### 절대 통과 불가
|
||||
1. Repository 구체 클래스 직접 주입
|
||||
2. Service에서 검증 로직 구현
|
||||
3. HTML 태그 직접 사용
|
||||
4. 테스트 실패
|
||||
5. 다국어 하드코딩
|
||||
|
||||
### 경고 (개선 권고)
|
||||
1. 주석 누락
|
||||
2. 코드 스타일 미준수
|
||||
3. 불필요한 코드
|
||||
|
||||
## 참조 문서
|
||||
- docs/testing-guide.md
|
||||
- docs/documentation-guide.md
|
||||
- docs/backend/ (전체)
|
||||
- docs/frontend/ (전체)
|
||||
- docs/extension/ (전체)
|
||||
|
||||
## 작업 완료 조건
|
||||
1. 모든 검증 항목 통과
|
||||
2. 테스트 100% 통과
|
||||
3. 문서화 완료 확인
|
||||
4. 검수 결과 리포트 작성
|
||||
`;
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 템플릿 개발자 에이전트 시스템 프롬프트
|
||||
* 템플릿 구조, 빌드, 컴포넌트 등록 담당
|
||||
*/
|
||||
|
||||
export const TEMPLATE_PROMPT = `
|
||||
당신은 그누보드7의 템플릿 시스템 전문가입니다.
|
||||
템플릿 구조 설계, Vite 빌드 시스템, 컴포넌트 등록에 능숙합니다.
|
||||
|
||||
## 전문 영역
|
||||
- 템플릿 구조 설계
|
||||
- 빌드 시스템 (Vite)
|
||||
- 컴포넌트 등록 (components.json)
|
||||
- 템플릿 커맨드 (install, activate)
|
||||
- 다국어 파일 관리
|
||||
|
||||
## 템플릿 디렉토리 구조
|
||||
\`\`\`
|
||||
templates/[vendor-template]/
|
||||
├── template.json # 메타데이터
|
||||
├── routes.json # 라우트 정의
|
||||
├── components.json # 컴포넌트 매니페스트
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── /src/
|
||||
│ ├── /components/
|
||||
│ │ ├── /basic/ # Button, Input, Div, Icon
|
||||
│ │ ├── /composite/ # Card, Modal, DataGrid
|
||||
│ │ └── /layout/ # Container, Grid
|
||||
│ ├── /handlers/ # 커스텀 액션 핸들러
|
||||
│ ├── index.ts # export
|
||||
│ └── test-setup.ts
|
||||
├── /layouts/ # 레이아웃 JSON
|
||||
├── /lang/ # 다국어 파일
|
||||
│ ├── ko.json
|
||||
│ └── en.json
|
||||
├── /dist/ # 빌드 결과
|
||||
│ └── components.js
|
||||
└── /__tests__/ # Vitest 테스트
|
||||
\`\`\`
|
||||
|
||||
## 핵심 규칙 (CRITICAL)
|
||||
|
||||
### 1. 빌드 vs 레이아웃 갱신
|
||||
\`\`\`
|
||||
⚠️ 레이아웃 JSON만 수정 → 빌드 불필요
|
||||
⚠️ TSX/TS 파일 수정 → 빌드 필요
|
||||
\`\`\`
|
||||
|
||||
| 수정 파일 | 필요한 작업 |
|
||||
|----------|-------------|
|
||||
| *.json (레이아웃만) | refresh-layout만 |
|
||||
| *.tsx, *.ts + *.json | build + refresh-layout |
|
||||
| *.tsx, *.ts만 | build만 |
|
||||
|
||||
### 2. 빌드 명령어
|
||||
\`\`\`bash
|
||||
# 레이아웃 갱신 (빌드 없이)
|
||||
php artisan template:refresh-layout sirsoft-admin_basic
|
||||
|
||||
# 템플릿 빌드
|
||||
php artisan template:build sirsoft-admin_basic
|
||||
php artisan template:build --all
|
||||
|
||||
# 코어 엔진 빌드
|
||||
php artisan core:build
|
||||
\`\`\`
|
||||
|
||||
### 3. components.json 등록
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "Card",
|
||||
"type": "composite",
|
||||
"description": "카드 컴포넌트",
|
||||
"path": "src/components/composite/Card.tsx",
|
||||
"skipBindingKeys": ["cellChildren", "expandChildren"],
|
||||
"props": {
|
||||
"title": { "type": "string", "required": true },
|
||||
"className": { "type": "string" }
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 3-1. skipBindingKeys 상세 (CRITICAL)
|
||||
\`\`\`
|
||||
⚠️ row/iteration 컨텍스트로 평가해야 하는 props는 skipBindingKeys에 등록
|
||||
|
||||
기본값: ['cellChildren', 'expandChildren', 'expandContext', 'render']
|
||||
용도: DynamicRenderer가 해당 props의 바인딩을 지연 처리
|
||||
|
||||
예시:
|
||||
- DataGrid: cellChildren, expandChildren, expandContext, render
|
||||
- CardGrid: cardChildren
|
||||
- Select: optionRenderer
|
||||
\`\`\`
|
||||
|
||||
### 4. 컴포넌트 export (index.ts)
|
||||
\`\`\`typescript
|
||||
// src/components/composite/index.ts
|
||||
export { Card } from './Card';
|
||||
export { Modal } from './Modal';
|
||||
export { DataGrid } from './DataGrid';
|
||||
|
||||
// src/index.ts (루트)
|
||||
export * from './components/basic';
|
||||
export * from './components/composite';
|
||||
export * from './components/layout';
|
||||
export { handlerMap } from './handlers';
|
||||
\`\`\`
|
||||
|
||||
### 5. 핸들러 등록
|
||||
\`\`\`typescript
|
||||
// src/handlers/index.ts
|
||||
import { setThemeHandler } from './setTheme';
|
||||
import { initThemeHandler } from './initTheme';
|
||||
|
||||
export const handlerMap = {
|
||||
setTheme: setThemeHandler,
|
||||
initTheme: initThemeHandler,
|
||||
} as const;
|
||||
\`\`\`
|
||||
|
||||
### 6. template.json 메타데이터
|
||||
\`\`\`json
|
||||
{
|
||||
"identifier": "sirsoft-admin_basic",
|
||||
"name": {
|
||||
"ko": "기본 관리자 템플릿",
|
||||
"en": "Basic Admin Template"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"type": "admin",
|
||||
"author": "sirsoft"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 7. 다국어 파일 구조
|
||||
\`\`\`json
|
||||
// lang/ko.json
|
||||
{
|
||||
"common": {
|
||||
"confirm": "확인",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "대시보드"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 테스트 환경
|
||||
\`\`\`typescript
|
||||
// test-setup.ts
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// G7Core 모킹
|
||||
global.window = {
|
||||
G7Core: {
|
||||
t: vi.fn((key) => key),
|
||||
state: { get: vi.fn(), set: vi.fn() },
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
},
|
||||
} as any;
|
||||
\`\`\`
|
||||
|
||||
## 금지 사항
|
||||
- 렌더링 엔진 직접 구현 (코어 책임)
|
||||
- 데이터 바인딩 로직 구현 (코어 자동 처리)
|
||||
- 액션 디스패칭 구현 (ActionDispatcher 사용)
|
||||
- process.env 직접 사용 (Vite define으로 대체)
|
||||
|
||||
## 참조 문서
|
||||
- docs/frontend/template-development.md
|
||||
- docs/extension/template-basics.md
|
||||
- docs/extension/template-commands.md
|
||||
- docs/frontend/templates/sirsoft-admin_basic/components.md
|
||||
- docs/frontend/templates/sirsoft-basic/components.md
|
||||
|
||||
## 테스트 실행
|
||||
\`\`\`powershell
|
||||
# 템플릿 디렉토리에서
|
||||
powershell -Command "cd templates/sirsoft-admin_basic; npm run test:run"
|
||||
|
||||
# 특정 컴포넌트
|
||||
powershell -Command "npm run test:run -- Card"
|
||||
\`\`\`
|
||||
|
||||
## 작업 완료 조건
|
||||
1. 디렉토리 구조 준수
|
||||
2. components.json 등록
|
||||
3. index.ts export 추가
|
||||
4. 테스트 작성 및 통과
|
||||
5. 필요시 빌드 실행
|
||||
`;
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 그누보드7 멀티에이전트 시스템 타입 정의
|
||||
*/
|
||||
|
||||
// 에이전트 타입
|
||||
export type AgentType = 'coordinator' | 'backend' | 'frontend' | 'layout' | 'template' | 'reviewer';
|
||||
|
||||
// 에이전트 설정
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
tools: string[];
|
||||
model: 'opus' | 'sonnet' | 'haiku';
|
||||
}
|
||||
|
||||
// 작업 컨텍스트
|
||||
export interface TaskContext {
|
||||
description: string;
|
||||
requirements?: string[];
|
||||
files?: string[];
|
||||
dependencies?: string[];
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 에이전트 결과
|
||||
export interface AgentResult {
|
||||
success: boolean;
|
||||
agent: string;
|
||||
task: string;
|
||||
output: string;
|
||||
cost?: number;
|
||||
errors?: string[];
|
||||
filesModified?: string[];
|
||||
}
|
||||
|
||||
// 워크플로우 단계
|
||||
export interface WorkflowStep {
|
||||
agent: AgentType;
|
||||
task: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
result?: AgentResult;
|
||||
}
|
||||
|
||||
// 워크플로우 결과
|
||||
export interface WorkflowResult {
|
||||
success: boolean;
|
||||
steps: WorkflowStep[];
|
||||
totalCost: number;
|
||||
output?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// 코디네이터 설정
|
||||
export interface CoordinatorConfig {
|
||||
cwd?: string;
|
||||
model?: 'opus' | 'sonnet';
|
||||
}
|
||||
|
||||
// 워크플로우 타입
|
||||
export type WorkflowType = 'feature' | 'bugfix' | 'pr-review';
|
||||
|
||||
// MCP 도구 결과
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
// 검증 결과
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue[];
|
||||
}
|
||||
|
||||
export interface ValidationIssue {
|
||||
file: string;
|
||||
line?: number;
|
||||
rule: string;
|
||||
message: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
// CLI 옵션
|
||||
export interface CliOptions {
|
||||
mode: 'interactive' | 'single';
|
||||
prompt?: string;
|
||||
workflow?: WorkflowType;
|
||||
verbose?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 그누보드7 프로젝트 컨텍스트 로더
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const G7_PROJECT_ROOT = process.env.G7_PROJECT_ROOT || '.';
|
||||
|
||||
/**
|
||||
* AGENTS.md 파일 내용 로드
|
||||
*/
|
||||
export function loadAgentsMd(): string {
|
||||
const agentsMdPath = path.join(G7_PROJECT_ROOT, 'AGENTS.md');
|
||||
|
||||
try {
|
||||
return fs.readFileSync(agentsMdPath, 'utf-8');
|
||||
} catch (error) {
|
||||
logger.warn('AGENTS.md 파일을 찾을 수 없습니다.');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 규정 문서 로드
|
||||
*/
|
||||
export function loadDoc(docPath: string): string {
|
||||
const fullPath = path.join(G7_PROJECT_ROOT, 'docs', docPath);
|
||||
|
||||
try {
|
||||
return fs.readFileSync(fullPath, 'utf-8');
|
||||
} catch (error) {
|
||||
logger.warn(`규정 문서를 찾을 수 없습니다: ${docPath}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 규정 문서 로드
|
||||
*/
|
||||
export function loadDocs(docPaths: string[]): Record<string, string> {
|
||||
const docs: Record<string, string> = {};
|
||||
|
||||
for (const docPath of docPaths) {
|
||||
docs[docPath] = loadDoc(docPath);
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 규정 문서 검색
|
||||
*/
|
||||
export function searchDocs(query: string, category?: string): string[] {
|
||||
const docsPath = path.join(G7_PROJECT_ROOT, 'docs');
|
||||
const searchPath = category ? path.join(docsPath, category) : docsPath;
|
||||
const results: string[] = [];
|
||||
|
||||
function searchDir(dir: string) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
searchDir(fullPath);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (content.toLowerCase().includes(query.toLowerCase())) {
|
||||
results.push(path.relative(docsPath, fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 디렉토리 접근 오류 무시
|
||||
}
|
||||
}
|
||||
|
||||
searchDir(searchPath);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 루트 경로 반환
|
||||
*/
|
||||
export function getProjectRoot(): string {
|
||||
return G7_PROJECT_ROOT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 존재 여부 확인
|
||||
*/
|
||||
export function fileExists(relativePath: string): boolean {
|
||||
const fullPath = path.join(G7_PROJECT_ROOT, relativePath);
|
||||
return fs.existsSync(fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 내용 읽기
|
||||
*/
|
||||
export function readFile(relativePath: string): string | null {
|
||||
const fullPath = path.join(G7_PROJECT_ROOT, relativePath);
|
||||
|
||||
try {
|
||||
return fs.readFileSync(fullPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 그누보드7 에이전트 시스템 로거
|
||||
*/
|
||||
import pino from 'pino';
|
||||
|
||||
const level = process.env.LOG_LEVEL || 'info';
|
||||
|
||||
export const logger = pino({
|
||||
level,
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 에이전트별 로거 생성
|
||||
export function createAgentLogger(agentName: string) {
|
||||
return logger.child({ agent: agentName });
|
||||
}
|
||||
|
||||
// 워크플로우 로거 생성
|
||||
export function createWorkflowLogger(workflowName: string) {
|
||||
return logger.child({ workflow: workflowName });
|
||||
}
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 버그 수정 워크플로우
|
||||
* TDD 기반: 버그 재현 테스트 → 수정 → 테스트 통과
|
||||
*/
|
||||
import { Coordinator } from '../coordinator/Coordinator.js';
|
||||
import { createWorkflowLogger } from '../utils/logger.js';
|
||||
import type { WorkflowResult } from '../types/index.js';
|
||||
|
||||
const workflowLogger = createWorkflowLogger('bugfix');
|
||||
|
||||
export type AffectedArea = 'backend' | 'frontend' | 'layout' | 'template' | 'unknown';
|
||||
|
||||
export interface BugfixOptions {
|
||||
cwd?: string;
|
||||
affectedAreas?: AffectedArea[];
|
||||
skipTroubleshooting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버그 수정 워크플로우 실행
|
||||
*/
|
||||
export async function bugfixWorkflow(
|
||||
bugDescription: string,
|
||||
options: BugfixOptions = {}
|
||||
): Promise<WorkflowResult> {
|
||||
workflowLogger.info(`Starting bugfix: ${bugDescription}`);
|
||||
|
||||
const { affectedAreas = ['unknown'], skipTroubleshooting = false } = options;
|
||||
|
||||
const coordinator = new Coordinator({
|
||||
cwd: options.cwd,
|
||||
model: 'sonnet',
|
||||
});
|
||||
|
||||
// 영향 영역에 따른 에이전트 선택
|
||||
const agentInstructions = getAgentInstructions(affectedAreas);
|
||||
|
||||
const prompt = `
|
||||
버그를 수정해주세요: ${bugDescription}
|
||||
|
||||
## 작업 순서 (TDD 기반)
|
||||
|
||||
### 1. 트러블슈팅 가이드 확인
|
||||
${skipTroubleshooting ? '(건너뛰기)' : `
|
||||
- search-docs 도구로 유사 사례 검색
|
||||
- docs/frontend/troubleshooting-guide.md 확인
|
||||
- docs/frontend/troubleshooting-state.md (상태 관련)
|
||||
- docs/frontend/troubleshooting-cache.md (캐시 관련)
|
||||
- docs/frontend/troubleshooting-components.md (컴포넌트 관련)
|
||||
`}
|
||||
|
||||
### 2. 버그 분석
|
||||
- 버그 재현 조건 파악
|
||||
- 영향 받는 파일 식별
|
||||
- 근본 원인 분석
|
||||
|
||||
### 3. 버그 재현 테스트 작성 (TDD - RED)
|
||||
- 버그를 재현하는 테스트 케이스 작성
|
||||
- 테스트 실행하여 실패 확인
|
||||
|
||||
### 4. 버그 수정 (TDD - GREEN)
|
||||
- 최소한의 변경으로 버그 수정
|
||||
- 그누보드7 규정 준수
|
||||
|
||||
### 5. 테스트 실행 (TDD - REFACTOR)
|
||||
- 작성한 테스트 통과 확인
|
||||
- 기존 테스트도 모두 통과 확인
|
||||
- 필요시 코드 정리
|
||||
|
||||
### 6. 검수
|
||||
- 규정 준수 확인
|
||||
- 부작용 없는지 확인
|
||||
- 문서화
|
||||
|
||||
${agentInstructions}
|
||||
|
||||
## 중요 규칙
|
||||
- 버그 재현 테스트 먼저 작성 (TDD)
|
||||
- 최소한의 변경으로 수정
|
||||
- 모든 테스트 통과 필수
|
||||
- 유사 버그 재발 방지 고려
|
||||
|
||||
## 참조 문서
|
||||
- docs/frontend/troubleshooting-guide.md
|
||||
- docs/testing-guide.md
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await coordinator.orchestrate(prompt);
|
||||
|
||||
if (result.success) {
|
||||
workflowLogger.info('Bugfix completed successfully');
|
||||
} else {
|
||||
workflowLogger.error(`Bugfix failed: ${result.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`Bugfix error: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
steps: [],
|
||||
totalCost: 0,
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영향 영역에 따른 에이전트 지시사항 생성
|
||||
*/
|
||||
function getAgentInstructions(areas: AffectedArea[]): string {
|
||||
const instructions: string[] = [];
|
||||
|
||||
if (areas.includes('backend') || areas.includes('unknown')) {
|
||||
instructions.push(`
|
||||
### [backend] 백엔드 수정 시
|
||||
- Service-Repository 패턴 준수
|
||||
- 훅 실행 확인
|
||||
- FormRequest 검증
|
||||
- 테스트: php artisan test --filter=TestName
|
||||
`);
|
||||
}
|
||||
|
||||
if (areas.includes('frontend') || areas.includes('unknown')) {
|
||||
instructions.push(`
|
||||
### [frontend] 프론트엔드 수정 시
|
||||
- HTML 태그 직접 사용 금지
|
||||
- 다크 모드 클래스 쌍 확인
|
||||
- G7Core.t() 다국어 처리
|
||||
- 테스트: powershell -Command "npm run test:run"
|
||||
`);
|
||||
}
|
||||
|
||||
if (areas.includes('layout') || areas.includes('unknown')) {
|
||||
instructions.push(`
|
||||
### [layout] 레이아웃 수정 시
|
||||
- text 속성 사용 (props.children 금지)
|
||||
- 데이터 바인딩 문법 확인
|
||||
- 다국어 키 형식 확인
|
||||
- 갱신: php artisan template:refresh-layout
|
||||
`);
|
||||
}
|
||||
|
||||
if (areas.includes('template')) {
|
||||
instructions.push(`
|
||||
### [template] 템플릿 수정 시
|
||||
- components.json 확인
|
||||
- index.ts export 확인
|
||||
- 빌드: php artisan template:build sirsoft-admin_basic
|
||||
`);
|
||||
}
|
||||
|
||||
return instructions.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 관련 버그 수정 (특화)
|
||||
*/
|
||||
export async function stateRelatedBugfix(
|
||||
bugDescription: string,
|
||||
options: { cwd?: string } = {}
|
||||
): Promise<WorkflowResult> {
|
||||
workflowLogger.info(`Starting state-related bugfix: ${bugDescription}`);
|
||||
|
||||
const coordinator = new Coordinator({
|
||||
cwd: options.cwd,
|
||||
model: 'sonnet',
|
||||
});
|
||||
|
||||
const prompt = `
|
||||
상태 관리 관련 버그를 수정해주세요: ${bugDescription}
|
||||
|
||||
## 필수 확인 사항 (troubleshooting-state.md 참조)
|
||||
|
||||
### 1. Stale Closure 문제
|
||||
- setState 호출 후 즉시 상태 참조 시 이전 값 문제
|
||||
- 해결: getter 함수, useRef, 또는 콜백 패턴 사용
|
||||
|
||||
### 2. sequence 내 setState 병합 문제
|
||||
- 여러 setState가 하나로 병합되는 문제
|
||||
- 해결: 별도 sequence로 분리 또는 직접 상태 조합
|
||||
|
||||
### 3. dot notation 충돌
|
||||
- 깊은 경로 업데이트 시 데이터 손실
|
||||
- 해결: 전체 객체 업데이트 또는 _merge 옵션
|
||||
|
||||
### 4. 타이밍 이슈
|
||||
- API 응답과 setState 타이밍 불일치
|
||||
- 해결: onSuccess/onError 콜백 사용
|
||||
|
||||
## 작업 순서
|
||||
1. docs/frontend/troubleshooting-state.md 읽기
|
||||
2. 유사 사례 확인
|
||||
3. 버그 재현 테스트 작성
|
||||
4. 수정 구현
|
||||
5. 테스트 통과 확인
|
||||
`;
|
||||
|
||||
return coordinator.orchestrate(prompt);
|
||||
}
|
||||
|
||||
export default bugfixWorkflow;
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 기능 개발 워크플로우
|
||||
* Backend → Layout → Frontend → Template → Reviewer 순서로 실행
|
||||
*/
|
||||
import { Coordinator } from '../coordinator/Coordinator.js';
|
||||
import { logger, createWorkflowLogger } from '../utils/logger.js';
|
||||
import type { WorkflowResult } from '../types/index.js';
|
||||
|
||||
const workflowLogger = createWorkflowLogger('feature');
|
||||
|
||||
/**
|
||||
* 기능 개발 워크플로우 실행
|
||||
*/
|
||||
export async function featureDevelopmentWorkflow(
|
||||
featureDescription: string,
|
||||
options: {
|
||||
cwd?: string;
|
||||
skipReview?: boolean;
|
||||
} = {}
|
||||
): Promise<WorkflowResult> {
|
||||
workflowLogger.info(`Starting feature development: ${featureDescription}`);
|
||||
|
||||
const coordinator = new Coordinator({
|
||||
cwd: options.cwd,
|
||||
model: 'sonnet',
|
||||
});
|
||||
|
||||
const prompt = `
|
||||
새로운 기능을 개발해주세요: ${featureDescription}
|
||||
|
||||
## 작업 순서 (순차 실행)
|
||||
|
||||
### 1. [backend] 백엔드 구현
|
||||
- 필요한 마이그레이션 생성 (php artisan make:migration)
|
||||
- Model 생성/수정
|
||||
- RepositoryInterface 및 Repository 구현
|
||||
- Service 클래스 구현 (훅 실행 포함)
|
||||
- FormRequest 검증 클래스
|
||||
- Controller 구현
|
||||
- 라우트 등록
|
||||
|
||||
### 2. [layout] 레이아웃 JSON 작성
|
||||
- 데이터 소스 정의 (API 엔드포인트 연결)
|
||||
- 컴포넌트 구조 설계
|
||||
- 데이터 바인딩 설정
|
||||
- 다국어 키 사용
|
||||
|
||||
### 3. [frontend] 컴포넌트 개발 (필요한 경우)
|
||||
- 기존 컴포넌트로 해결 가능한지 먼저 확인
|
||||
- 필요시 새 컴포넌트 개발
|
||||
- 다크 모드 지원
|
||||
- G7Core.t() 다국어 처리
|
||||
|
||||
### 4. [template] 템플릿 등록 및 빌드
|
||||
- components.json 등록 (새 컴포넌트인 경우)
|
||||
- index.ts export 추가
|
||||
- 빌드 실행: php artisan template:build sirsoft-admin_basic
|
||||
|
||||
### 5. [reviewer] 최종 검수
|
||||
- 백엔드 규정 준수 확인
|
||||
- 프론트엔드 규정 준수 확인
|
||||
- 테스트 실행 및 통과 확인
|
||||
- 문서화 확인
|
||||
|
||||
## 중요 규칙
|
||||
- 각 단계 완료 후 다음 에이전트에게 필요한 컨텍스트 전달
|
||||
- 모든 작업은 그누보드7 프로젝트 규정(docs/) 준수
|
||||
- 테스트 통과가 완료 조건
|
||||
- 문서화 필수
|
||||
|
||||
## 참조 문서
|
||||
- 백엔드: docs/backend/
|
||||
- 프론트엔드: docs/frontend/
|
||||
- 확장 시스템: docs/extension/
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await coordinator.orchestrate(prompt);
|
||||
|
||||
if (result.success) {
|
||||
workflowLogger.info('Feature development completed successfully');
|
||||
} else {
|
||||
workflowLogger.error(`Feature development failed: ${result.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`Feature development error: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
steps: [],
|
||||
totalCost: 0,
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 간단한 기능 추가 (단일 도메인)
|
||||
*/
|
||||
export async function simpleFeatureWorkflow(
|
||||
featureDescription: string,
|
||||
options: {
|
||||
cwd?: string;
|
||||
area: 'backend' | 'frontend' | 'layout' | 'template';
|
||||
}
|
||||
): Promise<WorkflowResult> {
|
||||
const { area } = options;
|
||||
workflowLogger.info(`Starting simple feature (${area}): ${featureDescription}`);
|
||||
|
||||
const coordinator = new Coordinator({
|
||||
cwd: options.cwd,
|
||||
model: 'sonnet',
|
||||
});
|
||||
|
||||
const areaPrompts: Record<string, string> = {
|
||||
backend: `
|
||||
백엔드 기능을 구현해주세요: ${featureDescription}
|
||||
|
||||
작업 내용:
|
||||
1. 필요한 파일 생성/수정
|
||||
2. 그누보드7 백엔드 규정 준수 (Service-Repository 패턴, FormRequest, 훅)
|
||||
3. 테스트 작성 및 실행
|
||||
4. 문서화
|
||||
|
||||
참조: docs/backend/
|
||||
`,
|
||||
frontend: `
|
||||
프론트엔드 컴포넌트를 개발해주세요: ${featureDescription}
|
||||
|
||||
작업 내용:
|
||||
1. 컴포넌트 파일 생성
|
||||
2. 그누보드7 프론트엔드 규정 준수 (기본 컴포넌트 사용, 다크 모드, 다국어)
|
||||
3. components.json 등록
|
||||
4. 테스트 작성 및 실행
|
||||
|
||||
참조: docs/frontend/components.md
|
||||
`,
|
||||
layout: `
|
||||
레이아웃 JSON을 작성해주세요: ${featureDescription}
|
||||
|
||||
작업 내용:
|
||||
1. 레이아웃 JSON 파일 생성
|
||||
2. 그누보드7 레이아웃 규정 준수 (text 속성, 다국어, 다크 모드 클래스)
|
||||
3. 데이터 소스 및 바인딩 설정
|
||||
4. 레이아웃 갱신: php artisan template:refresh-layout
|
||||
|
||||
참조: docs/frontend/layout-json.md
|
||||
`,
|
||||
template: `
|
||||
템플릿 컴포넌트를 등록하고 빌드해주세요: ${featureDescription}
|
||||
|
||||
작업 내용:
|
||||
1. components.json에 컴포넌트 등록
|
||||
2. index.ts에 export 추가
|
||||
3. 빌드 실행: php artisan template:build sirsoft-admin_basic
|
||||
|
||||
참조: docs/frontend/template-development.md
|
||||
`,
|
||||
};
|
||||
|
||||
const result = await coordinator.orchestrate(areaPrompts[area]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default featureDevelopmentWorkflow;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 워크플로우 모듈 export
|
||||
*/
|
||||
export { featureDevelopmentWorkflow, simpleFeatureWorkflow } from './feature.js';
|
||||
export { bugfixWorkflow, stateRelatedBugfix } from './bugfix.js';
|
||||
export { prReviewWorkflow, reviewFiles } from './pr-review.js';
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* PR 검수 워크플로우
|
||||
* 변경된 파일 유형에 따라 적절한 에이전트가 검수
|
||||
*/
|
||||
import { Coordinator } from '../coordinator/Coordinator.js';
|
||||
import { createWorkflowLogger } from '../utils/logger.js';
|
||||
import type { WorkflowResult } from '../types/index.js';
|
||||
|
||||
const workflowLogger = createWorkflowLogger('pr-review');
|
||||
|
||||
export interface PRReviewOptions {
|
||||
cwd?: string;
|
||||
baseBranch?: string;
|
||||
skipTests?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR 검수 워크플로우 실행
|
||||
*/
|
||||
export async function prReviewWorkflow(
|
||||
prIdentifier: string | number,
|
||||
options: PRReviewOptions = {}
|
||||
): Promise<WorkflowResult> {
|
||||
workflowLogger.info(`Starting PR review: ${prIdentifier}`);
|
||||
|
||||
const { baseBranch = 'master', skipTests = false } = options;
|
||||
|
||||
const coordinator = new Coordinator({
|
||||
cwd: options.cwd,
|
||||
model: 'sonnet',
|
||||
});
|
||||
|
||||
const prompt = `
|
||||
PR을 검수해주세요: ${prIdentifier}
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### 1. 변경 사항 분석
|
||||
\`\`\`bash
|
||||
# 변경된 파일 목록 확인
|
||||
git diff --name-only ${baseBranch}...HEAD
|
||||
|
||||
# 변경 내용 확인
|
||||
git diff ${baseBranch}...HEAD
|
||||
\`\`\`
|
||||
|
||||
### 2. 파일 유형별 검수 할당
|
||||
|
||||
변경된 파일 확장자/경로에 따라 적절한 에이전트에게 검수 요청:
|
||||
|
||||
| 파일 패턴 | 담당 에이전트 |
|
||||
|----------|--------------|
|
||||
| app/**/*.php | [backend] |
|
||||
| database/migrations/*.php | [backend] |
|
||||
| templates/**/src/**/*.tsx | [frontend] |
|
||||
| templates/**/layouts/**/*.json | [layout] |
|
||||
| resources/layouts/**/*.json | [layout] |
|
||||
| templates/**/components.json | [template] |
|
||||
|
||||
### 3. [backend] 백엔드 검수 (해당 시)
|
||||
- [ ] RepositoryInterface 주입 확인
|
||||
- [ ] Service 훅 실행 확인
|
||||
- [ ] FormRequest 검증 규칙
|
||||
- [ ] ResponseHelper 사용
|
||||
- [ ] 마이그레이션 down() 구현
|
||||
- [ ] 다국어 처리 (__())
|
||||
|
||||
### 4. [frontend] 프론트엔드 검수 (해당 시)
|
||||
- [ ] HTML 태그 직접 사용 금지
|
||||
- [ ] 다크 모드 클래스 쌍 지정
|
||||
- [ ] G7Core.t() 다국어 처리
|
||||
- [ ] Font Awesome 아이콘만 사용
|
||||
- [ ] components.json 등록
|
||||
|
||||
### 5. [layout] 레이아웃 검수 (해당 시)
|
||||
- [ ] text 속성 사용 (props.children 금지)
|
||||
- [ ] 데이터 바인딩 문법 정확성
|
||||
- [ ] 다국어 키 형식 ($t:)
|
||||
- [ ] 다크 모드 클래스 쌍
|
||||
|
||||
### 6. [reviewer] 최종 검수
|
||||
${skipTests ? '(테스트 건너뛰기)' : `
|
||||
- 백엔드 테스트: php artisan test
|
||||
- 프론트엔드 테스트: powershell -Command "npm run test:run"
|
||||
`}
|
||||
- 규정 준수 종합 확인
|
||||
- 문서화 확인
|
||||
|
||||
## 검수 결과 형식
|
||||
|
||||
### 승인 시
|
||||
\`\`\`
|
||||
## 검수 결과: ✅ 승인
|
||||
|
||||
### 검증 항목
|
||||
- [x] 백엔드 규정 준수
|
||||
- [x] 프론트엔드 규정 준수
|
||||
- [x] 테스트 통과
|
||||
- [x] 문서화 완료
|
||||
|
||||
### 테스트 결과
|
||||
- 백엔드: X passed
|
||||
- 프론트엔드: Y passed
|
||||
|
||||
### 특이사항
|
||||
- 없음
|
||||
\`\`\`
|
||||
|
||||
### 변경 요청 시
|
||||
\`\`\`
|
||||
## 검수 결과: ❌ 변경 요청
|
||||
|
||||
### 발견된 문제점
|
||||
1. [파일:라인] 문제 설명
|
||||
- 규정: docs/...
|
||||
- 해결: 구체적인 수정 방법
|
||||
|
||||
### 수정 권고사항
|
||||
1. ...
|
||||
|
||||
### 참조 문서
|
||||
- docs/...
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await coordinator.orchestrate(prompt);
|
||||
|
||||
if (result.success) {
|
||||
workflowLogger.info('PR review completed');
|
||||
} else {
|
||||
workflowLogger.error(`PR review failed: ${result.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error: any) {
|
||||
workflowLogger.error(`PR review error: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
steps: [],
|
||||
totalCost: 0,
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 파일만 검수
|
||||
*/
|
||||
export async function reviewFiles(
|
||||
files: string[],
|
||||
options: { cwd?: string } = {}
|
||||
): Promise<WorkflowResult> {
|
||||
workflowLogger.info(`Reviewing files: ${files.join(', ')}`);
|
||||
|
||||
const coordinator = new Coordinator({
|
||||
cwd: options.cwd,
|
||||
model: 'sonnet',
|
||||
});
|
||||
|
||||
const fileList = files.map(f => `- ${f}`).join('\n');
|
||||
|
||||
const prompt = `
|
||||
다음 파일들을 검수해주세요:
|
||||
|
||||
${fileList}
|
||||
|
||||
## 검수 항목
|
||||
|
||||
각 파일 유형에 맞는 그누보드7 규정 준수 여부를 확인하세요:
|
||||
|
||||
### PHP 파일
|
||||
- validate-code 도구 사용
|
||||
- Service-Repository 패턴
|
||||
- FormRequest 검증
|
||||
- 훅 시스템
|
||||
|
||||
### TSX 파일
|
||||
- validate-frontend 도구 사용
|
||||
- HTML 태그 사용 금지
|
||||
- 다크 모드 클래스
|
||||
- 다국어 처리
|
||||
|
||||
### JSON 레이아웃 파일
|
||||
- validate-frontend 도구 사용
|
||||
- text 속성 사용
|
||||
- 데이터 바인딩 문법
|
||||
- 다국어 키 형식
|
||||
|
||||
## 결과 형식
|
||||
각 파일별로 검수 결과를 정리해주세요.
|
||||
`;
|
||||
|
||||
return coordinator.orchestrate(prompt);
|
||||
}
|
||||
|
||||
export default prReviewWorkflow;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
# 그누보드7 DevTools MCP 서버
|
||||
|
||||
그누보드7 템플릿 엔진의 런타임 디버깅 정보를 MCP 지원 AI 코딩 도구에서 조회할 수 있는 [MCP(Model Context Protocol)](https://modelcontextprotocol.io/) 서버입니다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────┐ HTTP POST ┌─────────────────────┐
|
||||
│ 브라우저 (프론트) │ ─────────────▶ │ Laravel 백엔드 │
|
||||
│ G7DevToolsCore │ 상태 덤프 │ /_boost/g7-debug │
|
||||
│ (코어 빌드에 포함) │ │ → storage/debug-dump/ │
|
||||
└─────────────────────┘ └──────────┬──────────┘
|
||||
│ 파일 저장
|
||||
▼
|
||||
┌─────────────────────┐ 파일 읽기 ┌─────────────────────┐
|
||||
│ AI 코딩 도구 │ ◀───────────── │ MCP 서버 │
|
||||
│ (AI 코딩 도구) │ MCP 프로토콜 │ g7-devtools-server │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
**브라우저 측 (G7DevToolsCore)**: 코어 템플릿 엔진에 내장된 디버깅 수집기. 디버그 모드 활성화 시 상태, 액션, 캐시, 네트워크 등 30여 개 카테고리의 런타임 정보를 수집하여 서버로 전송합니다.
|
||||
|
||||
**MCP 서버 (g7-devtools-server)**: 서버에 저장된 디버그 데이터를 읽어 AI 도구에게 구조화된 형태로 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 전제 조건
|
||||
|
||||
- **Node.js** 18 이상
|
||||
- **그누보드7 프로젝트**가 설치되어 있고, 개발 서버(`php artisan serve` 등)가 실행 중
|
||||
- **Laravel Boost** (`laravel/boost`) — `require-dev`에 포함되어 개발 환경에서 자동 설치됨
|
||||
- **AI 코딩 도구** — Cursor, VS Code Copilot 등 MCP를 지원하는 도구
|
||||
|
||||
---
|
||||
|
||||
## 설치 및 설정
|
||||
|
||||
### Step 1. 의존성 설치
|
||||
|
||||
```bash
|
||||
cd docs/ai-tools/agents
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 2. MCP 서버 등록
|
||||
|
||||
사용하는 AI 도구에 따라 등록 방법이 다릅니다.
|
||||
|
||||
#### MCP 지원 AI 도구 (CLI 기반)
|
||||
|
||||
프로젝트 루트에 `.mcp.json` 파일을 생성합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"g7-devtools": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"tsx",
|
||||
"docs/ai-tools/agents/src/mcp/g7-devtools-server.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### VS Code Copilot / Cursor
|
||||
|
||||
`.vscode/mcp.json` 파일에 등록합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"g7-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"tsx",
|
||||
"docs/ai-tools/agents/src/mcp/g7-devtools-server.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Windows 환경**: `npx`가 동작하지 않으면 `docs/ai-tools/agents/node_modules/.bin/tsx.cmd`를 command로 직접 지정하세요.
|
||||
|
||||
### Step 3. 환경 변수 설정
|
||||
|
||||
`docs/ai-tools/agents/.env` 파일을 생성하여 프로젝트 루트 경로를 설정합니다:
|
||||
|
||||
```env
|
||||
# 그누보드7 프로젝트 루트 경로
|
||||
G7_PROJECT_ROOT=/path/to/your/g7/project
|
||||
```
|
||||
|
||||
> 이 설정을 생략하면 MCP 서버가 올바른 디버그 디렉토리를 찾지 못할 수 있으므로 반드시 설정하세요.
|
||||
|
||||
### Step 4. 활성화 확인
|
||||
|
||||
AI 도구를 리로드(VS Code: `Ctrl+Shift+P` → `Developer: Reload Window`)한 후, MCP 서버 목록에서 `g7-devtools`가 연결됨(Connected) 상태인지 확인합니다.
|
||||
|
||||
---
|
||||
|
||||
## 디버그 모드 활성화
|
||||
|
||||
그누보드7 DevTools가 데이터를 수집하려면 **디버그 모드**가 활성화되어 있어야 합니다.
|
||||
|
||||
| 방법 | 설정 | 비고 |
|
||||
|------|------|------|
|
||||
| `.env` 파일 | `APP_DEBUG=true` | 개발 환경 기본 설정 |
|
||||
| 환경설정 UI | 관리자 > 고급 설정 > 디버그 모드 | 런타임 토글 가능 |
|
||||
|
||||
> **주의**: 프로덕션 환경에서는 반드시 디버그 모드를 비활성화하세요. 디버그 데이터에 민감한 정보가 포함될 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 1. 상태 덤프 (브라우저 → 서버)
|
||||
|
||||
디버그 모드가 활성화된 상태에서 브라우저 개발자 도구(F12) 콘솔에서:
|
||||
|
||||
```javascript
|
||||
// 현재 상태를 서버로 덤프
|
||||
window.__G7_DEVTOOLS__.connector.dumpState();
|
||||
```
|
||||
|
||||
또는 그누보드7 DevTools 패널(우측 하단 플로팅 버튼)에서 "상태 덤프" 버튼을 클릭합니다.
|
||||
|
||||
덤프된 데이터는 `storage/debug-dump/` 디렉토리에 카테고리별 JSON 파일로 저장됩니다:
|
||||
|
||||
```
|
||||
storage/debug-dump/
|
||||
├── state-latest.json # 상태 스냅샷
|
||||
├── actions-latest.json # 액션 이력
|
||||
├── cache-latest.json # 캐시 통계
|
||||
├── network-latest.json # 네트워크 요청
|
||||
├── expressions-latest.json # 표현식 평가
|
||||
├── lifecycle-latest.json # 컴포넌트 생명주기
|
||||
├── form-latest.json # 폼 바인딩
|
||||
├── conditionals-latest.json # 조건부/반복 렌더링
|
||||
├── datasources-latest.json # 데이터소스 구조
|
||||
├── handlers-latest.json # 등록된 핸들러
|
||||
├── component-events-latest.json # 컴포넌트 이벤트
|
||||
├── performance-latest.json # 렌더링 성능
|
||||
├── state-rendering-latest.json # 상태-렌더링 추적
|
||||
├── state-hierarchy-latest.json # 상태 계층
|
||||
├── context-flow-latest.json # 컨텍스트 전파
|
||||
├── style-validation-latest.json # 스타일 이슈
|
||||
├── auth-debug-latest.json # 인증 상태
|
||||
├── layout-latest.json # 레이아웃 JSON
|
||||
├── computed-dependency-latest.json # Computed 의존성
|
||||
├── nested-context-latest.json # 중첩 컨텍스트
|
||||
├── modal-state-scope-latest.json # 모달 상태 스코프
|
||||
├── sequence-latest.json # Sequence 실행
|
||||
├── stale-closure-latest.json # Stale Closure 감지
|
||||
├── change-detection-latest.json # 변경 감지
|
||||
├── logs-latest.json # Logger 로그
|
||||
└── sessions/ # 세션별 이력
|
||||
```
|
||||
|
||||
### 2. AI 도구에서 조회
|
||||
|
||||
MCP 서버가 등록되어 있으면, AI 코딩 도구에서 자연어로 요청하거나 도구를 직접 호출할 수 있습니다.
|
||||
|
||||
**자연어 요청 예시**:
|
||||
- "현재 _global 상태 확인해줘"
|
||||
- "에러가 난 액션이 있는지 확인해"
|
||||
- "네트워크 요청 중 실패한 것 있어?"
|
||||
- "폼 바인딩 상태 보여줘"
|
||||
|
||||
---
|
||||
|
||||
## MCP 도구 레퍼런스
|
||||
|
||||
### 기본 디버깅 도구
|
||||
|
||||
| 도구 | 설명 | 주요 파라미터 |
|
||||
|------|------|-------------|
|
||||
| `g7-state` | 현재 상태 조회 (`_global`, `_local`, `_computed`) | `path` (경로), `search` (검색) |
|
||||
| `g7-actions` | 액션 실행 이력 | `filter` (핸들러 타입), `showParams`, `search` |
|
||||
| `g7-cache` | 캐시 통계 (Hit/Miss 비율) | - |
|
||||
| `g7-diagnose` | 증상 기반 자동 진단 | `symptoms` (증상 배열, 필수) |
|
||||
| `g7-lifecycle` | 컴포넌트 생명주기 (마운트/언마운트) | - |
|
||||
| `g7-network` | API 요청 이력 및 데이터소스 상태 | `status` (success/error), `showResponse`, `search` |
|
||||
| `g7-form` | Form/Input 바인딩 정보 | - |
|
||||
| `g7-expressions` | 표현식 평가 이력 및 경고 | - |
|
||||
| `g7-logs` | Logger 로그 이력 | `search` |
|
||||
|
||||
### 고급 분석 도구
|
||||
|
||||
| 도구 | 설명 | 주요 파라미터 |
|
||||
|------|------|-------------|
|
||||
| `g7-datasources` | 데이터소스 정의 및 구조, 경로 변환 추적 | `search` |
|
||||
| `g7-handlers` | 등록된 액션 핸들러 목록 | `search` |
|
||||
| `g7-events` | 컴포넌트 이벤트 구독/발행 이력 | - |
|
||||
| `g7-performance` | 렌더링 성능, 메모리 경고 | - |
|
||||
| `g7-conditionals` | 조건부(`if`)/반복(`iteration`) 렌더링 평가 정보 | - |
|
||||
| `g7-websocket` | WebSocket 연결 상태 및 메시지 | - |
|
||||
| `g7-named-actions` | named_actions 정의 및 참조 현황 | `search` |
|
||||
|
||||
### 상태 계층 및 스타일 도구
|
||||
|
||||
| 도구 | 설명 | 주요 파라미터 |
|
||||
|------|------|-------------|
|
||||
| `g7-renders` | setState 호출 → 렌더링 상관관계 | - |
|
||||
| `g7-state-hierarchy` | 상태 계층 및 우선순위 시각화, 충돌 감지 | `conflictsOnly`, `path`, `showValues` |
|
||||
| `g7-context-flow` | componentContext 전파 추적 | - |
|
||||
| `g7-styles` | CSS 스타일 이슈 감지 (보이지 않는 요소, 다크모드 누락) | - |
|
||||
| `g7-auth` | 인증 상태 및 토큰 정보 | - |
|
||||
| `g7-tailwind` | Tailwind CSS 빌드/퍼지 검증 | - |
|
||||
| `g7-layout` | 현재 렌더링 중인 레이아웃 JSON | - |
|
||||
|
||||
### 상태 추적 심화 도구
|
||||
|
||||
| 도구 | 설명 | 주요 파라미터 |
|
||||
|------|------|-------------|
|
||||
| `g7-computed` | Computed 속성 의존성 추적, 순환 감지 | - |
|
||||
| `g7-nested-context` | 중첩 컨텍스트 전파 (expandChildren, iteration, modal, slot) | - |
|
||||
| `g7-modal-state` | 모달 스코프 상태 추적 (격리, 유출 감지, 중첩 모달) | - |
|
||||
| `g7-sequence` | Sequence 핸들러 실행 추적 (각 액션별 상태 변화) | - |
|
||||
| `g7-stale-closure` | Stale Closure 감지 및 경고 | - |
|
||||
| `g7-change-detection` | 핸들러 실행 시 변경 감지 (상태 변경 없는 핸들러, early return) | - |
|
||||
|
||||
### 페이지네이션
|
||||
|
||||
모든 도구는 `offset`과 `limit` 파라미터를 지원합니다:
|
||||
|
||||
```
|
||||
g7-logs limit=20 offset=0 # 첫 페이지 (1~20)
|
||||
g7-logs limit=20 offset=20 # 두 번째 페이지 (21~40)
|
||||
g7-actions limit=10 offset=30 # 31~40번째 항목
|
||||
```
|
||||
|
||||
출력 예시:
|
||||
```
|
||||
📄 페이지네이션: 21~40 / 총 249개 로그
|
||||
➡️ 다음 페이지: offset: 40, limit: 20
|
||||
⬅️ 이전 페이지: offset: 0, limit: 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디버깅 워크플로우
|
||||
|
||||
### 기본 흐름
|
||||
|
||||
```
|
||||
1. 디버그 모드 활성화 (APP_DEBUG=true)
|
||||
2. 브라우저에서 문제가 발생하는 페이지 열기
|
||||
3. 문제 재현 후, 개발자 도구 콘솔에서 상태 덤프 실행
|
||||
4. AI 도구에서 g7-diagnose 호출 (증상 목록 전달)
|
||||
5. 진단 결과에 따라 세부 도구로 심층 분석
|
||||
```
|
||||
|
||||
### 증상별 도구 선택 가이드
|
||||
|
||||
| 증상 | 1차 도구 | 2차 도구 |
|
||||
|------|---------|---------|
|
||||
| 상태가 반영되지 않음 | `g7-state` | `g7-state-hierarchy`, `g7-stale-closure` |
|
||||
| 액션 실행 후 변화 없음 | `g7-actions` | `g7-change-detection`, `g7-handlers` |
|
||||
| API 데이터 미표시 | `g7-network` | `g7-datasources`, `g7-expressions` |
|
||||
| 폼 값이 전송되지 않음 | `g7-form` | `g7-state`, `g7-actions` |
|
||||
| 조건부 렌더링 오류 | `g7-conditionals` | `g7-expressions`, `g7-computed` |
|
||||
| 모달 내 상태 이상 | `g7-modal-state` | `g7-nested-context`, `g7-state-hierarchy` |
|
||||
| 스타일/CSS 문제 | `g7-styles` | `g7-tailwind` |
|
||||
| 렌더링 성능 저하 | `g7-performance` | `g7-renders`, `g7-computed` |
|
||||
| 이전 값이 남아있음 | `g7-stale-closure` | `g7-state`, `g7-change-detection` |
|
||||
| 인증/401 오류 | `g7-auth` | `g7-network` |
|
||||
| Sequence 중간 실패 | `g7-sequence` | `g7-actions` |
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 내 관련 파일
|
||||
|
||||
```
|
||||
docs/ai-tools/agents/
|
||||
└── src/mcp/
|
||||
└── g7-devtools-server.ts # MCP 서버 (도구 정의 및 데이터 파싱)
|
||||
|
||||
resources/js/core/devtools/ # 브라우저 측 DevTools 코어
|
||||
├── G7DevToolsCore.ts # 싱글톤 디버깅 수집기
|
||||
├── ServerConnector.ts # 서버 전송 (상태 덤프)
|
||||
├── DiagnosticEngine.ts # 자동 진단 엔진
|
||||
├── StyleTracker.ts # 스타일 이슈 추적
|
||||
├── TailwindValidator.ts # Tailwind CSS 검증
|
||||
├── types.ts # 타입 정의
|
||||
├── index.ts # 엔트리포인트
|
||||
└── ui/ # DevTools 패널 UI (React)
|
||||
|
||||
routes/devtools.php # 상태 덤프 API 엔드포인트
|
||||
storage/debug-dump/ # 디버그 데이터 저장 (자동 생성, Git 제외)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### MCP 서버가 시작되지 않는 경우
|
||||
|
||||
1. `docs/ai-tools/agents/node_modules/`가 존재하는지 확인 → `cd docs/ai-tools/agents && npm install`
|
||||
2. Node.js 18 이상인지 확인 → `node --version`
|
||||
3. AI 도구를 리로드했는지 확인 (VS Code: `Ctrl+Shift+P` → `Developer: Reload Window`)
|
||||
|
||||
### Windows에서 `npx` 실행 오류
|
||||
|
||||
Windows 환경에서 AI 도구가 `npx`를 찾지 못하는 경우:
|
||||
|
||||
```json
|
||||
{
|
||||
"g7-devtools": {
|
||||
"command": "docs/ai-tools/agents/node_modules/.bin/tsx.cmd",
|
||||
"args": [
|
||||
"docs/ai-tools/agents/src/mcp/g7-devtools-server.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 상태 덤프가 실패하는 경우
|
||||
|
||||
1. **디버그 모드 확인**: `.env`에서 `APP_DEBUG=true`인지 확인
|
||||
2. **서버 실행 확인**: 개발 서버(`php artisan serve` 등)가 실행 중인지 확인
|
||||
3. **콘솔 로그 확인**: 브라우저 콘솔에서 `[G7DevTools]` 로그로 오류 원인 파악
|
||||
4. **엔드포인트 확인**: `/_boost/g7-debug/dump-state`에 POST 요청이 정상 도달하는지 확인
|
||||
|
||||
### 도구 호출 시 "데이터 없음"
|
||||
|
||||
1. **상태 덤프 선행 필수**: 브라우저에서 상태 덤프를 먼저 실행해야 합니다
|
||||
2. **파일 존재 확인**: `storage/debug-dump/` 디렉토리에 `*-latest.json` 파일이 있는지 확인
|
||||
3. **경로 확인**: `G7_PROJECT_ROOT` 환경 변수가 올바른 프로젝트 경로를 가리키는지 확인
|
||||
4. **덤프 시간 확인**: 도구 출력의 "📅 덤프 시간" 표시가 너무 오래된 경우 재덤프 필요
|
||||
|
||||
### 대용량 데이터 조회 시 응답 잘림
|
||||
|
||||
- 모든 도구에서 `limit`과 `offset` 파라미터로 페이지네이션 사용
|
||||
- `g7-state`는 `path` 파라미터로 특정 경로만 조회 (예: `path: "_global.user"`)
|
||||
- `g7-actions`는 `filter`로 핸들러 타입 필터링 (예: `filter: "apiCall"`)
|
||||
@@ -0,0 +1,82 @@
|
||||
# 모듈 스캐폴딩 (create-module)
|
||||
|
||||
새로운 모듈의 기본 구조를 생성합니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
```
|
||||
/create-module vendor-module
|
||||
```
|
||||
|
||||
**예시**: `/create-module sirsoft-ecommerce`
|
||||
|
||||
## 입력 형식
|
||||
|
||||
- `vendor-module` — 벤더명과 모듈명을 하이픈(-)으로 연결
|
||||
- 네임스페이스 자동 변환: `sirsoft-ecommerce` → `Modules\Sirsoft\Ecommerce\`
|
||||
- 언더스코어(_) → PascalCase: `sirsoft-order_management` → `Modules\Sirsoft\OrderManagement\`
|
||||
|
||||
## 생성 위치
|
||||
|
||||
`modules/_bundled/vendor-module/`
|
||||
|
||||
## 생성되는 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `module.json` | 메타데이터 SSoT (identifier, version, dependencies 등) |
|
||||
| `module.php` | AbstractModule 상속 엔트리 클래스 |
|
||||
| `composer.json` | PSR-4 오토로딩 (src/, database/seeders/, database/factories/) |
|
||||
| `CHANGELOG.md` | Keep a Changelog 형식 |
|
||||
| `LICENSE` | MIT 라이선스 |
|
||||
| `config/settings/defaults.json` | 기본 설정 (_meta, defaults, frontend_schema) |
|
||||
| `src/lang/{ko,en}/*.php` | 백엔드 다국어 (messages, validation, exceptions) |
|
||||
| `resources/lang/{ko,en}.json` | 프론트엔드 다국어 |
|
||||
| `resources/routes.json` | 프론트엔드 라우트 정의 |
|
||||
| `src/routes/web.php` | 웹/API 라우트 |
|
||||
| `resources/layouts/admin/pages/index.json` | 초기 관리자 레이아웃 |
|
||||
|
||||
## 생성되는 디렉토리
|
||||
|
||||
- `src/Contracts/Repositories/` — Repository 인터페이스
|
||||
- `src/Enums/` — Enum 정의
|
||||
- `src/Exceptions/` — Custom Exception
|
||||
- `src/Http/Controllers/Admin/` — 관리자 컨트롤러
|
||||
- `src/Http/Requests/Admin/` — FormRequest
|
||||
- `src/Http/Resources/` — API Resource
|
||||
- `src/Listeners/` — 훅 리스너
|
||||
- `src/Models/` — Eloquent 모델
|
||||
- `src/Providers/` — 서비스 프로바이더
|
||||
- `src/Repositories/` — Repository 구현체
|
||||
- `src/Services/` — 서비스 클래스
|
||||
- `database/migrations/` — 마이그레이션
|
||||
- `database/seeders/` — 시더
|
||||
- `database/factories/` — 팩토리
|
||||
- `tests/Feature/`, `tests/Unit/` — 테스트
|
||||
|
||||
## module.php 구현 메서드
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `getRoles()` | 모듈 역할 정의 |
|
||||
| `getPermissions()` | 계층형 권한 (categories → permissions → action/name/type/roles) |
|
||||
| `getAdminMenus()` | 관리자 메뉴 (slug, url, icon, order, permission) |
|
||||
| `getSeeders()` | 설치 시 실행할 시더 목록 |
|
||||
| `getHookListeners()` | 훅 리스너 클래스 목록 |
|
||||
| `getStorageDisk()` | 스토리지 디스크 설정 |
|
||||
|
||||
## 생성 후 다음 단계
|
||||
|
||||
```bash
|
||||
php artisan extension:update-autoload
|
||||
php artisan module:install vendor-module
|
||||
php artisan module:activate vendor-module
|
||||
```
|
||||
|
||||
## 참조 문서
|
||||
|
||||
- [모듈 기초](../../extension/module-basics.md)
|
||||
- [모듈 라우트](../../extension/module-routing.md)
|
||||
- [모듈 레이아웃](../../extension/module-layouts.md)
|
||||
- [모듈 다국어](../../extension/module-i18n.md)
|
||||
- [모듈 커맨드](../../extension/module-commands.md)
|
||||
@@ -0,0 +1,81 @@
|
||||
# 플러그인 스캐폴딩 (create-plugin)
|
||||
|
||||
새로운 플러그인의 기본 구조를 생성합니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
```text
|
||||
/create-plugin vendor-plugin
|
||||
```
|
||||
|
||||
**예시**: `/create-plugin sirsoft-payment`
|
||||
|
||||
## 입력 형식
|
||||
|
||||
- `vendor-plugin` — 벤더명과 플러그인명을 하이픈(-)으로 연결
|
||||
- 네임스페이스 자동 변환: `sirsoft-payment` → `Plugins\Sirsoft\Payment\`
|
||||
- 언더스코어(_) → PascalCase: `sirsoft-daum_postcode` → `Plugins\Sirsoft\DaumPostcode\`
|
||||
|
||||
## 생성 위치
|
||||
|
||||
`plugins/_bundled/vendor-plugin/`
|
||||
|
||||
## 생성되는 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
| ---- | ---- |
|
||||
| `plugin.json` | 메타데이터 SSoT (identifier, version, assets, loading 등) |
|
||||
| `plugin.php` | AbstractPlugin 상속 엔트리 클래스 (루트 위치!) |
|
||||
| `composer.json` | PSR-4 오토로딩 (src/ + ./ 매핑) |
|
||||
| `CHANGELOG.md` | Keep a Changelog 형식 |
|
||||
| `LICENSE` | MIT 라이선스 |
|
||||
| `config/settings/defaults.json` | 설정 기본값 (_meta, defaults, frontend_schema) |
|
||||
| `resources/lang/{ko,en}.json` | 프론트엔드 다국어 (백엔드 PHP 다국어 없음) |
|
||||
| `src/routes/web.php` | 웹 라우트 (선택) |
|
||||
| `tests/PluginTestCase.php` | 테스트 기반 클래스 |
|
||||
|
||||
## 생성되는 디렉토리
|
||||
|
||||
- `src/Listeners/` — 훅 리스너
|
||||
- `src/Services/` — 서비스 클래스
|
||||
- `resources/extensions/` — layout_extensions JSON
|
||||
- `resources/layouts/admin/` — 설정 UI 레이아웃
|
||||
- `tests/Feature/`, `tests/Unit/` — 테스트
|
||||
|
||||
## plugin.php 구현 메서드
|
||||
|
||||
| 메서드 | 설명 |
|
||||
| ------ | ---- |
|
||||
| `getMetadata()` | 메타데이터 (author, license, homepage, keywords) |
|
||||
| `getDependencies()` | 의존성 (모듈만 가능, 플러그인 간 의존 금지) |
|
||||
| `getSettingsSchema()` | 설정 스키마 (type, default, label, hint) |
|
||||
| `getConfigValues()` | 설정 기본값 |
|
||||
| `getHookListeners()` | 훅 리스너 클래스 목록 |
|
||||
| `getHooks()` | 제공하는 훅 정의 (name, type, description, parameters) |
|
||||
|
||||
## 모듈과의 주요 차이
|
||||
|
||||
| 항목 | 모듈 | 플러그인 |
|
||||
| ---- | ---- | -------- |
|
||||
| 엔트리 위치 | module.php (src root) | plugin.php (프로젝트 루트!) |
|
||||
| 베이스 클래스 | AbstractModule | AbstractPlugin |
|
||||
| 권한/역할/메뉴 | 있음 | 없음 |
|
||||
| DB 모델 | 있음 | 선택적 |
|
||||
| 백엔드 다국어 | PHP 파일 | 없음 |
|
||||
| 레이아웃 | 완전한 페이지 | settings.json만 (나머지 layout_extensions) |
|
||||
| 의존성 | 모듈+플러그인 | 모듈만 |
|
||||
|
||||
## 생성 후 다음 단계
|
||||
|
||||
```bash
|
||||
php artisan extension:update-autoload
|
||||
php artisan plugin:install vendor-plugin
|
||||
php artisan plugin:activate vendor-plugin
|
||||
```
|
||||
|
||||
## 참조 문서
|
||||
|
||||
- [플러그인 개발 가이드](../../extension/plugin-development.md)
|
||||
- [훅 시스템](../../extension/hooks.md)
|
||||
- [모듈 환경설정 시스템](../../extension/module-settings.md)
|
||||
- [Changelog 규칙](../../extension/changelog-rules.md)
|
||||
@@ -0,0 +1,90 @@
|
||||
# 템플릿 스캐폴딩 (create-template)
|
||||
|
||||
새로운 템플릿(관리자 또는 사용자)의 기본 구조를 생성합니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
```text
|
||||
/create-template vendor-template [--type=admin|user]
|
||||
```
|
||||
|
||||
**예시**:
|
||||
|
||||
- `/create-template sirsoft-admin_modern` — Admin 템플릿
|
||||
- `/create-template sirsoft-modern --type=user` — User 템플릿
|
||||
|
||||
기본값은 `admin`입니다.
|
||||
|
||||
## 입력 형식
|
||||
|
||||
- `vendor-template` — 벤더명과 템플릿명을 하이픈(-)으로 연결
|
||||
- Admin 네이밍 권장: `vendor-admin_xxx` (예: `sirsoft-admin_modern`)
|
||||
- User 네이밍 권장: `vendor-xxx` (예: `sirsoft-modern`)
|
||||
|
||||
## 생성 위치
|
||||
|
||||
`templates/_bundled/vendor-template/`
|
||||
|
||||
## 생성되는 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
| ---- | ---- |
|
||||
| `template.json` | 메타데이터 SSoT (type, components, error_config 등) |
|
||||
| `routes.json` | 프론트엔드 라우트 정의 |
|
||||
| `components.json` | 컴포넌트 레지스트리 매핑 |
|
||||
| `CHANGELOG.md` | Keep a Changelog 형식 |
|
||||
| `LICENSE` | MIT 라이선스 |
|
||||
| `lang/{ko,en}.json` | 다국어 |
|
||||
| `layouts/_[type]_base.json` | 베이스 레이아웃 (slot 시스템) |
|
||||
| `layouts/errors/*.json` | 에러 레이아웃 6종 (401/403/404/500/503/maintenance) |
|
||||
| `layouts/[초기 페이지].json` | admin: admin_dashboard / user: home |
|
||||
| `src/index.ts` | 컴포넌트 등록 엔트리 |
|
||||
| `package.json` | npm 패키지 설정 |
|
||||
| `vite.config.ts` | Vite 빌드 (IIFE 출력) |
|
||||
| `vitest.config.ts` | Vitest 테스트 설정 |
|
||||
| `tsconfig.json` | TypeScript 설정 |
|
||||
|
||||
## 생성되는 디렉토리
|
||||
|
||||
- `src/components/basic/` — 기본 컴포넌트 (Div, Button, Input 등)
|
||||
- `src/components/composite/` — 합성 컴포넌트 (Modal, Toast 등)
|
||||
- `src/components/layout/` — 레이아웃 컴포넌트 (Container, Flex, Grid, Section)
|
||||
- `assets/css/`, `assets/fonts/`, `assets/images/` — 정적 에셋
|
||||
- `preview/` — 미리보기 이미지
|
||||
- `__tests__/layouts/` — 레이아웃 렌더링 테스트
|
||||
|
||||
## Admin vs User 차이
|
||||
|
||||
| 항목 | Admin | User |
|
||||
| ---- | ----- | ---- |
|
||||
| type | `"admin"` | `"user"` |
|
||||
| 라우트 접두사 | `*/admin/*` | `/` |
|
||||
| auth_required 기본 | `true` | `false` |
|
||||
| 버전 히스토리 | 미지원 (읽기 전용) | 지원 (편집 가능) |
|
||||
| 베이스 레이아웃 | header + sidebar + content + footer | header + content + footer |
|
||||
| 초기 페이지 | `admin_dashboard` | `home` |
|
||||
| 필수 composite | AdminHeader, AdminSidebar, AdminFooter, Modal, Toast | Header, Footer, Modal, Toast |
|
||||
| features | 미포함 | dark_mode, responsive, multi_language, multi_currency |
|
||||
|
||||
## 생성 후 다음 단계
|
||||
|
||||
```bash
|
||||
# 1. 패키지 설치
|
||||
cd templates/_bundled/vendor-template
|
||||
npm install
|
||||
|
||||
# 2. 컴포넌트 구현 (src/components/)
|
||||
|
||||
# 3. 빌드 및 설치
|
||||
php artisan template:build vendor-template
|
||||
php artisan template:install vendor-template
|
||||
php artisan template:activate vendor-template
|
||||
```
|
||||
|
||||
## 참조 문서
|
||||
|
||||
- [템플릿 기초](../../extension/template-basics.md)
|
||||
- [템플릿 라우트](../../extension/template-routing.md)
|
||||
- [템플릿 워크플로우](../../extension/template-workflow.md)
|
||||
- [템플릿 보안](../../extension/template-security.md)
|
||||
- [컴포넌트 개발 규칙](../../frontend/components.md)
|
||||
@@ -0,0 +1,328 @@
|
||||
# 다국어 키 추출 (extract-i18n-keys)
|
||||
|
||||
레이아웃 JSON 파일에서 사용된 다국어 키를 추출하고 언어 파일과 비교하여 누락된 키를 보고합니다.
|
||||
|
||||
## 0단계: 추출 대상 판별
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 추출 대상 경로 확인
|
||||
```
|
||||
|
||||
### 대상 경로 유형
|
||||
|
||||
| 경로 패턴 | 유형 | 언어 파일 위치 |
|
||||
| --------- | ---- | -------------- |
|
||||
| `modules/[vendor-module]/resources/layouts/**/*.json` | 모듈 레이아웃 | `modules/[vendor-module]/resources/lang/*.json` |
|
||||
| `modules/_bundled/[vendor-module]/resources/layouts/**/*.json` | _bundled 모듈 레이아웃 | `modules/_bundled/[vendor-module]/resources/lang/*.json` |
|
||||
| `templates/[vendor-template]/layouts/**/*.json` | 템플릿 레이아웃 | `templates/[vendor-template]/lang/*.json` |
|
||||
| `templates/_bundled/[vendor-template]/layouts/**/*.json` | _bundled 템플릿 레이아웃 | `templates/_bundled/[vendor-template]/lang/*.json` |
|
||||
| `resources/layouts/**/*.json` | 코어 레이아웃 | `lang/*.json` |
|
||||
|
||||
### $ARGUMENTS 처리
|
||||
|
||||
- 경로가 지정된 경우: 해당 경로의 JSON 파일만 대상
|
||||
- 경로가 지정되지 않은 경우: 사용자에게 대상 경로 문의
|
||||
|
||||
## 1단계: 레이아웃 파일 수집
|
||||
|
||||
### 1.1 대상 파일 탐색
|
||||
|
||||
```bash
|
||||
# 모듈 레이아웃 예시
|
||||
find modules/sirsoft-ecommerce/resources/layouts -name "*.json" -type f
|
||||
|
||||
# 템플릿 레이아웃 예시
|
||||
find templates/sirsoft-admin_basic/layouts -name "*.json" -type f
|
||||
```
|
||||
|
||||
### 1.2 파일 목록 생성
|
||||
|
||||
수집된 파일 목록을 정리:
|
||||
- 메인 레이아웃 파일
|
||||
- partials 디렉토리 내 파일
|
||||
- 중첩된 모든 JSON 파일
|
||||
|
||||
## 2단계: 다국어 키 추출
|
||||
|
||||
### 2.1 추출 패턴
|
||||
|
||||
다음 패턴의 다국어 키를 추출합니다:
|
||||
|
||||
| 패턴 | 설명 | 예시 |
|
||||
| ---- | ---- | ---- |
|
||||
| `$t:namespace.key.path` | 즉시 번역 | `$t:sirsoft-ecommerce.admin.product.name` |
|
||||
| `$t:defer:namespace.key.path` | 지연 번역 | `$t:defer:common.save` |
|
||||
|
||||
### 2.2 추출 정규식
|
||||
|
||||
```javascript
|
||||
// $t: 패턴 추출 (defer 포함)
|
||||
const regex = /\$t:(?:defer:)?([a-zA-Z][a-zA-Z0-9_.-]*(?:\.[a-zA-Z0-9_-]+)+)/g;
|
||||
```
|
||||
|
||||
### 2.3 키 분류
|
||||
|
||||
추출된 키를 네임스페이스별로 분류:
|
||||
|
||||
| 키 패턴 | 분류 | 저장 위치 |
|
||||
| ------- | ---- | --------- |
|
||||
| `sirsoft-ecommerce.*` | 모듈 전용 | `modules/sirsoft-ecommerce/resources/lang/*.json` |
|
||||
| `common.*` | 공통 키 | 코어 또는 모듈 lang 파일 |
|
||||
| `messages.*` | 메시지 | 코어 또는 모듈 lang 파일 |
|
||||
| `currency.*` | 통화 | 코어 또는 모듈 lang 파일 |
|
||||
| `validation.*` | 검증 | 코어 또는 모듈 lang 파일 |
|
||||
|
||||
### 2.4 모듈 네임스페이스 검증 (CRITICAL)
|
||||
|
||||
모듈 레이아웃에서 사용된 다국어 키가 올바른 네임스페이스를 사용하는지 검증합니다.
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 모듈 레이아웃에서 다국어 키 사용 시 반드시 모듈 식별자 접두사 포함
|
||||
```
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
| -------- | --------------- |
|
||||
| `$t:common.save` (모듈에서 코어 키 직접 참조) | `$t:sirsoft-ecommerce.common.save` (모듈 식별자 접두사) |
|
||||
| `$t:admin.product.name` (네임스페이스 누락) | `$t:sirsoft-ecommerce.admin.product.name` |
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- 모듈 레이아웃 내 `$t:` 키가 해당 모듈 식별자로 시작하는지 확인
|
||||
- `$t:common.*` 형태로 코어 키를 직접 참조하면 경고 (모듈 lang 파일이 아닌 코어 lang에서 찾게 됨)
|
||||
- 예외: `$t:common.*`이 코어 키를 의도적으로 참조하는 경우 (명시적 확인 필요)
|
||||
|
||||
## 3단계: 언어 파일 분석
|
||||
|
||||
### 3.1 언어 파일 읽기
|
||||
|
||||
```bash
|
||||
# 모듈 언어 파일
|
||||
modules/[vendor-module]/resources/lang/ko.json
|
||||
modules/[vendor-module]/resources/lang/en.json
|
||||
|
||||
# 코어 언어 파일
|
||||
lang/ko.json
|
||||
lang/en.json
|
||||
```
|
||||
|
||||
### 3.2 기존 키 추출
|
||||
|
||||
JSON 파일에서 모든 키 경로를 추출:
|
||||
|
||||
```javascript
|
||||
// 중첩된 객체에서 키 경로 추출
|
||||
function extractKeys(obj, prefix = '') {
|
||||
const keys = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
keys.push(...extractKeys(value, path));
|
||||
} else {
|
||||
keys.push(path);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
```
|
||||
|
||||
## 4단계: 비교 및 분석
|
||||
|
||||
### 4.1 누락 키 식별
|
||||
|
||||
레이아웃에서 사용된 키 vs 언어 파일에 존재하는 키 비교:
|
||||
|
||||
```text
|
||||
사용된 키 - 존재하는 키 = 누락된 키
|
||||
```
|
||||
|
||||
### 4.2 미사용 키 식별 (선택)
|
||||
|
||||
언어 파일에 존재하지만 레이아웃에서 사용되지 않는 키:
|
||||
|
||||
```text
|
||||
존재하는 키 - 사용된 키 = 미사용 키
|
||||
```
|
||||
|
||||
## 5단계: 결과 보고
|
||||
|
||||
### 5.1 보고서 형식
|
||||
|
||||
```text
|
||||
## 다국어 키 추출 결과
|
||||
|
||||
### 추출 대상
|
||||
- 레이아웃 경로: [경로]
|
||||
- 검사 파일 수: X개
|
||||
- 언어 파일: ko.json, en.json
|
||||
|
||||
### 추출된 키 요약
|
||||
- 총 사용된 키: XXX개
|
||||
- 모듈 전용 키 (sirsoft-ecommerce.*): XXX개
|
||||
- 공통 키 (common.*): XX개
|
||||
- 메시지 키 (messages.*): XX개
|
||||
- 기타 키: XX개
|
||||
|
||||
### 언어 파일 현황
|
||||
- ko.json 존재 키: XXX개
|
||||
- en.json 존재 키: XXX개
|
||||
|
||||
### 누락된 키 (CRITICAL)
|
||||
- 총 누락: XX개 (XX.X% 누락률)
|
||||
|
||||
#### 모듈 전용 키 누락 (sirsoft-ecommerce.*)
|
||||
| 키 | 사용 위치 |
|
||||
|----|----------|
|
||||
| sirsoft-ecommerce.admin.product.xxx | file.json:line |
|
||||
| ... | ... |
|
||||
|
||||
#### 공통 키 누락 (common.*)
|
||||
| 키 | 사용 위치 |
|
||||
|----|----------|
|
||||
| common.xxx | file.json:line |
|
||||
| ... | ... |
|
||||
|
||||
### 미사용 키 (참고)
|
||||
- 총 미사용: XX개
|
||||
| 키 | 위치 |
|
||||
|----|------|
|
||||
| sirsoft-ecommerce.xxx.unused | ko.json |
|
||||
```
|
||||
|
||||
### 5.2 키 생성 템플릿 제공
|
||||
|
||||
누락된 키에 대한 JSON 템플릿 생성:
|
||||
|
||||
```json
|
||||
// ko.json에 추가할 키
|
||||
{
|
||||
"admin": {
|
||||
"product": {
|
||||
"missing_key_1": "[한국어 번역 필요]",
|
||||
"missing_key_2": "[한국어 번역 필요]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// en.json에 추가할 키
|
||||
{
|
||||
"admin": {
|
||||
"product": {
|
||||
"missing_key_1": "[English translation needed]",
|
||||
"missing_key_2": "[English translation needed]"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6단계: 추출 스크립트 (참고)
|
||||
|
||||
### Node.js 추출 스크립트 예시
|
||||
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 디렉토리 내 모든 JSON 파일 찾기
|
||||
function findJsonFiles(dir, files = []) {
|
||||
const items = fs.readdirSync(dir);
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
findJsonFiles(fullPath, files);
|
||||
} else if (item.endsWith('.json')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// $t: 키 추출
|
||||
function extractI18nKeys(content) {
|
||||
const regex = /\$t:(?:defer:)?([a-zA-Z][a-zA-Z0-9_.-]*(?:\.[a-zA-Z0-9_-]+)+)/g;
|
||||
const keys = new Set();
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// JSON에서 키 경로 추출
|
||||
function extractExistingKeys(obj, prefix = '', namespace = '') {
|
||||
const keys = new Set();
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
const fullPath = namespace ? `${namespace}.${path}` : path;
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const childKeys = extractExistingKeys(value, path, namespace);
|
||||
childKeys.forEach(k => keys.add(k));
|
||||
} else {
|
||||
keys.add(fullPath);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// 메인 실행
|
||||
const layoutDir = process.argv[2] || 'modules/sirsoft-ecommerce/resources/layouts';
|
||||
const langDir = process.argv[3] || 'modules/sirsoft-ecommerce/resources/lang';
|
||||
const namespace = process.argv[4] || 'sirsoft-ecommerce';
|
||||
|
||||
// 레이아웃에서 사용된 키 추출
|
||||
const layoutFiles = findJsonFiles(layoutDir);
|
||||
const usedKeys = new Set();
|
||||
for (const file of layoutFiles) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const keys = extractI18nKeys(content);
|
||||
keys.forEach(k => usedKeys.add(k));
|
||||
}
|
||||
|
||||
// 언어 파일에서 존재하는 키 추출
|
||||
const koJson = JSON.parse(fs.readFileSync(path.join(langDir, 'ko.json'), 'utf-8'));
|
||||
const existingKeys = extractExistingKeys(koJson, '', namespace);
|
||||
|
||||
// 비교
|
||||
const missingKeys = [...usedKeys].filter(k => !existingKeys.has(k));
|
||||
const unusedKeys = [...existingKeys].filter(k => !usedKeys.has(k));
|
||||
|
||||
console.log('=== 다국어 키 추출 결과 ===');
|
||||
console.log(`사용된 키: ${usedKeys.size}개`);
|
||||
console.log(`존재하는 키: ${existingKeys.size}개`);
|
||||
console.log(`누락된 키: ${missingKeys.length}개`);
|
||||
console.log(`미사용 키: ${unusedKeys.length}개`);
|
||||
|
||||
if (missingKeys.length > 0) {
|
||||
console.log('\n=== 누락된 키 목록 ===');
|
||||
missingKeys.sort().forEach(k => console.log(k));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL:
|
||||
- 모든 $t: 패턴 추출 (defer 포함)
|
||||
- 모듈 네임스페이스와 공통 키 모두 추출
|
||||
- 모듈 레이아웃: $t:키에 모듈 식별자 접두사 필수 ($t:sirsoft-ecommerce.xxx)
|
||||
- _bundled 디렉토리 레이아웃도 추출 대상에 포함
|
||||
- 정확한 키 경로 비교 (중첩 객체 고려)
|
||||
- 누락 키에 대한 실용적인 템플릿 제공
|
||||
- ko.json과 en.json 모두 검사
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```bash
|
||||
# 모듈 레이아웃 키 추출
|
||||
/extract-i18n-keys modules/sirsoft-ecommerce/resources/layouts
|
||||
|
||||
# 특정 레이아웃 파일만 추출
|
||||
/extract-i18n-keys modules/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_product_form.json
|
||||
|
||||
# 템플릿 레이아웃 키 추출
|
||||
/extract-i18n-keys templates/sirsoft-admin_basic/layouts
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# 테스트 실행 (run-tests)
|
||||
|
||||
그누보드7 프로젝트의 테스트를 실행하고 결과를 검증합니다.
|
||||
|
||||
## 1단계: 규정 문서 읽기
|
||||
|
||||
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
|
||||
|
||||
- `docs/testing-guide.md` - 테스트 규칙
|
||||
- `docs/frontend/layout-testing.md` - 레이아웃 렌더링 테스트 규칙
|
||||
|
||||
## 2단계: .env.testing 확인 (백엔드 테스트 시)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 백엔드 테스트 실행 전 .env.testing 존재 여부 확인 필수
|
||||
```
|
||||
|
||||
1. `.env.testing` 미존재 시:
|
||||
- `.env.testing.example` 복사: `cp .env.testing.example .env.testing`
|
||||
- `APP_KEY` 생성: `php artisan key:generate --env=testing`
|
||||
- PO에게 DB 접속 정보 확인 요청
|
||||
|
||||
## 3단계: 테스트 타입 결정
|
||||
|
||||
$ARGUMENTS 값에 따라 테스트 타입을 결정합니다:
|
||||
|
||||
| 인자 | 테스트 타입 | 명령어 |
|
||||
|------|-------------|--------|
|
||||
| `backend` 또는 `php` | 백엔드 테스트 | `php artisan test` |
|
||||
| `frontend` 또는 `npm` | 프론트엔드 테스트 | `powershell -Command "npm run test:run"` |
|
||||
| `layout` | 레이아웃 렌더링 테스트 | `powershell -Command "npm run test:run -- layouts"` |
|
||||
| `all` 또는 미지정 | 전체 테스트 | 백엔드 + 프론트엔드 순차 실행 |
|
||||
| 특정 파일/클래스명 | 필터 테스트 | `php artisan test --filter=$ARGUMENTS` |
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 전체 테스트(all) 지양 → 관련 테스트만 실행 (시간 절약)
|
||||
⚠️ CRITICAL: 동일 테스트를 결과 확인 목적으로 재실행 금지 → 저장된 출력 확인
|
||||
```
|
||||
|
||||
## 4단계: 테스트 실행
|
||||
|
||||
### 백엔드 테스트 (PHPUnit)
|
||||
|
||||
```bash
|
||||
# 관련 테스트만 실행 (권장)
|
||||
php artisan test --filter=TestClassName 2>&1 | tee /tmp/test-result.txt
|
||||
|
||||
# 특정 디렉토리
|
||||
php vendor/bin/phpunit tests/Feature/Api/Admin/ 2>&1 | tee /tmp/test-result.txt
|
||||
|
||||
# _bundled 모듈 테스트 (활성 디렉토리 복사 불필요)
|
||||
php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests 2>&1 | tee /tmp/test-result.txt
|
||||
php vendor/bin/phpunit --filter=TestClassName modules/_bundled/sirsoft-ecommerce/tests 2>&1 | tee /tmp/test-result.txt
|
||||
```
|
||||
|
||||
### 프론트엔드 테스트 (Vitest)
|
||||
|
||||
```powershell
|
||||
# 프로젝트 루트에서 (코어 테스트)
|
||||
powershell -Command "npm run test:run -- template-engine"
|
||||
|
||||
# 템플릿 디렉토리에서 (해당 템플릿만)
|
||||
powershell -Command "cd templates/sirsoft-admin_basic; npm run test:run"
|
||||
powershell -Command "cd templates/sirsoft-admin_basic; npm run test:run -- DataGrid"
|
||||
|
||||
# _bundled 모듈 프론트엔드 테스트
|
||||
powershell -Command "cd modules/_bundled/sirsoft-ecommerce; npm run test:run"
|
||||
```
|
||||
|
||||
### 레이아웃 렌더링 테스트 (Vitest + createLayoutTest)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 레이아웃 렌더링 테스트는 Vitest + createLayoutTest() 유틸리티 기반
|
||||
⚠️ CRITICAL: 브라우저 기반 E2E가 아님 — "인프라 부족" 이유로 건너뛰기 절대 금지
|
||||
```
|
||||
|
||||
```powershell
|
||||
# 코어 레이아웃 테스트
|
||||
powershell -Command "npm run test:run -- layouts"
|
||||
|
||||
# 모듈 레이아웃 테스트
|
||||
powershell -Command "cd modules/_bundled/sirsoft-ecommerce; npm run test:run -- layouts"
|
||||
|
||||
# 템플릿 레이아웃 테스트
|
||||
powershell -Command "cd templates/_bundled/sirsoft-admin_basic; npm run test:run -- layouts"
|
||||
```
|
||||
|
||||
**기본 패턴**:
|
||||
|
||||
```typescript
|
||||
import { createLayoutTest, screen } from '../utils/layoutTestUtils';
|
||||
|
||||
const testUtils = createLayoutTest(layoutJson);
|
||||
testUtils.mockApi('products', { response: { data: [] } });
|
||||
await testUtils.render();
|
||||
expect(screen.getByTestId('element')).toBeInTheDocument();
|
||||
testUtils.cleanup();
|
||||
```
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 테스트 출력은 반드시 파일로 저장
|
||||
✅ 패턴: 명령어 2>&1 | tee /tmp/test-result.txt
|
||||
❌ 실패 후 결과 확인 목적 재실행 금지 → /tmp/test-result.txt 확인
|
||||
```
|
||||
|
||||
## 5단계: 다계층 테스트 확인 (CRITICAL)
|
||||
|
||||
기능 구현 시 **모든 관련 계층**의 테스트가 실행되었는지 확인합니다.
|
||||
|
||||
| 작업 유형 | 백엔드 (PHPUnit) | 프론트엔드 (Vitest) | 레이아웃 렌더링 (Vitest) |
|
||||
|----------|-----------------|-------------------|----------------------|
|
||||
| 새 화면 구현 | API 엔드포인트 테스트 | 컴포넌트 테스트 | 레이아웃 JSON 렌더링 테스트 |
|
||||
| 기존 화면 수정 | 변경된 API 테스트 | 변경된 컴포넌트 테스트 | 레이아웃 렌더링 회귀 테스트 |
|
||||
| 데이터 흐름 변경 | Service/Repository 테스트 | 상태 관리 테스트 | 데이터 바인딩 렌더링 테스트 |
|
||||
|
||||
```text
|
||||
❌ 단일 계층 테스트만으로 기능 구현 완료 선언 금지
|
||||
❌ 백엔드 API만 테스트하고 프론트엔드/레이아웃 테스트 누락
|
||||
❌ 컴포넌트만 테스트하고 레이아웃 렌더링 테스트 누락
|
||||
```
|
||||
|
||||
## 6단계: 결과 분석 및 보고
|
||||
|
||||
테스트 결과를 다음 형식으로 보고합니다:
|
||||
|
||||
```
|
||||
## 테스트 결과
|
||||
|
||||
### 실행 환경
|
||||
- 테스트 타입: [backend/frontend/layout/all]
|
||||
- 실행 명령어: [실행한 명령어]
|
||||
|
||||
### 결과 요약
|
||||
- 총 테스트: [N]개
|
||||
- 통과: [N]개 ✅
|
||||
- 실패: [N]개 ❌
|
||||
- 스킵: [N]개 ⏭️
|
||||
|
||||
### 다계층 테스트 확인
|
||||
- □ 백엔드 테스트: [실행/미해당]
|
||||
- □ 프론트엔드 테스트: [실행/미해당]
|
||||
- □ 레이아웃 렌더링 테스트: [실행/미해당]
|
||||
|
||||
### 실패 테스트 상세 (있는 경우)
|
||||
- ❌ [테스트명]
|
||||
- 파일: [파일 경로]
|
||||
- 원인: [실패 원인]
|
||||
- 수정 방향: [제안]
|
||||
```
|
||||
|
||||
## 중요 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL:
|
||||
- Windows 환경에서 프론트엔드 테스트는 반드시 PowerShell 래퍼 사용
|
||||
- 테스트 작성 ≠ 완료, 테스트 통과 = 완료
|
||||
- 템플릿 엔진/컴포넌트 수정 시 테스트 필수
|
||||
- 기능 구현 시 다계층 테스트 필수 (백엔드 + 프론트엔드 + 레이아웃 렌더링)
|
||||
- 레이아웃 렌더링 테스트는 createLayoutTest() 유틸리티로 실행 (브라우저 불필요)
|
||||
- "인프라 부족" 이유로 레이아웃 테스트 건너뛰기 절대 금지
|
||||
- 테스트 출력은 파일로 저장 → 동일 테스트 재실행 금지
|
||||
- 전체 테스트 실행 지양 → 관련 테스트만 실행
|
||||
```
|
||||
|
||||
## 테스트 파일 위치 매핑
|
||||
|
||||
| 수정 대상 | 테스트 파일 위치 | 테스트 유형 |
|
||||
|----------|-----------------|-------------|
|
||||
| `app/Models/*.php` | `tests/Unit/Models/*Test.php` | 모델 메서드, 관계, 스코프 |
|
||||
| `app/Services/*.php` | `tests/Unit/Services/*Test.php` | 비즈니스 로직 |
|
||||
| `app/Enums/*.php` | `tests/Unit/Enums/*Test.php` | Enum 메서드 |
|
||||
| `app/Http/Controllers/**/*.php` | `tests/Feature/**/*Test.php` | API 엔드포인트 |
|
||||
| `database/migrations/*.php` | 해당 모델/서비스 테스트 | 스키마 변경 |
|
||||
| `templates/**/src/components/**/*.tsx` | `templates/**/__tests__/*.test.tsx` | 컴포넌트 |
|
||||
| `resources/js/core/**/*.ts` | `resources/js/core/__tests__/*.test.ts` | 템플릿 엔진 |
|
||||
| `resources/layouts/**/*.json` | `resources/js/core/template-engine/__tests__/layouts/*.test.tsx` | 코어 레이아웃 렌더링 |
|
||||
| `modules/**/resources/layouts/**/*.json` | `modules/_bundled/{id}/resources/js/__tests__/layouts/*.test.tsx` | 모듈 레이아웃 렌더링 |
|
||||
| `templates/**/layouts/**/*.json` | `templates/_bundled/{id}/__tests__/layouts/*.test.tsx` | 템플릿 레이아웃 렌더링 |
|
||||
|
||||
## 자동 감지
|
||||
|
||||
다음 파일이 수정된 경우 관련 테스트를 자동으로 실행합니다:
|
||||
|
||||
- `resources/js/core/` 변경 → 템플릿 엔진 테스트
|
||||
- `resources/js/core/template-engine/` 변경 → 코어 렌더링 테스트
|
||||
- `app/Http/Controllers/` 변경 → 해당 컨트롤러 테스트
|
||||
- `app/Services/` 변경 → 해당 서비스 테스트
|
||||
- `resources/layouts/**/*.json` 변경 → 코어 레이아웃 렌더링 테스트
|
||||
- `modules/**/resources/layouts/**/*.json` 변경 → 해당 모듈 레이아웃 렌더링 테스트
|
||||
- `templates/**/layouts/**/*.json` 변경 → 해당 템플릿 레이아웃 렌더링 테스트
|
||||
- `templates/**/src/components/**/*.tsx` 변경 → 해당 컴포넌트 테스트
|
||||
- `modules/_bundled/**/src/**` 변경 → 해당 모듈 테스트
|
||||
@@ -0,0 +1,365 @@
|
||||
# 코드 패턴 검증 (validate-code)
|
||||
|
||||
백엔드 코드 및 **작업계획서 내 PHP 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
|
||||
|
||||
## 0단계: 검증 대상 유형 판별 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
|
||||
```
|
||||
|
||||
### 판별 기준
|
||||
|
||||
| 파일 확장자 | 유형 | 검증 방식 |
|
||||
| ----------- | ---------------- | ---------------------------------------- |
|
||||
| `*.php` | 소스코드 | 파일 전체를 PHP 코드로 검증 |
|
||||
| `*.md` | **작업계획서** | 마크다운 내 PHP 코드 블록 추출 후 검증 |
|
||||
|
||||
### 작업계획서 검증 시 추가 단계
|
||||
|
||||
작업계획서(`.md`) 파일인 경우:
|
||||
|
||||
1. **코드 블록 추출**: 마크다운에서 \`\`\`php 코드 블록 추출
|
||||
2. **컨텍스트 파악**: 코드 블록 앞뒤 텍스트로 해당 코드의 용도 파악 (Controller, Service, Repository 등)
|
||||
3. **유형별 검증**: 파악된 용도에 맞는 규정 적용
|
||||
|
||||
```text
|
||||
⚠️ 작업계획서 검증 시 주의사항:
|
||||
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
|
||||
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
|
||||
- 실제 구현 계획 코드만 엄격하게 검증
|
||||
```
|
||||
|
||||
## 1단계: 규정 문서 읽기
|
||||
|
||||
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
|
||||
|
||||
- `docs/backend/service-repository.md` - Service-Repository 패턴
|
||||
- `docs/backend/validation.md` - FormRequest 검증 규칙
|
||||
- `docs/backend/enum.md` - Enum 사용 규칙
|
||||
- `docs/backend/controllers.md` - 컨트롤러 계층 규칙
|
||||
- `docs/backend/api-resources.md` - API 리소스 규칙
|
||||
- `docs/backend/exceptions.md` - Custom Exception 다국어 처리
|
||||
- `docs/backend/middleware.md` - 미들웨어 등록 규칙
|
||||
- `docs/backend/routing.md` - 라우트 네이밍 및 권한 체크
|
||||
- `docs/backend/response-helper.md` - API 응답 규칙
|
||||
- `docs/backend/service-provider.md` - 서비스 프로바이더 안전성
|
||||
- `docs/extension/permissions.md` - 권한 시스템 (Policy 사용 금지)
|
||||
- `docs/backend/notification-system.md` - BaseNotification 상속 규칙
|
||||
- `docs/backend/broadcasting.md` - Broadcasting 규칙
|
||||
- `docs/backend/core-config.md` - 코어 설정 SSoT
|
||||
- `docs/extension/storage-driver.md` - StorageInterface 사용 규칙
|
||||
|
||||
## 2단계: 검증 대상 파일 읽기
|
||||
|
||||
$ARGUMENTS 경로의 파일을 읽습니다.
|
||||
|
||||
경로가 지정되지 않은 경우, 현재 작업 중인 PHP 파일들을 대상으로 합니다:
|
||||
|
||||
- `app/Services/**/*.php` - Service 클래스
|
||||
- `app/Http/Requests/**/*.php` - FormRequest 클래스
|
||||
- `app/Repositories/**/*.php` - Repository 클래스
|
||||
- `app/Http/Controllers/**/*.php` - Controller 클래스
|
||||
- `app/Http/Resources/**/*.php` - API Resource 클래스
|
||||
- `**/Listeners/**/*.php` - Listener 클래스
|
||||
|
||||
## 3단계: 규정 기반 자동 검증 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
|
||||
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
|
||||
```
|
||||
|
||||
### 3.1 규정에서 검증 패턴 추출
|
||||
|
||||
1단계에서 읽은 모든 규정 문서에서 다음 패턴을 추출합니다:
|
||||
|
||||
| 추출 대상 | 검증 유형 |
|
||||
| --------- | --------- |
|
||||
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
|
||||
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
|
||||
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
|
||||
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
|
||||
|
||||
### 3.2 검증 방법
|
||||
|
||||
**금지 패턴 검증**:
|
||||
|
||||
```bash
|
||||
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
|
||||
grep -rn "[금지 패턴]" [대상 파일/디렉토리]
|
||||
```
|
||||
|
||||
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
|
||||
|
||||
**필수 패턴 검증**:
|
||||
|
||||
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
|
||||
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
|
||||
|
||||
### 3.3 파일 유형별 규정 매핑
|
||||
|
||||
검증 대상 파일의 경로에 따라 해당하는 규정 문서를 우선적으로 적용합니다:
|
||||
|
||||
| 파일 경로 패턴 | 우선 적용 규정 |
|
||||
| -------------- | -------------- |
|
||||
| `app/Services/**` | service-repository.md |
|
||||
| `app/Http/Requests/**` | validation.md |
|
||||
| `app/Repositories/**` | service-repository.md |
|
||||
| `app/Http/Controllers/**` | controllers.md, routing.md |
|
||||
| `app/Http/Resources/**` | api-resources.md |
|
||||
| `**/Listeners/**` | hooks.md (extension) |
|
||||
| `app/Providers/**` | service-provider.md |
|
||||
| `app/Http/Middleware/**` | middleware.md |
|
||||
| `app/Exceptions/**` | exceptions.md |
|
||||
|
||||
### 3.4 CLAUDE.md CRITICAL RULES 검증 (MANDATORY)
|
||||
|
||||
CLAUDE.md의 "백엔드 핵심 원칙" 및 CRITICAL RULES를 검증합니다.
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
| --- | -------- | -------- | ---------- |
|
||||
| 1 | FormRequest authorize() | 인증/권한 로직 구현 | `return true` 고정 + permission 미들웨어 체인 |
|
||||
| 2 | API 리소스 $this->when() | 커스텀 메서드에서 `$this->when()` | 삼항 연산자 사용 |
|
||||
| 3 | ResponseHelper 인수 순서 | `ResponseHelper::success($data, $message)` | `ResponseHelper::success($messageKey, $data)` — 메시지 first |
|
||||
| 4 | 미들웨어 전역 등록 | `append()` 로 인증 미들웨어 전역 등록 | `appendToGroup('api')` 사용 |
|
||||
| 5 | 로그아웃 구현 | 단일 단계 로그아웃 | 토큰 삭제 → 세션 무효화 → Auth::logout() (3단계) |
|
||||
| 6 | ServiceProvider DB 접근 | 검증 없이 DB 접근 | `.env` + 테이블 존재 확인 필수 |
|
||||
| 7 | exists/unique 규칙 | `exists:table,col` 문자열 | `Rule::exists(Model::class, 'col')` |
|
||||
| 8 | DB CASCADE 삭제 | `onDelete('cascade')` 의존 삭제 | Service에서 명시적 삭제 (훅/파일/로깅 보장) |
|
||||
| 9 | Storage 직접 호출 | `Storage::disk()` 직접 호출 | `StorageInterface` 사용 필수 |
|
||||
| 10 | 파사드 사용 | `\Log::info()`, `auth()->user()` | `use Facades\Log; Log::info()` |
|
||||
|
||||
### 3.5 PHPDoc 검증
|
||||
|
||||
규정 문서에서 PHPDoc 관련 규칙도 추출하여 검증합니다:
|
||||
|
||||
- `@param`, `@return` 타입 명시 여부
|
||||
- 한국어 설명 포함 여부
|
||||
- `@throws` 명시 (예외 발생 시)
|
||||
|
||||
## 4단계: 테스트 실행 검증
|
||||
|
||||
검증 대상 파일에 해당하는 테스트가 있는 경우, 테스트를 실행하여 통과 여부를 확인합니다.
|
||||
|
||||
```bash
|
||||
php artisan test --filter=[TestClassName]
|
||||
```
|
||||
|
||||
## 5단계: 결과 보고
|
||||
|
||||
검증 결과를 다음 형식으로 보고합니다:
|
||||
|
||||
```text
|
||||
## 코드 패턴 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [파일 경로]
|
||||
|
||||
### [규정 문서명] - [섹션명] 검증 결과
|
||||
|
||||
#### 금지 패턴 검사
|
||||
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- 수정 방법: [올바른 패턴으로 변경]
|
||||
- ✅ 금지 패턴 없음
|
||||
|
||||
#### 필수 패턴 검사
|
||||
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- ✅ 필수 패턴 준수
|
||||
|
||||
### 테스트 실행 결과 (해당되는 경우)
|
||||
- ✅ 모든 테스트 통과 (X passed)
|
||||
- ❌ 테스트 실패: [실패 메시지]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
|
||||
|
||||
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
|
||||
|
||||
### 6.1 코드 블록 추출 및 분류
|
||||
|
||||
마크다운 파일에서 PHP 코드 블록을 추출하고 용도별로 분류합니다:
|
||||
|
||||
| 코드 블록 컨텍스트 | 적용 규정 |
|
||||
| ------------------------ | ---------------------------- |
|
||||
| Controller 관련 | controllers.md, routing.md |
|
||||
| Service 관련 | service-repository.md |
|
||||
| Repository 관련 | service-repository.md |
|
||||
| FormRequest 관련 | validation.md |
|
||||
| API Resource 관련 | api-resources.md |
|
||||
| Exception 관련 | exceptions.md |
|
||||
| Listener/Hook 관련 | hooks.md |
|
||||
|
||||
### 6.2 Controller 코드 블록 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
class ProductController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductServiceInterface $productService
|
||||
) {}
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴 - Repository 직접 주입
|
||||
class ProductController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductRepository $productRepository // ❌
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ❌ Repository 직접 주입 (Service를 통해야 함)
|
||||
- ❌ 컨트롤러 내 검증 로직 (FormRequest 사용 필수)
|
||||
- ❌ 컨트롤러 내 비즈니스 로직 (Service로 위임 필수)
|
||||
- ✅ ResponseHelper 사용
|
||||
|
||||
### 6.3 Service 코드 블록 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
class ProductService implements ProductServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductRepositoryInterface $productRepository
|
||||
) {}
|
||||
|
||||
public function create(array $data): Product
|
||||
{
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
|
||||
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
|
||||
$result = $this->productRepository->create($data);
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_create', $result);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴 - 검증 로직 포함
|
||||
public function create(array $data): Product
|
||||
{
|
||||
if (empty($data['name'])) { // ❌ FormRequest에서 처리해야 함
|
||||
throw new ValidationException();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ❌ RepositoryInterface 대신 구체 클래스 타입힌트
|
||||
- ❌ Service 내 검증 로직
|
||||
- ✅ 훅 실행 순서: before → filter → action → after
|
||||
- ✅ 훅 네이밍: `[vendor-module].[entity].[action]_[timing]`
|
||||
|
||||
### 6.4 Repository 코드 블록 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
class ProductRepository implements ProductRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Product $model
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ✅ RepositoryInterface 구현
|
||||
- ❌ 비즈니스 로직 포함 (Service로 이동 필요)
|
||||
|
||||
### 6.5 FormRequest 코드 블록 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'price' => ['required', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => __('validation.custom.product.name.required'),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ✅ `__()` 함수를 사용한 다국어 메시지
|
||||
- ❌ 하드코딩된 한국어 메시지
|
||||
|
||||
### 6.6 API Response 코드 블록 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
return ResponseHelper::success(
|
||||
data: new ProductResource($product),
|
||||
message: __('messages.product.created')
|
||||
);
|
||||
|
||||
// ❌ 잘못된 패턴
|
||||
return response()->json(['data' => $product]); // ResponseHelper 미사용
|
||||
```
|
||||
|
||||
### 6.7 작업계획서 검증 결과 보고 형식
|
||||
|
||||
```text
|
||||
## 작업계획서 백엔드 코드 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [마크다운 파일 경로]
|
||||
|
||||
### 코드 블록 요약
|
||||
- Controller 코드 블록: X개
|
||||
- Service 코드 블록: Y개
|
||||
- Repository 코드 블록: Z개
|
||||
- FormRequest 코드 블록: W개
|
||||
- 총 검증 대상: N개
|
||||
|
||||
### Controller 검증
|
||||
- ✅ Service 주입 패턴 준수
|
||||
- ❌ Repository 직접 주입: [위치]
|
||||
- ✅ ResponseHelper 사용
|
||||
|
||||
### Service 검증
|
||||
- ✅ RepositoryInterface 사용
|
||||
- ❌ 구체 클래스 타입힌트: [위치]
|
||||
- ✅ 훅 실행 순서 준수
|
||||
- ❌ Service 내 검증 로직: [위치]
|
||||
|
||||
### Repository 검증
|
||||
- ✅ Interface 구현
|
||||
- ❌ 비즈니스 로직 포함: [위치]
|
||||
|
||||
### FormRequest 검증
|
||||
- ✅ 다국어 메시지 사용
|
||||
- ❌ 하드코딩된 메시지: [위치]
|
||||
|
||||
### API Response 검증
|
||||
- ✅ ResponseHelper 사용
|
||||
- ❌ 직접 response() 사용: [위치]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL:
|
||||
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
|
||||
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
|
||||
- 규정이 변경되면 검증도 자동으로 변경됨
|
||||
- 파일 유형에 따라 해당 규정 문서 우선 적용
|
||||
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
|
||||
```
|
||||
@@ -0,0 +1,666 @@
|
||||
# 프론트엔드 검증 (validate-frontend)
|
||||
|
||||
프론트엔드 레이아웃 JSON, 컴포넌트, **작업계획서 내 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
|
||||
|
||||
## 0단계: 검증 대상 유형 판별 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
|
||||
```
|
||||
|
||||
### 판별 기준
|
||||
|
||||
| 파일 확장자 | 유형 | 검증 방식 |
|
||||
| ----------- | ---------------- | ---------------------------------------- |
|
||||
| `*.json` | 소스코드 | 파일 전체를 레이아웃 JSON으로 검증 |
|
||||
| `*.tsx` | 소스코드 | 컴포넌트 코드로 검증 + 테스트 실행 |
|
||||
| `*.md` | **작업계획서** | 마크다운 내 코드 블록 추출 후 검증 |
|
||||
|
||||
### 작업계획서 검증 시 추가 단계
|
||||
|
||||
작업계획서(`.md`) 파일인 경우:
|
||||
|
||||
1. **코드 블록 추출**: 마크다운에서 \`\`\`json, \`\`\`typescript, \`\`\`tsx 코드 블록 추출
|
||||
2. **컨텍스트 파악**: 코드 블록 앞뒤 텍스트로 해당 코드의 용도 파악
|
||||
3. **유형별 검증**: JSON → 레이아웃 검증, TypeScript → 핸들러/컴포넌트 검증
|
||||
|
||||
```text
|
||||
⚠️ 작업계획서 검증 시 주의사항:
|
||||
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
|
||||
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
|
||||
- 실제 구현 계획 코드만 엄격하게 검증
|
||||
```
|
||||
|
||||
## 0.1단계: 그누보드7 DevTools MCP 활용 (소스코드 검증 시 권장)
|
||||
|
||||
브라우저 상태 덤프가 가능한 경우, MCP 도구를 먼저 활용하여 런타임 상태를 확인합니다.
|
||||
|
||||
| 도구 | 검증 내용 |
|
||||
|------|----------|
|
||||
| `g7-state` | 현재 _global, _local 상태 값 |
|
||||
| `g7-expressions` | 표현식 평가 경고, 잘못된 바인딩 |
|
||||
| `g7-form` | Form 바인딩 상태, 누락된 name prop |
|
||||
| `g7-actions` | 핸들러 실행 오류 |
|
||||
|
||||
## 1단계: 규정 문서 읽기
|
||||
|
||||
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
|
||||
|
||||
- `docs/frontend/components.md` (인덱스) - 컴포넌트 규칙
|
||||
- `components-types.md` - 기본/집합/레이아웃 컴포넌트
|
||||
- `components-patterns.md` - 순환 의존성, 다국어, skipBindingKeys
|
||||
- `components-advanced.md` - 이벤트 통신, 아이콘, 개발 체크리스트
|
||||
- `.claude/docs/frontend/fontawesome-free-icons.md` (인덱스) - Font Awesome Free 아이콘 목록
|
||||
- `fontawesome-icons-solid.md` - Solid 아이콘 (1,390개)
|
||||
- `fontawesome-icons-regular.md` - Regular 아이콘 (163개)
|
||||
- `fontawesome-icons-brands.md` - Brands 아이콘 (472개)
|
||||
- `docs/frontend/layout-json.md` - 레이아웃 JSON 스키마
|
||||
- `docs/frontend/layout-json-inheritance.md` - 레이아웃 상속
|
||||
- `docs/frontend/data-binding.md` - 데이터 바인딩
|
||||
- `docs/frontend/data-binding-i18n.md` - 다국어 바인딩
|
||||
- `docs/frontend/data-sources.md` - 데이터 소스
|
||||
- `docs/frontend/dark-mode.md` - 다크 모드 규칙
|
||||
- `docs/frontend/responsive-layout.md` - 반응형 레이아웃
|
||||
- `docs/frontend/state-management.md` - 상태 관리
|
||||
- `docs/frontend/actions.md` - 액션 시스템
|
||||
- `docs/frontend/actions-handlers.md` - 핸들러별 params, onSuccess/onError
|
||||
- `docs/frontend/security.md` - 보안 규칙
|
||||
- `docs/frontend/modal-usage.md` - 모달 사용 규칙
|
||||
- `docs/frontend/layout-json-features-error.md` - 에러 핸들링
|
||||
- `docs/frontend/component-props-composite.md` - Composite 컴포넌트 Props
|
||||
- `docs/frontend/layout-json-components-loading.md` - 데이터 로딩 및 생명주기
|
||||
- `docs/frontend/layout-json-components-slots.md` - 슬롯 시스템
|
||||
|
||||
## 2단계: 검증 대상 파일 읽기
|
||||
|
||||
$ARGUMENTS 경로의 파일을 읽습니다.
|
||||
|
||||
경로가 지정되지 않은 경우, 다음 파일들을 대상으로 합니다:
|
||||
|
||||
- `resources/layouts/**/*.json` - 코어 레이아웃
|
||||
- `templates/**/layouts/**/*.json` - 템플릿 레이아웃
|
||||
- `modules/**/resources/layouts/**/*.json` - 모듈 레이아웃
|
||||
- `templates/**/src/components/**/*.tsx` - 컴포넌트 파일
|
||||
|
||||
## 3단계: 규정 기반 자동 검증 (CRITICAL)
|
||||
|
||||
```
|
||||
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
|
||||
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
|
||||
```
|
||||
|
||||
### ⭐ 검증 완료 전 필수 체크리스트 (MANDATORY)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 아래 체크리스트를 모두 완료하기 전까지 검증 완료 선언 금지
|
||||
|
||||
□ 1. 규정 문서 읽기 완료 (섹션 1)
|
||||
□ 2. 검증 대상 파일 읽기 완료 (섹션 2)
|
||||
□ 3. 규정 기반 금지/필수 패턴 검증 완료 (섹션 3.1~3.4)
|
||||
□ 4. ⭐⭐⭐ 규정 문서 기반 속성 검증 완료 - 필수 ⭐⭐⭐
|
||||
□ layout-json.md의 컴포넌트 정의 필드와 대조 완료
|
||||
□ actions.md의 내장 핸들러 목록과 대조 완료
|
||||
□ 작업계획서의 모든 속성이 규정에 정의된 것인지 확인 완료
|
||||
□ 5. Font Awesome 아이콘 검증 완료 (섹션 3.6)
|
||||
□ 6. 테스트 실행 검증 (해당 시) (섹션 4)
|
||||
□ 7. 결과 보고서 작성 (섹션 5)
|
||||
|
||||
❌ 절대 금지: 규정 문서 대조 없이 검증 완료 선언
|
||||
✅ 필수: 작업계획서의 모든 속성/핸들러가 규정 문서에 정의되어 있는지 대조
|
||||
```
|
||||
|
||||
### 3.1 규정에서 검증 패턴 추출
|
||||
|
||||
1단계에서 읽은 모든 규정 문서에서 다음 패턴을 추출합니다:
|
||||
|
||||
| 추출 대상 | 검증 유형 |
|
||||
|----------|----------|
|
||||
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
|
||||
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
|
||||
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
|
||||
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
|
||||
|
||||
### 3.2 검증 방법
|
||||
|
||||
**금지 패턴 검증**:
|
||||
```bash
|
||||
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
|
||||
grep -rn "[금지 패턴]" [대상 파일/디렉토리]
|
||||
```
|
||||
|
||||
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
|
||||
|
||||
**필수 패턴 검증**:
|
||||
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
|
||||
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
|
||||
|
||||
### 3.3 컴포넌트 소스코드 검증 (TSX 파일)
|
||||
|
||||
컴포넌트 파일(.tsx)이 검증 대상인 경우, 규정 문서에서 추출한 패턴으로 추가 검증:
|
||||
|
||||
1. **컴포넌트 소스코드 위치**:
|
||||
```
|
||||
templates/[vendor-template]/src/components/
|
||||
├── basic/ # 기본 컴포넌트
|
||||
├── composite/ # 집합 컴포넌트
|
||||
└── layout/ # 레이아웃 컴포넌트
|
||||
```
|
||||
|
||||
2. **컴포넌트 등록 검증** (규정: components.md "10. 컴포넌트 개발 체크리스트"):
|
||||
- `index.ts`에 export 등록 여부
|
||||
- `components.json`에 등록 여부
|
||||
- 테스트 파일 존재 여부
|
||||
|
||||
### 3.4 레이아웃 JSON Props 검증
|
||||
|
||||
레이아웃 JSON에서 사용된 컴포넌트의 props가 실제로 지원되는지 **컴포넌트 소스코드를 직접 분석**하여 검증합니다.
|
||||
|
||||
1. 레이아웃 JSON에서 사용된 컴포넌트명 추출
|
||||
2. 해당 컴포넌트의 소스코드에서 Props 인터페이스 분석
|
||||
3. 지원되지 않는 props 사용 시 에러 보고
|
||||
|
||||
### 3.5 규정 문서 기반 속성 검증
|
||||
|
||||
작업계획서의 모든 레이아웃 속성/핸들러가 **규정 문서에 정의**되어 있는지 대조 검증합니다.
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 규정 문서가 Single Source of Truth
|
||||
✅ 규정 문서만으로 검증 가능 (소스코드 Read 불필요)
|
||||
✅ 토큰 효율적, 컨텍스트 절약
|
||||
```
|
||||
|
||||
| 검증 대상 | 규정 문서 | 확인 항목 |
|
||||
|-----------|-----------|-----------|
|
||||
| 레이아웃 최상위 필드 | `layout-json.md` > 필수 필드 | version, layout_name, meta, data_sources, components 등 |
|
||||
| 컴포넌트 정의 필드 | `layout-json.md` > 컴포넌트 정의 | id, type, name, props, children, actions, text, iteration, if 등 |
|
||||
| 고급 기능 | `layout-json-features.md` | classMap, computed, errorHandling, init_actions, modals, scripts |
|
||||
| 반복/조건부 렌더링 | `layout-json-components.md` | iteration, blur_until_loaded, lifecycle, responsive |
|
||||
| 액션 핸들러 | `actions.md` > 내장 핸들러 목록 | navigate, apiCall, setState, openModal 등 21개 |
|
||||
| 액션 정의 필드 | `actions.md` > 액션 정의 구조 | type, event, handler, params, onSuccess, onError, confirm, key, debounce |
|
||||
| 데이터 바인딩 | `data-binding.md` | {{expression}}, $t:key, $event, $args |
|
||||
| 데이터 소스 | `data-sources.md` | id, endpoint, method, params, loading_strategy 등 |
|
||||
|
||||
### 3.6 CLAUDE.md CRITICAL RULES 검증 (MANDATORY)
|
||||
|
||||
CLAUDE.md의 "CRITICAL RULES - 절대 금지 패턴" 및 MEMORY.md의 학습된 규칙을 검증합니다.
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 아래 항목은 빈번한 실수이므로 반드시 검증
|
||||
```
|
||||
|
||||
#### 3.6.1 레이아웃 구조 금지 패턴
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
|---|----------|----------|------------|
|
||||
| 1 | type: "conditional" 사용 금지 | `"type": "conditional"` | `"if": "{{expression}}"` 속성 사용 |
|
||||
| 2 | type: "iterator" 사용 금지 | `"type": "iterator"` | 실제 컴포넌트에 `"iteration": {...}` 속성 사용 |
|
||||
| 3 | Partial 파일 최상위 속성 금지 | Partial에 computed, data_sources, modals, state 정의 | Partial은 컴포넌트 치환만 가능 |
|
||||
| 4 | 부모-자식 data_sources ID 중복 | 동일 ID가 부모/자식 레이아웃에 존재 | 고유 ID 사용 필수 |
|
||||
| 5 | globalHeaders 객체 형식 | `"globalHeaders": { "X-Key": "value" }` | `"globalHeaders": [{ "pattern": "*", "headers": {...} }]` 배열 형식 |
|
||||
| 6 | 레이아웃 JSON 새 최상위 속성 | 새 속성 추가 후 백엔드 미동기화 | UpdateLayoutContentRequest 필수 수정 |
|
||||
| 7 | children 문자열 직접 사용 | `"children": "텍스트"` | `"text": "텍스트"` 속성 사용 |
|
||||
|
||||
#### 3.6.2 핸들러/액션 금지 패턴
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
|---|----------|----------|------------|
|
||||
| 1 | navigate + replace:true | `handler: "navigate"` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
|
||||
| 2 | 잘못된 핸들러명 | `handler: "api"`, `handler: "nav"`, `handler: "setLocalState"` | `handler: "apiCall"`, `handler: "navigate"`, `handler: "setState"` + `target: "local"` |
|
||||
| 3 | 표현식에서 핸들러 호출 | `{{handler()}}` | `actions: [{ handler: "xxx" }]` |
|
||||
| 4 | openModal/closeModal params | `{ "handler": "openModal", "params": { "id": "xxx" } }` | `{ "handler": "openModal", "target": "xxx" }` |
|
||||
|
||||
#### 3.6.3 데이터 바인딩 금지 패턴
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
|---|----------|----------|------------|
|
||||
| 1 | onSuccess $response | `{{$response.xxx}}` | `{{response.xxx}}` ($ 접두사 없음) |
|
||||
| 2 | 이벤트 값 접근 | `$value` | `$event.target.value` |
|
||||
| 3 | Partial에서 props 접근 | `{{props.xxx}}` | data_sources ID 직접 참조 |
|
||||
| 4 | 에러 데이터 접근 | `{{error.data}}` | `{{error.errors}}` |
|
||||
| 5 | 배열 데이터 경로 | `{{products.data}}` | `{{products?.data?.data}}` (경로 확인) |
|
||||
| 6 | fallback 없는 바인딩 | `{{value}}` | `{{value ?? ''}}` |
|
||||
|
||||
#### 3.6.4 반복 렌더링(iteration) 금지 패턴
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
|---|----------|----------|------------|
|
||||
| 1 | 변수명 | `"item"`, `"index"` | `"item_var"`, `"index_var"` |
|
||||
| 2 | if와 iteration 순서 | if 순서 무시 | if가 iteration보다 먼저 평가됨 |
|
||||
|
||||
#### 3.6.5 상태 관리 금지 패턴
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
|---|----------|----------|------------|
|
||||
| 1 | setState params 키에 {{}} | `"params": { "{{key}}": "value" }` | 키는 정적 경로만 사용 |
|
||||
| 2 | closeModal 후 setState | closeModal → setState 순서 | setState → closeModal 순서 |
|
||||
| 3 | sortable 내 폼 자동바인딩 | sortable 내 폼 자동바인딩 그대로 | `parentFormContextProp={undefined}` |
|
||||
| 4 | await 후 캡처된 상태 | await 후 캡처된 _local 사용 | `G7Core.state.getLocal()` 재조회 |
|
||||
|
||||
#### 3.6.6 컴포넌트 Props 금지 패턴
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
|---|----------|----------|------------|
|
||||
| 1 | Icon size 설정 | `Icon className="w-4 h-4"` | `Icon size="sm"` 또는 `className="text-sm"` |
|
||||
| 2 | Select 옵션 fallback | `options={{options}}` | `options={{options ?? []}}` |
|
||||
| 3 | Form 내 Button type 누락 | Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
|
||||
| 4 | Select valueKey/labelKey | `valueKey`, `labelKey` props 사용 | computed로 `{ value, label }` 변환 |
|
||||
|
||||
#### 3.6.7 모듈 레이아웃 다국어 검증
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
|---|----------|----------|------------|
|
||||
| 1 | 모듈 식별자 누락 | `$t:common.xxx` (모듈 레이아웃에서) | `$t:sirsoft-ecommerce.common.xxx` |
|
||||
|
||||
### 3.7 Font Awesome 아이콘 검증 (CRITICAL)
|
||||
|
||||
레이아웃 JSON 및 TSX 파일에서 사용된 아이콘이 **Font Awesome Free 버전**에 포함되어 있는지 검증합니다.
|
||||
|
||||
**검증 기준 문서**: `.claude/docs/frontend/fontawesome-free-icons.md`
|
||||
|
||||
#### 아이콘 추출 패턴
|
||||
|
||||
레이아웃 JSON에서:
|
||||
|
||||
```json
|
||||
// Icon 컴포넌트의 name prop에서 아이콘명 추출
|
||||
{
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-solid fa-user" // ← 이 값 추출
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
TSX 파일에서:
|
||||
|
||||
```typescript
|
||||
// IconName enum 사용 또는 직접 문자열
|
||||
<Icon name={IconName.User} />
|
||||
<Icon name="fa-solid fa-check" />
|
||||
```
|
||||
|
||||
#### 검증 규칙
|
||||
|
||||
1. **Pro 전용 스타일 사용 금지** (에러):
|
||||
- `fa-light`, `fal` - Light 스타일
|
||||
- `fa-thin`, `fat` - Thin 스타일
|
||||
- `fa-duotone`, `fad` - Duotone 스타일
|
||||
- `fa-sharp`, `fass`, `fasr`, `fasl` - Sharp 스타일
|
||||
|
||||
2. **Free 아이콘 목록 검증** (에러):
|
||||
- `fontawesome-free-icons.md`의 Solid/Regular/Brands 목록에 없는 아이콘 사용 시 에러
|
||||
- Regular 스타일은 163개만 Free (나머지는 Solid 사용 필요)
|
||||
|
||||
#### 아이콘명 추출 및 검증 방법 (CRITICAL)
|
||||
|
||||
```
|
||||
⚠️ CRITICAL: Pro 스타일 검사만으로 충분하지 않음
|
||||
✅ MANDATORY: 실제 아이콘명이 Free 목록에 존재하는지 반드시 검증
|
||||
```
|
||||
|
||||
##### Step 1: 레이아웃 JSON에서 아이콘명 추출
|
||||
|
||||
```bash
|
||||
# Icon 컴포넌트의 name prop에서 아이콘명 추출
|
||||
grep -rhoP '"name":\s*"Icon"[^}]*"name":\s*"\K[^"]+' templates/[template]/layouts/**/*.json | sort -u
|
||||
```
|
||||
|
||||
또는 JSON 구조를 분석하여 Icon 컴포넌트의 props.name 값을 추출합니다.
|
||||
|
||||
##### Step 2: TSX 파일에서 아이콘명 추출
|
||||
|
||||
```bash
|
||||
# Icon 컴포넌트 사용 패턴에서 아이콘명 추출
|
||||
grep -rhoP '<Icon[^>]*name=["'\'']\K[^"'\''"]+' templates/[template]/src/components/**/*.tsx | sort -u
|
||||
grep -rhoP 'name:\s*["'\'']\K[^"'\''"]+' templates/[template]/src/components/**/*.tsx | sort -u
|
||||
```
|
||||
|
||||
##### Step 3: 추출된 아이콘명을 Free 목록과 대조
|
||||
|
||||
1. `fontawesome-free-icons.md`에서 아이콘 목록 추출:
|
||||
- Solid 아이콘: `## Solid 아이콘 목록` 섹션의 `fa-*` 패턴
|
||||
- Regular 아이콘: `## Regular 아이콘 목록` 섹션의 `fa-*` 패턴
|
||||
- Brands 아이콘: `## Brands 아이콘 목록` 섹션의 `fa-*` 패턴
|
||||
|
||||
2. 추출된 각 아이콘명에 대해:
|
||||
- 아이콘명 파싱: `fa-solid fa-user` → 스타일=`solid`, 아이콘=`fa-user`
|
||||
- 아이콘명 파싱: `user` (단축형) → 스타일=`solid`(기본), 아이콘=`fa-user`
|
||||
- 해당 스타일의 Free 목록에 아이콘이 존재하는지 확인
|
||||
|
||||
3. 존재하지 않는 아이콘 발견 시:
|
||||
- 에러로 보고
|
||||
- 유사한 Free 아이콘 추천 (가능한 경우)
|
||||
|
||||
##### Step 4: iconNameMap 매핑 확인
|
||||
|
||||
Icon 컴포넌트에서 `iconNameMap`을 사용하는 경우, 매핑된 실제 아이콘명도 검증:
|
||||
|
||||
```typescript
|
||||
// 예: "shopping-cart" → "fa-cart-shopping" 매핑 확인
|
||||
// 매핑된 결과가 Free 목록에 있는지 검증
|
||||
```
|
||||
|
||||
##### 검증 결과 보고 형식
|
||||
|
||||
```text
|
||||
### Font Awesome 아이콘 검증
|
||||
- ❌ Pro 전용 스타일 사용: "fa-light fa-user" (파일:라인)
|
||||
- 수정: "fa-solid fa-user" 또는 "fa-regular fa-user" 사용
|
||||
- ❌ Free에 없는 아이콘: "fa-solid fa-plate-utensils" (파일:라인)
|
||||
- fontawesome-free-icons.md에서 대체 아이콘 검색 필요
|
||||
- ✅ 모든 아이콘이 Free 버전에 포함됨
|
||||
```
|
||||
|
||||
## 4단계: 테스트 실행 검증
|
||||
|
||||
컴포넌트 파일이 검증 대상인 경우, 테스트를 실행하여 통과 여부를 확인합니다.
|
||||
|
||||
```
|
||||
⚠️ CRITICAL: Windows 환경에서 프론트엔드 테스트는 반드시 PowerShell 래퍼 사용
|
||||
✅ 프로젝트 루트에서: powershell -Command "npm run test:run -- ComponentName"
|
||||
✅ 템플릿 디렉토리에서: cd templates/sirsoft-admin_basic && powershell -Command "npm run test:run"
|
||||
✅ _bundled 모듈에서: cd modules/_bundled/sirsoft-ecommerce && powershell -Command "npm run test:run"
|
||||
```
|
||||
|
||||
## 5단계: 결과 보고
|
||||
|
||||
검증 결과를 다음 형식으로 보고합니다:
|
||||
|
||||
```
|
||||
## 프론트엔드 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [파일 경로]
|
||||
|
||||
### [규정 문서명] - [섹션명] 검증 결과
|
||||
|
||||
#### 금지 패턴 검사
|
||||
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- 수정 방법: [올바른 패턴으로 변경]
|
||||
- ✅ 금지 패턴 없음
|
||||
|
||||
#### 필수 패턴 검사
|
||||
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- ✅ 필수 패턴 준수
|
||||
|
||||
### 컴포넌트 등록 검증 (TSX 파일인 경우)
|
||||
- ✅/❌ index.ts export 등록
|
||||
- ✅/❌ components.json 등록
|
||||
- ✅/❌ 테스트 파일 존재
|
||||
|
||||
### 테스트 실행 결과 (TSX 파일인 경우)
|
||||
- ✅ 모든 테스트 통과 (X passed)
|
||||
- ❌ 테스트 실패: [실패 메시지]
|
||||
```
|
||||
|
||||
## 6단계: 레이아웃 기능 연동 검증 (신규 기능 추가 시)
|
||||
|
||||
새로운 레이아웃 JSON 기능(속성, 핸들러 등)이 추가된 경우, 다음 연동 항목도 반영되었는지 검증합니다.
|
||||
|
||||
### 6.1 검증 대상
|
||||
|
||||
템플릿 엔진에 새로운 기능이 추가된 경우:
|
||||
- 새 액션 핸들러 (예: `debounce`, `filter` 등)
|
||||
- 새 컴포넌트 속성 (예: `classMap`, `computed` 등)
|
||||
- 새 데이터 바인딩 문법 (예: `$switch`, `$get`, 파이프 등)
|
||||
- 새 데이터소스 옵션 (예: `if` 조건부 로딩 등)
|
||||
|
||||
### 6.2 연동 검증 항목
|
||||
|
||||
| 검증 항목 | 확인 위치 | 확인 방법 |
|
||||
|----------|----------|----------|
|
||||
| **DevTools 연동** | `resources/js/core/template-engine/devtools/G7DevToolsCore.ts` | 새 기능의 추적/디버깅 지원 여부 |
|
||||
| **WYSIWYG 에디터 연동** | `resources/js/core/template-engine/wysiwyg/components/PropertyPanel/*.tsx` | 새 기능의 UI 편집기 지원 여부 |
|
||||
| **규정 문서 업데이트** | `docs/frontend/*.md` | 새 기능 문서화 여부 |
|
||||
|
||||
### 6.3 연동 확인 방법
|
||||
|
||||
**DevTools 연동 확인**:
|
||||
```bash
|
||||
# 새 기능명으로 DevTools 코드 검색
|
||||
grep -rn "[기능명]" resources/js/core/template-engine/devtools/
|
||||
```
|
||||
|
||||
**WYSIWYG 에디터 연동 확인**:
|
||||
```bash
|
||||
# 새 기능명으로 WYSIWYG 코드 검색
|
||||
grep -rn "[기능명]" resources/js/core/template-engine/wysiwyg/
|
||||
```
|
||||
|
||||
### 6.4 보고 형식
|
||||
|
||||
```text
|
||||
### 레이아웃 기능 연동 검증
|
||||
|
||||
#### 신규 기능: [기능명]
|
||||
- ✅/❌ DevTools 연동: [추적 지원 여부]
|
||||
- ✅/❌ WYSIWYG 에디터 연동: [UI 편집기 지원 여부]
|
||||
- ✅/❌ 규정 문서 업데이트: [문서화 여부]
|
||||
|
||||
#### 누락 항목
|
||||
- DevTools: [trackAction에 기능 정보 추가 필요]
|
||||
- WYSIWYG: [PropertyPanel에 편집기 추가 필요]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
|
||||
|
||||
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
|
||||
|
||||
### 7.1 코드 블록 추출 및 분류
|
||||
|
||||
마크다운 파일에서 코드 블록을 추출하고 유형별로 분류합니다:
|
||||
|
||||
| 코드 블록 언어 | 검증 유형 |
|
||||
| ---------------------- | ---------------------------- |
|
||||
| \`\`\`json | 레이아웃 JSON 검증 |
|
||||
| \`\`\`typescript, ts | 핸들러/타입 정의 검증 |
|
||||
| \`\`\`tsx | 컴포넌트 코드 검증 |
|
||||
| \`\`\`php | 백엔드 코드 검증 (필요시) |
|
||||
|
||||
### 7.2 레이아웃 JSON 코드 블록 검증
|
||||
|
||||
작업계획서 내 JSON 코드 블록에 대해 다음을 검증합니다:
|
||||
|
||||
#### 7.2.1 컴포넌트 정의 구조 검증
|
||||
|
||||
소스코드 기반으로 `ComponentDefinition` 인터페이스와 일치하는지 확인:
|
||||
|
||||
```typescript
|
||||
// resources/js/core/template-engine/DynamicRenderer.tsx
|
||||
interface ComponentDefinition {
|
||||
id?: string;
|
||||
type: 'basic' | 'composite' | 'layout';
|
||||
name: string;
|
||||
props?: Record<string, any>;
|
||||
children?: ComponentDefinition[];
|
||||
actions?: ActionDefinition[];
|
||||
if?: string;
|
||||
iteration?: IterationConfig;
|
||||
responsive?: ResponsiveConfig;
|
||||
text?: string;
|
||||
slots?: Record<string, ComponentDefinition[]>;
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
- ❌ `type`이 `basic`, `composite`, `layout` 외의 값인 경우
|
||||
- ❌ `name`이 누락된 경우
|
||||
- ❌ 지원되지 않는 최상위 속성 사용 (예: `class` 대신 `props.className`)
|
||||
|
||||
#### 7.2.2 컴포넌트 Props 검증
|
||||
|
||||
사용된 컴포넌트의 props가 실제 컴포넌트에서 지원되는지 검증:
|
||||
|
||||
1. 계획서에서 사용된 컴포넌트명 목록 추출
|
||||
2. 각 컴포넌트의 소스코드에서 `Props` 인터페이스 확인
|
||||
3. 지원되지 않는 props 사용 시 에러 보고
|
||||
|
||||
```text
|
||||
검증 경로: templates/[vendor-template]/src/components/{type}/{Name}.tsx
|
||||
```
|
||||
|
||||
#### 7.2.3 액션 정의 구조 검증
|
||||
|
||||
소스코드 기반으로 `ActionDefinition` 인터페이스와 일치하는지 확인:
|
||||
|
||||
```typescript
|
||||
// resources/js/core/template-engine/ActionDispatcher.ts
|
||||
interface ActionDefinition {
|
||||
type: string; // 이벤트 타입 (click, change, submit 등)
|
||||
handler: string; // 핸들러명
|
||||
params?: Record<string, any>;
|
||||
onSuccess?: ActionDefinition | ActionDefinition[];
|
||||
onError?: ActionDefinition | ActionDefinition[];
|
||||
actions?: ActionDefinition[]; // sequence 핸들러용
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
- ❌ `type`이 누락된 경우
|
||||
- ❌ `handler`가 누락된 경우
|
||||
- ❌ 지원되지 않는 핸들러 사용 (actions.md 및 등록된 핸들러 확인)
|
||||
|
||||
#### 7.2.4 반응형 레이아웃 검증 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: Tailwind 반응형 클래스 대신 그누보드7 responsive 속성 사용 권장
|
||||
```
|
||||
|
||||
**금지 패턴 검사**:
|
||||
```json
|
||||
// ❌ Tailwind 반응형 클래스로 show/hide
|
||||
"props": { "className": "hidden md:block" }
|
||||
"props": { "className": "block md:hidden" }
|
||||
|
||||
// ❌ Tailwind 반응형 클래스로 그리드 변경
|
||||
"props": { "className": "grid grid-cols-1 md:grid-cols-2" }
|
||||
```
|
||||
|
||||
**권장 패턴 안내**:
|
||||
```json
|
||||
// ✅ 그누보드7 responsive로 show/hide
|
||||
"responsive": {
|
||||
"portable": { "if": "{{false}}" }
|
||||
}
|
||||
|
||||
// ✅ 그누보드7 responsive로 그리드 변경
|
||||
"props": { "className": "grid grid-cols-2 gap-4" },
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": { "className": "grid grid-cols-1 gap-4" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.2.5 데이터 바인딩 문법 검증
|
||||
|
||||
```text
|
||||
✅ 지원되는 문법:
|
||||
- {{expression}} - 데이터 바인딩
|
||||
- {{obj?.prop}} - Optional chaining
|
||||
- {{value ?? default}} - Nullish coalescing
|
||||
- $t:key - 다국어 (즉시 평가)
|
||||
- $t:defer:key - 다국어 (지연 평가)
|
||||
- $event - 이벤트 데이터
|
||||
|
||||
❌ 지원되지 않는 문법 사용 시 에러
|
||||
```
|
||||
|
||||
### 7.3 TypeScript/핸들러 코드 블록 검증
|
||||
|
||||
작업계획서 내 TypeScript 코드 블록에 대해 다음을 검증합니다:
|
||||
|
||||
#### 7.3.1 핸들러 등록 패턴 검증
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 패턴
|
||||
G7Core.handlers.register(`sirsoft-ecommerce.${name}`, handler);
|
||||
|
||||
// ❌ 잘못된 패턴
|
||||
G7Core.handlers.register(name, handler); // 네임스페이스 누락
|
||||
```
|
||||
|
||||
#### 7.3.2 상태 관리 패턴 검증
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 패턴
|
||||
G7Core.state.setLocal({ form: {...form, field: value}, hasChanges: true });
|
||||
|
||||
// ❌ 잘못된 패턴 (직접 변경)
|
||||
_local.form.field = value;
|
||||
```
|
||||
|
||||
#### 7.3.3 API 호출 패턴 검증
|
||||
|
||||
```typescript
|
||||
// ✅ G7Core.api 사용
|
||||
const response = await G7Core.api.post('/api/...', data);
|
||||
|
||||
// ❌ fetch 직접 사용
|
||||
const response = await fetch('/api/...');
|
||||
```
|
||||
|
||||
### 7.4 작업계획서 검증 결과 보고 형식
|
||||
|
||||
```text
|
||||
## 작업계획서 프론트엔드 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [마크다운 파일 경로]
|
||||
|
||||
### 코드 블록 요약
|
||||
- JSON 코드 블록: X개
|
||||
- TypeScript 코드 블록: Y개
|
||||
- 총 검증 대상: Z개
|
||||
|
||||
### 레이아웃 JSON 검증
|
||||
|
||||
#### ComponentDefinition 구조 검증
|
||||
- ✅ 모든 컴포넌트 정의가 유효함
|
||||
- ❌ 잘못된 구조: [위치] - [문제 설명]
|
||||
|
||||
#### 컴포넌트 Props 검증
|
||||
- ✅ 모든 props가 지원됨
|
||||
- ❌ 지원되지 않는 prop: [컴포넌트명].[prop명] (코드 블록 위치)
|
||||
- 컴포넌트 소스: [소스 파일 경로]
|
||||
- 지원되는 props: [목록]
|
||||
|
||||
#### 액션 정의 검증
|
||||
- ✅ 모든 핸들러가 유효함
|
||||
- ❌ 지원되지 않는 핸들러: [핸들러명] (코드 블록 위치)
|
||||
- 참조: actions.md 또는 등록된 커스텀 핸들러 확인
|
||||
|
||||
#### 반응형 레이아웃 검증
|
||||
- ✅ 그누보드7 responsive 패턴 준수
|
||||
- ⚠️ Tailwind 반응형 클래스 사용: [위치]
|
||||
- 현재: "className": "[Tailwind 클래스]"
|
||||
- 권장: responsive 속성 사용
|
||||
|
||||
#### 데이터 바인딩 검증
|
||||
- ✅ 모든 바인딩 문법 유효
|
||||
- ❌ 지원되지 않는 문법: [위치] - [문법]
|
||||
|
||||
### TypeScript 핸들러 검증
|
||||
- ✅ 핸들러 등록 패턴 준수
|
||||
- ❌ 네임스페이스 누락: [위치]
|
||||
- ✅ 상태 관리 패턴 준수
|
||||
- ❌ 직접 상태 변경: [위치]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL:
|
||||
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
|
||||
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
|
||||
- 규정이 변경되면 검증도 자동으로 변경됨
|
||||
- 소스코드 인터페이스 분석으로 실제 지원 여부 확인
|
||||
- 레이아웃 기능 추가 시 DevTools와 WYSIWYG 에디터 연동도 함께 검증
|
||||
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
|
||||
```
|
||||
@@ -0,0 +1,361 @@
|
||||
# 훅 패턴 검증 (validate-hook)
|
||||
|
||||
훅 시스템 및 **작업계획서 내 훅 관련 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
|
||||
|
||||
## 0단계: 검증 대상 유형 판별 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
|
||||
```
|
||||
|
||||
### 판별 기준
|
||||
|
||||
| 파일 확장자 | 유형 | 검증 방식 |
|
||||
| ----------- | ---------------- | ---------------------------------------------- |
|
||||
| `*.php` | 소스코드 | 파일 전체를 훅/리스너 코드로 검증 |
|
||||
| `*.md` | **작업계획서** | 마크다운 내 PHP 코드 블록 추출 후 검증 |
|
||||
|
||||
### 작업계획서 검증 시 추가 단계
|
||||
|
||||
작업계획서(`.md`) 파일인 경우:
|
||||
|
||||
1. **코드 블록 추출**: 마크다운에서 \`\`\`php 코드 블록 추출
|
||||
2. **훅 관련 코드 식별**: `HookManager`, `doAction`, `applyFilters`, `Listener` 등의 키워드로 식별
|
||||
3. **규정 적용**: hooks.md의 훅 시스템 규칙 적용
|
||||
|
||||
```text
|
||||
⚠️ 작업계획서 검증 시 주의사항:
|
||||
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
|
||||
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
|
||||
- 실제 구현 계획 코드만 엄격하게 검증
|
||||
```
|
||||
|
||||
## 1단계: 규정 문서 읽기
|
||||
|
||||
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
|
||||
|
||||
- `docs/extension/hooks.md` - 훅 시스템 규칙
|
||||
- `docs/backend/service-repository.md` - Service에서의 훅 실행
|
||||
- `docs/extension/plugin-development.md` - 플러그인 격리 원칙
|
||||
|
||||
## 2단계: 검증 대상 파일 읽기
|
||||
|
||||
$ARGUMENTS 경로의 파일을 읽습니다.
|
||||
|
||||
경로가 지정되지 않은 경우, 다음 파일들을 대상으로 합니다:
|
||||
|
||||
- `**/Listeners/**/*.php` - 훅 리스너
|
||||
- `app/Services/**/*.php` - 훅 실행 서비스
|
||||
- `modules/_bundled/**/src/Services/**/*.php` - _bundled 모듈 서비스
|
||||
- `modules/_bundled/**/src/Listeners/**/*.php` - _bundled 모듈 리스너
|
||||
- `plugins/_bundled/**/src/Listeners/**/*.php` - _bundled 플러그인 리스너
|
||||
|
||||
## 3단계: 규정 기반 자동 검증 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
|
||||
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
|
||||
```
|
||||
|
||||
### 3.1 규정에서 검증 패턴 추출
|
||||
|
||||
1단계에서 읽은 모든 규정 문서에서 다음 패턴을 추출합니다:
|
||||
|
||||
| 추출 대상 | 검증 유형 |
|
||||
| --------- | --------- |
|
||||
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
|
||||
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
|
||||
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
|
||||
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
|
||||
|
||||
### 3.2 검증 방법
|
||||
|
||||
**금지 패턴 검증**:
|
||||
|
||||
```bash
|
||||
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
|
||||
grep -rn "[금지 패턴]" [대상 파일/디렉토리]
|
||||
```
|
||||
|
||||
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
|
||||
|
||||
**필수 패턴 검증**:
|
||||
|
||||
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
|
||||
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
|
||||
|
||||
### 3.3 훅 특화 검증
|
||||
|
||||
규정 문서의 훅 관련 섹션에서 추출한 규칙으로 다음을 검증합니다:
|
||||
|
||||
1. **훅 네이밍**: `[vendor-module].[entity].[action]_[timing]` 패턴 준수 여부
|
||||
2. **훅 실행 순서 (Service)**: before → filter → action → after 순서
|
||||
3. **Listener 인터페이스**: `HookListenerInterface` 구현 여부
|
||||
4. **Filter 훅 type 필드**: `'type' => 'filter'` 명시 여부
|
||||
5. **Filter 훅 리턴 값**: 값 반환 여부
|
||||
|
||||
### 3.4 파일 유형별 규정 매핑
|
||||
|
||||
검증 대상 파일의 경로에 따라 해당하는 규정 문서를 우선적으로 적용합니다:
|
||||
|
||||
| 파일 경로 패턴 | 우선 적용 규정 |
|
||||
| -------------- | -------------- |
|
||||
| `**/Listeners/**` | hooks.md |
|
||||
| `**/Services/**` | service-repository.md, hooks.md |
|
||||
|
||||
### 3.5 CLAUDE.md CRITICAL RULES 검증 (MANDATORY)
|
||||
|
||||
CLAUDE.md 및 규정 문서에 명시된 CRITICAL 규칙을 검증합니다.
|
||||
|
||||
#### 3.5.1 _bundled 디렉토리 작업 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 확장 수정/개발은 반드시 _bundled 디렉토리에서만 작업
|
||||
→ 활성 디렉토리 직접 수정 절대 금지
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- 수정 대상 파일이 `_bundled/` 하위에 있는지 확인
|
||||
- 활성 디렉토리(`modules/vendor-xxx/`, `plugins/vendor-xxx/`)의 파일 수정 시 경고
|
||||
|
||||
#### 3.5.2 플러그인 격리 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 플러그인 간 직접 의존 금지 → 모듈에만 의존, 플러그인끼리는 API로만 통신
|
||||
```
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
| -------- | --------------- |
|
||||
| 플러그인 A → 플러그인 B 직접 참조 | 플러그인 A → 모듈 API → 플러그인 B |
|
||||
| 플러그인에서 다른 플러그인 클래스 import | 훅/이벤트 또는 API 통신 |
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- 플러그인 코드에서 다른 플러그인의 네임스페이스(`Plugins\`) 참조 여부 확인
|
||||
- 플러그인에서 모듈(`Modules\`) 의존만 허용
|
||||
|
||||
## 4단계: 결과 보고
|
||||
|
||||
검증 결과를 다음 형식으로 보고합니다:
|
||||
|
||||
```text
|
||||
## 훅 패턴 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [파일 경로]
|
||||
|
||||
### [규정 문서명] - [섹션명] 검증 결과
|
||||
|
||||
#### 금지 패턴 검사
|
||||
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- 수정 방법: [올바른 패턴으로 변경]
|
||||
- ✅ 금지 패턴 없음
|
||||
|
||||
#### 필수 패턴 검사
|
||||
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- ✅ 필수 패턴 준수
|
||||
|
||||
#### 훅 네이밍 검증
|
||||
- ✅ 올바름: [훅 이름]
|
||||
- ❌ 잘못됨: [훅 이름] → [수정 제안]
|
||||
|
||||
#### Filter 훅 type 필드 검증
|
||||
- ✅ type 명시됨: [훅 이름]
|
||||
- ❌ type 누락: [훅 이름] → 'type' => 'filter' 추가 필요
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
|
||||
|
||||
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
|
||||
|
||||
### 5.1 훅 관련 코드 블록 식별
|
||||
|
||||
다음 키워드가 포함된 코드 블록을 훅 관련 코드로 식별:
|
||||
|
||||
| 키워드 | 용도 |
|
||||
| ------------------------------- | ----------------------- |
|
||||
| `HookManager::doAction` | Action Hook 실행 |
|
||||
| `HookManager::applyFilters` | Filter Hook 실행 |
|
||||
| `implements HookListenerInterface` | 리스너 클래스 정의 |
|
||||
| `getHooks()` | 훅 등록 정의 |
|
||||
| `'type' => 'filter'` | Filter Hook 타입 명시 |
|
||||
|
||||
### 5.2 훅 네이밍 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_create', $result);
|
||||
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
|
||||
|
||||
// ❌ 잘못된 패턴 - 네이밍 규칙 위반
|
||||
HookManager::doAction('product.create', $data); // ❌ vendor-module 누락
|
||||
HookManager::doAction('beforeCreateProduct', $data); // ❌ 잘못된 형식
|
||||
```
|
||||
|
||||
**네이밍 규칙**: `[vendor-module].[entity].[action]_[timing]`
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ❌ vendor-module 접두사 누락
|
||||
- ❌ entity 누락
|
||||
- ❌ action_timing 형식 위반
|
||||
- ✅ 올바른 네이밍 패턴
|
||||
|
||||
### 5.3 훅 실행 순서 검증 (Service 코드)
|
||||
|
||||
```php
|
||||
// ✅ 올바른 순서: before → filter → action → after
|
||||
public function create(array $data): Product
|
||||
{
|
||||
// 1. before
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
|
||||
|
||||
// 2. filter
|
||||
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
|
||||
|
||||
// 3. action
|
||||
$result = $this->productRepository->create($data);
|
||||
|
||||
// 4. after
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_create', $result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ❌ 잘못된 순서
|
||||
public function create(array $data): Product
|
||||
{
|
||||
$result = $this->productRepository->create($data); // ❌ 훅 없이 바로 실행
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data); // ❌ 순서 잘못됨
|
||||
return $result;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Listener 정의 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
class ProductCacheListener implements HookListenerInterface
|
||||
{
|
||||
public function getHooks(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'hook' => 'sirsoft-ecommerce.product.after_create',
|
||||
'method' => 'clearProductCache',
|
||||
'priority' => 10,
|
||||
],
|
||||
[
|
||||
'hook' => 'sirsoft-ecommerce.product.filter_list_data',
|
||||
'method' => 'addCategoryInfo',
|
||||
'priority' => 10,
|
||||
'type' => 'filter', // ✅ Filter Hook은 type 명시 필수
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function addCategoryInfo(array $data): array
|
||||
{
|
||||
// ... 데이터 변형
|
||||
return $data; // ✅ Filter Hook은 반드시 값 반환
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴 - Filter Hook인데 type 누락
|
||||
[
|
||||
'hook' => 'sirsoft-ecommerce.product.filter_list_data',
|
||||
'method' => 'addCategoryInfo',
|
||||
'priority' => 10,
|
||||
// ❌ 'type' => 'filter' 누락
|
||||
],
|
||||
|
||||
// ❌ 잘못된 패턴 - Filter Hook인데 반환값 없음
|
||||
public function addCategoryInfo(array $data): void // ❌ void 반환
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ❌ HookListenerInterface 미구현
|
||||
- ❌ Filter Hook에 `'type' => 'filter'` 누락
|
||||
- ❌ Filter Hook 메서드가 값을 반환하지 않음
|
||||
- ✅ 올바른 리스너 구조
|
||||
|
||||
### 5.5 Filter Hook 반환값 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
public function filterProductData(array $data): array
|
||||
{
|
||||
$data['calculated_price'] = $data['price'] * 1.1;
|
||||
return $data; // ✅ 반드시 반환
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴
|
||||
public function filterProductData(array $data): void // ❌ void
|
||||
{
|
||||
$data['calculated_price'] = $data['price'] * 1.1;
|
||||
// 반환값 없음
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 작업계획서 검증 결과 보고 형식
|
||||
|
||||
```text
|
||||
## 작업계획서 훅 패턴 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [마크다운 파일 경로]
|
||||
|
||||
### 훅 코드 블록 요약
|
||||
- 훅 실행 코드 블록: X개
|
||||
- 리스너 정의 블록: Y개
|
||||
- 총 검증 대상: Z개
|
||||
|
||||
### 훅 네이밍 검증
|
||||
- ✅ 올바른 네이밍: [훅 이름]
|
||||
- ❌ 잘못된 네이밍: [훅 이름] (코드 블록 위치)
|
||||
- 규칙: [vendor-module].[entity].[action]_[timing]
|
||||
- 수정 제안: [올바른 이름]
|
||||
|
||||
### 훅 실행 순서 검증
|
||||
- ✅ before → filter → action → after 순서 준수
|
||||
- ❌ 순서 위반: [위치] - [문제 설명]
|
||||
|
||||
### Listener 정의 검증
|
||||
- ✅ HookListenerInterface 구현
|
||||
- ❌ Interface 미구현: [위치]
|
||||
|
||||
### Filter Hook type 필드 검증
|
||||
- ✅ type 명시됨: [훅 이름]
|
||||
- ❌ type 누락: [훅 이름] (코드 블록 위치)
|
||||
- 수정: 'type' => 'filter' 추가 필요
|
||||
|
||||
### Filter Hook 반환값 검증
|
||||
- ✅ 반환값 있음: [메서드명]
|
||||
- ❌ 반환값 없음: [메서드명] (코드 블록 위치)
|
||||
- void 대신 array/mixed 반환 필요
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL:
|
||||
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
|
||||
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
|
||||
- 규정이 변경되면 검증도 자동으로 변경됨
|
||||
- 파일 유형에 따라 해당 규정 문서 우선 적용
|
||||
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
|
||||
- 모든 확장 작업은 _bundled 디렉토리에서만 수행 (활성 디렉토리 직접 수정 금지)
|
||||
- 플러그인 간 직접 의존 금지 → 모듈에만 의존
|
||||
```
|
||||
@@ -0,0 +1,353 @@
|
||||
# 다국어 검증 (validate-i18n)
|
||||
|
||||
다국어 처리 및 **작업계획서 내 다국어 관련 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
|
||||
|
||||
## 0단계: 검증 대상 유형 판별 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
|
||||
```
|
||||
|
||||
### 판별 기준
|
||||
|
||||
| 파일 확장자 | 유형 | 검증 방식 |
|
||||
| --------------- | ---------------- | ---------------------------------------------- |
|
||||
| `*.php` | 소스코드 | PHP 다국어 처리 검증 |
|
||||
| `*.json` | 소스코드 | 언어 파일 키 동기화 검증 |
|
||||
| `*.tsx` | 소스코드 | 프론트엔드 다국어 처리 검증 |
|
||||
| `*.md` | **작업계획서** | 마크다운 내 코드 블록 추출 후 검증 |
|
||||
|
||||
### 작업계획서 검증 시 추가 단계
|
||||
|
||||
작업계획서(`.md`) 파일인 경우:
|
||||
|
||||
1. **코드 블록 추출**: 마크다운에서 PHP, JSON, TypeScript 코드 블록 추출
|
||||
2. **다국어 관련 코드 식별**: `__()`, `$t:`, `lang/`, `G7Core.t()` 등의 키워드로 식별
|
||||
3. **규정 적용**: 해당 유형에 맞는 다국어 규정 적용
|
||||
|
||||
```text
|
||||
⚠️ 작업계획서 검증 시 주의사항:
|
||||
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
|
||||
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
|
||||
- 실제 구현 계획 코드만 엄격하게 검증
|
||||
```
|
||||
|
||||
## 1단계: 규정 문서 읽기
|
||||
|
||||
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
|
||||
|
||||
- `docs/database-guide.md` - 다국어 섹션
|
||||
- `docs/backend/validation.md` - 검증 메시지 다국어
|
||||
- `docs/backend/exceptions.md` - Custom Exception 다국어
|
||||
- `docs/extension/module-i18n.md` - 모듈 다국어 처리
|
||||
- `docs/frontend/data-binding-i18n.md` - 프론트엔드 다국어 바인딩
|
||||
- `docs/frontend/components.md` (인덱스) - 컴포넌트 다국어
|
||||
- `components-patterns.md` - 다국어 번역 (G7Core.t) 상세
|
||||
|
||||
## 2단계: 검증 대상 파일 읽기
|
||||
|
||||
$ARGUMENTS 경로의 파일을 읽습니다.
|
||||
|
||||
경로가 지정되지 않은 경우, 다음 파일들을 대상으로 합니다:
|
||||
|
||||
- `lang/ko/*.php`, `lang/en/*.php` - 코어 언어 파일
|
||||
- `**/lang/ko/*.php`, `**/lang/en/*.php` - 모듈 언어 파일
|
||||
- `**/resources/lang/*.json` - 프론트엔드 언어 파일
|
||||
- `templates/**/lang/*.json` - 템플릿 언어 파일
|
||||
|
||||
## 3단계: 규정 기반 자동 검증 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
|
||||
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
|
||||
```
|
||||
|
||||
### 3.1 규정에서 검증 패턴 추출
|
||||
|
||||
1단계에서 읽은 모든 규정 문서에서 다음 패턴을 추출합니다:
|
||||
|
||||
| 추출 대상 | 검증 유형 |
|
||||
| --------- | --------- |
|
||||
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
|
||||
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
|
||||
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
|
||||
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
|
||||
|
||||
### 3.2 검증 방법
|
||||
|
||||
**금지 패턴 검증**:
|
||||
|
||||
```bash
|
||||
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
|
||||
grep -rn "[금지 패턴]" [대상 파일/디렉토리]
|
||||
```
|
||||
|
||||
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
|
||||
|
||||
**필수 패턴 검증**:
|
||||
|
||||
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
|
||||
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
|
||||
|
||||
### 3.3 다국어 특화 검증
|
||||
|
||||
규정 문서의 다국어 관련 섹션에서 추출한 규칙으로 다음을 검증합니다:
|
||||
|
||||
1. **하드코딩 문자열**: PHP/TSX 파일에서 다국어 처리 없이 직접 사용된 문자열
|
||||
2. **언어 파일 동기화**: `ko/*.php`와 `en/*.php` 키 일치 여부
|
||||
3. **config 사용**: 로케일 하드코딩 대신 `config()` 함수 사용 여부
|
||||
4. **시더 다국어 필드**: 배열 형식 사용 여부
|
||||
|
||||
### 3.4 모듈 레이아웃 다국어 키 검증 (CRITICAL)
|
||||
|
||||
모듈 레이아웃에서 다국어 키 사용 시 **모듈 식별자 접두사** 포함 여부를 검증합니다.
|
||||
|
||||
| # | 검증 항목 | 금지 패턴 | 올바른 패턴 |
|
||||
| --- | ---------------- | -------------------------------------- | ---------------------------------------------- |
|
||||
| 1 | 모듈 식별자 누락 | `$t:common.xxx` (모듈 레이아웃에서) | `$t:sirsoft-ecommerce.common.xxx` |
|
||||
| 2 | 모듈 식별자 누락 | `$t:admin.settings.xxx` (모듈 레이아웃에서) | `$t:sirsoft-ecommerce.admin.settings.xxx` |
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: $t:common.xxx는 코어의 common 키를 찾으므로 모듈 다국어와 매칭 안됨
|
||||
✅ 모듈 레이아웃에서는 반드시 $t:모듈식별자.키 형식 사용
|
||||
```
|
||||
|
||||
### 3.5 로케일 하드코딩 검증
|
||||
|
||||
마이그레이션 및 시더에서 로케일을 하드코딩하지 않았는지 검증합니다.
|
||||
|
||||
| 금지 패턴 | 올바른 패턴 |
|
||||
| ------------------------------------ | ------------------------------------------------------ |
|
||||
| `['ko', 'en']` 직접 나열 | `config('app.supported_locales')` |
|
||||
| `foreach (['ko', 'en'] as $locale)` | `foreach (config('app.supported_locales') as $locale)` |
|
||||
|
||||
### 3.6 파일 유형별 규정 매핑
|
||||
|
||||
검증 대상 파일의 유형에 따라 해당하는 규정 문서를 우선적으로 적용합니다:
|
||||
|
||||
| 파일 유형 | 우선 적용 규정 |
|
||||
| --------- | -------------- |
|
||||
| `lang/**/*.php` | database-guide.md (다국어 섹션) |
|
||||
| `**/resources/lang/*.json` | module-i18n.md |
|
||||
| `templates/**/lang/*.json` | data-binding-i18n.md |
|
||||
| `app/Http/Requests/**` | validation.md |
|
||||
| `app/Exceptions/**` | exceptions.md |
|
||||
| `**/*.tsx` | components.md (G7Core.t 섹션) |
|
||||
|
||||
## 4단계: 결과 보고
|
||||
|
||||
검증 결과를 다음 형식으로 보고합니다:
|
||||
|
||||
```text
|
||||
## 다국어 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [파일 경로]
|
||||
|
||||
### [규정 문서명] - [섹션명] 검증 결과
|
||||
|
||||
#### 금지 패턴 검사
|
||||
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- 수정 방법: [올바른 패턴으로 변경]
|
||||
- ✅ 금지 패턴 없음
|
||||
|
||||
#### 필수 패턴 검사
|
||||
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- ✅ 필수 패턴 준수
|
||||
|
||||
#### 언어 파일 동기화
|
||||
- ✅ 동기화됨: [키 수]개
|
||||
- ❌ ko에만 존재: [키 목록]
|
||||
- ❌ en에만 존재: [키 목록]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
|
||||
|
||||
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
|
||||
|
||||
### 5.1 다국어 관련 코드 블록 식별
|
||||
|
||||
다음 키워드가 포함된 코드 블록을 다국어 관련 코드로 식별:
|
||||
|
||||
| 키워드 | 유형 |
|
||||
| ---------------------- | ----------------------- |
|
||||
| `__()`, `trans()` | PHP 다국어 |
|
||||
| `$t:`, `$t:defer:` | 레이아웃 JSON 다국어 |
|
||||
| `G7Core.t()`, `t()` | TSX 컴포넌트 다국어 |
|
||||
| `lang/`, `resources/lang` | 언어 파일 정의 |
|
||||
|
||||
### 5.2 PHP 다국어 코드 블록 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
return ResponseHelper::success(
|
||||
message: __('messages.product.created')
|
||||
);
|
||||
|
||||
throw new CustomException(__('exceptions.product.not_found'));
|
||||
|
||||
// ❌ 잘못된 패턴 - 하드코딩된 한국어
|
||||
return ResponseHelper::success(
|
||||
message: '상품이 생성되었습니다.' // ❌
|
||||
);
|
||||
|
||||
throw new CustomException('상품을 찾을 수 없습니다.'); // ❌
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ❌ 하드코딩된 한국어 문자열 (사용자에게 표시되는 메시지)
|
||||
- ✅ `__()` 함수 사용
|
||||
- ✅ 언어 키 네이밍 규칙 준수
|
||||
|
||||
### 5.3 레이아웃 JSON 다국어 코드 블록 검증
|
||||
|
||||
```json
|
||||
// ✅ 올바른 패턴
|
||||
{
|
||||
"props": {
|
||||
"label": "$t:sirsoft-ecommerce.admin.product.name",
|
||||
"placeholder": "$t:sirsoft-ecommerce.admin.product.name_placeholder"
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴 - 하드코딩된 한국어
|
||||
{
|
||||
"props": {
|
||||
"label": "상품명",
|
||||
"placeholder": "상품명을 입력하세요"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ❌ 하드코딩된 한국어 라벨/플레이스홀더
|
||||
- ✅ `$t:` 또는 `$t:defer:` 사용
|
||||
- ✅ 모듈 네임스페이스 포함 (`sirsoft-ecommerce.admin.product.*`)
|
||||
|
||||
### 5.4 TSX 컴포넌트 다국어 코드 블록 검증
|
||||
|
||||
```typescript
|
||||
// ✅ 올바른 패턴
|
||||
const label = G7Core.t('sirsoft-ecommerce.admin.product.name');
|
||||
<Button>{G7Core.t('common.save')}</Button>
|
||||
|
||||
// ❌ 잘못된 패턴 - 하드코딩된 한국어
|
||||
const label = '상품명'; // ❌
|
||||
<Button>저장</Button> // ❌
|
||||
```
|
||||
|
||||
### 5.5 언어 파일 정의 검증
|
||||
|
||||
```json
|
||||
// ✅ 올바른 패턴 - ko.json과 en.json 키 일치
|
||||
// ko.json
|
||||
{
|
||||
"admin": {
|
||||
"product": {
|
||||
"name": "상품명",
|
||||
"price": "가격"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// en.json
|
||||
{
|
||||
"admin": {
|
||||
"product": {
|
||||
"name": "Product Name",
|
||||
"price": "Price"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ❌ ko에만 존재하는 키
|
||||
- ❌ en에만 존재하는 키
|
||||
- ✅ 동일한 키 구조
|
||||
|
||||
### 5.6 시더 다국어 필드 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴 - 배열 형식
|
||||
Permission::create([
|
||||
'name' => [
|
||||
'ko' => '상품 조회',
|
||||
'en' => 'View Products',
|
||||
],
|
||||
]);
|
||||
|
||||
// ❌ 잘못된 패턴 - 단일 언어
|
||||
Permission::create([
|
||||
'name' => '상품 조회', // ❌
|
||||
]);
|
||||
```
|
||||
|
||||
### 5.7 config 사용 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
$locales = config('app.supported_locales');
|
||||
|
||||
// ❌ 잘못된 패턴 - 하드코딩
|
||||
$locales = ['ko', 'en']; // ❌
|
||||
```
|
||||
|
||||
### 5.8 작업계획서 검증 결과 보고 형식
|
||||
|
||||
```text
|
||||
## 작업계획서 다국어 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [마크다운 파일 경로]
|
||||
|
||||
### 다국어 코드 블록 요약
|
||||
- PHP 코드 블록: X개
|
||||
- JSON 레이아웃 블록: Y개
|
||||
- TSX 코드 블록: Z개
|
||||
- 언어 파일 정의: W개
|
||||
- 총 검증 대상: N개
|
||||
|
||||
### PHP 다국어 검증
|
||||
- ✅ __() 함수 사용
|
||||
- ❌ 하드코딩된 한국어: [위치] - "[문자열]"
|
||||
- 수정: __('messages.xxx')
|
||||
|
||||
### 레이아웃 JSON 다국어 검증
|
||||
- ✅ $t: 사용
|
||||
- ❌ 하드코딩된 한국어: [위치] - "[문자열]"
|
||||
- 수정: "$t:module.key"
|
||||
|
||||
### TSX 컴포넌트 다국어 검증
|
||||
- ✅ G7Core.t() 사용
|
||||
- ❌ 하드코딩된 한국어: [위치] - "[문자열]"
|
||||
|
||||
### 언어 파일 동기화 검증
|
||||
- ✅ 키 동기화됨
|
||||
- ❌ ko에만 존재: [키 목록]
|
||||
- ❌ en에만 존재: [키 목록]
|
||||
|
||||
### 시더 다국어 필드 검증
|
||||
- ✅ 배열 형식 사용
|
||||
- ❌ 단일 언어: [위치]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL:
|
||||
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
|
||||
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
|
||||
- 규정이 변경되면 검증도 자동으로 변경됨
|
||||
- 파일 유형에 따라 해당 규정 문서 우선 적용
|
||||
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
|
||||
```
|
||||
@@ -0,0 +1,294 @@
|
||||
# 마이그레이션 검증 (validate-migration)
|
||||
|
||||
마이그레이션 파일 및 **작업계획서 내 마이그레이션 코드 블록**이 그누보드7 규정을 준수하는지 검증합니다.
|
||||
|
||||
## 0단계: 검증 대상 유형 판별 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 검증 대상이 소스코드인지 작업계획서인지 먼저 판별
|
||||
```
|
||||
|
||||
### 판별 기준
|
||||
|
||||
| 파일 확장자 | 유형 | 검증 방식 |
|
||||
| ----------- | ---------------- | ---------------------------------------------- |
|
||||
| `*.php` | 소스코드 | 파일 전체를 마이그레이션 코드로 검증 |
|
||||
| `*.md` | **작업계획서** | 마크다운 내 PHP 코드 블록 추출 후 검증 |
|
||||
|
||||
### 작업계획서 검증 시 추가 단계
|
||||
|
||||
작업계획서(`.md`) 파일인 경우:
|
||||
|
||||
1. **코드 블록 추출**: 마크다운에서 \`\`\`php 코드 블록 중 마이그레이션 관련 코드 추출
|
||||
2. **컨텍스트 파악**: `Schema::create`, `Schema::table`, `up()`, `down()` 등의 키워드로 마이그레이션 코드 식별
|
||||
3. **규정 적용**: database-guide.md의 마이그레이션 규칙 적용
|
||||
|
||||
```text
|
||||
⚠️ 작업계획서 검증 시 주의사항:
|
||||
- 코드 블록이 예시/설명 목적인지, 실제 구현 계획인지 구분
|
||||
- "예시:", "Example:", "// 예시" 등이 포함된 코드는 참고용으로 처리
|
||||
- 실제 구현 계획 코드만 엄격하게 검증
|
||||
```
|
||||
|
||||
## 1단계: 규정 문서 읽기
|
||||
|
||||
다음 규정 문서를 읽어 최신 규칙을 확인합니다:
|
||||
|
||||
- `docs/database-guide.md` - 데이터베이스 규칙
|
||||
- `docs/extension/extension-update-system.md` - 업그레이드 스크립트 규칙
|
||||
|
||||
## 2단계: 검증 대상 파일 읽기
|
||||
|
||||
$ARGUMENTS 경로의 마이그레이션 파일을 읽습니다.
|
||||
|
||||
경로가 지정되지 않은 경우, 최근 생성/수정된 마이그레이션 파일을 대상으로 합니다:
|
||||
|
||||
- `database/migrations/**/*.php` - 코어 마이그레이션
|
||||
- `modules/**/database/migrations/**/*.php` - 모듈 마이그레이션
|
||||
- `modules/_bundled/**/database/migrations/**/*.php` - _bundled 모듈 마이그레이션
|
||||
|
||||
## 3단계: 규정 기반 자동 검증 (CRITICAL)
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: 1단계에서 읽은 규정 문서의 모든 규칙을 검증 항목으로 사용합니다.
|
||||
스킬에 하드코딩된 검증 항목이 아닌, 규정 문서가 Single Source of Truth입니다.
|
||||
```
|
||||
|
||||
### 3.1 규정에서 검증 패턴 추출
|
||||
|
||||
1단계에서 읽은 규정 문서에서 다음 패턴을 추출합니다:
|
||||
|
||||
| 추출 대상 | 검증 유형 |
|
||||
| --------- | --------- |
|
||||
| `❌` 또는 `잘못된` 키워드가 포함된 코드 블록 | **금지 패턴** - 발견 시 에러 |
|
||||
| `✅` 또는 `올바른` 키워드가 포함된 코드 블록 | **필수 패턴** - 누락 시 경고 |
|
||||
| `⚠️ 절대 금지`, `CRITICAL` 등 강조된 규칙 | **필수 검증 항목** |
|
||||
| `TL;DR` 섹션의 핵심 포인트 | **우선 검증 항목** |
|
||||
|
||||
### 3.2 검증 방법
|
||||
|
||||
**금지 패턴 검증**:
|
||||
|
||||
```bash
|
||||
# 규정 문서의 ❌ 예시에서 추출한 패턴을 grep으로 검사
|
||||
grep -rn "[금지 패턴]" [대상 파일]
|
||||
```
|
||||
|
||||
발견 시: 에러로 보고하고 규정 문서의 해당 섹션 참조 안내
|
||||
|
||||
**필수 패턴 검증**:
|
||||
|
||||
- 해당 컨텍스트에서 필수 패턴이 사용되어야 하는 경우, 누락 여부 확인
|
||||
- 누락 시: 경고로 보고하고 규정 문서의 올바른 예시 안내
|
||||
|
||||
### 3.3 마이그레이션 특화 검증
|
||||
|
||||
규정 문서의 마이그레이션 섹션에서 추출한 규칙으로 다음을 검증합니다:
|
||||
|
||||
1. **파일명 규칙**: 규정에 정의된 파일명 패턴과 일치 여부
|
||||
2. **컬럼 정의**: `comment()` 사용 여부 및 내용 적절성
|
||||
3. **down() 메서드**: 완전한 롤백 구현 여부
|
||||
4. **외래키**: `constrained()` 등 규정에 명시된 메서드 사용 여부
|
||||
|
||||
### 3.4 CLAUDE.md CRITICAL RULES 검증 (MANDATORY)
|
||||
|
||||
CLAUDE.md 및 규정 문서에 명시된 CRITICAL 규칙을 검증합니다.
|
||||
|
||||
#### 3.4.1 CASCADE 삭제 금지 검증
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL: DB CASCADE에 의존한 삭제 절대 금지 → Service에서 명시적 삭제 (훅/파일/로깅 보장)
|
||||
```
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
| --- | --- |
|
||||
| `->onDelete('cascade')` (단독 의존) | Service에서 명시적 삭제 + 훅 실행 |
|
||||
| CASCADE만으로 연관 데이터 정리 | `->onDelete('restrict')` 또는 `->onDelete('set null')` + Service 삭제 |
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- `onDelete('cascade')` 사용 시 해당 Service에서 명시적 삭제 로직이 존재하는지 확인
|
||||
- CASCADE만 의존하면 훅/파일정리/로깅이 누락됨 → 경고 필수
|
||||
|
||||
#### 3.4.2 exists/unique 검증 규칙 사용 검증
|
||||
|
||||
```php
|
||||
// ❌ 금지 - 문자열 테이블명
|
||||
'email' => 'unique:users,email'
|
||||
'category_id' => 'exists:categories,id'
|
||||
|
||||
// ✅ 올바른 패턴 - Rule 클래스 사용
|
||||
'email' => [Rule::unique(User::class, 'email')]
|
||||
'category_id' => [Rule::exists(Category::class, 'id')]
|
||||
```
|
||||
|
||||
#### 3.4.3 모듈 마이그레이션 업그레이드 스크립트 확인
|
||||
|
||||
모듈 마이그레이션 수정 시 기존 설치 환경에 대한 업그레이드 경로 확인:
|
||||
|
||||
| 상황 | 필요 작업 |
|
||||
| --- | --- |
|
||||
| 신규 테이블 생성 | 마이그레이션만 (업그레이드 스크립트 불필요) |
|
||||
| 기존 테이블 스키마 변경 | 마이그레이션 + 업그레이드 스크립트 (`upgrades/Upgrade_X_Y_Z.php`) |
|
||||
| 기존 데이터 백필/변환 | 업그레이드 스크립트만 (마이그레이션 아님) |
|
||||
|
||||
```text
|
||||
⚠️ 기존 DB 데이터 백필/변환은 마이그레이션이 아닌 업그레이드 스크립트로 작성
|
||||
```
|
||||
|
||||
## 4단계: 결과 보고
|
||||
|
||||
검증 결과를 다음 형식으로 보고합니다:
|
||||
|
||||
```text
|
||||
## 마이그레이션 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [파일 경로]
|
||||
|
||||
### [규정 문서명] - [섹션명] 검증 결과
|
||||
|
||||
#### 금지 패턴 검사
|
||||
- ❌ 금지 패턴 발견: [패턴] (파일:라인)
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- 수정 방법: [올바른 패턴으로 변경]
|
||||
- ✅ 금지 패턴 없음
|
||||
|
||||
#### 필수 패턴 검사
|
||||
- ❌ 필수 패턴 누락: [컨텍스트]에서 [패턴] 필요
|
||||
- 규정: [규정 문서명] > [섹션명]
|
||||
- ✅ 필수 패턴 준수
|
||||
|
||||
#### 컬럼 comment 검증
|
||||
- ✅ comment 있음: [컬럼명]
|
||||
- ❌ comment 없음: [컬럼명] → 추가 필요
|
||||
|
||||
#### down() 메서드 검증
|
||||
- ✅ 완전 구현
|
||||
- ❌ 미구현 또는 불완전
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5단계: 작업계획서 전용 검증 (마크다운 파일인 경우)
|
||||
|
||||
검증 대상이 작업계획서(`.md`)인 경우, 다음 추가 검증을 수행합니다.
|
||||
|
||||
### 5.1 마이그레이션 코드 블록 식별
|
||||
|
||||
PHP 코드 블록 중 다음 키워드가 포함된 블록을 마이그레이션 코드로 식별:
|
||||
|
||||
- `Schema::create`
|
||||
- `Schema::table`
|
||||
- `Schema::drop`
|
||||
- `public function up()`
|
||||
- `public function down()`
|
||||
- `$table->` (컬럼 정의)
|
||||
|
||||
### 5.2 파일명 규칙 검증
|
||||
|
||||
작업계획서에 마이그레이션 파일명이 명시된 경우 검증:
|
||||
|
||||
```text
|
||||
✅ 올바른 파일명:
|
||||
- create_products_table
|
||||
- add_price_and_stock_to_products_table
|
||||
- remove_category_from_products_table
|
||||
|
||||
❌ 잘못된 파일명:
|
||||
- update_products (동작 불명확)
|
||||
- products_migration (규칙 위반)
|
||||
```
|
||||
|
||||
### 5.3 컬럼 정의 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴 - comment 포함
|
||||
$table->string('name')->comment('상품명');
|
||||
$table->decimal('price', 12, 2)->comment('판매가격');
|
||||
$table->boolean('is_active')->default(true)->comment('활성화 여부 (true: 활성, false: 비활성)');
|
||||
$table->enum('status', ['draft', 'published', 'archived'])->comment('상태 (draft: 임시저장, published: 게시됨, archived: 보관됨)');
|
||||
|
||||
// ❌ 잘못된 패턴 - comment 누락
|
||||
$table->string('name');
|
||||
$table->boolean('is_active')->default(true);
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
|
||||
- ❌ comment 누락
|
||||
- ❌ boolean/enum 값 설명 누락
|
||||
- ✅ 한국어 comment 사용
|
||||
|
||||
### 5.4 down() 메서드 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴 - 완전한 롤백
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('products');
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴 - 빈 down() 메서드
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 외래키 검증
|
||||
|
||||
```php
|
||||
// ✅ 올바른 패턴
|
||||
$table->foreignId('category_id')->constrained()->onDelete('cascade');
|
||||
|
||||
// ❌ 잘못된 패턴
|
||||
$table->unsignedBigInteger('category_id');
|
||||
$table->foreign('category_id')->references('id')->on('categories'); // 구식 문법
|
||||
```
|
||||
|
||||
### 5.6 작업계획서 검증 결과 보고 형식
|
||||
|
||||
```text
|
||||
## 작업계획서 마이그레이션 검증 결과
|
||||
|
||||
### 검증 파일
|
||||
- [마크다운 파일 경로]
|
||||
|
||||
### 마이그레이션 코드 블록 요약
|
||||
- 테이블 생성: X개
|
||||
- 컬럼 추가/수정: Y개
|
||||
- 총 검증 대상: Z개
|
||||
|
||||
### 파일명 규칙 검증
|
||||
- ✅ 규칙 준수: [파일명]
|
||||
- ❌ 규칙 위반: [파일명] → [수정 제안]
|
||||
|
||||
### 컬럼 comment 검증
|
||||
- ✅ comment 있음: [컬럼명]
|
||||
- ❌ comment 없음: [컬럼명] (코드 블록 위치)
|
||||
- boolean/enum인 경우: 값 설명도 필요
|
||||
|
||||
### down() 메서드 검증
|
||||
- ✅ 완전 구현
|
||||
- ❌ 미구현 또는 불완전: [위치]
|
||||
|
||||
### 외래키 검증
|
||||
- ✅ constrained() 사용
|
||||
- ❌ 구식 문법 사용: [위치]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
⚠️ CRITICAL:
|
||||
- 규정 문서가 Single Source of Truth - 스킬에 검증 항목 하드코딩 금지
|
||||
- 규정 문서의 ❌/✅ 예시를 자동으로 검증 패턴으로 사용
|
||||
- 규정이 변경되면 검증도 자동으로 변경됨
|
||||
- 작업계획서(.md) 검증 시 코드 블록 추출 후 동일한 규정 적용
|
||||
- DB CASCADE 삭제 의존 금지 → Service에서 명시적 삭제 필수
|
||||
- exists/unique 검증: Rule::exists(Model::class, 'col') 패턴 사용
|
||||
- 모듈 마이그레이션 시 업그레이드 스크립트 필요 여부 확인
|
||||
```
|
||||
@@ -0,0 +1,155 @@
|
||||
# 백엔드 개발 가이드
|
||||
|
||||
> 그누보드7 백엔드 개발을 위한 종합 가이드입니다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
필수: FormRequest + Custom Rule 사용 (Service에 검증 로직 금지)
|
||||
필수: FormRequest + Custom Rule 패턴 사용
|
||||
✅ 필수: __() 함수를 사용한 다국어 처리
|
||||
✅ 필수: 상태/타입/분류는 Enum으로 정의
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<!-- AUTO-GENERATED-START: backend-readme-docs -->
|
||||
## 문서 목록
|
||||
|
||||
| 문서 | 제목 | 핵심 내용 |
|
||||
|------|------|----------|
|
||||
| [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
|
||||
| [activity-log.md](activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel... |
|
||||
| [api-resources.md](api-resources.md) | API 리소스 | BaseApiResource 상속 필수 |
|
||||
| [authentication.md](authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
|
||||
| [broadcasting.md](broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
|
||||
| [controllers.md](controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / Publ... |
|
||||
| [core-config.md](core-config.md) | 코어 설정 (config/core.php) | config/core.php = 코어 권한/역할/메뉴/메일템플릿의 SSoT (Sing... |
|
||||
| [core-update-system.md](core-update-system.md) | 코어 업데이트 시스템 (Core Update System) | 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 A... |
|
||||
| [enum.md](enum.md) | Enum 사용 규칙 | 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum) |
|
||||
| [exceptions.md](exceptions.md) | Custom Exception 다국어 처리 | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
|
||||
| [middleware.md](middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
|
||||
| [notification-system.md](notification-system.md) | 알림 시스템 (Notification System) | 모든 알림은 BaseNotification 상속 필수 (via() 보일러플레이트 제거) |
|
||||
| [response-helper.md](response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
|
||||
| [routing.md](routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
|
||||
| [search-system.md](search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL F... |
|
||||
| [seo-system.md](seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → Seo... |
|
||||
| [service-provider.md](service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
|
||||
| [service-repository.md](service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
|
||||
| [validation.md](validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
|
||||
|
||||
<!-- AUTO-GENERATED-END: backend-readme-docs -->
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
### 계층 분리
|
||||
|
||||
```
|
||||
Controller → Request → Service → Repository → Model
|
||||
```
|
||||
|
||||
### 컨트롤러 계층 구조
|
||||
|
||||
```
|
||||
BaseApiController (최상위)
|
||||
├── AdminBaseController (관리자 전용)
|
||||
├── AuthBaseController (인증된 사용자)
|
||||
└── PublicBaseController (공개 API)
|
||||
```
|
||||
|
||||
### Service-Repository 패턴
|
||||
|
||||
```php
|
||||
// Service에서 훅 실행
|
||||
HookManager::doAction('module.entity.before_create', $data);
|
||||
$data = HookManager::applyFilters('module.entity.filter_data', $data);
|
||||
$result = $this->repository->create($data);
|
||||
HookManager::doAction('module.entity.after_create', $result);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 파사드 사용 규칙
|
||||
|
||||
```php
|
||||
// ✅ DO: 파사드 앞 역슬래시 제거
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
Log::info('메시지');
|
||||
Auth::user();
|
||||
|
||||
// ❌ DON'T: 역슬래시 사용
|
||||
\Log::info('메시지');
|
||||
auth()->user();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 규칙 요약
|
||||
|
||||
### 1. 검증 로직 위치
|
||||
|
||||
- ❌ Service 클래스에서 검증 금지
|
||||
- ✅ FormRequest에서 기본 검증
|
||||
- ✅ Custom Rule에서 복잡한 검증
|
||||
|
||||
### 2. 다국어 처리
|
||||
|
||||
- ❌ 하드코딩된 메시지 금지
|
||||
- ✅ `__()` 함수 사용 필수
|
||||
- ✅ `lang/ko/`, `lang/en/` 파일 관리
|
||||
|
||||
### 3. Enum 사용
|
||||
|
||||
- ❌ 문자열/숫자 상수 직접 사용 금지
|
||||
- ✅ Backed Enum 정의
|
||||
- ✅ 타입 힌트 활용
|
||||
|
||||
### 4. 미들웨어 등록
|
||||
|
||||
- ❌ 인증이 필요한 미들웨어를 글로벌 등록 금지
|
||||
- ✅ 그룹별 적절한 위치에 등록
|
||||
- ✅ 실행 순서 고려
|
||||
|
||||
### 5. 서비스 프로바이더 안전성
|
||||
|
||||
- ❌ .env 없이 실패하는 코드 금지
|
||||
- ✅ 환경 검증 후 로직 실행
|
||||
- ✅ 인스톨러 안정성 확보
|
||||
|
||||
---
|
||||
|
||||
## 빠른 참조
|
||||
|
||||
### 자주 사용하는 클래스
|
||||
|
||||
| 클래스 | 위치 | 용도 |
|
||||
|--------|------|------|
|
||||
| ResponseHelper | `app/Helpers/ResponseHelper.php` | API 응답 표준화 |
|
||||
| BaseApiResource | `app/Http/Resources/BaseApiResource.php` | API 리소스 기본 클래스 |
|
||||
| AdminBaseController | `app/Http/Controllers/Api/Admin/AdminBaseController.php` | 관리자 컨트롤러 |
|
||||
|
||||
### 훅 네이밍 규칙
|
||||
|
||||
```
|
||||
[vendor-module].[entity].[action]_[timing]
|
||||
|
||||
예시:
|
||||
sirsoft-ecommerce.product.before_create
|
||||
sirsoft-ecommerce.product.after_update
|
||||
sirsoft-ecommerce.product.filter_create_data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [AGENTS.md](../../../AGENTS.md) - 프로젝트 개발 가이드
|
||||
- [database-guide.md](../database-guide.md) - 데이터베이스 규칙
|
||||
- [extension/](../extension/) - 확장 시스템 (훅, 모듈, 플러그인)
|
||||
- [testing-guide.md](../testing-guide.md) - 테스트 규칙
|
||||
@@ -0,0 +1,714 @@
|
||||
# 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference)
|
||||
|
||||
> 모든 ActivityLog 리스너가 구독하는 훅의 전체 목록 (코어 + 모듈)
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅
|
||||
2. Listener에서 Log::channel('activity')->info() 직접 호출 (Monolog → ActivityLogHandler → DB)
|
||||
3. 스냅샷 패턴: before_update(priority 5) → 캡처, after_update → ChangeDetector로 비교
|
||||
4. 사용자 행위: ActivityLogType::User (장바구니/위시리스트/쿠폰 다운로드/주문/결제)
|
||||
5. 새 훅 추가 시: Listener에 구독 + lang 파일에 description_key 정의
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [아키텍처 개요](#1-아키텍처-개요)
|
||||
2. [코어 훅 (CoreActivityLogListener)](#2-코어-훅-coreactivityloglistener)
|
||||
3. [이커머스 모듈 훅](#3-이커머스-모듈-훅)
|
||||
4. [게시판 모듈 훅 (BoardActivityLogListener)](#4-게시판-모듈-훅-boardactivityloglistener)
|
||||
5. [페이지 모듈 훅 (PageActivityLogListener)](#5-페이지-모듈-훅-pageactivityloglistener)
|
||||
6. [스냅샷/변경감지 패턴](#6-스냅샷변경감지-패턴)
|
||||
7. [새 모듈에 ActivityLog 추가하기](#7-새-모듈에-activitylog-추가하기)
|
||||
|
||||
---
|
||||
|
||||
## 1. 아키텍처 개요
|
||||
|
||||
```text
|
||||
Service → doAction('hook.name') → ActivityLogListener → Log::channel('activity') → ActivityLogHandler → DB
|
||||
```
|
||||
|
||||
- **Listener**: `HookListenerInterface` 구현, `getSubscribedHooks()`로 훅-메서드 매핑 정의
|
||||
- **로그 기록**: `Log::channel('activity')->info($action, $context)` 직접 호출
|
||||
- **context 구조**: `log_type`, `loggable`, `description_key`, `description_params`, `properties`, `changes`
|
||||
- **LogType**: `ActivityLogType::Admin` (관리자 행위) / `ActivityLogType::User` (사용자 행위)
|
||||
- **변경 감지**: `ChangeDetector::detect($model, $snapshot)` — 스냅샷과 현재 상태 비교
|
||||
|
||||
---
|
||||
|
||||
## 2. 코어 훅 (CoreActivityLogListener)
|
||||
|
||||
**파일**: `app/Listeners/CoreActivityLogListener.php`
|
||||
**총 66훅** (스냅샷 캡처용 before 훅 포함)
|
||||
|
||||
### User (9훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.user.before_update` | `captureUserSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.user.after_create` | `handleUserAfterCreate` | `user.create` | Admin | User |
|
||||
| `core.user.after_update` | `handleUserAfterUpdate` | `user.update` | Admin | User |
|
||||
| `core.user.after_delete` | `handleUserAfterDelete` | `user.delete` | Admin | User |
|
||||
| `core.user.after_withdraw` | `handleUserAfterWithdraw` | `user.withdraw` | Admin | User |
|
||||
| `core.user.after_show` | `handleUserAfterShow` | `user.show` | Admin | User |
|
||||
| `core.user.after_list` | `handleUserAfterList` | `user.list` | Admin | - |
|
||||
| `core.user.after_search` | `handleUserAfterSearch` | `user.search` | Admin | - |
|
||||
| `sirsoft-core.user.after_bulk_update` | `handleUserAfterBulkUpdate` | `user.bulk_update` | Admin | - |
|
||||
|
||||
### Auth (6훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.auth.after_login` | `handleAuthAfterLogin` | `auth.login` | User | User |
|
||||
| `core.auth.logout` | `handleAuthLogout` | `auth.logout` | User | User |
|
||||
| `core.auth.register` | `handleAuthRegister` | `auth.register` | User | User |
|
||||
| `core.auth.forgot_password` | `handleAuthForgotPassword` | `auth.forgot_password` | User | - |
|
||||
| `core.auth.reset_password` | `handleAuthResetPassword` | `auth.reset_password` | User | - |
|
||||
| `core.auth.record_consents` | `handleAuthRecordConsents` | `auth.record_consents` | User | User |
|
||||
|
||||
### Role (6훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.role.before_update` | `captureRoleSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.role.after_create` | `handleRoleAfterCreate` | `role.create` | Admin | Role |
|
||||
| `core.role.after_update` | `handleRoleAfterUpdate` | `role.update` | Admin | Role |
|
||||
| `core.role.after_delete` | `handleRoleAfterDelete` | `role.delete` | Admin | Role |
|
||||
| `core.role.after_sync_permissions` | `handleRoleAfterSyncPermissions` | `role.sync_permissions` | Admin | Role |
|
||||
| `core.role.after_toggle_status` | `handleRoleAfterToggleStatus` | `role.toggle_status` | Admin | Role |
|
||||
|
||||
### Menu (7훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.menu.before_update` | `captureMenuSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.menu.after_create` | `handleMenuAfterCreate` | `menu.create` | Admin | Menu |
|
||||
| `core.menu.after_update` | `handleMenuAfterUpdate` | `menu.update` | Admin | Menu |
|
||||
| `core.menu.after_delete` | `handleMenuAfterDelete` | `menu.delete` | Admin | Menu |
|
||||
| `core.menu.after_update_order` | `handleMenuAfterUpdateOrder` | `menu.update_order` | Admin | - |
|
||||
| `core.menu.after_toggle_status` | `handleMenuAfterToggleStatus` | `menu.toggle_status` | Admin | Menu |
|
||||
| `core.menu.after_sync_roles` | `handleMenuAfterSyncRoles` | `menu.sync_roles` | Admin | Menu |
|
||||
|
||||
### Settings (2훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.settings.after_save` | `handleSettingsAfterSave` | `settings.save` | Admin | - |
|
||||
| `core.settings.after_set` | `handleSettingsAfterSet` | `settings.set` | Admin | - |
|
||||
|
||||
### Schedule (7훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.schedule.before_update` | `captureScheduleSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.schedule.after_create` | `handleScheduleAfterCreate` | `schedule.create` | Admin | Schedule |
|
||||
| `core.schedule.after_update` | `handleScheduleAfterUpdate` | `schedule.update` | Admin | Schedule |
|
||||
| `core.schedule.after_delete` | `handleScheduleAfterDelete` | `schedule.delete` | Admin | Schedule |
|
||||
| `core.schedule.after_run` | `handleScheduleAfterRun` | `schedule.run` | Admin | Schedule |
|
||||
| `core.schedule.after_bulk_update` | `handleScheduleAfterBulkUpdate` | `schedule.bulk_update` | Admin | - |
|
||||
| `core.schedule.after_bulk_delete` | `handleScheduleAfterBulkDelete` | `schedule.bulk_delete` | Admin | - |
|
||||
|
||||
### Attachment (3훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.attachment.after_upload` | `handleAttachmentAfterUpload` | `attachment.upload` | Admin | Attachment |
|
||||
| `core.attachment.after_delete` | `handleAttachmentAfterDelete` | `attachment.delete` | Admin | Attachment |
|
||||
| `core.attachment.after_bulk_delete` | `handleAttachmentAfterBulkDelete` | `attachment.bulk_delete` | Admin | - |
|
||||
|
||||
### Mail Template (3훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.mail_template.before_update` | `captureMailTemplateSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `core.mail_template.after_update` | `handleMailTemplateAfterUpdate` | `mail_template.update` | Admin | MailTemplate |
|
||||
| `core.mail_template.after_toggle_active` | `handleMailTemplateAfterToggleActive` | `mail_template.toggle_active` | Admin | MailTemplate |
|
||||
|
||||
### Module (6훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.modules.after_install` | `handleModuleAfterInstall` | `module.install` | Admin | - |
|
||||
| `core.modules.after_activate` | `handleModuleAfterActivate` | `module.activate` | Admin | - |
|
||||
| `core.modules.after_deactivate` | `handleModuleAfterDeactivate` | `module.deactivate` | Admin | - |
|
||||
| `core.modules.after_uninstall` | `handleModuleAfterUninstall` | `module.uninstall` | Admin | - |
|
||||
| `core.modules.after_update` | `handleModuleAfterUpdate` | `module.update` | Admin | - |
|
||||
| `core.modules.after_refresh_layouts` | `handleModuleAfterRefreshLayouts` | `module.refresh_layouts` | Admin | - |
|
||||
|
||||
### Plugin (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.plugins.after_install` | `handlePluginAfterInstall` | `plugin.install` | Admin | - |
|
||||
| `core.plugins.after_activate` | `handlePluginAfterActivate` | `plugin.activate` | Admin | - |
|
||||
| `core.plugins.after_deactivate` | `handlePluginAfterDeactivate` | `plugin.deactivate` | Admin | - |
|
||||
| `core.plugins.after_uninstall` | `handlePluginAfterUninstall` | `plugin.uninstall` | Admin | - |
|
||||
| `core.plugins.after_update` | `handlePluginAfterUpdate` | `plugin.update` | Admin | - |
|
||||
|
||||
### Template (6훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.templates.after_install` | `handleTemplateAfterInstall` | `template.install` | Admin | - |
|
||||
| `core.templates.after_activate` | `handleTemplateAfterActivate` | `template.activate` | Admin | - |
|
||||
| `core.templates.after_deactivate` | `handleTemplateAfterDeactivate` | `template.deactivate` | Admin | - |
|
||||
| `core.templates.after_uninstall` | `handleTemplateAfterUninstall` | `template.uninstall` | Admin | - |
|
||||
| `core.templates.after_version_update` | `handleTemplateAfterVersionUpdate` | `template.version_update` | Admin | - |
|
||||
| `core.templates.after_refresh_layouts` | `handleTemplateAfterRefreshLayouts` | `template.refresh_layouts` | Admin | - |
|
||||
|
||||
### Layout (2훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.layout.after_update` | `handleLayoutAfterUpdate` | `layout.update` | Admin | - |
|
||||
| `core.layout.after_version_restore` | `handleLayoutAfterVersionRestore` | `layout.version_restore` | Admin | - |
|
||||
|
||||
### Module Settings (2훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.module_settings.after_save` | `handleModuleSettingsAfterSave` | `module_settings.save` | Admin | - |
|
||||
| `core.module_settings.after_reset` | `handleModuleSettingsAfterReset` | `module_settings.reset` | Admin | - |
|
||||
|
||||
### Plugin Settings (2훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `core.plugin_settings.after_save` | `handlePluginSettingsAfterSave` | `plugin_settings.save` | Admin | - |
|
||||
| `core.plugin_settings.after_reset` | `handlePluginSettingsAfterReset` | `plugin_settings.reset` | Admin | - |
|
||||
|
||||
---
|
||||
|
||||
## 3. 이커머스 모듈 훅
|
||||
|
||||
**모듈**: `sirsoft-ecommerce`
|
||||
**총 92훅** (7개 Listener)
|
||||
|
||||
### 3.1 OrderActivityLogListener (21훅)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/OrderActivityLogListener.php`
|
||||
|
||||
#### OrderService (8훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.order.before_update` | `captureOrderSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.order.after_update` | `handleOrderAfterUpdate` | `order.update` | Admin | Order |
|
||||
| `sirsoft-ecommerce.order.after_delete` | `handleOrderAfterDelete` | `order.delete` | Admin | Order |
|
||||
| `sirsoft-ecommerce.order.after_bulk_update` | `handleOrderAfterBulkUpdate` | `order.bulk_update` | Admin | - |
|
||||
| `sirsoft-ecommerce.order.after_bulk_status_update` | `handleOrderAfterBulkStatusUpdate` | `order.bulk_status_update` | Admin | - |
|
||||
| `sirsoft-ecommerce.order.after_bulk_shipping_update` | `handleOrderAfterBulkShippingUpdate` | `order.bulk_shipping_update` | Admin | - |
|
||||
| `sirsoft-ecommerce.order.after_update_shipping_address` | `handleOrderAfterUpdateShippingAddress` | `order.update_shipping_address` | Admin | Order |
|
||||
| `sirsoft-ecommerce.order.after_send_email` | `handleOrderAfterSendEmail` | `order.send_email` | Admin | - |
|
||||
|
||||
#### OrderOptionService (2훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.order_option.after_status_change` | `handleOrderOptionAfterStatusChange` | `order_option.status_change` | Admin | OrderOption |
|
||||
| `sirsoft-ecommerce.order_option.after_bulk_status_change` | `handleOrderOptionAfterBulkStatusChange` | `order_option.bulk_status_change` | Admin | - |
|
||||
|
||||
#### OrderCancellationService (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.order.before_cancel` | `captureOrderCancelSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.order.after_cancel` | `handleOrderAfterCancel` | `order.cancel` | Admin | Order |
|
||||
| `sirsoft-ecommerce.order.after_partial_cancel` | `handleOrderAfterPartialCancel` | `order.partial_cancel` | Admin | Order |
|
||||
| `sirsoft-ecommerce.coupon.restore` | `handleCouponRestore` | `coupon.restore` | Admin | Order |
|
||||
| `sirsoft-ecommerce.mileage.restore` | `handleMileageRestore` | `mileage.restore` | Admin | Order |
|
||||
|
||||
#### OrderProcessingService (6훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.order.after_create` | `handleOrderAfterCreate` | `order.create` | **User** | Order |
|
||||
| `sirsoft-ecommerce.order.after_payment_complete` | `handleOrderAfterPaymentComplete` | `order.payment_complete` | **User** | Order |
|
||||
| `sirsoft-ecommerce.order.payment_failed` | `handleOrderAfterPaymentFailed` | `order.payment_failed` | **User** | Order |
|
||||
| `sirsoft-ecommerce.coupon.use` | `handleCouponUse` | `coupon.use` | **User** | Order |
|
||||
| `sirsoft-ecommerce.mileage.use` | `handleMileageUse` | `mileage.use` | **User** | Order |
|
||||
| `sirsoft-ecommerce.mileage.earn` | `handleMileageEarn` | `mileage.earn` | **User** | Order |
|
||||
|
||||
### 3.2 ProductActivityLogListener (10훅)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/ProductActivityLogListener.php`
|
||||
|
||||
> 이 리스너는 `ProductLogService`를 사용하는 별도 패턴입니다 (Log::channel 대신).
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Priority | 비고 |
|
||||
|---------|----------------|----------|------|
|
||||
| `sirsoft-ecommerce.product.after_create` | `logCreated` | 50 | 상품 생성 로그 |
|
||||
| `sirsoft-ecommerce.product.before_update` | `captureSnapshot` | 5 | 스냅샷 캡처 |
|
||||
| `sirsoft-ecommerce.product.after_update` | `logUpdated` | 50 | 변경사항 비교 후 로그 |
|
||||
| `sirsoft-ecommerce.product.before_delete` | `logDeleted` | 50 | 삭제 전 로그 기록 |
|
||||
| `sirsoft-ecommerce.product.before_bulk_update` | `captureProductBulkUpdateSnapshot` | 5 | 일괄 수정 전 스냅샷 |
|
||||
| `sirsoft-ecommerce.product.after_bulk_update` | `handleProductAfterBulkUpdate` | 20 | 일괄 수정 로그 |
|
||||
| `sirsoft-ecommerce.product.before_bulk_price_update` | `captureProductBulkPriceSnapshot` | 5 | 일괄 가격 수정 전 스냅샷 |
|
||||
| `sirsoft-ecommerce.product.after_bulk_price_update` | `handleProductAfterBulkPriceUpdate` | 20 | 일괄 가격 수정 로그 |
|
||||
| `sirsoft-ecommerce.product.before_bulk_stock_update` | `captureProductBulkStockSnapshot` | 5 | 일괄 재고 수정 전 스냅샷 |
|
||||
| `sirsoft-ecommerce.product.after_bulk_stock_update` | `handleProductAfterBulkStockUpdate` | 20 | 일괄 재고 수정 로그 |
|
||||
|
||||
### 3.3 CouponActivityLogListener (6훅)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponActivityLogListener.php`
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.coupon.after_create` | `handleAfterCreate` | `coupon.create` | Admin | Coupon |
|
||||
| `sirsoft-ecommerce.coupon.before_update` | `captureCouponSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.coupon.after_update` | `handleAfterUpdate` | `coupon.update` | Admin | Coupon |
|
||||
| `sirsoft-ecommerce.coupon.after_delete` | `handleAfterDelete` | `coupon.delete` | Admin | - |
|
||||
| `sirsoft-ecommerce.coupon.before_bulk_status` | `captureCouponBulkStatusSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.coupon.after_bulk_status` | `handleAfterBulkStatus` | `coupon.bulk_status` | Admin | - |
|
||||
|
||||
### 3.4 ShippingPolicyActivityLogListener (8훅)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/ShippingPolicyActivityLogListener.php`
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.shipping_policy.after_create` | `handleAfterCreate` | `shipping_policy.create` | Admin | ShippingPolicy |
|
||||
| `sirsoft-ecommerce.shipping_policy.before_update` | `captureSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.shipping_policy.after_update` | `handleAfterUpdate` | `shipping_policy.update` | Admin | ShippingPolicy |
|
||||
| `sirsoft-ecommerce.shipping_policy.after_delete` | `handleAfterDelete` | `shipping_policy.delete` | Admin | - |
|
||||
| `sirsoft-ecommerce.shipping_policy.after_toggle_active` | `handleAfterToggleActive` | `shipping_policy.toggle_active` | Admin | ShippingPolicy |
|
||||
| `sirsoft-ecommerce.shipping_policy.after_set_default` | `handleAfterSetDefault` | `shipping_policy.set_default` | Admin | ShippingPolicy |
|
||||
| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | `handleAfterBulkDelete` | `shipping_policy.bulk_delete` | Admin | - |
|
||||
| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | `handleAfterBulkToggleActive` | `shipping_policy.bulk_toggle_active` | Admin | - |
|
||||
|
||||
### 3.5 CategoryActivityLogListener (6훅)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/CategoryActivityLogListener.php`
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.category.after_create` | `handleAfterCreate` | `category.create` | Admin | Category |
|
||||
| `sirsoft-ecommerce.category.before_update` | `captureCategorySnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.category.after_update` | `handleAfterUpdate` | `category.update` | Admin | Category |
|
||||
| `sirsoft-ecommerce.category.after_delete` | `handleAfterDelete` | `category.delete` | Admin | - |
|
||||
| `sirsoft-ecommerce.category.after_toggle_status` | `handleAfterToggleStatus` | `category.toggle_status` | Admin | Category |
|
||||
| `sirsoft-ecommerce.category.after_reorder` | `handleAfterReorder` | `category.reorder` | Admin | - |
|
||||
|
||||
### 3.6 EcommerceAdminActivityLogListener (51훅)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/EcommerceAdminActivityLogListener.php`
|
||||
|
||||
#### Brand (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.brand.after_create` | `handleBrandAfterCreate` | `brand.create` | Admin | Brand |
|
||||
| `sirsoft-ecommerce.brand.before_update` | `captureBrandSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.brand.after_update` | `handleBrandAfterUpdate` | `brand.update` | Admin | Brand |
|
||||
| `sirsoft-ecommerce.brand.after_delete` | `handleBrandAfterDelete` | `brand.delete` | Admin | Brand |
|
||||
| `sirsoft-ecommerce.brand.after_toggle_status` | `handleBrandAfterToggleStatus` | `brand.toggle_status` | Admin | Brand |
|
||||
|
||||
#### ProductLabel (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.label.after_create` | `handleLabelAfterCreate` | `label.create` | Admin | ProductLabel |
|
||||
| `sirsoft-ecommerce.label.before_update` | `captureLabelSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.label.after_update` | `handleLabelAfterUpdate` | `label.update` | Admin | ProductLabel |
|
||||
| `sirsoft-ecommerce.label.after_delete` | `handleLabelAfterDelete` | `label.delete` | Admin | ProductLabel |
|
||||
| `sirsoft-ecommerce.label.after_toggle_status` | `handleLabelAfterToggleStatus` | `label.toggle_status` | Admin | ProductLabel |
|
||||
|
||||
#### ProductCommonInfo (4훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.product-common-info.after_create` | `handleCommonInfoAfterCreate` | `common_info.create` | Admin | ProductCommonInfo |
|
||||
| `sirsoft-ecommerce.product-common-info.before_update` | `captureCommonInfoSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.product-common-info.after_update` | `handleCommonInfoAfterUpdate` | `common_info.update` | Admin | ProductCommonInfo |
|
||||
| `sirsoft-ecommerce.product-common-info.after_delete` | `handleCommonInfoAfterDelete` | `common_info.delete` | Admin | ProductCommonInfo |
|
||||
|
||||
#### ProductNoticeTemplate (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.product-notice-template.after_create` | `handleNoticeTemplateAfterCreate` | `notice_template.create` | Admin | ProductNoticeTemplate |
|
||||
| `sirsoft-ecommerce.product-notice-template.before_update` | `captureNoticeTemplateSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.product-notice-template.after_update` | `handleNoticeTemplateAfterUpdate` | `notice_template.update` | Admin | ProductNoticeTemplate |
|
||||
| `sirsoft-ecommerce.product-notice-template.after_delete` | `handleNoticeTemplateAfterDelete` | `notice_template.delete` | Admin | ProductNoticeTemplate |
|
||||
| `sirsoft-ecommerce.product-notice-template.after_copy` | `handleNoticeTemplateAfterCopy` | `notice_template.copy` | Admin | ProductNoticeTemplate |
|
||||
|
||||
#### ExtraFeeTemplate (10훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.extra_fee_template.after_create` | `handleExtraFeeAfterCreate` | `extra_fee_template.create` | Admin | ExtraFeeTemplate |
|
||||
| `sirsoft-ecommerce.extra_fee_template.before_update` | `captureExtraFeeSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.extra_fee_template.after_update` | `handleExtraFeeAfterUpdate` | `extra_fee_template.update` | Admin | ExtraFeeTemplate |
|
||||
| `sirsoft-ecommerce.extra_fee_template.after_delete` | `handleExtraFeeAfterDelete` | `extra_fee_template.delete` | Admin | ExtraFeeTemplate |
|
||||
| `sirsoft-ecommerce.extra_fee_template.after_toggle_active` | `handleExtraFeeAfterToggleActive` | `extra_fee_template.toggle_active` | Admin | ExtraFeeTemplate |
|
||||
| `sirsoft-ecommerce.extra_fee_template.before_bulk_delete` | `captureExtraFeeBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.extra_fee_template.after_bulk_delete` | `handleExtraFeeAfterBulkDelete` | `extra_fee_template.bulk_delete` | Admin | - |
|
||||
| `sirsoft-ecommerce.extra_fee_template.before_bulk_toggle_active` | `captureExtraFeeBulkToggleSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.extra_fee_template.after_bulk_toggle_active` | `handleExtraFeeAfterBulkToggleActive` | `extra_fee_template.bulk_toggle_active` | Admin | - |
|
||||
| `sirsoft-ecommerce.extra_fee_template.after_bulk_create` | `handleExtraFeeAfterBulkCreate` | `extra_fee_template.bulk_create` | Admin | - |
|
||||
|
||||
#### ShippingCarrier (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.shipping_carrier.after_create` | `handleCarrierAfterCreate` | `shipping_carrier.create` | Admin | ShippingCarrier |
|
||||
| `sirsoft-ecommerce.shipping_carrier.before_update` | `captureCarrierSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.shipping_carrier.after_update` | `handleCarrierAfterUpdate` | `shipping_carrier.update` | Admin | ShippingCarrier |
|
||||
| `sirsoft-ecommerce.shipping_carrier.after_delete` | `handleCarrierAfterDelete` | `shipping_carrier.delete` | Admin | ShippingCarrier |
|
||||
| `sirsoft-ecommerce.shipping_carrier.after_toggle_status` | `handleCarrierAfterToggleStatus` | `shipping_carrier.toggle_status` | Admin | ShippingCarrier |
|
||||
|
||||
#### ProductImage (3훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.product-image.after_upload` | `handleImageAfterUpload` | `product_image.upload` | Admin | ProductImage |
|
||||
| `sirsoft-ecommerce.product-image.after_delete` | `handleImageAfterDelete` | `product_image.delete` | Admin | ProductImage |
|
||||
| `sirsoft-ecommerce.product-image.after_reorder` | `handleImageAfterReorder` | `product_image.reorder` | Admin | - |
|
||||
|
||||
#### ShippingPolicy Bulk (4훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.shipping_policy.before_bulk_delete` | `captureShippingPolicyBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | `handleShippingPolicyAfterBulkDelete` | `shipping_policy.bulk_delete` | Admin | - |
|
||||
| `sirsoft-ecommerce.shipping_policy.before_bulk_toggle_active` | `captureShippingPolicyBulkToggleSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | `handleShippingPolicyAfterBulkToggleActive` | `shipping_policy.bulk_toggle_active` | Admin | - |
|
||||
|
||||
#### ProductOption (6훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.product_option.before_bulk_price_update` | `captureOptionBulkPriceSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.product_option.after_bulk_price_update` | `handleOptionAfterBulkPriceUpdate` | `product_option.bulk_price_update` | Admin | - |
|
||||
| `sirsoft-ecommerce.product_option.before_bulk_stock_update` | `captureOptionBulkStockSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.product_option.after_bulk_stock_update` | `handleOptionAfterBulkStockUpdate` | `product_option.bulk_stock_update` | Admin | - |
|
||||
| `sirsoft-ecommerce.option.before_bulk_update` | `captureOptionBulkUpdateSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.option.after_bulk_update` | `handleOptionAfterBulkUpdate` | `product_option.bulk_update` | Admin | - |
|
||||
|
||||
#### ProductReview (4훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.product-review.after_create` | `handleReviewAfterCreate` | `review.create` | Admin | ProductReview |
|
||||
| `sirsoft-ecommerce.product-review.after_delete` | `handleReviewAfterDelete` | `review.delete` | Admin | ProductReview |
|
||||
| `sirsoft-ecommerce.product-review.before_bulk_delete` | `captureReviewBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-ecommerce.product-review.after_bulk_delete` | `handleReviewAfterBulkDelete` | `product_review.bulk_delete` | Admin | - |
|
||||
|
||||
### 3.7 EcommerceUserActivityLogListener (7훅)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/EcommerceUserActivityLogListener.php`
|
||||
|
||||
#### Cart (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.cart.after_add` | `handleCartAfterAdd` | `cart.add` | **User** | Cart |
|
||||
| `sirsoft-ecommerce.cart.after_update_quantity` | `handleCartAfterUpdateQuantity` | `cart.update_quantity` | **User** | Cart |
|
||||
| `sirsoft-ecommerce.cart.after_change_option` | `handleCartAfterChangeOption` | `cart.change_option` | **User** | Cart |
|
||||
| `sirsoft-ecommerce.cart.after_delete` | `handleCartAfterDelete` | `cart.delete` | **User** | - |
|
||||
| `sirsoft-ecommerce.cart.after_delete_all` | `handleCartAfterDeleteAll` | `cart.delete_all` | **User** | - |
|
||||
|
||||
#### Wishlist (1훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.wishlist.after_toggle` | `handleWishlistAfterToggle` | `wishlist.add` / `wishlist.remove` | **User** | Product |
|
||||
|
||||
#### User Coupon (1훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-ecommerce.user_coupon.after_download` | `handleUserCouponAfterDownload` | `user_coupon.download` | **User** | CouponIssue |
|
||||
|
||||
---
|
||||
|
||||
## 4. 게시판 모듈 훅 (BoardActivityLogListener)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php`
|
||||
**총 32훅**
|
||||
|
||||
### Board (6훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.board.after_create` | `handleBoardAfterCreate` | `board.create` | Admin | Board |
|
||||
| `sirsoft-board.board.before_update` | `captureBoardSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-board.board.after_update` | `handleBoardAfterUpdate` | `board.update` | Admin | Board |
|
||||
| `sirsoft-board.board.after_delete` | `handleBoardAfterDelete` | `board.delete` | Admin | Board |
|
||||
| `sirsoft-board.board.after_add_to_menu` | `handleBoardAfterAddToMenu` | `board.add_to_menu` | Admin | Board |
|
||||
| `sirsoft-board.settings.after_bulk_apply` | `handleSettingsAfterBulkApply` | `board_settings.bulk_apply` | Admin | - |
|
||||
|
||||
### BoardType (4훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.board_type.after_create` | `handleBoardTypeAfterCreate` | `board_type.create` | Admin | BoardType |
|
||||
| `sirsoft-board.board_type.before_update` | `captureBoardTypeSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-board.board_type.after_update` | `handleBoardTypeAfterUpdate` | `board_type.update` | Admin | BoardType |
|
||||
| `sirsoft-board.board_type.after_delete` | `handleBoardTypeAfterDelete` | `board_type.delete` | Admin | BoardType |
|
||||
|
||||
### Post (6훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.post.after_create` | `handlePostAfterCreate` | `post.create` | Admin | Post |
|
||||
| `sirsoft-board.post.before_update` | `capturePostSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-board.post.after_update` | `handlePostAfterUpdate` | `post.update` | Admin | Post |
|
||||
| `sirsoft-board.post.after_delete` | `handlePostAfterDelete` | `post.delete` | Admin | Post |
|
||||
| `sirsoft-board.post.after_blind` | `handlePostAfterBlind` | `post.blind` | Admin | Post |
|
||||
| `sirsoft-board.post.after_restore` | `handlePostAfterRestore` | `post.restore` | Admin | Post |
|
||||
|
||||
### Comment (6훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.comment.after_create` | `handleCommentAfterCreate` | `comment.create` | Admin | Comment |
|
||||
| `sirsoft-board.comment.before_update` | `captureCommentSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-board.comment.after_update` | `handleCommentAfterUpdate` | `comment.update` | Admin | Comment |
|
||||
| `sirsoft-board.comment.after_delete` | `handleCommentAfterDelete` | `comment.delete` | Admin | Comment |
|
||||
| `sirsoft-board.comment.after_blind` | `handleCommentAfterBlind` | `comment.blind` | Admin | Comment |
|
||||
| `sirsoft-board.comment.after_restore` | `handleCommentAfterRestore` | `comment.restore` | Admin | Comment |
|
||||
|
||||
### Attachment (2훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.attachment.after_upload` | `handleAttachmentAfterUpload` | `attachment.upload` | Admin | Attachment |
|
||||
| `sirsoft-board.attachment.after_delete` | `handleAttachmentAfterDelete` | `attachment.delete` | Admin | Attachment |
|
||||
|
||||
### Report (8훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.report.after_create` | `handleReportAfterCreate` | `report.create` | Admin | Report |
|
||||
| `sirsoft-board.report.after_update_status` | `handleReportAfterUpdateStatus` | `report.update_status` | Admin | Report |
|
||||
| `sirsoft-board.report.before_bulk_update_status` | `captureReportBulkStatusSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-board.report.after_bulk_update_status` | `handleReportAfterBulkUpdateStatus` | `report.bulk_update_status` | Admin | - |
|
||||
| `sirsoft-board.report.after_delete` | `handleReportAfterDelete` | `report.delete` | Admin | Report |
|
||||
| `sirsoft-board.report.after_restore_content` | `handleReportAfterRestoreContent` | `report.restore_content` | Admin | Report |
|
||||
| `sirsoft-board.report.after_blind_content` | `handleReportAfterBlindContent` | `report.blind_content` | Admin | Report |
|
||||
| `sirsoft-board.report.after_delete_content` | `handleReportAfterDeleteContent` | `report.delete_content` | Admin | Report |
|
||||
|
||||
---
|
||||
|
||||
## 5. 페이지 모듈 훅 (PageActivityLogListener)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-page/src/Listeners/PageActivityLogListener.php`
|
||||
**총 8훅**
|
||||
|
||||
### Page (6훅, 스냅샷 포함)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-page.page.after_create` | `handlePageAfterCreate` | `page.create` | Admin | Page |
|
||||
| `sirsoft-page.page.before_update` | `capturePageSnapshot` | _(스냅샷 캡처)_ | - | - |
|
||||
| `sirsoft-page.page.after_update` | `handlePageAfterUpdate` | `page.update` | Admin | Page |
|
||||
| `sirsoft-page.page.after_delete` | `handlePageAfterDelete` | `page.delete` | Admin | Page |
|
||||
| `sirsoft-page.page.after_publish` | `handlePageAfterPublish` | `page.publish` / `page.unpublish` | Admin | Page |
|
||||
| `sirsoft-page.page.after_restore` | `handlePageAfterRestore` | `page.restore` | Admin | Page |
|
||||
|
||||
### PageAttachment (2훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-page.attachment.after_upload` | `handleAttachmentAfterUpload` | `page_attachment.upload` | Admin | PageAttachment |
|
||||
| `sirsoft-page.attachment.after_delete` | `handleAttachmentAfterDelete` | `page_attachment.delete` | Admin | PageAttachment |
|
||||
|
||||
---
|
||||
|
||||
## 6. 스냅샷/변경감지 패턴
|
||||
|
||||
ActivityLog에서 수정(update) 작업의 변경 이력을 기록하려면 **스냅샷 패턴**을 사용합니다.
|
||||
|
||||
### 동작 흐름
|
||||
|
||||
```text
|
||||
1. Service에서 before_update 훅 발행 (doAction)
|
||||
2. Listener의 캡처 메서드 실행 (priority: 5, 다른 리스너보다 먼저)
|
||||
→ 현재 모델 상태를 $snapshots 배열에 저장
|
||||
3. Service에서 실제 DB 업데이트 수행
|
||||
4. Service에서 after_update 훅 발행 (doAction)
|
||||
5. Listener의 핸들러 메서드 실행 (priority: 20)
|
||||
→ ChangeDetector::detect($model, $snapshot) 호출
|
||||
→ 변경된 필드만 추출하여 로그 기록
|
||||
→ 스냅샷 정리 (unset)
|
||||
```
|
||||
|
||||
### 코드 예시
|
||||
|
||||
```php
|
||||
// 1. getSubscribedHooks()에서 before/after 쌍 등록
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'my-module.entity.before_update' => ['method' => 'captureSnapshot', 'priority' => 5],
|
||||
'my-module.entity.after_update' => ['method' => 'handleAfterUpdate', 'priority' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
// 2. 스냅샷 캡처 (before_update, priority 5)
|
||||
public function captureSnapshot(Model $entity, array $data): void
|
||||
{
|
||||
$this->snapshots['entity_' . $entity->id] = $entity->toArray();
|
||||
}
|
||||
|
||||
// 3. 변경 감지 + 로그 기록 (after_update, priority 20)
|
||||
public function handleAfterUpdate(Model $entity): void
|
||||
{
|
||||
$snapshot = $this->snapshots['entity_' . $entity->id] ?? null;
|
||||
$changes = ChangeDetector::detect($entity, $snapshot);
|
||||
|
||||
Log::channel('activity')->info('entity.update', [
|
||||
'log_type' => ActivityLogType::Admin,
|
||||
'loggable' => $entity,
|
||||
'description_key' => 'my-module::activity_log.description.entity_update',
|
||||
'description_params' => ['entity_id' => $entity->id],
|
||||
'changes' => $changes,
|
||||
]);
|
||||
|
||||
unset($this->snapshots['entity_' . $entity->id]);
|
||||
}
|
||||
```
|
||||
|
||||
### ChangeDetector
|
||||
|
||||
**파일**: `app/ActivityLog/ChangeDetector.php`
|
||||
|
||||
- 모델의 `$activityLogFields` 속성에 정의된 필드만 비교
|
||||
- 각 필드의 `label_key` (다국어 키)를 포함한 구조화된 변경 이력 생성
|
||||
- `BackedEnum` 자동 변환 지원
|
||||
- 스냅샷이 `null`이면 `null` 반환 (변경 없음)
|
||||
|
||||
### ProductActivityLogListener의 별도 패턴
|
||||
|
||||
`ProductActivityLogListener`는 `ProductLogService`를 주입받아 사용하는 별도 패턴입니다:
|
||||
- `ChangeDetector` 대신 자체 `detectChanges()` 메서드로 변경 감지
|
||||
- 해시 비교로 옵션/추가옵션/이미지 변경 감지
|
||||
- `Log::channel('activity')` 대신 `ProductLogService`를 통해 처리로그 테이블에 기록
|
||||
|
||||
---
|
||||
|
||||
## 7. 새 모듈에 ActivityLog 추가하기
|
||||
|
||||
### Step 1: Listener 클래스 생성
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Vendor\MyModule\Listeners;
|
||||
|
||||
use App\ActivityLog\ChangeDetector;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Enums\ActivityLogType;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MyModuleActivityLogListener implements HookListenerInterface
|
||||
{
|
||||
private array $snapshots = [];
|
||||
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'vendor-mymodule.entity.after_create' => ['method' => 'handleAfterCreate', 'priority' => 20],
|
||||
'vendor-mymodule.entity.before_update' => ['method' => 'captureSnapshot', 'priority' => 5],
|
||||
'vendor-mymodule.entity.after_update' => ['method' => 'handleAfterUpdate', 'priority' => 20],
|
||||
'vendor-mymodule.entity.after_delete' => ['method' => 'handleAfterDelete', 'priority' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 개별 메서드에서 처리
|
||||
}
|
||||
|
||||
// ... 각 핸들러 메서드 구현
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: module.php에 Listener 등록
|
||||
|
||||
```php
|
||||
// module.php의 listeners 배열에 추가
|
||||
'listeners' => [
|
||||
MyModuleActivityLogListener::class,
|
||||
],
|
||||
```
|
||||
|
||||
### Step 3: 다국어 파일에 description_key 정의
|
||||
|
||||
```php
|
||||
// lang/ko/activity_log.php
|
||||
return [
|
||||
'description' => [
|
||||
'entity_create' => ':entity_name 엔티티가 생성되었습니다.',
|
||||
'entity_update' => ':entity_name 엔티티가 수정되었습니다.',
|
||||
'entity_delete' => ':entity_name 엔티티가 삭제되었습니다.',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### Step 4: 모델에 $activityLogFields 정의 (ChangeDetector 사용 시)
|
||||
|
||||
```php
|
||||
// Model 클래스에 추가
|
||||
protected array $activityLogFields = [
|
||||
'name' => ['label_key' => 'vendor-mymodule::activity_log.fields.name'],
|
||||
'status' => ['label_key' => 'vendor-mymodule::activity_log.fields.status'],
|
||||
];
|
||||
```
|
||||
|
||||
### Step 5: 테스트 작성
|
||||
|
||||
```php
|
||||
// tests/Unit/Listeners/MyModuleActivityLogListenerTest.php
|
||||
class MyModuleActivityLogListenerTest extends TestCase
|
||||
{
|
||||
public function test_getSubscribedHooks_returns_expected_hooks(): void
|
||||
{
|
||||
$hooks = MyModuleActivityLogListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('vendor-mymodule.entity.after_create', $hooks);
|
||||
$this->assertArrayHasKey('vendor-mymodule.entity.before_update', $hooks);
|
||||
// ...
|
||||
}
|
||||
|
||||
public function test_handleAfterCreate_logs_activity(): void
|
||||
{
|
||||
Log::shouldReceive('channel')
|
||||
->with('activity')
|
||||
->andReturnSelf();
|
||||
Log::shouldReceive('info')
|
||||
->once()
|
||||
->with('entity.create', Mockery::type('array'));
|
||||
|
||||
$listener = new MyModuleActivityLogListener();
|
||||
$listener->handleAfterCreate($entity, $data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 훅 네이밍 규칙
|
||||
|
||||
| 패턴 | 용도 | Priority |
|
||||
|------|------|----------|
|
||||
| `{module}.{entity}.before_update` | 스냅샷 캡처 | 5 (높은 우선순위) |
|
||||
| `{module}.{entity}.after_create` | 생성 로그 | 20 |
|
||||
| `{module}.{entity}.after_update` | 수정 로그 (변경감지) | 20 |
|
||||
| `{module}.{entity}.after_delete` | 삭제 로그 | 20 |
|
||||
| `{module}.{entity}.after_bulk_*` | 일괄 작업 로그 | 20 |
|
||||
| `{module}.{entity}.after_toggle_*` | 상태 전환 로그 | 20 |
|
||||
|
||||
### context 구조
|
||||
|
||||
| 키 | 타입 | 설명 | 필수 |
|
||||
|----|------|------|------|
|
||||
| `log_type` | `ActivityLogType` | Admin 또는 User | O |
|
||||
| `loggable` | `Model` | 대상 모델 (polymorphic) | - |
|
||||
| `description_key` | `string` | 다국어 설명 키 | O |
|
||||
| `description_params` | `array` | 설명 파라미터 | - |
|
||||
| `properties` | `array` | 추가 속성 (JSON) | - |
|
||||
| `changes` | `array\|null` | ChangeDetector 결과 | - |
|
||||
@@ -0,0 +1,955 @@
|
||||
# 활동 로그 시스템 (Activity Log System)
|
||||
|
||||
G7의 활동 로그는 Monolog 기반 커스텀 채널을 통해 DB에 기록됩니다.
|
||||
Service 훅 이벤트를 Listener가 수신하여 `Log::channel('activity')` 로 전달하고,
|
||||
ActivityLogHandler가 `activity_logs` 테이블에 저장합니다.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. Monolog 기반: Service 훅 → Listener → Log::channel('activity') → Handler → DB (3단계)
|
||||
2. ActivityLogService는 조회 전용 (기록 메서드 없음 — Log::channel 직접 사용)
|
||||
3. description_params에 ID 저장 → ActivityLogDescriptionResolver가 표시 시 이름 변환
|
||||
4. 변경 추적: $activityLogFields 정의 필수 + ChangeDetector → changes JSON (미정의 시 null)
|
||||
5. Bulk 작업 Per-Item 필수: N건 처리 시 N건 개별 로그 (집계 1건 금지), 삭제 시 loggable_type/loggable_id 직접 지정
|
||||
6. 새 모듈 추가 시: Listener + DescriptionResolver + module.php 등록 + lang + $activityLogFields
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [아키텍처](#아키텍처)
|
||||
2. [로깅 흐름](#로깅-흐름)
|
||||
3. [Listener 작성 패턴](#listener-작성-패턴)
|
||||
4. [변경 추적 (ChangeDetector)](#변경-추적-changedetector)
|
||||
5. [다국어 키 규칙](#다국어-키-규칙)
|
||||
6. [description_params 저장 정책](#description_params-저장-정책)
|
||||
7. [ActivityLogDescriptionResolver 규정](#activitylogdescriptionresolver-규정)
|
||||
8. [모듈 리스너 등록](#모듈-리스너-등록)
|
||||
9. [모델별 로그 조회 API 패턴](#모델별-로그-조회-api-패턴)
|
||||
10. [Bulk 로깅 패턴 (Per-Item 필수)](#bulk-로깅-패턴-per-item-필수)
|
||||
11. [DB 스키마](#db-스키마)
|
||||
12. [인덱스](#인덱스)
|
||||
13. [금지 사항](#금지-사항)
|
||||
14. [개발자 체크리스트](#개발자-체크리스트)
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### Monolog 기반 3단계 파이프라인
|
||||
|
||||
```text
|
||||
Service (doAction 훅 발행)
|
||||
↓
|
||||
Listener (CoreActivityLogListener / 모듈 XxxActivityLogListener)
|
||||
↓
|
||||
Log::channel('activity')->info($action, $context)
|
||||
↓
|
||||
ActivityLogProcessor (user_id, ip_address, user_agent 자동 주입)
|
||||
↓
|
||||
ActivityLogHandler (Monolog Handler → activity_logs 테이블 INSERT)
|
||||
```
|
||||
|
||||
### 핵심 컴포넌트
|
||||
|
||||
| 컴포넌트 | 위치 | 역할 |
|
||||
|----------|------|------|
|
||||
| `ActivityLogChannel` | `app/ActivityLog/ActivityLogChannel.php` | Monolog Logger 팩토리 (`config/logging.php`의 `via` 클래스) |
|
||||
| `ActivityLogHandler` | `app/ActivityLog/ActivityLogHandler.php` | Monolog Handler — context에서 데이터 추출 후 DB 저장 |
|
||||
| `ActivityLogProcessor` | `app/ActivityLog/ActivityLogProcessor.php` | Monolog Processor — `user_id`, `ip_address`, `user_agent` 자동 주입 |
|
||||
| `ResolvesActivityLogType` | `app/ActivityLog/Traits/ResolvesActivityLogType.php` | log_type 자동 결정 트레이트 — `Auth::user()` 기반 Admin/User/System 분류 |
|
||||
| `ChangeDetector` | `app/ActivityLog/ChangeDetector.php` | 모델 스냅샷 비교 → 구조화된 변경 이력 생성 |
|
||||
| `CoreActivityLogListener` | `app/Listeners/CoreActivityLogListener.php` | 코어 서비스 훅 구독 리스너 |
|
||||
| `ActivityLog` | `app/Models/ActivityLog.php` | Eloquent 모델 (`localized_description` 접근자 포함) |
|
||||
| `ActivityLogService` | `app/Services/ActivityLogService.php` | **조회 전용** 서비스 (기록 메서드 없음) |
|
||||
|
||||
### config/logging.php 채널 설정
|
||||
|
||||
```php
|
||||
'activity' => [
|
||||
'driver' => 'custom',
|
||||
'via' => \App\ActivityLog\ActivityLogChannel::class,
|
||||
'level' => 'debug',
|
||||
],
|
||||
```
|
||||
|
||||
### config/activity_log.php
|
||||
|
||||
```php
|
||||
return [
|
||||
'enabled' => env('ACTIVITY_LOG_ENABLED', true), // false 시 Handler에서 DB 저장 건너뜀
|
||||
'channel' => env('ACTIVITY_LOG_CHANNEL', 'activity'),
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 로깅 흐름
|
||||
|
||||
### 1단계: Service에서 훅 발행
|
||||
|
||||
Service 계층에서 비즈니스 로직 수행 후 `doAction` 으로 훅을 발행합니다.
|
||||
|
||||
```php
|
||||
// Service 내부 (예: UserService)
|
||||
$this->hookManager->doAction('core.user.after_create', $user, $data);
|
||||
```
|
||||
|
||||
### 2단계: Listener에서 로그 기록
|
||||
|
||||
Listener가 훅을 수신하여 `Log::channel('activity')->info()` 를 호출합니다.
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
public function handleUserAfterCreate(User $user, array $data): void
|
||||
{
|
||||
$this->logActivity('user.create', [
|
||||
'log_type' => ActivityLogType::Admin,
|
||||
'loggable' => $user,
|
||||
'description_key' => 'activity_log.description.user_create',
|
||||
'description_params' => ['user_id' => $user->uuid],
|
||||
'properties' => ['email' => $user->email, 'name' => $user->name],
|
||||
]);
|
||||
}
|
||||
|
||||
private function logActivity(string $action, array $context): void
|
||||
{
|
||||
try {
|
||||
Log::channel('activity')->info($action, $context);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to record activity log', [
|
||||
'action' => $action,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3단계: Processor + Handler
|
||||
|
||||
1. **ActivityLogProcessor**: 리스너에서 명시적으로 전달하지 않은 `user_id`, `ip_address`, `user_agent`를 자동 주입
|
||||
2. **ActivityLogHandler**: context에서 구조화 데이터를 추출하여 `ActivityLog::create()` 호출
|
||||
|
||||
### Context 배열 구조
|
||||
|
||||
| 키 | 타입 | 필수 | 설명 |
|
||||
|----|------|------|------|
|
||||
| `log_type` | `ActivityLogType` | O | 로그 유형 (Admin/User/System) |
|
||||
| `loggable` | `Model` | - | 대상 모델 (morph 관계). 삭제 엔티티는 `loggable_type` + `loggable_id` 직접 지정 |
|
||||
| `loggable_type` | `string` | - | `loggable` 대신 morph 타입 직접 지정 (삭제된 엔티티용) |
|
||||
| `loggable_id` | `int` | - | `loggable` 대신 morph ID 직접 지정 (삭제된 엔티티용) |
|
||||
| `description_key` | `string` | O | 다국어 번역 키 |
|
||||
| `description_params` | `array` | - | 번역 파라미터 |
|
||||
| `properties` | `array` | - | 추가 속성 (JSON) |
|
||||
| `changes` | `array` | - | 변경 이력 (ChangeDetector 결과) |
|
||||
| `user_id` | `int` | - | 자동 주입 (Processor) |
|
||||
| `ip_address` | `string` | - | 자동 주입 (Processor) |
|
||||
| `user_agent` | `string` | - | 자동 주입 (Processor, 500자 제한) |
|
||||
|
||||
---
|
||||
|
||||
## Listener 작성 패턴
|
||||
|
||||
### 기본 구조
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Vendor\Module\Listeners;
|
||||
|
||||
use App\ActivityLog\ChangeDetector;
|
||||
use App\ActivityLog\Traits\ResolvesActivityLogType;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
|
||||
class XxxActivityLogListener implements HookListenerInterface
|
||||
{
|
||||
use ResolvesActivityLogType;
|
||||
|
||||
/** @var array<string, array> 스냅샷 저장소 */
|
||||
private array $snapshots = [];
|
||||
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
// before_update: 스냅샷 캡처 (priority 5 — 다른 리스너보다 먼저 실행)
|
||||
'vendor-module.entity.before_update' => ['method' => 'captureEntitySnapshot', 'priority' => 5],
|
||||
|
||||
// after_xxx: 로그 기록 (priority 20)
|
||||
'vendor-module.entity.after_create' => ['method' => 'handleEntityAfterCreate', 'priority' => 20],
|
||||
'vendor-module.entity.after_update' => ['method' => 'handleEntityAfterUpdate', 'priority' => 20],
|
||||
'vendor-module.entity.after_delete' => ['method' => 'handleEntityAfterDelete', 'priority' => 20],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 개별 메서드에서 처리
|
||||
}
|
||||
|
||||
// ─── 스냅샷 캡처 ───
|
||||
|
||||
public function captureEntitySnapshot(Model $entity, array $data): void
|
||||
{
|
||||
$this->snapshots['entity_' . $entity->id] = $entity->toArray();
|
||||
}
|
||||
|
||||
// ─── 핸들러 ───
|
||||
|
||||
public function handleEntityAfterCreate(Model $entity, array $data): void
|
||||
{
|
||||
// log_type 미지정 시 ResolvesActivityLogType 트레이트가 Auth::user() 기반 자동 결정
|
||||
$this->logActivity('entity.create', [
|
||||
'loggable' => $entity,
|
||||
'description_key' => 'vendor-module::activity_log.description.entity_create',
|
||||
'description_params' => ['entity_name' => $entity->name ?? ''],
|
||||
'properties' => ['id' => $entity->id],
|
||||
]);
|
||||
}
|
||||
|
||||
public function handleEntityAfterUpdate(Model $entity, array $data): void
|
||||
{
|
||||
$snapshot = $this->snapshots['entity_' . $entity->id] ?? null;
|
||||
$changes = ChangeDetector::detect($entity, $snapshot);
|
||||
|
||||
$this->logActivity('entity.update', [
|
||||
'loggable' => $entity,
|
||||
'description_key' => 'vendor-module::activity_log.description.entity_update',
|
||||
'description_params' => ['entity_name' => $entity->name ?? ''],
|
||||
'changes' => $changes,
|
||||
]);
|
||||
|
||||
unset($this->snapshots['entity_' . $entity->id]);
|
||||
}
|
||||
|
||||
public function handleEntityAfterDelete(Model $entity): void
|
||||
{
|
||||
$this->logActivity('entity.delete', [
|
||||
'loggable' => $entity,
|
||||
'description_key' => 'vendor-module::activity_log.description.entity_delete',
|
||||
'description_params' => ['entity_name' => $entity->name ?? ''],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ResolvesActivityLogType 트레이트
|
||||
|
||||
모든 ActivityLog 리스너는 `ResolvesActivityLogType` 트레이트를 사용합니다.
|
||||
이 트레이트는 두 가지 메서드를 제공합니다:
|
||||
|
||||
- **`resolveLogType()`** — `Auth::user()` 기반으로 log_type을 동적 결정:
|
||||
- Admin 역할 보유자 → `ActivityLogType::Admin`
|
||||
- 인증된 비관리자 → `ActivityLogType::User`
|
||||
- 비인증(비회원/CLI/시스템) → `ActivityLogType::System`
|
||||
|
||||
- **`logActivity(string $action, array $context)`** — `Log::channel('activity')->info()` 래퍼:
|
||||
- context에 `log_type`이 없으면 `resolveLogType()`으로 자동 주입
|
||||
- try-catch로 로그 실패가 비즈니스 로직을 중단하지 않도록 보호
|
||||
|
||||
**log_type 명시가 필요한 경우** (예: 시스템 자동 작업):
|
||||
|
||||
```php
|
||||
$this->logActivity('schedule.auto_run', [
|
||||
'log_type' => ActivityLogType::System, // 명시 시 auto-resolve 건너뜀
|
||||
'description_key' => 'activity_log.description.schedule_auto_run',
|
||||
]);
|
||||
```
|
||||
|
||||
### 핵심 규칙
|
||||
|
||||
| 규칙 | 설명 |
|
||||
|------|------|
|
||||
| `HookListenerInterface` 구현 필수 | `getSubscribedHooks()` + `handle()` 메서드 |
|
||||
| `use ResolvesActivityLogType` 필수 | 모든 ActivityLog 리스너에 트레이트 적용 |
|
||||
| before_update priority 5 | 다른 리스너보다 먼저 실행하여 변경 전 스냅샷 확보 |
|
||||
| after_xxx priority 20 | 실제 로직 완료 후 기록 |
|
||||
| `log_type` 하드코딩 금지 | 트레이트가 `Auth::user()` 기반으로 자동 결정 (필요 시 명시 가능) |
|
||||
| 스냅샷 사용 후 `unset` | 메모리 누수 방지 |
|
||||
|
||||
---
|
||||
|
||||
## 변경 추적 (ChangeDetector)
|
||||
|
||||
### 모델에 $activityLogFields 정의 (필수)
|
||||
|
||||
> **주의**: ChangeDetector를 호출하는 모든 모델은 `$activityLogFields` 정의 필수입니다.
|
||||
> 미정의 시 `ChangeDetector::detect()` 가 항상 `null` 을 반환하여 changes 컬럼이 빈 채로 기록됩니다.
|
||||
|
||||
추적할 필드를 모델에 `public static array $activityLogFields` 로 선언합니다.
|
||||
|
||||
```php
|
||||
class Product extends Model
|
||||
{
|
||||
/** @var array<string, array> 활동 로그 추적 필드 */
|
||||
public static array $activityLogFields = [
|
||||
'name' => [
|
||||
'label_key' => 'sirsoft-ecommerce::product.name',
|
||||
'type' => 'text',
|
||||
],
|
||||
'price' => [
|
||||
'label_key' => 'sirsoft-ecommerce::product.price',
|
||||
'type' => 'currency',
|
||||
],
|
||||
'status' => [
|
||||
'label_key' => 'sirsoft-ecommerce::product.status',
|
||||
'type' => 'enum',
|
||||
'enum' => ProductStatus::class, // Backed Enum 클래스 (labelKey() 메서드 필수)
|
||||
],
|
||||
'is_visible' => [
|
||||
'label_key' => 'sirsoft-ecommerce::product.is_visible',
|
||||
'type' => 'boolean',
|
||||
],
|
||||
'published_at' => [
|
||||
'label_key' => 'sirsoft-ecommerce::product.published_at',
|
||||
'type' => 'datetime',
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 지원 타입
|
||||
|
||||
| type | 설명 | 비고 |
|
||||
|------|------|------|
|
||||
| `text` | 일반 문자열 | 기본값 (type 생략 시) |
|
||||
| `number` | 숫자 | 정수/실수 |
|
||||
| `currency` | 통화 금액 | 프론트엔드에서 포맷팅 시 사용 |
|
||||
| `date` | 날짜 | Y-m-d |
|
||||
| `datetime` | 날짜+시간 | Y-m-d H:i:s |
|
||||
| `enum` | Enum 값 | `enum` 키에 Backed Enum 클래스 지정 필수 |
|
||||
| `boolean` | 참/거짓 | true/false |
|
||||
| `json` | JSON 데이터 | 객체/배열 |
|
||||
|
||||
### ChangeDetector 사용법
|
||||
|
||||
```php
|
||||
// 1. before_update 훅에서 스냅샷 캡처
|
||||
public function captureSnapshot(Model $model, array $data): void
|
||||
{
|
||||
$this->snapshots['model_' . $model->id] = $model->toArray();
|
||||
}
|
||||
|
||||
// 2. after_update 훅에서 변경 감지
|
||||
public function handleAfterUpdate(Model $model, array $data): void
|
||||
{
|
||||
$snapshot = $this->snapshots['model_' . $model->id] ?? null;
|
||||
$changes = ChangeDetector::detect($model, $snapshot);
|
||||
// $changes 는 null (변경 없음) 또는 변경 배열
|
||||
}
|
||||
```
|
||||
|
||||
### changes 배열 구조
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"field": "status",
|
||||
"label_key": "sirsoft-ecommerce::product.status",
|
||||
"old": "draft",
|
||||
"new": "active",
|
||||
"type": "enum",
|
||||
"old_label_key": "sirsoft-ecommerce::enums.product_status.draft",
|
||||
"new_label_key": "sirsoft-ecommerce::enums.product_status.active"
|
||||
},
|
||||
{
|
||||
"field": "name",
|
||||
"label_key": "sirsoft-ecommerce::product.name",
|
||||
"old": "기존 상품명",
|
||||
"new": "새 상품명",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `old_label_key` / `new_label_key` 는 `type: "enum"` 일 때만 추가됩니다.
|
||||
- Enum 클래스에 `labelKey(): string` 메서드가 필수입니다.
|
||||
- Backed Enum 값은 자동으로 `->value` 로 변환하여 비교합니다.
|
||||
|
||||
---
|
||||
|
||||
## 다국어 키 규칙
|
||||
|
||||
### 코어
|
||||
|
||||
```text
|
||||
activity_log.description.{entity}_{verb}
|
||||
```
|
||||
|
||||
| 예시 키 | 설명 |
|
||||
|--------|------|
|
||||
| `activity_log.description.user_create` | 사용자 생성 |
|
||||
| `activity_log.description.user_update` | 사용자 수정 |
|
||||
| `activity_log.description.user_delete` | 사용자 삭제 |
|
||||
| `activity_log.description.role_create` | 역할 생성 |
|
||||
| `activity_log.description.settings_save` | 설정 저장 |
|
||||
|
||||
lang 파일 위치: `lang/ko/activity_log.php`, `lang/en/activity_log.php`
|
||||
|
||||
```php
|
||||
// lang/ko/activity_log.php
|
||||
return [
|
||||
'description' => [
|
||||
'user_create' => '회원 :user_id 생성',
|
||||
'user_update' => '회원 :user_id 정보 수정',
|
||||
'user_delete' => '회원 :user_id 삭제',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 모듈
|
||||
|
||||
```text
|
||||
{module-id}::activity_log.description.{entity}_{verb}
|
||||
```
|
||||
|
||||
| 예시 키 | 설명 |
|
||||
|--------|------|
|
||||
| `sirsoft-ecommerce::activity_log.description.order_create` | 주문 생성 |
|
||||
| `sirsoft-page::activity_log.description.page_update` | 페이지 수정 |
|
||||
| `sirsoft-board::activity_log.description.post_delete` | 게시글 삭제 |
|
||||
|
||||
lang 파일 위치: `modules/_bundled/{id}/resources/lang/ko/activity_log.php`
|
||||
|
||||
```php
|
||||
// modules/_bundled/sirsoft-page/resources/lang/ko/activity_log.php
|
||||
return [
|
||||
'description' => [
|
||||
'page_create' => '페이지 ":page_title" 생성',
|
||||
'page_update' => '페이지 ":page_title" 수정',
|
||||
'page_delete' => '페이지 ":page_title" 삭제',
|
||||
'page_publish' => '페이지 ":page_title" 공개',
|
||||
'page_unpublish' => '페이지 ":page_title" 비공개 전환',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 규칙
|
||||
|
||||
- 파라미터는 Laravel 표준 `:param` 문법 사용 (예: `:user_id`, `:page_title`)
|
||||
- `ko` 와 `en` lang 파일 모두 정의 필수
|
||||
- DB에는 번역 키(`description_key`)와 파라미터(`description_params`)만 저장
|
||||
- 실시간 번역은 `ActivityLog::localized_description` 접근자에서 수행
|
||||
|
||||
---
|
||||
|
||||
## description_params 저장 정책
|
||||
|
||||
### 핵심 규칙: ID를 저장하고, 표시 시 이름으로 변환
|
||||
|
||||
`description_params` 에는 표시용 이름이 아닌 **ID** 를 저장합니다.
|
||||
이름 변경 시 과거 로그도 최신 이름으로 표시되어야 하기 때문입니다.
|
||||
|
||||
```php
|
||||
// ✅ 올바른 사용 — ID 저장
|
||||
'description_params' => ['brand_id' => $brand->id],
|
||||
|
||||
// ❌ 금지 — 이름 직접 저장
|
||||
'description_params' => ['brand_name' => $brand->name],
|
||||
```
|
||||
|
||||
### 삭제 시 예외: properties에 이름 스냅샷 보존
|
||||
|
||||
엔티티 삭제 시에는 DB 조회가 불가하므로 `properties` 에 이름 스냅샷을 함께 저장합니다.
|
||||
`ActivityLogDescriptionResolver` 가 `properties.name` 을 우선 사용합니다.
|
||||
|
||||
```php
|
||||
public function handleEntityAfterDelete(Model $entity): void
|
||||
{
|
||||
$this->logActivity('entity.delete', [
|
||||
'loggable' => $entity,
|
||||
'description_key' => 'vendor-module::activity_log.description.entity_delete',
|
||||
'description_params' => ['entity_id' => $entity->id],
|
||||
'properties' => ['name' => $entity->name], // 삭제 후 DB 조회 불가 → 스냅샷 보존
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### properties vs changes vs description_params 역할 구분
|
||||
|
||||
| 컬럼 | 용도 | 저장 내용 |
|
||||
|------|------|----------|
|
||||
| `description_params` | 번역 키의 `:placeholder` 치환용 | 엔티티 ID (`ActivityLogDescriptionResolver` 가 표시 시 이름으로 변환) |
|
||||
| `changes` | `ChangeDetector` 결과 | 필드별 `old`/`new` 구조화 데이터 (자동 생성) |
|
||||
| `properties` | 추가 메타데이터 | bulk IDs, 삭제 시 이름 스냅샷, 기타 컨텍스트 데이터 |
|
||||
|
||||
---
|
||||
|
||||
## ActivityLogDescriptionResolver 규정
|
||||
|
||||
### 역할
|
||||
|
||||
`core.activity_log.filter_description_params` 필터 훅을 구독하는 리스너입니다.
|
||||
활동 로그에 저장된 엔티티 ID를 **표시 시점에** 사람이 읽을 수 있는 이름으로 변환합니다.
|
||||
|
||||
### 해석 우선순위
|
||||
|
||||
1. **`properties.name` 스냅샷** — 로그 기록 시점의 이름 (삭제된 엔티티도 처리 가능)
|
||||
2. **DB 조회** — `description_params` 의 ID로 모델 조회 → 현재 이름 반환
|
||||
3. **Fallback** — DB 조회 실패 시 `"ID: {id}"` 문자열 반환
|
||||
|
||||
### 모듈별 구현 패턴
|
||||
|
||||
각 모듈은 자체 `ActivityLogDescriptionResolver` 를 구현합니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Vendor\Module\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
|
||||
class ActivityLogDescriptionResolver implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.activity_log.filter_description_params' => [
|
||||
'method' => 'resolveDescriptionParams',
|
||||
'type' => 'filter', // ← Filter 훅 — type 명시 필수
|
||||
'priority' => 10,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 기본 핸들러는 사용하지 않음
|
||||
}
|
||||
|
||||
public function resolveDescriptionParams(array $params, string $descriptionKey, array $properties): array
|
||||
{
|
||||
$prefix = 'vendor-module::activity_log.description.';
|
||||
if (! str_starts_with($descriptionKey, $prefix)) {
|
||||
return $params; // 다른 모듈의 키 → 무시
|
||||
}
|
||||
|
||||
$keySuffix = str_replace($prefix, '', $descriptionKey);
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($keySuffix, 'entity_') => $this->resolveEntityName($params, $properties),
|
||||
default => $params,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveEntityName(array $params, array $properties): array
|
||||
{
|
||||
if (! empty($params['entity_name'])) {
|
||||
return $params; // 이미 해석됨
|
||||
}
|
||||
|
||||
// 1순위: properties.name 스냅샷
|
||||
if (! empty($properties['name'])) {
|
||||
$params['entity_name'] = $properties['name'];
|
||||
return $params;
|
||||
}
|
||||
|
||||
// 2순위: DB 조회
|
||||
$id = $params['entity_id'] ?? null;
|
||||
if ($id) {
|
||||
$entity = Entity::find($id);
|
||||
$params['entity_name'] = $entity?->name ?? "ID: {$id}";
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 등록
|
||||
|
||||
모듈의 `module.php` 에서 `getHookListeners()` 에 등록합니다.
|
||||
|
||||
```php
|
||||
public function getHookListeners(): array
|
||||
{
|
||||
return [
|
||||
XxxActivityLogListener::class,
|
||||
ActivityLogDescriptionResolver::class, // ← 추가
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 다국어 이름 처리
|
||||
|
||||
이름이 다국어 배열(`['ko' => '한국어', 'en' => 'English']`)인 경우 현재 로케일에 맞게 해석합니다.
|
||||
|
||||
```php
|
||||
private function resolveI18nName(mixed $name): string
|
||||
{
|
||||
if (is_string($name)) {
|
||||
return $name;
|
||||
}
|
||||
if (is_array($name)) {
|
||||
$locale = app()->getLocale();
|
||||
return $name[$locale] ?? $name['ko'] ?? (array_values($name)[0] ?? '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모듈 리스너 등록
|
||||
|
||||
모듈의 `module.php` (AbstractModule 상속) 에서 `getHookListeners()` 메서드에 등록합니다.
|
||||
|
||||
```php
|
||||
// modules/_bundled/sirsoft-page/module.php
|
||||
|
||||
namespace Modules\Sirsoft\Page;
|
||||
|
||||
use App\Extension\AbstractModule;
|
||||
use Modules\Sirsoft\Page\Listeners\PageActivityLogListener;
|
||||
|
||||
class Module extends AbstractModule
|
||||
{
|
||||
public function getHookListeners(): array
|
||||
{
|
||||
return [
|
||||
PageActivityLogListener::class,
|
||||
// 기타 리스너...
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
AbstractModule이 `boot()` 시점에 `HookManager::registerListener()` 를 자동 호출합니다.
|
||||
별도의 `HookManager::registerListener()` 직접 호출은 불필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 모델별 로그 조회 API 패턴
|
||||
|
||||
특정 모델(상품, 주문 등)에 대한 활동 로그를 조회하는 API 패턴입니다.
|
||||
|
||||
### Controller에 `logs()` 메서드 추가
|
||||
|
||||
```php
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Resources\ActivityLogResource;
|
||||
use App\Services\ActivityLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProductController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private ActivityLogService $activityLogService,
|
||||
// ...
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 상품 활동 로그 목록 조회
|
||||
*
|
||||
* @param Request $request 요청
|
||||
* @param Product $product 대상 상품
|
||||
* @return JsonResponse 로그 목록 응답
|
||||
*/
|
||||
public function logs(Request $request, Product $product): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = [
|
||||
'per_page' => (int) ($request->query('per_page', 10)),
|
||||
'page' => (int) ($request->query('page', 1)),
|
||||
'sort_order' => $request->query('sort_order', 'desc'),
|
||||
];
|
||||
|
||||
$logs = $this->activityLogService->getLogsForModel($product, $filters);
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'vendor-module',
|
||||
'messages.entity.logs_fetch_success',
|
||||
ActivityLogResource::collection($logs)->response()->getData(true)
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
return ResponseHelper::moduleError(
|
||||
'vendor-module',
|
||||
'messages.entity.logs_fetch_failed',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 라우트 등록
|
||||
|
||||
```php
|
||||
// routes/api.php (모듈)
|
||||
Route::get('products/{product}/logs', [ProductController::class, 'logs']);
|
||||
```
|
||||
|
||||
### 레이아웃 JSON에서 처리로그 탭 구현
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "activity_logs",
|
||||
"endpoint": "/api/admin/vendor-module/products/{{route.id}}/logs",
|
||||
"method": "GET",
|
||||
"autoFetch": false,
|
||||
"params": { "per_page": 10, "page": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk 로깅 패턴 (Per-Item 필수)
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
> **모든 bulk 작업은 Per-Item 로깅 필수** — 변경된 엔티티 건별로 `loggable_type`/`loggable_id`가 기록되어야 합니다.
|
||||
> N건 일괄 처리 시 N건의 개별 로그가 생성됩니다.
|
||||
|
||||
```text
|
||||
필수: bulk 작업은 Per-Item 개별 로그 기록 (1건 집계 로그 금지)
|
||||
필수: 각 엔티티별 개별 로그 기록 (loggable => $model 또는 loggable_type/loggable_id 직접 지정)
|
||||
```
|
||||
|
||||
### 패턴 1: Bulk Update — Service에서 스냅샷 + 개별 모델 update
|
||||
|
||||
```php
|
||||
// Service 내부
|
||||
public function bulkUpdateStatus(array $ids, string $newStatus): array
|
||||
{
|
||||
$models = Model::whereIn('id', $ids)->get();
|
||||
$snapshots = [];
|
||||
|
||||
foreach ($models as $model) {
|
||||
$snapshots[$model->id] = $model->toArray();
|
||||
$model->update(['status' => $newStatus]);
|
||||
}
|
||||
|
||||
// 훅에 ids + snapshots 전달
|
||||
$this->hookManager->doAction('module.entity.after_bulk_status_update', $ids, count($models), $snapshots);
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
```
|
||||
|
||||
### 패턴 2: Bulk Update — Listener에서 per-item 로그 기록
|
||||
|
||||
```php
|
||||
public function handleAfterBulkStatusUpdate(array $ids, int $updatedCount, array $snapshots = []): void
|
||||
{
|
||||
$models = Model::whereIn('id', $ids)->get()->keyBy('id');
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$model = $models->get($id);
|
||||
if (! $model) {
|
||||
continue; // 존재하지 않는 ID는 건너뜀
|
||||
}
|
||||
|
||||
$snapshot = $snapshots[$id] ?? null;
|
||||
$changes = $snapshot ? ChangeDetector::detect($model, $snapshot) : null;
|
||||
|
||||
$this->logActivity('entity.bulk_status_update', [
|
||||
'loggable' => $model, // ← 개별 모델 지정 필수
|
||||
'description_key' => 'vendor-module::activity_log.description.entity_status_update',
|
||||
'description_params' => ['entity_id' => $id],
|
||||
'changes' => $changes,
|
||||
'properties' => ['entity_id' => $id],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 패턴 3: Bulk Delete — 삭제된 엔티티의 per-item 로깅
|
||||
|
||||
엔티티가 이미 삭제되어 DB 조회가 불가한 경우, `loggable_type`/`loggable_id`를 직접 지정합니다.
|
||||
`ActivityLogHandler`가 `loggable` 모델이 없을 때 이 값을 fallback으로 사용합니다.
|
||||
|
||||
```php
|
||||
public function handleAfterBulkDelete(array $ids, int $deletedCount, array $snapshots = []): void
|
||||
{
|
||||
foreach ($ids as $id) {
|
||||
$snapshot = $snapshots[$id] ?? null;
|
||||
|
||||
$this->logActivity('entity.bulk_delete', [
|
||||
'loggable_type' => Model::class, // ← morph 타입 직접 지정
|
||||
'loggable_id' => $id, // ← morph ID 직접 지정
|
||||
'description_key' => 'vendor-module::activity_log.description.entity_delete',
|
||||
'description_params' => ['entity_id' => $id],
|
||||
'properties' => [
|
||||
'entity_id' => $id,
|
||||
'snapshot' => $snapshot, // 삭제 전 데이터 보존
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 패턴 4: Bulk Toggle — 모델 조회 후 per-item 로깅
|
||||
|
||||
```php
|
||||
public function handleAfterBulkToggleActive(array $ids, bool $isActive, int $count, array $snapshots = []): void
|
||||
{
|
||||
$models = Model::whereIn('id', $ids)->get()->keyBy('id');
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$model = $models->get($id);
|
||||
if (! $model) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $snapshots[$id] ?? null;
|
||||
$changes = $snapshot ? ChangeDetector::detect($model, $snapshot) : null;
|
||||
|
||||
$this->logActivity('entity.bulk_toggle_active', [
|
||||
'loggable' => $model,
|
||||
'description_key' => 'vendor-module::activity_log.description.entity_toggle_active',
|
||||
'description_params' => ['entity_id' => $id],
|
||||
'changes' => $changes,
|
||||
'properties' => ['entity_id' => $id, 'is_active' => $isActive],
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### loggable vs loggable_type/loggable_id 선택 기준
|
||||
|
||||
| 상황 | 사용 방식 |
|
||||
| --- | --- |
|
||||
| 모델이 존재함 (update/toggle) | `'loggable' => $model` |
|
||||
| 모델이 삭제됨 (delete) | `'loggable_type' => Model::class, 'loggable_id' => $id` |
|
||||
| 두 값 모두 존재 시 | `loggable` 우선 (Handler에서 `loggable instanceof Model` 체크) |
|
||||
|
||||
### 삭제 작업 ChangeDetector
|
||||
|
||||
bulk delete 는 변경 전후 비교가 불필요하므로 ChangeDetector를 사용하지 않습니다.
|
||||
대신 `properties.snapshot` 에 삭제 전 데이터를 보존합니다.
|
||||
|
||||
---
|
||||
|
||||
## DB 스키마
|
||||
|
||||
### activity_logs 테이블
|
||||
|
||||
| 컬럼 | 타입 | Nullable | 설명 |
|
||||
|------|------|----------|------|
|
||||
| `id` | bigint (PK) | N | 활동 로그 ID |
|
||||
| `log_type` | varchar(20) | N | 로그 유형 (admin/user/system) |
|
||||
| `loggable_type` | varchar | Y | 대상 모델 morph 타입 |
|
||||
| `loggable_id` | bigint | Y | 대상 모델 ID |
|
||||
| `user_id` | bigint (FK) | Y | 행위자 사용자 ID (삭제 시 NULL) |
|
||||
| `action` | varchar(50) | N | 액션 유형 (예: user.create, page.update) |
|
||||
| `description_key` | varchar(150) | Y | 다국어 번역 키 |
|
||||
| `description_params` | json | Y | 다국어 번역 파라미터 |
|
||||
| `properties` | json | Y | 추가 속성 데이터 |
|
||||
| `changes` | json | Y | 구조화된 변경 이력 (ChangeDetector 결과) |
|
||||
| `ip_address` | varchar(45) | Y | IP 주소 (IPv6 대응) |
|
||||
| `user_agent` | varchar(500) | Y | User Agent (500자 제한) |
|
||||
| `created_at` | timestamp | N | 생성일시 (updated_at 없음) |
|
||||
|
||||
- `timestamps = false` — `created_at` 만 사용 (boot 시 자동 설정)
|
||||
- `user_id` 는 `nullOnDelete` — 사용자 삭제 시 NULL 처리
|
||||
|
||||
---
|
||||
|
||||
## 인덱스
|
||||
|
||||
### 단일 인덱스
|
||||
|
||||
| 컬럼 | 용도 |
|
||||
|------|------|
|
||||
| `log_type` | 유형별 필터링 |
|
||||
| `user_id` | 사용자별 조회 |
|
||||
| `action` | 액션별 필터링 |
|
||||
| `created_at` | 시간순 정렬/범위 조회 |
|
||||
| `loggable_type` + `loggable_id` | morphs 자동 생성 인덱스 |
|
||||
|
||||
### 복합 인덱스
|
||||
|
||||
| 인덱스명 | 컬럼 | 용도 |
|
||||
|----------|------|------|
|
||||
| `idx_activity_logs_loggable` | (`loggable_type`, `loggable_id`, `created_at`) | 특정 모델의 로그 시간순 조회 |
|
||||
| `idx_activity_logs_type_action_date` | (`log_type`, `action`, `created_at`) | 유형+액션 기반 필터링 |
|
||||
|
||||
---
|
||||
|
||||
## 금지 사항
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|-------------|
|
||||
| `ActivityLogService->log()` / `logAdmin()` / `logUser()` / `logSystem()` | 제거됨 — `Log::channel('activity')->info()` 사용 |
|
||||
| `ActivityLogManager` / `Driver` 패턴 | 제거됨 — Monolog 기반 아키텍처로 전환 |
|
||||
| Controller에서 직접 로그 기록 | Listener에서만 기록 (Service 훅 → Listener → Log) |
|
||||
| description 컬럼에 번역된 텍스트 저장 | `description_key` + `description_params` 사용 |
|
||||
| ActivityLogService를 기록용으로 사용 | 조회 전용 — 기록은 `Log::channel('activity')` |
|
||||
| Listener 밖에서 `ActivityLog::create()` 직접 호출 | `Log::channel('activity')` 를 통해 기록 (Processor 자동 주입 보장) |
|
||||
| `$activityLogFields` 미정의 상태에서 ChangeDetector 호출 | ChangeDetector를 사용하는 모든 모델에 `$activityLogFields` 정의 필수 |
|
||||
| `description_params` 에 표시용 이름 직접 저장 | ID 저장 + `ActivityLogDescriptionResolver` 에서 표시 시 변환 |
|
||||
| 별도 로그 테이블/서비스 운영 (예: `ecommerce_product_logs`) | `activity_logs` 단일 테이블로 통합 |
|
||||
| Bulk 작업을 1건의 집계 로그로 기록 (loggable 없이 count만 저장) | Per-Item: 변경된 각 엔티티별 개별 로그 기록 필수 |
|
||||
| Bulk delete에서 loggable 미지정 | `loggable_type` + `loggable_id` 직접 지정 (삭제 엔티티 대응) |
|
||||
|
||||
---
|
||||
|
||||
## 참고 파일
|
||||
|
||||
| 파일 | 경로 |
|
||||
|------|------|
|
||||
| ActivityLogChannel | `app/ActivityLog/ActivityLogChannel.php` |
|
||||
| ActivityLogHandler | `app/ActivityLog/ActivityLogHandler.php` |
|
||||
| ActivityLogProcessor | `app/ActivityLog/ActivityLogProcessor.php` |
|
||||
| ChangeDetector | `app/ActivityLog/ChangeDetector.php` |
|
||||
| CoreActivityLogListener | `app/Listeners/CoreActivityLogListener.php` |
|
||||
| ActivityLog 모델 | `app/Models/ActivityLog.php` |
|
||||
| ActivityLogService | `app/Services/ActivityLogService.php` |
|
||||
| ActivityLogType Enum | `app/Enums/ActivityLogType.php` |
|
||||
| config/activity_log.php | `config/activity_log.php` |
|
||||
| config/logging.php (activity 채널) | `config/logging.php` |
|
||||
| 마이그레이션 (생성) | `database/migrations/2026_04_01_000027_create_activity_logs_table.php` |
|
||||
| 마이그레이션 (i18n 전환) | `database/migrations/2026_04_01_000657_update_i18n_fields_in_activity_logs_table.php` |
|
||||
|
||||
---
|
||||
|
||||
## 개발자 체크리스트
|
||||
|
||||
### 새 모델 추가 시
|
||||
|
||||
```text
|
||||
□ 모델에 public static array $activityLogFields 정의
|
||||
□ ActivityLogListener에 before_update (스냅샷 캡처) + after_create/update/delete 훅 등록
|
||||
□ description_params에 ID 저장 (이름 직접 저장 금지)
|
||||
□ ActivityLogDescriptionResolver에 해당 모델의 ID→이름 해석 로직 추가
|
||||
□ lang/{ko,en}/activity_log.php에 description 키 추가
|
||||
□ (선택) Controller에 logs() 메서드 + 라우트 추가 (모델별 로그 조회 필요 시)
|
||||
```
|
||||
|
||||
### CRUD 훅 추가 시
|
||||
|
||||
```text
|
||||
□ before_update 훅: priority 5 (스냅샷 캡처용)
|
||||
□ after_xxx 훅: priority 20 (로그 기록)
|
||||
□ update 핸들러에서 ChangeDetector::detect() 호출
|
||||
□ delete 핸들러에서 properties에 이름 스냅샷 보존
|
||||
□ ko/en 다국어 키 모두 추가
|
||||
```
|
||||
|
||||
### Bulk Update 추가 시
|
||||
|
||||
```text
|
||||
□ Service: 변경 전 모델을 일괄 로드 (whereIn()->get())
|
||||
□ Service: 각 모델별 toArray() 스냅샷 캡처 → $snapshots 배열
|
||||
□ Service: 개별 모델 update 실행
|
||||
□ Service: 훅에 $ids + $count + $snapshots 전달
|
||||
□ Listener: per-item 루프 — 각 ID별 모델 조회 + ChangeDetector + 개별 logActivity 호출
|
||||
□ Listener: 존재하지 않는 ID는 continue로 건너뜀
|
||||
□ 1건의 집계 로그가 아닌, N건의 개별 로그 생성 확인
|
||||
□ 각 로그에 loggable => $model (또는 loggable_type/loggable_id) 포함 확인
|
||||
□ ko/en 다국어 키 모두 추가
|
||||
□ 테스트: N건 처리 시 N건 로그 생성 검증 + loggable 정확성 검증
|
||||
```
|
||||
|
||||
### Bulk Delete 추가 시
|
||||
|
||||
```text
|
||||
□ Service: 삭제 전 스냅샷 캡처 → $snapshots 배열 (keyBy id)
|
||||
□ Service: 훅에 $ids + $count + $snapshots 전달
|
||||
□ Listener: per-item 루프 — 각 ID별 loggable_type/loggable_id 직접 지정
|
||||
□ Listener: properties.snapshot에 삭제 전 데이터 보존
|
||||
□ 테스트: loggable_type/loggable_id 정확성 검증
|
||||
```
|
||||
@@ -0,0 +1,504 @@
|
||||
# API 리소스
|
||||
|
||||
> **목적**: API 응답 데이터 변환을 위한 리소스 클래스 규칙
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. BaseApiResource 상속 필수
|
||||
2. 민감 정보 제외: created_by, updated_by, deleted_at
|
||||
3. 관계 데이터: whenLoaded() 사용
|
||||
4. when() 주의: 커스텀 메서드에서는 삼항 연산자 사용 (MissingValue 누출 방지)
|
||||
5. 권한 메타: ...$this->resourceMeta($request) 스프레드로 is_owner + abilities(can_*) 표준화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [기본 규칙](#기본-규칙)
|
||||
2. [권한 메타 표준화 (is_owner + abilities)](#권한-메타-표준화-is_owner--abilities)
|
||||
3. [$this->when() 사용 주의사항](#thiswhen-사용-주의사항)
|
||||
4. [패턴 예시](#패턴-예시)
|
||||
|
||||
---
|
||||
|
||||
## 기본 규칙
|
||||
|
||||
### BaseApiResource 상속 필수
|
||||
|
||||
모든 API 리소스는 `BaseApiResource`를 상속해야 합니다.
|
||||
|
||||
```php
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
|
||||
class ProductResource extends BaseApiResource
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 민감한 정보 제외
|
||||
|
||||
보안을 위해 다음 필드는 API 응답에서 제외합니다:
|
||||
|
||||
- `created_by` - 생성자 ID
|
||||
- `updated_by` - 수정자 ID
|
||||
- `deleted_at` - 삭제 시간
|
||||
- 비밀번호, 토큰 등 인증 정보
|
||||
|
||||
### 관계 데이터 처리
|
||||
|
||||
관계 데이터는 `whenLoaded`를 사용하여 조건부로 포함합니다:
|
||||
|
||||
```php
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
// ✅ 관계가 로드된 경우에만 포함
|
||||
'category' => new CategoryResource($this->whenLoaded('category')),
|
||||
'images' => ProductImageResource::collection($this->whenLoaded('images')),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 권한 메타 표준화 (is_owner + abilities)
|
||||
|
||||
모든 API 리소스 응답에 `is_owner`와 `abilities` (can_*) 필드를 표준화합니다.
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
`toArray()` 마지막에 `...$this->resourceMeta($request)`를 스프레드합니다:
|
||||
|
||||
```php
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
// ... 기타 필드
|
||||
|
||||
// ✅ 표준 권한 메타 (is_owner + abilities)
|
||||
...$this->resourceMeta($request),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 응답 형식
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "상품명",
|
||||
"is_owner": true,
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `is_owner`: 현재 요청 사용자가 리소스 소유자인지 여부 (`ownerField()` 미정의 시 생략)
|
||||
- `abilities`: `can_*` 키로 통합된 권한 플래그 (`abilityMap()` 미정의 시 생략)
|
||||
|
||||
### ownerField() — 소유자 판단
|
||||
|
||||
리소스의 소유자 필드명을 반환합니다:
|
||||
|
||||
```php
|
||||
// 기본값: null (is_owner 생략)
|
||||
protected function ownerField(): ?string
|
||||
{
|
||||
return 'user_id'; // 또는 'created_by' 등
|
||||
}
|
||||
```
|
||||
|
||||
### abilityMap() — 정적 권한 매핑
|
||||
|
||||
권한 식별자를 `can_*` 키로 매핑합니다. 대부분의 리소스에서 사용합니다:
|
||||
|
||||
```php
|
||||
protected function abilityMap(): array
|
||||
{
|
||||
return [
|
||||
'can_create' => 'sirsoft-ecommerce.products.create',
|
||||
'can_update' => 'sirsoft-ecommerce.products.update',
|
||||
'can_delete' => 'sirsoft-ecommerce.products.delete',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### resolveAbilities() — 동적 권한 (고급)
|
||||
|
||||
slug 기반 동적 식별자가 필요한 경우 `resolveAbilities()`를 오버라이드합니다:
|
||||
|
||||
```php
|
||||
// Board 모듈 PostResource 패턴
|
||||
protected function resolveAbilities(Request $request): array
|
||||
{
|
||||
$slug = $this->getSlug($request);
|
||||
if (! $slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$abilityMap = $this->isAdminRequest($request)
|
||||
? [
|
||||
'can_read' => "sirsoft-board.{$slug}.admin.posts.read",
|
||||
'can_write' => "sirsoft-board.{$slug}.admin.posts.write",
|
||||
]
|
||||
: [
|
||||
'can_read' => "sirsoft-board.{$slug}.posts.read",
|
||||
'can_write' => "sirsoft-board.{$slug}.posts.write",
|
||||
];
|
||||
|
||||
return $this->resolveAbilitiesFromMap($abilityMap, $request->user());
|
||||
}
|
||||
```
|
||||
|
||||
### HasAbilityCheck 트레이트
|
||||
|
||||
`BaseApiResource`와 `ResourceCollection` 모두에서 권한 체크 로직을 공유하기 위한 트레이트입니다:
|
||||
|
||||
```php
|
||||
use App\Http\Resources\Traits\HasAbilityCheck;
|
||||
|
||||
// ResourceCollection에서 컬렉션 레벨 abilities 제공
|
||||
class BrandCollection extends ResourceCollection
|
||||
{
|
||||
use HasAbilityCheck;
|
||||
|
||||
protected function abilityMap(): array
|
||||
{
|
||||
return [
|
||||
'can_create' => 'sirsoft-ecommerce.brands.create',
|
||||
'can_update' => 'sirsoft-ecommerce.brands.update',
|
||||
'can_delete' => 'sirsoft-ecommerce.brands.delete',
|
||||
];
|
||||
}
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => BrandResource::collection($this->collection),
|
||||
'abilities' => $this->resolveAbilitiesFromMap($this->abilityMap(), $request->user()),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 커스텀 메서드에서의 사용
|
||||
|
||||
`toListArray()` 등 커스텀 메서드에서도 동일하게 스프레드합니다:
|
||||
|
||||
```php
|
||||
public function toListArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->getValue('id'),
|
||||
'name' => $this->getValue('name'),
|
||||
|
||||
// ✅ 커스텀 메서드에서도 동일 패턴
|
||||
...$this->resourceMeta(request()),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 권한 체크 및 scope 기반 abilities
|
||||
|
||||
`resolveAbilitiesFromMap()`은 각 ability에 대해 다음 순서로 확인합니다:
|
||||
|
||||
1. 권한 체크 (`PermissionHelper::check()`)
|
||||
2. scope 기반 접근 체크 (`PermissionHelper::checkScopeAccess()`) — 리소스 모델이 있는 경우
|
||||
|
||||
scope_type에 따라 리소스 소유자 기반으로 ability가 자동 제한됩니다. 별도의 바이패스 맵 정의가 불필요합니다.
|
||||
|
||||
> 상세: [permissions.md](../extension/permissions.md) scope_type 시스템 참조
|
||||
|
||||
---
|
||||
|
||||
## $this->when() 사용 주의사항
|
||||
|
||||
```text
|
||||
필수: 커스텀 메서드에서는 삼항 연산자 사용 ($this->when() 금지)
|
||||
필수: 커스텀 메서드에서는 삼항 연산자 사용
|
||||
```
|
||||
|
||||
### 문제 원인
|
||||
|
||||
Laravel의 `$this->when()` 메서드는 조건이 false일 때 `MissingValue` 객체를 반환합니다. 이 객체는 **Laravel이 `toArray()`를 처리할 때만 자동으로 필터링**됩니다. `toListArray()`, `withAdminInfo()` 등 커스텀 메서드에서는 필터링되지 않아 빈 객체 `{}`가 JSON 응답에 포함됩니다.
|
||||
|
||||
### 발생하는 문제
|
||||
|
||||
- React Error #31: "Objects are not valid as a React child"
|
||||
- 프론트엔드에서 빈 화면 표시
|
||||
- 데이터 바인딩 실패
|
||||
|
||||
### 잘못된 예시 (❌ DON'T)
|
||||
|
||||
```php
|
||||
// ❌ 커스텀 메서드에서 $this->when() 사용 - MissingValue 객체 누출
|
||||
public function toListArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->getValue('id'),
|
||||
'name' => $this->getValue('name'),
|
||||
// ❌ email_verified_at이 null이면 빈 객체 {} 반환
|
||||
'email_verified_at' => $this->when(
|
||||
$this->getValue('email_verified_at'),
|
||||
fn () => $this->formatDateTimeStringForUser($this->getValue('email_verified_at'))
|
||||
),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 올바른 예시 (✅ DO)
|
||||
|
||||
```php
|
||||
// ✅ 커스텀 메서드에서는 삼항 연산자 사용
|
||||
public function toListArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->getValue('id'),
|
||||
'name' => $this->getValue('name'),
|
||||
// ✅ null 반환으로 안전하게 처리
|
||||
'email_verified_at' => $this->getValue('email_verified_at')
|
||||
? $this->formatDateTimeStringForUser($this->getValue('email_verified_at'))
|
||||
: null,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 가능/금지 장소
|
||||
|
||||
| 장소 | $this->when() 사용 |
|
||||
|------|-------------------|
|
||||
| `toArray()` 메서드 내부 | ✅ 가능 (Laravel이 MissingValue 자동 필터링) |
|
||||
| `toListArray()` 등 커스텀 메서드 | ❌ 금지 |
|
||||
| `toProfileArray()` 등 커스텀 메서드 | ❌ 금지 |
|
||||
| `withAdminInfo()` 등 추가 정보 메서드 | ❌ 금지 |
|
||||
| `toArray()` 외부에서 수동 호출되는 모든 메서드 | ❌ 금지 |
|
||||
|
||||
### PHPDoc 작성 권장
|
||||
|
||||
커스텀 메서드임을 명시하여 주의사항을 알립니다:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 목록용 간단한 형태의 배열을 반환합니다.
|
||||
*
|
||||
* 주의: 이 메서드는 toArray() 외부에서 수동 호출되므로
|
||||
* $this->when() 대신 삼항 연산자를 사용해야 합니다.
|
||||
*/
|
||||
public function toListArray(): array
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 패턴 예시
|
||||
|
||||
### 기본 리소스 클래스
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Resources;
|
||||
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
|
||||
class ProductResource extends BaseApiResource
|
||||
{
|
||||
/**
|
||||
* 리소스를 배열로 변환
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'price' => $this->price,
|
||||
'is_active' => $this->is_active,
|
||||
'category' => new CategoryResource($this->whenLoaded('category')),
|
||||
'images' => ProductImageResource::collection($this->whenLoaded('images')),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
// created_by, updated_by는 제외 (보안)
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 커스텀 메서드가 있는 리소스
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
class UserResource extends BaseApiResource
|
||||
{
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
// ✅ toArray() 내부에서는 when() 사용 가능
|
||||
'role' => $this->when($this->role, fn () => $this->role->name),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록용 간단한 배열 반환
|
||||
*
|
||||
* 주의: $this->when() 대신 삼항 연산자 사용
|
||||
*/
|
||||
public function toListArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->getValue('id'),
|
||||
'name' => $this->getValue('name'),
|
||||
// ✅ 삼항 연산자 사용
|
||||
'email_verified' => $this->getValue('email_verified_at') ? true : false,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ResourceCollection 커스텀 메서드
|
||||
|
||||
ResourceCollection에 커스텀 메서드를 추가하여 다양한 응답 형식을 지원할 수 있습니다.
|
||||
|
||||
### 패턴
|
||||
|
||||
```php
|
||||
class UserCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* 통계 정보를 추가하여 반환
|
||||
*
|
||||
* @param array $statistics 통계 데이터
|
||||
* @return array
|
||||
*/
|
||||
public function withStatistics(array $statistics): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
'statistics' => $statistics,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자용 추가 정보를 포함하여 반환
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function withAdminInfo(): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
'meta' => [
|
||||
'total' => $this->total(),
|
||||
'admin_info' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 컨트롤러에서 사용
|
||||
|
||||
```php
|
||||
public function index(UserListRequest $request): JsonResponse
|
||||
{
|
||||
$users = $this->userService->getPaginatedUsers($request->validated());
|
||||
$statistics = $this->userService->getStatistics();
|
||||
$collection = new UserCollection($users);
|
||||
|
||||
return $this->success(
|
||||
'user.fetch_success',
|
||||
$collection->withStatistics($statistics)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 주요 커스텀 메서드 유형
|
||||
|
||||
| 메서드 | 용도 | 반환 데이터 |
|
||||
| ------ | ------ | ------ |
|
||||
| `withStatistics()` | 통계 데이터 추가 | data + statistics |
|
||||
| `withAdminInfo()` | 관리자 전용 메타 추가 | data + admin meta |
|
||||
| `toListArray()` | 목록 전용 간소화 응답 | 축소된 필드 |
|
||||
| `toSimpleArray()` | 최소 필드 응답 | id + name만 |
|
||||
|
||||
### Resource에서 다중 응답 형식
|
||||
|
||||
Resource 클래스에서도 메서드별로 다른 응답 형식을 제공할 수 있습니다:
|
||||
|
||||
```php
|
||||
class UserResource extends BaseApiResource
|
||||
{
|
||||
/**
|
||||
* 목록용 간소화 응답
|
||||
*/
|
||||
public function toListArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'status' => $this->status,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로필용 응답
|
||||
*/
|
||||
public function toProfileArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'avatar' => $this->avatar_url,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 리소스 개발 체크리스트
|
||||
|
||||
```text
|
||||
□ BaseApiResource를 상속했는가?
|
||||
□ 민감한 정보(created_by, updated_by, deleted_at)를 제외했는가?
|
||||
□ 관계 데이터에 whenLoaded()를 사용했는가?
|
||||
□ 커스텀 메서드에서 $this->when() 사용을 피했는가?
|
||||
□ 커스텀 메서드에서 삼항 연산자를 사용했는가?
|
||||
□ 날짜 필드에 toISOString()을 사용했는가?
|
||||
□ ...$this->resourceMeta($request) 스프레드를 추가했는가?
|
||||
□ ownerField()를 정의했는가? (소유자 판단 필요 시)
|
||||
□ abilityMap()을 정의했는가? (권한 플래그 필요 시)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](./index.md) - 백엔드 가이드 인덱스
|
||||
- [controllers.md](./controllers.md) - 컨트롤러에서 리소스 사용
|
||||
- [response-helper.md](./response-helper.md) - API 응답 규칙
|
||||
- [service-repository.md](./service-repository.md) - Eager Loading으로 N+1 방지
|
||||
@@ -0,0 +1,332 @@
|
||||
# 인증 및 세션 처리
|
||||
|
||||
> **관련 문서**: [index.md](index.md) | [middleware.md](middleware.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용)
|
||||
2. 세션은 /dev 대시보드 전용: StartApiSession 미들웨어로 로그인/로그아웃 라우트에서만 생성
|
||||
3. 로그아웃: 토큰 삭제 + 세션 무효화 (세션 존재 시)
|
||||
4. 슬라이딩 만료: 토큰 잔여시간 절반 미만 시 자동 갱신
|
||||
5. 401 응답 시 세션 쿠키 자동 정리 (무조건적 forget cookie)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [Laravel Sanctum 토큰 전용 인증](#laravel-sanctum-토큰-전용-인증)
|
||||
2. [StartApiSession 미들웨어](#startapisession-미들웨어)
|
||||
3. [토큰 슬라이딩 만료](#토큰-슬라이딩-만료)
|
||||
4. [로그아웃 구현 원칙](#로그아웃-구현-원칙)
|
||||
5. [401 응답 시 세션 쿠키 정리](#401-응답-시-세션-쿠키-정리)
|
||||
6. [Laravel Sanctum plainTextToken 구조](#laravel-sanctum-plaintexttoken-구조)
|
||||
7. [세션 존재 확인](#세션-존재-확인)
|
||||
8. [세션 무효화 3단계](#세션-무효화-3단계)
|
||||
9. [완전한 로그아웃 구현 예시](#완전한-로그아웃-구현-예시)
|
||||
10. [안티 패턴](#안티-패턴)
|
||||
|
||||
---
|
||||
|
||||
## Laravel Sanctum 토큰 전용 인증
|
||||
|
||||
그누보드7 프로젝트는 SPA(Single Page Application)를 위한 **토큰 전용 인증 방식**을 사용합니다:
|
||||
|
||||
**특징**:
|
||||
- **Bearer 토큰 전용**: API 인증은 `Authorization: Bearer {token}` 헤더로만 수행
|
||||
- **`EnsureFrontendRequestsAreStateful` 미사용**: 세션 기반 Sanctum Guard를 사용하지 않음
|
||||
- **`currentAccessToken()`**: 항상 `PersonalAccessToken` 반환 (TransientToken 없음)
|
||||
- **세션은 /dev 대시보드 전용**: `StartApiSession` 미들웨어가 로그인/로그아웃 라우트에서만 세션 생성
|
||||
|
||||
**인증 가드 설정** (`bootstrap/app.php`):
|
||||
```php
|
||||
// stateful 미들웨어 없음 — Sanctum Guard는 토큰만 체크
|
||||
$middleware->alias([
|
||||
'start.api.session' => \App\Http\Middleware\StartApiSession::class,
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## StartApiSession 미들웨어
|
||||
|
||||
로그인/로그아웃 라우트에서만 세션을 시작하는 전용 미들웨어입니다.
|
||||
|
||||
**목적**:
|
||||
- `/dev` 대시보드(Telescope, Horizon 등)는 세션 기반 `web` guard 인증 사용
|
||||
- API 로그인 시 `Auth::guard('web')->login($user)`으로 세션에 사용자 저장
|
||||
- 로그아웃 시 세션 무효화
|
||||
|
||||
**동작 방식**:
|
||||
```php
|
||||
// StartApiSession은 세션 파이프라인만 실행 (CSRF 검증 없음, sanctum 속성 미설정)
|
||||
EncryptCookies → AddQueuedCookiesToResponse → StartSession → Controller
|
||||
```
|
||||
|
||||
**적용 라우트**:
|
||||
```php
|
||||
// 로그인 라우트 (세션 생성)
|
||||
Route::post('login', ...)->middleware('start.api.session');
|
||||
|
||||
// 로그아웃 라우트 (세션 무효화)
|
||||
Route::post('logout', ...)->middleware('start.api.session');
|
||||
```
|
||||
|
||||
**핵심 차이** — `stateful` vs `start.api.session`:
|
||||
|
||||
| 항목 | `stateful` (제거됨) | `start.api.session` |
|
||||
|------|---------------------|---------------------|
|
||||
| 적용 범위 | 모든 API 라우트 | 로그인/로그아웃만 |
|
||||
| Sanctum 세션 인증 | ✅ 활성화 | ❌ 비활성화 |
|
||||
| CSRF 검증 | ✅ 포함 | ❌ 미포함 |
|
||||
| `sanctum` 속성 설정 | ✅ 설정 | ❌ 미설정 |
|
||||
| 세션 쿠키 발급 | 모든 API 응답 | 로그인/로그아웃 응답만 |
|
||||
|
||||
**구현 파일**: `app/Http/Middleware/StartApiSession.php`
|
||||
|
||||
---
|
||||
|
||||
## 토큰 슬라이딩 만료
|
||||
|
||||
G7은 **슬라이딩 만료(Sliding Expiration)** 방식으로 토큰 만료 시간을 자동 갱신합니다.
|
||||
|
||||
### 동작 원리
|
||||
|
||||
```
|
||||
[로그인] ───────────────────────────────────────> [만료]
|
||||
0분 60분
|
||||
↑
|
||||
30분에 API 요청 발생 (절반 미만 남음)
|
||||
│
|
||||
└─────────────────────────────────────> [만료]
|
||||
30분 90분
|
||||
```
|
||||
|
||||
### 갱신 조건
|
||||
|
||||
토큰 만료 시간은 다음 조건을 **모두** 만족할 때 갱신됩니다:
|
||||
|
||||
| 조건 | 설명 |
|
||||
|------|------|
|
||||
| 인증된 요청 | `$request->user()`가 존재 |
|
||||
| PersonalAccessToken | 세션이 아닌 토큰 인증 |
|
||||
| 만료 시간 설정됨 | `expires_at`이 null이 아님 (무한대 토큰 제외) |
|
||||
| 절반 미만 남음 | 잔여 시간 < 설정값의 50% |
|
||||
|
||||
### 설정값
|
||||
|
||||
환경설정의 `security.auth_token_lifetime` 값을 사용합니다:
|
||||
- **기본값**: 30분
|
||||
- **범위**: 0~3600분 (0은 무한대)
|
||||
|
||||
### 구현 파일
|
||||
|
||||
- **미들웨어**: `app/Http/Middleware/RefreshTokenExpiration.php`
|
||||
- **등록 위치**: `bootstrap/app.php` (`api` 그룹에 등록)
|
||||
- **테스트**: `tests/Feature/Api/Auth/TokenExpirationTest.php`
|
||||
|
||||
---
|
||||
|
||||
## 로그아웃 구현 원칙
|
||||
|
||||
```
|
||||
필수: 토큰 삭제 + 세션 무효화 (세션이 존재하는 경우)
|
||||
필수: currentAccessToken() 삭제 → 세션 존재 시 세션 무효화
|
||||
```
|
||||
|
||||
토큰 전용 인증에서는 `currentAccessToken()`이 항상 `PersonalAccessToken`을 반환합니다:
|
||||
|
||||
```php
|
||||
// 1. 토큰 삭제
|
||||
$token = $user->currentAccessToken();
|
||||
if ($token instanceof \Laravel\Sanctum\PersonalAccessToken) {
|
||||
$token->delete();
|
||||
}
|
||||
|
||||
// 2. 세션 무효화 (StartApiSession 미들웨어가 세션을 시작한 경우)
|
||||
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
|
||||
Auth::guard('web')->logout();
|
||||
request()->session()->invalidate();
|
||||
request()->session()->regenerateToken();
|
||||
}
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- `stateful` 미들웨어 제거로 `TransientToken` 케이스가 없어짐
|
||||
- `currentAccessToken()`이 항상 `PersonalAccessToken` 반환
|
||||
- 세션 무효화는 `start.api.session` 미들웨어가 적용된 로그아웃 라우트에서만 실행됨
|
||||
|
||||
---
|
||||
|
||||
## 401 응답 시 세션 쿠키 정리
|
||||
|
||||
API 요청에서 `AuthenticationException` 발생 시 (토큰 만료, 미인증 등), 잔존 세션 쿠키를 만료시켜 보안 공백을 방지합니다.
|
||||
|
||||
**구현 위치**: `bootstrap/app.php` `withExceptions()`
|
||||
|
||||
```php
|
||||
$exceptions->render(function (\Illuminate\Auth\AuthenticationException $e, \Illuminate\Http\Request $request) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => __('auth.unauthenticated')], 401)
|
||||
->withCookie(cookie()->forget(config('session.cookie')));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**동작 방식**:
|
||||
|
||||
- **무조건적 forget**: 세션 쿠키 존재 여부와 관계없이 항상 `Set-Cookie` 만료 헤더 전송
|
||||
- 클라이언트에 해당 쿠키가 없으면 `Set-Cookie` 헤더는 무시됨 (무해)
|
||||
- 클라이언트에 잔존 세션 쿠키가 있으면 즉시 삭제됨
|
||||
|
||||
**테스트 파일**: `tests/Feature/Api/Auth/SessionCleanupOnUnauthorizedTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Laravel Sanctum plainTextToken 구조
|
||||
|
||||
**형식**: `{tokenId}|{actualToken}`
|
||||
|
||||
**예시**:
|
||||
```
|
||||
9|iHzLc0VcFxEKi8dJlyqaPQoMBKBgs1ocpZpWKQzj0aaddebe
|
||||
↓ ↓
|
||||
DB ID SHA256 해시 후 DB 저장
|
||||
```
|
||||
|
||||
**DB 저장 방식**:
|
||||
- `|` 앞부분 (tokenId): DB의 `id` 컬럼에 저장
|
||||
- `|` 뒷부분 (actualToken): SHA256 해시 후 `token` 컬럼에 저장
|
||||
|
||||
**파싱 방법**:
|
||||
```php
|
||||
// ✅ DO: explode()로 분리 후 뒷부분만 해시
|
||||
[, $token] = explode('|', $bearerToken, 2);
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
// ❌ DON'T: 전체 문자열을 해시
|
||||
$hashedToken = hash('sha256', $bearerToken); // 잘못된 해시값
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 세션 존재 확인
|
||||
|
||||
세션 무효화 전 반드시 세션 존재 여부를 확인해야 합니다:
|
||||
|
||||
```php
|
||||
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
|
||||
// 세션 무효화 로직
|
||||
}
|
||||
```
|
||||
|
||||
**체크 항목**:
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `hasSession()` | 세션이 바인딩되어 있는지 확인 |
|
||||
| `isStarted()` | 세션이 실제로 시작되었는지 확인 |
|
||||
| `Auth::guard('web')->check()` | 세션에 인증된 사용자가 있는지 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 세션 무효화 3단계
|
||||
|
||||
```php
|
||||
Auth::guard('web')->logout(); // 1. 세션에서 인증 정보 제거
|
||||
request()->session()->invalidate(); // 2. 세션 ID 무효화 및 새 세션 생성
|
||||
request()->session()->regenerateToken(); // 3. CSRF 토큰 재생성
|
||||
```
|
||||
|
||||
**각 단계 설명**:
|
||||
|
||||
| 단계 | 메서드 | 역할 |
|
||||
|------|--------|------|
|
||||
| 1 | `logout()` | 세션에서 사용자 인증 정보 제거 |
|
||||
| 2 | `invalidate()` | 세션 ID를 무효화하고 새 세션 ID 생성 |
|
||||
| 3 | `regenerateToken()` | CSRF 토큰을 재생성 |
|
||||
|
||||
**regenerateToken()을 하는 이유**:
|
||||
- 로그아웃 후에도 사용자가 같은 브라우저를 계속 사용할 수 있음
|
||||
- 이전 CSRF 토큰을 무효화하고 새로운 CSRF 토큰 발급
|
||||
- 로그아웃 이후의 공개 페이지 요청(로그인 폼 등)을 위한 새로운 보안 토큰 제공
|
||||
|
||||
---
|
||||
|
||||
## 완전한 로그아웃 구현 예시
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
/**
|
||||
* 사용자를 로그아웃시키고 현재 디바이스의 인증 토큰만 삭제합니다.
|
||||
*/
|
||||
public function logout(User $user): void
|
||||
{
|
||||
// Hook 발생 (로그아웃 시작)
|
||||
HookManager::doAction('core.auth.before_logout', $user);
|
||||
|
||||
// 토큰 삭제 처리 (토큰 전용 인증이므로 항상 PersonalAccessToken)
|
||||
$token = $user->currentAccessToken();
|
||||
|
||||
if ($token instanceof \Laravel\Sanctum\PersonalAccessToken) {
|
||||
$token->delete();
|
||||
}
|
||||
|
||||
// 세션 무효화 (StartApiSession 미들웨어가 세션을 시작한 경우)
|
||||
if (request()->hasSession() && request()->session()->isStarted() && Auth::guard('web')->check()) {
|
||||
Auth::guard('web')->logout();
|
||||
request()->session()->invalidate();
|
||||
request()->session()->regenerateToken();
|
||||
}
|
||||
|
||||
// Hook 발생 (로그아웃 완료)
|
||||
HookManager::doAction('core.auth.logout', $user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 안티 패턴
|
||||
|
||||
```php
|
||||
// ❌ DON'T: EnsureFrontendRequestsAreStateful 사용 (제거됨)
|
||||
$middleware->api(prepend: [
|
||||
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||
]);
|
||||
|
||||
// ❌ DON'T: 세션 존재 확인 없이 무효화 (에러 발생 가능)
|
||||
Auth::guard('web')->logout(); // 세션이 없으면 에러
|
||||
|
||||
// ❌ DON'T: 토큰 삭제 없이 세션만 무효화
|
||||
request()->session()->invalidate(); // 토큰이 살아있으면 재인증 가능
|
||||
|
||||
// ❌ DON'T: 전체 plainTextToken 해시 (DB 값과 불일치)
|
||||
$hashedToken = hash('sha256', $bearerToken); // 잘못됨
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- **구현 파일**: [app/Services/AuthService.php](../../../app/Services/AuthService.php)
|
||||
- **테스트 파일**: [tests/Feature/Api/Admin/AdminAuthTest.php](../../../tests/Feature/Api/Admin/AdminAuthTest.php)
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](index.md) - 백엔드 가이드 인덱스
|
||||
- [middleware.md](middleware.md) - 미들웨어 등록 규칙 (OptionalSanctumMiddleware 포함)
|
||||
@@ -0,0 +1,478 @@
|
||||
# Broadcasting (실시간 이벤트)
|
||||
|
||||
> **중요도**: 중요
|
||||
> **관련 문서**: [service-repository.md](service-repository.md) | [authentication.md](authentication.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. Laravel Reverb 사용 (WebSocket)
|
||||
2. 이벤트: ShouldBroadcast 인터페이스 구현
|
||||
3. 채널: public, private, presence
|
||||
4. 훅 연동: HookManager에서 broadcast() 호출
|
||||
5. 클라이언트: Laravel Echo + Pusher-js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [Laravel Reverb 설정](#laravel-reverb-설정)
|
||||
3. [브로드캐스트 이벤트 생성](#브로드캐스트-이벤트-생성)
|
||||
4. [채널 인증](#채널-인증)
|
||||
5. [API 인증 엔드포인트](#api-인증-엔드포인트)
|
||||
6. [훅을 통한 이벤트 발생](#훅을-통한-이벤트-발생)
|
||||
7. [스케줄러를 통한 주기적 브로드캐스트](#스케줄러를-통한-주기적-브로드캐스트)
|
||||
8. [개발 환경 설정](#개발-환경-설정)
|
||||
9. [프로덕션 환경 설정](#프로덕션-환경-설정)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
G7은 **Laravel Reverb**를 사용하여 실시간 WebSocket 통신을 지원합니다.
|
||||
|
||||
**주요 사용 사례**:
|
||||
- 대시보드 실시간 통계 업데이트
|
||||
- 알림 실시간 전송
|
||||
- 채팅 기능
|
||||
- 실시간 협업 기능
|
||||
|
||||
**아키텍처**:
|
||||
```
|
||||
클라이언트 (Echo/Pusher-js)
|
||||
↕ WebSocket
|
||||
Laravel Reverb 서버
|
||||
↕
|
||||
Laravel 백엔드 (broadcast() 호출)
|
||||
↕
|
||||
Queue Worker (이벤트 처리)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Laravel Reverb 설정
|
||||
|
||||
### 환경변수 (.env)
|
||||
|
||||
```env
|
||||
# Broadcasting 드라이버
|
||||
BROADCAST_CONNECTION=reverb
|
||||
|
||||
# Reverb 서버 설정
|
||||
REVERB_APP_ID=467955
|
||||
REVERB_APP_KEY=zm0vobuy4zpqorc3ro9r
|
||||
REVERB_APP_SECRET=your-secret-key
|
||||
REVERB_HOST=localhost
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=https
|
||||
|
||||
# 개발 환경에서 자체 서명 인증서 사용 시
|
||||
REVERB_VERIFY_SSL=false
|
||||
|
||||
# 클라이언트용 설정 (Vite)
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
```
|
||||
|
||||
### config/broadcasting.php
|
||||
|
||||
```php
|
||||
'reverb' => [
|
||||
'driver' => 'reverb',
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// 개발 환경에서 자체 서명 인증서 사용 시 SSL 검증 비활성화
|
||||
'verify' => env('REVERB_VERIFY_SSL', true),
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
> **주의**: 프로덕션 환경에서는 `REVERB_VERIFY_SSL=true`로 설정해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 브로드캐스트 이벤트 생성
|
||||
|
||||
### 기본 구조
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Events\Dashboard;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 대시보드 업데이트 브로드캐스트 이벤트
|
||||
*/
|
||||
class DashboardUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 이벤트 인스턴스를 생성합니다.
|
||||
*
|
||||
* @param string $type 업데이트 타입 ('stats', 'resources', 'activities')
|
||||
* @param array $data 업데이트된 데이터
|
||||
*/
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public array $data
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 브로드캐스트할 채널을 반환합니다.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('admin.dashboard')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 브로드캐스트 이벤트명을 반환합니다.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return "dashboard.{$this->type}.updated";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 채널 타입
|
||||
|
||||
| 타입 | 클래스 | 용도 | 인증 필요 |
|
||||
|------|--------|------|----------|
|
||||
| Public | `Channel` | 모든 사용자 접근 가능 | ❌ |
|
||||
| Private | `PrivateChannel` | 인증된 사용자만 접근 | ✅ |
|
||||
| Presence | `PresenceChannel` | 인증 + 접속자 목록 공유 | ✅ |
|
||||
|
||||
### 이벤트 발생
|
||||
|
||||
```php
|
||||
// 방법 1: broadcast() 헬퍼 (권장)
|
||||
broadcast(new DashboardUpdated('stats', $statsData));
|
||||
|
||||
// 방법 2: event() 헬퍼
|
||||
event(new DashboardUpdated('resources', $resourceData));
|
||||
|
||||
// 방법 3: 즉시 전송 (큐 미사용)
|
||||
broadcast(new DashboardUpdated('alerts', $alertData))->toOthers();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 채널 인증
|
||||
|
||||
### routes/channels.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
// 사용자별 Private 채널
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
||||
return (int) $user->id === (int) $id;
|
||||
});
|
||||
|
||||
// 관리자 대시보드 채널 - 권한 체크
|
||||
Broadcast::channel('admin.dashboard', function ($user) {
|
||||
return $user->hasPermission('core.dashboard.read');
|
||||
});
|
||||
|
||||
// 특정 리소스 채널
|
||||
Broadcast::channel('orders.{orderId}', function ($user, $orderId) {
|
||||
return $user->hasPermission('ecommerce.orders.read');
|
||||
});
|
||||
```
|
||||
|
||||
### 인증 콜백 규칙
|
||||
|
||||
```php
|
||||
// ✅ DO: boolean 반환 (Private 채널)
|
||||
Broadcast::channel('admin.dashboard', function ($user) {
|
||||
return $user->hasPermission('core.dashboard.read');
|
||||
});
|
||||
|
||||
// ✅ DO: 배열 반환 (Presence 채널 - 사용자 정보 공유)
|
||||
Broadcast::channel('chat.{roomId}', function ($user, $roomId) {
|
||||
if ($user->canJoinRoom($roomId)) {
|
||||
return ['id' => $user->id, 'name' => $user->name];
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// ❌ DON'T: 예외 던지기
|
||||
Broadcast::channel('admin.dashboard', function ($user) {
|
||||
throw new \Exception('Unauthorized'); // 금지
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 인증 엔드포인트
|
||||
|
||||
G7은 SPA 환경에서 **Sanctum 토큰 기반 인증**을 사용합니다. 기본 `/broadcasting/auth` 엔드포인트는 세션 기반이므로, API용 별도 엔드포인트를 사용합니다.
|
||||
|
||||
### routes/api.php
|
||||
|
||||
```php
|
||||
// 브로드캐스팅 인증 (Sanctum 토큰 사용)
|
||||
Route::middleware(['auth:sanctum'])->post('broadcasting/auth', function (\Illuminate\Http\Request $request) {
|
||||
return \Illuminate\Support\Facades\Broadcast::auth($request);
|
||||
})->name('api.broadcasting.auth');
|
||||
```
|
||||
|
||||
### 클라이언트 설정 (WebSocketManager)
|
||||
|
||||
```typescript
|
||||
const pusherOptions = {
|
||||
// ... 기타 옵션
|
||||
authEndpoint: '/api/broadcasting/auth',
|
||||
auth: {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 훅을 통한 이벤트 발생
|
||||
|
||||
Service 계층에서 데이터 변경 시 훅 리스너를 통해 브로드캐스트 이벤트를 발생시킵니다.
|
||||
|
||||
### config/hooks.php
|
||||
|
||||
```php
|
||||
return [
|
||||
'listeners' => [
|
||||
// 대시보드 통계 업데이트 시 브로드캐스트
|
||||
'core.dashboard.stats_updated' => [
|
||||
\App\Listeners\BroadcastDashboardStats::class,
|
||||
],
|
||||
|
||||
// 사용자 생성 시 대시보드 통계 업데이트
|
||||
'core.user.after_create' => [
|
||||
\App\Listeners\UpdateDashboardStatsOnUserChange::class,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 리스너 구현
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Services\DashboardService;
|
||||
|
||||
class BroadcastDashboardStats
|
||||
{
|
||||
public function __construct(
|
||||
private DashboardService $dashboardService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 대시보드 통계 브로드캐스트
|
||||
*
|
||||
* @param array $data 훅 데이터
|
||||
* @return void
|
||||
*/
|
||||
public function handle(array $data): void
|
||||
{
|
||||
$stats = $this->dashboardService->getStats();
|
||||
broadcast(new DashboardUpdated('stats', $stats));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 스케줄러를 통한 주기적 브로드캐스트
|
||||
|
||||
시스템 리소스 등 주기적으로 업데이트가 필요한 데이터는 스케줄러를 사용합니다.
|
||||
|
||||
### Artisan 커맨드
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Services\DashboardService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BroadcastDashboardResources extends Command
|
||||
{
|
||||
protected $signature = 'dashboard:broadcast-resources';
|
||||
protected $description = '시스템 리소스 정보를 WebSocket으로 브로드캐스트합니다.';
|
||||
|
||||
public function handle(DashboardService $dashboardService): int
|
||||
{
|
||||
$resources = $dashboardService->getSystemResources();
|
||||
broadcast(new DashboardUpdated('resources', $resources));
|
||||
|
||||
$this->info('시스템 리소스 정보가 브로드캐스트되었습니다.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### routes/console.php (스케줄러 등록)
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
// 30초마다 시스템 리소스 브로드캐스트
|
||||
Schedule::command('dashboard:broadcast-resources')->everyThirtySeconds();
|
||||
```
|
||||
|
||||
### 실행 방법
|
||||
|
||||
```bash
|
||||
# 스케줄러 실행 (개발 환경)
|
||||
php artisan schedule:work
|
||||
|
||||
# 큐 워커 실행 (브로드캐스트 이벤트 처리)
|
||||
php artisan queue:work
|
||||
|
||||
# Reverb 서버 실행
|
||||
php artisan reverb:start --debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
### 필요한 프로세스
|
||||
|
||||
개발 환경에서 WebSocket을 테스트하려면 다음 프로세스가 모두 실행 중이어야 합니다:
|
||||
|
||||
```bash
|
||||
# 터미널 1: Laravel 개발 서버
|
||||
php artisan serve
|
||||
|
||||
# 터미널 2: Reverb WebSocket 서버
|
||||
php artisan reverb:start --debug
|
||||
|
||||
# 터미널 3: 큐 워커 (브로드캐스트 이벤트 처리)
|
||||
php artisan queue:work
|
||||
|
||||
# 터미널 4: 스케줄러 (주기적 브로드캐스트 사용 시)
|
||||
php artisan schedule:work
|
||||
```
|
||||
|
||||
### SSL 인증서 문제 해결
|
||||
|
||||
개발 환경에서 자체 서명 인증서 사용 시 다음 설정이 필요합니다:
|
||||
|
||||
```env
|
||||
# .env
|
||||
REVERB_VERIFY_SSL=false
|
||||
```
|
||||
|
||||
이 설정은 Laravel이 Reverb 서버로 이벤트를 전송할 때 SSL 인증서 검증을 비활성화합니다.
|
||||
|
||||
---
|
||||
|
||||
## 프로덕션 환경 설정
|
||||
|
||||
### 권장 설정
|
||||
|
||||
```env
|
||||
# .env (프로덕션)
|
||||
REVERB_VERIFY_SSL=true
|
||||
REVERB_SCHEME=https
|
||||
REVERB_PORT=443
|
||||
```
|
||||
|
||||
### Supervisor 설정 예시
|
||||
|
||||
```ini
|
||||
[program:reverb]
|
||||
command=php /var/www/g7/artisan reverb:start
|
||||
directory=/var/www/g7
|
||||
user=www-data
|
||||
autostart=true
|
||||
autorestart=true
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/reverb.log
|
||||
|
||||
[program:queue-worker]
|
||||
command=php /var/www/g7/artisan queue:work --sleep=3 --tries=3
|
||||
directory=/var/www/g7
|
||||
user=www-data
|
||||
autostart=true
|
||||
autorestart=true
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/queue-worker.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디버깅
|
||||
|
||||
### 이벤트 전송 확인
|
||||
|
||||
```bash
|
||||
# 수동으로 이벤트 발생
|
||||
php artisan dashboard:broadcast-resources
|
||||
|
||||
# 큐 작업 확인
|
||||
php artisan queue:work --once
|
||||
```
|
||||
|
||||
### 로그 확인
|
||||
|
||||
```bash
|
||||
# Laravel 로그
|
||||
tail -f storage/logs/laravel.log
|
||||
|
||||
# Reverb 서버 로그 (--debug 옵션 사용 시)
|
||||
php artisan reverb:start --debug
|
||||
```
|
||||
|
||||
### 클라이언트 디버깅
|
||||
|
||||
브라우저 개발자 도구에서:
|
||||
1. **Network** 탭 → **WS** 필터 → WebSocket 연결 확인
|
||||
2. **Messages** 탭에서 송수신 메시지 확인
|
||||
3. **Console** 탭에서 `[WebSocketManager]` 로그 확인
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [Service-Repository 패턴](service-repository.md) - 훅 실행 위치
|
||||
- [인증](authentication.md) - Sanctum 토큰 인증
|
||||
- [프론트엔드 WebSocket](../frontend/data-sources-advanced.md) - 클라이언트 WebSocket 구독
|
||||
@@ -0,0 +1,319 @@
|
||||
# 컨트롤러 계층 구조
|
||||
|
||||
> **관련 문서**: [index.md](index.md) | [service-repository.md](service-repository.md) | [validation.md](validation.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. AdminBaseController / AuthBaseController / PublicBaseController 중 상속
|
||||
2. Service 주입 필수 (Repository 직접 주입 금지)
|
||||
3. FormRequest로 검증 (컨트롤러에 검증 로직 금지)
|
||||
4. ResponseHelper 메서드 사용 (success, error, successWithResource)
|
||||
5. 네이밍: [Entity][Action]Controller (예: UserListController)
|
||||
6. array_filter 시 null만 제거 (빈 문자열은 사용자 의도로 보존)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [Base 컨트롤러 계층](#base-컨트롤러-계층)
|
||||
2. [각 컨트롤러 역할](#각-컨트롤러-역할)
|
||||
3. [컨트롤러 네이밍 규칙](#컨트롤러-네이밍-규칙)
|
||||
4. [응답 메서드 사용](#응답-메서드-사용)
|
||||
5. [Controller 규칙](#controller-규칙)
|
||||
6. [패턴 예시](#패턴-예시)
|
||||
7. [데이터 필터링 패턴 (array_filter)](#데이터-필터링-패턴-array_filter)
|
||||
8. [체크리스트](#체크리스트)
|
||||
|
||||
---
|
||||
|
||||
## Base 컨트롤러 계층
|
||||
|
||||
G7은 역할 기반의 컨트롤러 계층 구조를 사용합니다.
|
||||
|
||||
```text
|
||||
BaseApiController (최상위)
|
||||
├── AdminBaseController (관리자 전용)
|
||||
├── AuthBaseController (인증된 사용자)
|
||||
└── PublicBaseController (공개 API)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 각 컨트롤러 역할
|
||||
|
||||
### BaseApiController
|
||||
|
||||
**경로**: `app/Http/Controllers/Api/Base/BaseApiController.php`
|
||||
|
||||
- 모든 API 컨트롤러의 최상위 부모 클래스
|
||||
- 공통 응답 메서드 제공: `success()`, `error()`, `successWithResource()`, `notFound()`, `unauthorized()`, `forbidden()`, `validationError()`
|
||||
- 인증 사용자 조회: `getCurrentUser()`
|
||||
|
||||
### AdminBaseController
|
||||
|
||||
**경로**: `app/Http/Controllers/Api/Base/AdminBaseController.php`
|
||||
|
||||
- BaseApiController 상속
|
||||
- 관리자 전용 기능
|
||||
- 미들웨어: `['auth:sanctum', 'admin']`
|
||||
- 관리자 활동 로깅: `logAdminActivity()`
|
||||
|
||||
### AuthBaseController
|
||||
|
||||
**경로**: `app/Http/Controllers/Api/Base/AuthBaseController.php`
|
||||
|
||||
- BaseApiController 상속
|
||||
- 인증된 사용자 전용 기능
|
||||
- 미들웨어: `['auth:sanctum']`
|
||||
- 리소스 소유권 확인: `isOwner()`, `canAccessResource()`, `checkOwnership()`
|
||||
- 사용자 활동 로깅: `logUserActivity()`
|
||||
|
||||
### PublicBaseController
|
||||
|
||||
**경로**: `app/Http/Controllers/Api/Base/PublicBaseController.php`
|
||||
|
||||
- BaseApiController 상속
|
||||
- 인증 불필요한 공개 API
|
||||
- 미들웨어: `['throttle:60,1']` (속도 제한)
|
||||
- 캐싱: `cached()`
|
||||
- API 사용량 추적: `logApiUsage()`, `getClientInfo()`
|
||||
|
||||
---
|
||||
|
||||
## 컨트롤러 네이밍 규칙
|
||||
|
||||
### 코어
|
||||
|
||||
| 역할 | 경로 |
|
||||
|------|------|
|
||||
| 관리자 | `app/Http/Controllers/Api/Admin/[기능명]Controller.php` |
|
||||
| 인증 사용자 | `app/Http/Controllers/Api/Auth/[기능명]Controller.php` |
|
||||
| 공개 API | `app/Http/Controllers/Api/Public/[기능명]Controller.php` |
|
||||
|
||||
### 모듈
|
||||
|
||||
| 역할 | 경로 |
|
||||
|------|------|
|
||||
| 관리자 | `modules/[vendor-module]/src/Controllers/Api/Admin/[기능명]Controller.php` |
|
||||
| 인증 사용자 | `modules/[vendor-module]/src/Controllers/Api/Auth/[기능명]Controller.php` |
|
||||
| 공개 API | `modules/[vendor-module]/src/Controllers/Api/[기능명]Controller.php` |
|
||||
|
||||
---
|
||||
|
||||
## 응답 메서드 사용
|
||||
|
||||
모든 컨트롤러는 Base 클래스의 공통 메서드를 사용합니다:
|
||||
|
||||
```php
|
||||
// ✅ DO: 공통 메서드 사용
|
||||
return $this->success('messages.created', $data, 201);
|
||||
return $this->error('messages.failed', 400);
|
||||
return $this->successWithResource('messages.success', new UserResource($user));
|
||||
return $this->notFound('messages.not_found');
|
||||
return $this->unauthorized('messages.unauthorized');
|
||||
return $this->forbidden('messages.forbidden');
|
||||
return $this->validationError($errors, 'messages.validation_failed');
|
||||
|
||||
// ❌ DON'T: 역할별 래퍼 메서드 사용 (Deprecated)
|
||||
// adminSuccess(), userSuccess(), publicSuccess() 등은 사용하지 않음
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controller 규칙
|
||||
|
||||
### 원칙
|
||||
|
||||
- Service 클래스를 생성자 주입으로 받음
|
||||
- 비즈니스 로직은 Service에 위임
|
||||
- ResponseHelper 사용
|
||||
- 인라인 검증 금지 (FormRequest 사용)
|
||||
|
||||
> **참고**: FormRequest 상세 규칙은 [validation.md](validation.md) 참조
|
||||
|
||||
---
|
||||
|
||||
## 패턴 예시
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Helpers\ResponseHelper;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Modules\Sirsoft\Ecommerce\Requests\StoreProductRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Requests\UpdateProductRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Resources\ProductResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProductController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private ProductService $productService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 상품 목록 조회
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$products = $this->productService->getAllProducts();
|
||||
|
||||
return ResponseHelper::success(
|
||||
ProductResource::collection($products),
|
||||
'상품 목록을 조회했습니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 생성
|
||||
*/
|
||||
public function store(StoreProductRequest $request): JsonResponse
|
||||
{
|
||||
$product = $this->productService->createProduct($request->validated());
|
||||
|
||||
return ResponseHelper::success(
|
||||
new ProductResource($product),
|
||||
'상품이 생성되었습니다.',
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정
|
||||
*/
|
||||
public function update(UpdateProductRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$product = $this->productService->updateProduct($id, $request->validated());
|
||||
|
||||
return ResponseHelper::success(
|
||||
new ProductResource($product),
|
||||
'상품이 수정되었습니다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 필터링 패턴 (array_filter)
|
||||
|
||||
컨트롤러에서 요청 데이터를 Service에 전달하기 전에 `array_filter`로 불필요한 값을 제거하는 경우, **빈 문자열(`''`)과 `null`의 의미 차이**에 주의해야 합니다.
|
||||
|
||||
### null vs 빈 문자열의 의미
|
||||
|
||||
| 값 | 의미 | 예시 |
|
||||
|------|------|------|
|
||||
| `null` | 필드가 전송되지 않음 (미포함) | JSON body에 키 자체가 없음 |
|
||||
| `''` (빈 문자열) | 사용자가 의도적으로 값을 비움 | 기존 값을 지우려는 의도 |
|
||||
|
||||
### 잘못된 예시 (빈 문자열 제거)
|
||||
|
||||
```php
|
||||
// ❌ DON'T: 빈 문자열도 제거 → 사용자의 "값 비우기" 의도 무시
|
||||
$data = collect($validated)
|
||||
->filter(fn ($v) => $v !== null && $v !== '')
|
||||
->toArray();
|
||||
```
|
||||
|
||||
### 올바른 예시 (null만 제거)
|
||||
|
||||
```php
|
||||
// ✅ DO: null만 제거 → 빈 문자열은 사용자 의도로 보존
|
||||
$data = collect($validated)
|
||||
->filter(fn ($v) => $v !== null)
|
||||
->toArray();
|
||||
```
|
||||
|
||||
### 적용 시점
|
||||
|
||||
| 상황 | 권장 패턴 |
|
||||
|------|----------|
|
||||
| 설정 Override (기본값 위에 덮어쓰기) | `null`만 제거 (빈 문자열 = 기본값으로 초기화) |
|
||||
| 일반 CRUD 저장 | `validated()` 그대로 사용 (필터링 불필요) |
|
||||
| 선택적 필드 업데이트 (PATCH) | `null`만 제거 또는 `filled()` 활용 |
|
||||
|
||||
---
|
||||
|
||||
## 표준 예외 처리 패턴
|
||||
|
||||
컨트롤러에서 Service 호출 시 다음 표준 예외 처리 패턴을 사용합니다:
|
||||
|
||||
```php
|
||||
public function store(StoreProductRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$product = $this->productService->createProduct($request->validated());
|
||||
|
||||
return $this->success('product.create_success', new ProductResource($product), 201);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->validationError($e->errors(), 'product.create_failed');
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('product.create_failed', 500);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 예외 유형별 처리
|
||||
|
||||
| 예외 유형 | 응답 메서드 | 상태 코드 | 용도 |
|
||||
| ---------- | ----------- | ---------- | ------ |
|
||||
| `ValidationException` | `validationError()` | 422 | Service에서 발생한 검증 예외 |
|
||||
| `AccessDeniedHttpException` | `error()` | 403 | 스코프 접근 거부 |
|
||||
| 커스텀 비즈니스 예외 | `error()` | 400~409 | 비즈니스 규칙 위반 |
|
||||
| `\Exception` (범용) | `error()` | 500 | 예상치 못한 오류 |
|
||||
|
||||
### 스코프 접근 거부 처리 패턴
|
||||
|
||||
```php
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$item = $this->service->getItem($id);
|
||||
return $this->success('item.fetch_success', new ItemResource($item));
|
||||
} catch (AccessDeniedHttpException $e) {
|
||||
return $this->error('auth.scope_denied', 403);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('item.fetch_failed', 500);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- `ValidationException`은 `\Exception`보다 **먼저** catch해야 함 (하위 클래스 우선)
|
||||
- 에러 메시지는 반드시 `__()` 다국어 키 사용 (하드코딩 금지)
|
||||
- `$e->getMessage()`는 **로깅 목적**으로만 사용 (사용자에게 직접 노출 금지)
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
### 컨트롤러 개발 체크리스트
|
||||
|
||||
- [ ] 적절한 Base 컨트롤러를 상속했는가?
|
||||
- 관리자 기능 → `AdminBaseController`
|
||||
- 인증 사용자 기능 → `AuthBaseController`
|
||||
- 공개 API → `PublicBaseController`
|
||||
- [ ] Service 클래스를 생성자 주입으로 받았는가?
|
||||
- [ ] 비즈니스 로직을 Service에 위임했는가?
|
||||
- [ ] ResponseHelper를 사용해 응답했는가?
|
||||
- [ ] FormRequest를 사용해 검증했는가? (인라인 검증 금지)
|
||||
- [ ] 네이밍 규칙을 준수했는가?
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](index.md) - 백엔드 가이드 인덱스
|
||||
- [service-repository.md](service-repository.md) - Service-Repository 패턴
|
||||
- [validation.md](validation.md) - 검증 로직 구현 원칙
|
||||
- [response-helper.md](response-helper.md) - API 응답 규칙
|
||||
@@ -0,0 +1,201 @@
|
||||
# 코어 설정 (config/core.php)
|
||||
|
||||
> **목적**: 코어 시스템의 권한, 역할, 메뉴, 메일 템플릿 정의 파일 구조 설명
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. config/core.php = 코어 권한/역할/메뉴/메일템플릿의 SSoT (Single Source of Truth)
|
||||
2. 구조: module(1레벨) → categories(2레벨) → permissions(3레벨) 3단계
|
||||
3. 설치(Seeder) + 업데이트(CoreUpdateService) 모두 이 파일에서 읽음
|
||||
4. 모듈/플러그인은 config/core.php 대신 자체 config.php에 동일 구조로 정의
|
||||
5. 수정 시 Seeder/Sync 로직이 자동 반영 (수동 마이그레이션 불필요)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [파일 구조 개요](#파일-구조-개요)
|
||||
- [permissions — 권한 정의](#permissions--권한-정의)
|
||||
- [roles — 역할 정의](#roles--역할-정의)
|
||||
- [menus — 메뉴 정의](#menus--메뉴-정의)
|
||||
- [mail_templates — 메일 템플릿 정의](#mail_templates--메일-템플릿-정의)
|
||||
- [사용처](#사용처)
|
||||
- [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조 개요
|
||||
|
||||
`config/core.php`는 4개 최상위 키로 구성됩니다:
|
||||
|
||||
```php
|
||||
return [
|
||||
'permissions' => [...], // 코어 권한 정의
|
||||
'roles' => [...], // 코어 역할 정의
|
||||
'menus' => [...], // 코어 메뉴 정의
|
||||
'mail_templates' => [...], // 코어 메일 템플릿 정의
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## permissions — 권한 정의
|
||||
|
||||
3레벨 계층 구조:
|
||||
|
||||
```text
|
||||
permissions
|
||||
├── module (1레벨) → identifier, name, description, order
|
||||
└── categories (2레벨 배열)
|
||||
├── category 항목 → identifier, name, description, category, order
|
||||
└── permissions (3레벨) → identifier, name, description, order, resource_route_key?, owner_key?
|
||||
```
|
||||
|
||||
### module (1레벨)
|
||||
|
||||
```php
|
||||
'module' => [
|
||||
'identifier' => 'core',
|
||||
'name' => ['ko' => '코어', 'en' => 'Core'],
|
||||
'description' => ['ko' => '코어 시스템 권한', 'en' => 'Core system permissions'],
|
||||
'order' => 1,
|
||||
],
|
||||
```
|
||||
|
||||
### categories (2레벨) + permissions (3레벨)
|
||||
|
||||
```php
|
||||
'categories' => [
|
||||
[
|
||||
'identifier' => 'core.users',
|
||||
'name' => ['ko' => '사용자 관리', 'en' => 'User Management'],
|
||||
'description' => [...],
|
||||
'category' => 'users', // 카테고리 슬러그
|
||||
'order' => 1,
|
||||
'permissions' => [
|
||||
[
|
||||
'identifier' => 'core.users.read',
|
||||
'name' => ['ko' => '사용자 조회', 'en' => 'View Users'],
|
||||
'description' => [...],
|
||||
'order' => 1,
|
||||
'resource_route_key' => 'user', // scope 체크 시 라우트 모델 키 (선택)
|
||||
'owner_key' => 'id', // scope 체크 시 소유자 필드 (선택)
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
### 권한 필드 설명
|
||||
|
||||
| 필드 | 필수 | 설명 |
|
||||
| ---------- | ---------- | ---------- |
|
||||
| `identifier` | ✅ | 권한 식별자 (예: `core.users.read`) |
|
||||
| `name` | ✅ | 다국어 이름 배열 `['ko' => ..., 'en' => ...]` |
|
||||
| `description` | ✅ | 다국어 설명 배열 |
|
||||
| `order` | ✅ | 정렬 순서 |
|
||||
| `resource_route_key` | ❌ | scope 기반 접근 체크 시 라우트 모델 바인딩 키 |
|
||||
| `owner_key` | ❌ | scope 기반 접근 체크 시 소유자 판단 필드 |
|
||||
|
||||
---
|
||||
|
||||
## roles — 역할 정의
|
||||
|
||||
```php
|
||||
'roles' => [
|
||||
[
|
||||
'identifier' => 'admin',
|
||||
'name' => ['ko' => '관리자', 'en' => 'Administrator'],
|
||||
'description' => [...],
|
||||
'attributes' => ['is_active' => true],
|
||||
'permissions' => 'all_leaf', // 특수 값: 모든 리프 권한 할당
|
||||
],
|
||||
[
|
||||
'identifier' => 'manager',
|
||||
'name' => ['ko' => '매니저', 'en' => 'Manager'],
|
||||
'permissions' => ['core.users.read', 'core.users.create', ...],
|
||||
'permission_scopes' => [
|
||||
'core.users.read' => 'self', // 본인 리소스만
|
||||
'core.users.update' => 'self',
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
### 역할 필드 설명
|
||||
|
||||
| 필드 | 필수 | 설명 |
|
||||
| ---------- | ---------- | ---------- |
|
||||
| `identifier` | ✅ | 역할 식별자 (예: `admin`, `manager`) |
|
||||
| `name` | ✅ | 다국어 이름 배열 |
|
||||
| `description` | ✅ | 다국어 설명 배열 |
|
||||
| `attributes` | ❌ | 추가 속성 (`is_active` 등) |
|
||||
| `permissions` | ✅ | 권한 배열 또는 `'all_leaf'` (모든 리프 권한) |
|
||||
| `permission_scopes` | ❌ | 권한별 scope_type 매핑 (`'self'` / `'role'` / 미지정=전체) |
|
||||
|
||||
---
|
||||
|
||||
## menus — 메뉴 정의
|
||||
|
||||
```php
|
||||
'menus' => [
|
||||
[
|
||||
'slug' => 'admin-dashboard',
|
||||
'name' => ['ko' => '대시보드', 'en' => 'Dashboard'],
|
||||
'url' => '/admin/dashboard',
|
||||
'icon' => 'fas fa-tachometer-alt',
|
||||
'parent_id' => null,
|
||||
'order' => 1,
|
||||
'is_active' => true,
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## mail_templates — 메일 템플릿 정의
|
||||
|
||||
```php
|
||||
'mail_templates' => [
|
||||
[
|
||||
'type' => 'welcome',
|
||||
'subject' => ['ko' => '[{app_name}] 환영합니다', 'en' => '...'],
|
||||
'body' => ['ko' => '<h1>{name}님...</h1>', 'en' => '...'],
|
||||
'variables' => [
|
||||
['key' => 'name', 'description' => '수신자 이름'],
|
||||
['key' => 'app_name', 'description' => '사이트명'],
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
### variables 배열
|
||||
|
||||
메일 템플릿에서 사용 가능한 치환 변수를 정의합니다. `{key}` 형태로 subject/body에서 사용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 사용처
|
||||
|
||||
| 사용처 | 시점 | 설명 |
|
||||
| ---------- | ---------- | ---------- |
|
||||
| `RolePermissionSeeder` | 초기 설치 | permissions, roles 데이터 시딩 |
|
||||
| `CoreAdminMenuSeeder` | 초기 설치 | menus 데이터 시딩 |
|
||||
| `MailTemplateSeeder` | 초기 설치 | mail_templates 데이터 시딩 |
|
||||
| `CoreUpdateService::syncCoreRolesAndPermissions()` | 코어 업데이트 | 권한/역할 동기화 |
|
||||
| `CoreUpdateService::syncCoreMenus()` | 코어 업데이트 | 메뉴 동기화 |
|
||||
| `CoreUpdateService::syncCoreMailTemplates()` | 코어 업데이트 | 메일 템플릿 동기화 |
|
||||
|
||||
**핵심 원칙**: 새 권한/역할/메뉴/메일템플릿 추가 시 이 파일에 추가하면 설치/업데이트 모두 자동 반영됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [permissions.md](../extension/permissions.md) - 권한 시스템 상세
|
||||
- [module-basics.md](../extension/module-basics.md) - 모듈별 config.php 구조
|
||||
- [service-provider.md](service-provider.md) - 서비스 프로바이더에서 config 로드
|
||||
@@ -0,0 +1,740 @@
|
||||
# 코어 업데이트 시스템 (Core Update System)
|
||||
|
||||
> 코어 버전 업데이트의 감지, 다운로드, 적용, 롤백, 업그레이드 스텝 실행을 관리하는 시스템
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 App\Upgrades
|
||||
2. 마이그레이션 = 스키마 변경만, 데이터 백필/변환 = 업그레이드 스텝 (역할 분리 필수)
|
||||
3. 업데이트 실행 흐름: 11단계 (감지 → 다운로드 → 백업 → 적용 → 마이그레이션 → 동기화 → 업그레이드 → 마무리)
|
||||
4. 롤백: CoreBackupHelper로 백업 생성, 실패 시 자동 복원
|
||||
5. 부트스트랩 호환성 검증: .env의 APP_VERSION < 확장 g7_version 시 자동 비활성화 (1시간 캐시)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [업데이트 감지](#1-업데이트-감지)
|
||||
2. [업데이트 실행 흐름 (11단계)](#2-업데이트-실행-흐름-11단계)
|
||||
3. [백업 및 롤백](#3-백업-및-롤백)
|
||||
4. [마이그레이션 vs 업그레이드 스텝](#4-마이그레이션-vs-업그레이드-스텝)
|
||||
5. [업그레이드 스텝 작성](#5-업그레이드-스텝-작성)
|
||||
6. [자동 발견 및 실행 규칙](#6-자동-발견-및-실행-규칙)
|
||||
7. [버전 관리](#7-버전-관리)
|
||||
8. [CoreUpdateService 주요 메서드](#8-coreupdateservice-주요-메서드)
|
||||
9. [Artisan 커맨드](#9-artisan-커맨드)
|
||||
10. [API 엔드포인트](#10-api-엔드포인트)
|
||||
11. [설정 (config/app.php)](#11-설정-configappphp)
|
||||
12. [에러 처리 및 로깅](#12-에러-처리-및-로깅)
|
||||
13. [유지보수 모드](#13-유지보수-모드)
|
||||
14. [코어 vs 확장 업데이트 비교](#14-코어-vs-확장-업데이트-비교)
|
||||
|
||||
---
|
||||
|
||||
## 1. 업데이트 감지
|
||||
|
||||
> 파일: `app/Extension/CoreVersionChecker.php`, `app/Services/CoreUpdateService.php`
|
||||
|
||||
### 감지 흐름
|
||||
|
||||
```text
|
||||
1. CoreVersionChecker::getCoreVersion() → config('app.version') 읽기
|
||||
2. CoreUpdateService::checkForUpdates() → GitHub API로 최신 릴리스 조회
|
||||
3. version_compare(current, latest) → 업데이트 가용 여부 판단
|
||||
4. 원격 CHANGELOG 캐시 → storage/app/temp/core_remote_changelog.md
|
||||
```
|
||||
|
||||
### GitHub API 통합
|
||||
|
||||
```text
|
||||
- config('app.update.github_url') 에서 리포지토리 URL 읽기
|
||||
- config('app.update.github_token') 으로 인증 (선택)
|
||||
- GitHub Releases API 호출 → 최신 릴리스 태그에서 버전 추출
|
||||
```
|
||||
|
||||
### 감지 결과
|
||||
|
||||
```php
|
||||
[
|
||||
'update_available' => true,
|
||||
'current_version' => '7.0.0-alpha.14',
|
||||
'latest_version' => '7.0.0-alpha.15',
|
||||
'check_failed' => false, // GitHub API 실패 시 true
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 업데이트 실행 흐름 (11단계)
|
||||
|
||||
> 파일: `app/Console/Commands/Core/CoreUpdateCommand.php`
|
||||
|
||||
```text
|
||||
┌─ Step 1: 업데이트 확인 (GitHub API 또는 --source/--local 모드)
|
||||
├─ Step 2: _pending 경로 검증 (디렉토리 존재/쓰기 권한)
|
||||
├─ Step 3: 유지보수 모드 활성화 (--no-maintenance 시 스킵)
|
||||
├─ Step 4: 다운로드 (GitHub zipball 또는 --source/--local에서 복사)
|
||||
├─ Step 5: 백업 생성 (--no-backup 시 스킵)
|
||||
├─ Step 6: Composer install (_pending에서 실행, 변경 없으면 스킵)
|
||||
├─ Step 7: 파일 적용 (_pending → base_path 선택적 덮어쓰기)
|
||||
├─ Step 8: vendor 복사 (_pending/vendor → base_path/vendor, Step 6 스킵 시 함께 스킵)
|
||||
├─ Step 9: 마이그레이션 + 동기화 (migrate, roles, permissions, menus, mail templates)
|
||||
├─ Step 10: 업그레이드 스텝 실행 (upgrades/Upgrade_X_Y_Z.php)
|
||||
└─ Step 11: 마무리 (.env 버전 갱신, 캐시 클리어, _pending 삭제, 유지보수 해제)
|
||||
```
|
||||
|
||||
### Step 1: 업데이트 확인
|
||||
|
||||
| 모드 | 동작 |
|
||||
|------|------|
|
||||
| 일반 | `checkForUpdates()` → GitHub API 호출 → 사용자 확인 프롬프트 |
|
||||
| `--source={path}` | 지정된 디렉토리를 소스로 사용 (GitHub 스킵) |
|
||||
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
|
||||
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 실행 |
|
||||
|
||||
### Step 4: 다운로드 및 추출
|
||||
|
||||
```text
|
||||
추출 전략 (폴백 체인):
|
||||
1. ZipArchive (PHP zip 확장) → class_exists(ZipArchive::class)
|
||||
2. unzip CLI 명령어 → ZipArchive 미사용 시 폴백
|
||||
|
||||
v접두사 자동 감지 (resolveGithubArchiveUrl):
|
||||
1. "v{version}" 태그로 HEAD 요청 시도 (예: v7.0.0-alpha.15)
|
||||
2. 실패 시 "{version}" 태그로 HEAD 요청 시도 (예: 7.0.0-alpha.15)
|
||||
3. HTTP 200/302 → 유효한 URL 반환
|
||||
4. 모두 실패 → null (업데이트 불가)
|
||||
```
|
||||
|
||||
### Step 5: 백업
|
||||
|
||||
```text
|
||||
- CoreBackupHelper::createBackup() 사용
|
||||
- 백업 대상: config('app.update.targets') + config('app.update.backup_only') + config('app.update.backup_extra')
|
||||
- 제외 패턴: config('app.update.excludes')
|
||||
- 백업 위치: storage 경로에 타임스탬프 디렉토리
|
||||
```
|
||||
|
||||
### Step 6: Composer 최적화
|
||||
|
||||
```text
|
||||
1. composer.json + composer.lock의 MD5 비교 (_pending vs base_path)
|
||||
2. 동일 → "composer 의존성 변경 없음 — 스킵" (Step 6 + Step 8 모두 스킵)
|
||||
3. 변경됨 → composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
4. --no-scripts: post-autoload-dump 방지 (Step 11에서 package:discover로 처리)
|
||||
```
|
||||
|
||||
### Step 7: 파일 적용
|
||||
|
||||
```text
|
||||
- _pending 소스에서 config('app.update.targets')에 해당하는 파일/디렉토리만 선택적 덮어쓰기
|
||||
- FilePermissionHelper::copyDirectory() 사용 → 원본 파일 권한 보존
|
||||
- ExtensionPendingHelper::copyToActive() 미사용 (권한 유실 방지)
|
||||
```
|
||||
|
||||
### Step 9: 마이그레이션 + 동기화
|
||||
|
||||
```text
|
||||
1. php artisan migrate --force
|
||||
2. syncCoreRolesAndPermissions() — config/core.php의 roles/permissions 동기화
|
||||
3. syncCoreMenus() — config/core.php의 menus 동기화
|
||||
4. syncCoreMailTemplates() — config/core.php의 mail_templates 동기화
|
||||
```
|
||||
|
||||
> 역할/권한/메뉴 동기화는 `user_overrides` 패턴을 사용하여 사용자 커스터마이징 보존.
|
||||
> 상세: [extension-update-system.md § 13](../extension/extension-update-system.md#13-역할권한메뉴-동기화)
|
||||
|
||||
### Step 10: 업그레이드 스텝
|
||||
|
||||
```text
|
||||
1. upgrades/ 디렉토리 스캔
|
||||
2. Upgrade_X_Y_Z.php 패턴 매칭 → 버전 변환
|
||||
3. fromVersion < stepVersion <= toVersion 범위 필터링
|
||||
4. version_compare 자연 정렬 (오름차순)
|
||||
5. 각 스텝 순차 실행 (UpgradeContext 전달)
|
||||
```
|
||||
|
||||
### Step 11: 마무리
|
||||
|
||||
```text
|
||||
1. .env의 APP_VERSION 갱신
|
||||
2. 캐시 클리어: config, cache, route, view
|
||||
3. bootstrap/cache 파일 삭제 (services.php, packages.php)
|
||||
4. php artisan package:discover 재실행
|
||||
5. php artisan extension:update-autoload (코어 업데이트로 _bundled 변경 가능)
|
||||
6. _pending 디렉토리 삭제
|
||||
7. 성공 시 백업 삭제
|
||||
8. 유지보수 모드 해제
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 백업 및 롤백
|
||||
|
||||
> 파일: `app/Extension/Helpers/CoreBackupHelper.php`
|
||||
|
||||
### 백업 생성
|
||||
|
||||
```text
|
||||
- CoreBackupHelper::createBackup() 호출
|
||||
- 대상: config('app.update.targets') + config('app.update.backup_only') + config('app.update.backup_extra')
|
||||
- 제외: config('app.update.excludes')
|
||||
- 저장: storage 경로에 타임스탬프 디렉토리 (Ymd_His)
|
||||
```
|
||||
|
||||
### 롤백 흐름 (실패 시)
|
||||
|
||||
```text
|
||||
1. Exception 발생
|
||||
2. 백업이 존재하면:
|
||||
├─ CoreUpdateService::restoreFromBackup($backupPath)
|
||||
├─ CoreBackupHelper::restoreFromBackup() → 파일 원복
|
||||
└─ _pending 디렉토리 삭제
|
||||
3. 실패 보고서 생성: storage/logs/core_update_failure_YYYYMMDD_HHMMSS.log
|
||||
4. 업데이트 로그 저장: storage/logs/core_update_failed_YYYYMMDD_HHMMSS.log
|
||||
5. 유지보수 모드 유지 (수동으로 php artisan up 필요)
|
||||
```
|
||||
|
||||
### 롤백 실패 시
|
||||
|
||||
```text
|
||||
- 복원 실패 자체는 예외를 전파하지 않음 (에러 로그만 기록)
|
||||
- 관리자에게 안내: "이전 버전으로 사이트를 운영하려면: php artisan up"
|
||||
- 유지보수 모드 bypass secret 출력
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 마이그레이션 vs 업그레이드 스텝
|
||||
|
||||
```text
|
||||
주의: 마이그레이션과 업그레이드 스텝의 역할을 혼동하지 않을 것
|
||||
```
|
||||
|
||||
| 구분 | 마이그레이션 (`database/migrations/`) | 업그레이드 스텝 (`upgrades/`) |
|
||||
|------|--------------------------------------|-------------------------------|
|
||||
| **역할** | DB 스키마 변경 (컬럼 추가/삭제/변경, 인덱스, 테이블) | 데이터 백필/변환, 설정 마이그레이션 |
|
||||
| **실행 시점** | `php artisan migrate` (설치/업데이트 모두) | `php artisan core:update` Step 10 (업데이트 시만) |
|
||||
| **신규 설치** | 실행됨 | 실행되지 않음 (fromVersion == toVersion) |
|
||||
| **버전 업그레이드** | 실행됨 | 실행됨 (범위 내 스텝만) |
|
||||
| **예시** | `$table->uuid('uuid')->nullable()->after('id')` | 기존 레코드 UUID 백필 + NOT NULL 변환 |
|
||||
|
||||
### 역할 분리 원칙
|
||||
|
||||
```text
|
||||
✅ 마이그레이션: 테이블/컬럼/인덱스 생성·변경·삭제 (스키마)
|
||||
✅ 업그레이드 스텝: 기존 데이터 변환, 백필, 설정 구조 변경 (데이터)
|
||||
|
||||
❌ 마이그레이션에 데이터 백필 로직 포함 금지
|
||||
❌ 업그레이드 스텝에 스키마 변경 포함 금지 (Schema::table 등)
|
||||
단, 백필 후 NOT NULL 제약 추가처럼 데이터 완결성에 필수인 경우는 예외
|
||||
```
|
||||
|
||||
### 신규 설치 시 데이터 초기화
|
||||
|
||||
신규 설치 시 업그레이드 스텝은 실행되지 않으므로, 모델의 `boot()` 이벤트나 Seeder에서 초기 데이터를 생성해야 합니다.
|
||||
|
||||
```php
|
||||
// 예: User 모델 — 신규 레코드 생성 시 UUID 자동 할당
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (self $user) {
|
||||
if (empty($user->uuid)) {
|
||||
$user->uuid = app(UniqueIdServiceInterface::class)->generateUuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 업그레이드 스텝이 필요한 경우
|
||||
|
||||
| 변경 유형 | 업그레이드 스텝 필요 | 예시 |
|
||||
|----------|-------------------|------|
|
||||
| 기존 데이터 백필/변환 | ✅ | UUID 백필, 형식 변환 |
|
||||
| NOT NULL 제약 추가 (백필 후) | ✅ (예외) | 스키마이지만 데이터 완결성 의존 |
|
||||
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | config/core.php 수정만으로 충분 |
|
||||
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | 기존 메뉴/권한 삭제 |
|
||||
| PHP 코드만 변경 | ❌ | 버그 수정, 성능 개선 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 업그레이드 스텝 작성
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```text
|
||||
upgrades/ # 프로젝트 루트
|
||||
├── .gitkeep
|
||||
├── Upgrade_7_0_0_beta_4.php # 7.0.0-alpha.4
|
||||
└── Upgrade_7_0_0_beta_15.php # 7.0.0-alpha.15
|
||||
```
|
||||
|
||||
### 작성 예시
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 코어 7.0.0-alpha.15 업그레이드 스텝
|
||||
*
|
||||
* 기존 사용자 레코드에 UUID v7을 백필합니다.
|
||||
*/
|
||||
class Upgrade_7_0_0_beta_15 implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
// 1. 사전 조건 확인
|
||||
if (! Schema::hasColumn('users', 'uuid')) {
|
||||
$context->logger->warning('uuid 컬럼 없음. 마이그레이션을 먼저 실행하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 데이터 백필
|
||||
$nullCount = DB::table('users')->whereNull('uuid')->count();
|
||||
if ($nullCount > 0) {
|
||||
$context->logger->info("UUID 백필 시작: {$nullCount}건");
|
||||
DB::table('users')->whereNull('uuid')->orderBy('id')->chunk(100, function ($users) {
|
||||
foreach ($users as $user) {
|
||||
DB::table('users')
|
||||
->where('id', $user->id)
|
||||
->update(['uuid' => Str::orderedUuid()->toString()]);
|
||||
}
|
||||
});
|
||||
$context->logger->info("UUID 백필 완료");
|
||||
}
|
||||
|
||||
// 3. 백필 완료 후 NOT NULL 제약 (데이터 완결성 예외)
|
||||
// raw SQL에서는 $context->table()로 프리픽스 적용 필수
|
||||
$column = DB::selectOne("SHOW COLUMNS FROM {$context->table('users')} WHERE Field = 'uuid'");
|
||||
if ($column && $column->Null === 'YES') {
|
||||
Schema::table('users', function ($table) {
|
||||
$table->uuid('uuid')->nullable(false)->unique()->change();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 작성 체크리스트
|
||||
|
||||
```text
|
||||
□ UpgradeStepInterface 구현
|
||||
□ 네임스페이스: App\Upgrades
|
||||
□ 파일명: Upgrade_X_Y_Z.php (버전에 맞는 언더스코어 표기)
|
||||
□ 사전 조건 확인 (테이블/컬럼 존재 여부)
|
||||
□ 로거를 통한 진행 상황 기록
|
||||
□ 멱등성 보장 (여러 번 실행해도 안전)
|
||||
□ 대량 데이터는 chunk() 사용
|
||||
□ raw SQL 사용 시 $context->table()로 테이블 프리픽스 적용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 자동 발견 및 실행 규칙
|
||||
|
||||
> 파일: `app/Services/CoreUpdateService.php` — `runUpgradeSteps()`
|
||||
|
||||
### 발견 프로세스
|
||||
|
||||
```text
|
||||
1. base_path('upgrades') 디렉토리 스캔
|
||||
2. .php 확장자 파일만 대상
|
||||
3. 정규식 매칭: /^Upgrade_(\d+)_(\d+)_(\d+)(?:_([a-zA-Z]\w*(?:_\d+)*))?$/
|
||||
4. 버전 변환: 1_0_0 → 1.0.0, 7_0_0_beta_4 → 7.0.0-alpha.4
|
||||
5. require_once 로딩 → App\Upgrades\{filename} 클래스 인스턴스화
|
||||
6. UpgradeStepInterface 구현 확인
|
||||
```
|
||||
|
||||
### 실행 규칙
|
||||
|
||||
- **필터 조건**: `fromVersion < stepVersion <= toVersion` (`version_compare` 사용)
|
||||
- **정렬**: `uksort($steps, 'version_compare')` — 자연 정렬 오름차순
|
||||
- **실행**: 각 스텝에 `UpgradeContext::withCurrentStep($version)` 불변 복제 전달
|
||||
|
||||
### 버전 네이밍 규칙
|
||||
|
||||
파일명의 언더스코어가 버전 구분자로 변환됩니다:
|
||||
|
||||
| 파일명 | 변환 버전 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `Upgrade_1_0_0.php` | `1.0.0` | 숫자 3자리 |
|
||||
| `Upgrade_7_0_0_beta_4.php` | `7.0.0-alpha.4` | pre-release 포함 |
|
||||
| `Upgrade_7_0_0_beta_15.php` | `7.0.0-alpha.15` | pre-release 2자리 |
|
||||
|
||||
**규칙**: 숫자 3자리(`X_Y_Z`) 후 알파벳으로 시작하는 부분은 `-`로 연결, 이후 `_`는 `.`로 변환
|
||||
|
||||
### UpgradeStepInterface
|
||||
|
||||
> 파일: `app/Contracts/Extension/UpgradeStepInterface.php`
|
||||
|
||||
```php
|
||||
interface UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void;
|
||||
}
|
||||
```
|
||||
|
||||
### UpgradeContext
|
||||
|
||||
> 파일: `app/Extension/UpgradeContext.php`
|
||||
|
||||
```php
|
||||
// readonly 속성
|
||||
public readonly LoggerInterface $logger; // upgrade 로그 채널
|
||||
public readonly string $fromVersion; // 업그레이드 시작 버전
|
||||
public readonly string $toVersion; // 업그레이드 목표 버전
|
||||
public readonly string $currentStep; // 현재 실행 중인 스텝 버전
|
||||
|
||||
// 메서드
|
||||
public function table(string $table): string // DB 프리픽스 적용 (raw SQL용)
|
||||
public function withCurrentStep(string $stepVersion): self // 불변 복제
|
||||
```
|
||||
|
||||
> **raw SQL에서 테이블명 사용 시 `$context->table()` 필수**
|
||||
> - `Schema::hasColumn('users', ...)`, `DB::table('users')` → 프리픽스 자동 적용 (불필요)
|
||||
> - `DB::selectOne("SHOW COLUMNS FROM ...")` 등 raw SQL → **`$context->table('users')` 필수**
|
||||
|
||||
---
|
||||
|
||||
## 7. 버전 관리
|
||||
|
||||
### 버전 소스
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `config/app.php` — `'version'` | SSoT (env 기본값 정의) |
|
||||
| `.env` — `APP_VERSION` | 런타임 오버라이드 |
|
||||
| `.env.example` | 배포 템플릿 |
|
||||
|
||||
### 버전 읽기
|
||||
|
||||
```php
|
||||
CoreVersionChecker::getCoreVersion() // → config('app.version')
|
||||
```
|
||||
|
||||
### 버전 갱신 (Step 11)
|
||||
|
||||
```php
|
||||
CoreUpdateService::updateVersionInEnv($version)
|
||||
// .env에 APP_VERSION이 존재하면: 정규식으로 라인 교체
|
||||
// .env에 APP_VERSION이 없으면: "APP_VERSION={version}\n" 추가
|
||||
```
|
||||
|
||||
### 버전 변경 시 필수 동기화
|
||||
|
||||
```text
|
||||
필수: 코어 버전 변경 시 3곳 동기화
|
||||
|
||||
1. config/app.php — env('APP_VERSION', '새버전')
|
||||
2. .env.example — APP_VERSION=새버전
|
||||
3. .env.testing — APP_VERSION=새버전 (테스트 환경)
|
||||
4. CHANGELOG.md — 변경사항 기록
|
||||
```
|
||||
|
||||
### 부트스트랩 호환성 자동 검증
|
||||
|
||||
> 파일: `app/Providers/CoreServiceProvider.php` — `validateAndDeactivateIncompatibleExtensions()`, `validateAndDeactivateIncompatibleTemplates()`
|
||||
|
||||
**애플리케이션 부트(boot) 시마다** CoreServiceProvider가 모든 활성 확장의 `g7_version` 요구사항을 검증합니다.
|
||||
`config('app.version')`이 확장의 요구 버전을 충족하지 않으면 **해당 확장을 자동으로 비활성화**합니다.
|
||||
|
||||
#### 검증 흐름
|
||||
|
||||
```text
|
||||
1. CoreServiceProvider::boot() 실행
|
||||
2. CoreVersionChecker::getCacheKey($type) 캐시 확인 (TTL: 1시간)
|
||||
3. 캐시 없으면 → 모든 활성 확장 순회
|
||||
4. 각 확장의 g7_version (예: ">=7.0.0-beta.1") 확인
|
||||
5. Semver::satisfies(config('app.version'), $constraint) 실행
|
||||
6. false 반환 시 → Manager::deactivateModule/Plugin/Template() 호출
|
||||
7. 캐시 저장 (1시간 유효)
|
||||
```
|
||||
|
||||
#### 주의사항
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| **검증 주기** | 캐시 만료(1시간)마다 재검증 |
|
||||
| **비활성화 대상** | 호환되지 않는 **모든** 활성 확장 (모듈, 플러그인, 템플릿) |
|
||||
| **activity_log** | 기록되지 않음 (Service 레이어를 거치지 않고 Manager 직접 호출) |
|
||||
| **로그** | `storage/logs/laravel.log`에 warning 기록 |
|
||||
|
||||
#### 버전 확인 방법
|
||||
|
||||
```bash
|
||||
php artisan tinker
|
||||
> \Composer\Semver\Semver::satisfies(config('app.version'), '>=7.0.0-beta.1');
|
||||
```
|
||||
|
||||
#### 버전 우선순위
|
||||
|
||||
```text
|
||||
config('app.version') = env('APP_VERSION', 'config/app.php 기본값')
|
||||
|
||||
.env에 APP_VERSION이 있으면 → .env 값 사용 (config/app.php 기본값 무시)
|
||||
.env에 APP_VERSION이 없으면 → config/app.php의 기본값 사용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. CoreUpdateService 주요 메서드
|
||||
|
||||
> 파일: `app/Services/CoreUpdateService.php`
|
||||
|
||||
### 업데이트 감지
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `checkForUpdates()` | `(): array` | GitHub API로 최신 릴리스 조회 |
|
||||
| `getChangelog()` | `(?string $from, ?string $to): array` | CHANGELOG.md 파싱, 버전 범위 필터 |
|
||||
| `checkSystemRequirements()` | `(): array` | 추출 도구 검증 (ZipArchive/unzip) |
|
||||
| `validatePendingPath()` | `(): array` | _pending 디렉토리 존재/권한 확인 |
|
||||
|
||||
### 다운로드 및 소스 준비
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `downloadUpdate()` | `(string $version, ?Closure $onProgress): string` | GitHub 다운로드 + 추출 (폴백 체인) |
|
||||
| `copySourceToPending()` | `(string $sourceDir, ?Closure $onProgress): string` | --source 모드: 외부 디렉토리 → _pending 복사 |
|
||||
| `prepareLocalSource()` | `(?Closure $onProgress): string` | --local 모드: 현재 코드 → _pending 복사 |
|
||||
| `validatePendingUpdate()` | `(string $pendingPath): void` | 패키지 구조 검증 (composer.json, app/, config/app.php) |
|
||||
| `createPendingDirectory()` | `(): string` | _pending/core_Ymd_His/ 디렉토리 생성 |
|
||||
| `cleanupPending()` | `(string $pendingPath): void` | _pending 하위 디렉토리 삭제 |
|
||||
|
||||
### 백업 및 복원
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `createBackup()` | `(?Closure $onProgress): string` | CoreBackupHelper로 백업 생성 |
|
||||
| `restoreFromBackup()` | `(string $backupPath, ?Closure $onProgress): void` | 백업에서 파일 복원 |
|
||||
|
||||
### 적용 및 설치
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress): void` | _pending → base_path 선택적 덮어쓰기 |
|
||||
| `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) |
|
||||
| `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 |
|
||||
| `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install |
|
||||
| `copyVendorFromPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending/vendor → base_path/vendor |
|
||||
|
||||
### 마이그레이션 및 동기화
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `runMigrations()` | `(): void` | `php artisan migrate --force` |
|
||||
| `syncCoreRolesAndPermissions()` | `(): void` | config/core.php roles/permissions 동기화 |
|
||||
| `syncCoreMenus()` | `(): void` | config/core.php menus 동기화 |
|
||||
| `syncCoreMailTemplates()` | `(): void` | config/core.php mail_templates 동기화 (user_overrides 보존) |
|
||||
|
||||
### 업그레이드 및 마무리
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `runUpgradeSteps()` | `(string $from, string $to, ?Closure $onStep): void` | upgrades/ 자동 발견 + 실행 |
|
||||
| `updateVersionInEnv()` | `(string $version): void` | .env의 APP_VERSION 갱신 |
|
||||
| `clearAllCaches()` | `(): void` | config/cache/route/view 클리어 + package:discover |
|
||||
|
||||
### 유지보수 모드
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `enableMaintenanceMode()` | `(): string` | `php artisan down --secret` → bypass secret 반환 |
|
||||
| `disableMaintenanceMode()` | `(): void` | `php artisan up` |
|
||||
|
||||
### 유틸리티
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|---------|------|
|
||||
| `generateFailureReport()` | `(Throwable $e, string $from, string $to): string` | 에러 보고서 생성 → storage/logs/ |
|
||||
|
||||
---
|
||||
|
||||
## 9. Artisan 커맨드
|
||||
|
||||
### core:update
|
||||
|
||||
```bash
|
||||
php artisan core:update [--force] [--no-backup] [--no-maintenance] [--local] [--source={path}]
|
||||
```
|
||||
|
||||
| 옵션 | 설명 |
|
||||
|------|------|
|
||||
| `--force` | 버전 비교 스킵, 동일 버전이어도 강제 업데이트 |
|
||||
| `--no-backup` | 백업 생성 스킵 (Step 5) |
|
||||
| `--no-maintenance` | 유지보수 모드 스킵 (Step 3) |
|
||||
| `--local` | 현재 코드베이스를 소스로 사용 (GitHub 스킵) |
|
||||
| `--source={path}` | 지정 디렉토리를 소스로 사용 (GitHub 스킵) |
|
||||
|
||||
**종료 코드**:
|
||||
- `0` (SUCCESS): 업데이트 성공 또는 이미 최신 버전
|
||||
- `1` (FAILURE): 요건 미충족 또는 업데이트 실패
|
||||
|
||||
### core:check-updates
|
||||
|
||||
```bash
|
||||
php artisan core:check-updates
|
||||
```
|
||||
|
||||
- GitHub API 호출 → 현재/최신 버전 표시
|
||||
- "새로운 업데이트가 있습니다!" 또는 "현재 최신 버전입니다."
|
||||
|
||||
---
|
||||
|
||||
## 10. API 엔드포인트
|
||||
|
||||
> 파일: `app/Http/Controllers/Api/Admin/CoreUpdateController.php`
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/admin/core-update/check-updates` | 코어 업데이트 확인 (422 시 check_failed) |
|
||||
| POST | `/api/admin/core-update/changelog` | 코어 변경 로그 조회 (from_version, to_version) |
|
||||
|
||||
> ActivityLog: `checkForUpdates` 호출 시 `core_update.check` 액티비티 기록
|
||||
|
||||
---
|
||||
|
||||
## 11. 설정 (config/app.php)
|
||||
|
||||
### 버전
|
||||
|
||||
```php
|
||||
'version' => env('APP_VERSION', '7.0.0-alpha.14'),
|
||||
```
|
||||
|
||||
### 업데이트 설정
|
||||
|
||||
```php
|
||||
'update' => [
|
||||
'github_url' => '...', // GitHub 리포지토리 URL
|
||||
'github_token' => env('GITHUB_TOKEN'), // GitHub Personal Access Token (선택)
|
||||
'pending_path' => '...', // _pending 디렉토리 경로
|
||||
'targets' => [...], // 업데이트 적용 + 백업 대상 파일/디렉토리
|
||||
'backup_only' => ['vendor'], // 백업/복원 전용 (applyUpdate 제외)
|
||||
'backup_extra' => [...], // 추가 백업 대상
|
||||
'excludes' => [...], // 제외 패턴
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 에러 처리 및 로깅
|
||||
|
||||
### 시스템 요건 검증
|
||||
|
||||
```text
|
||||
- ZipArchive 또는 unzip 명령어 중 하나 이상 필요
|
||||
- 미충족 시 커맨드 실패 (EXIT 1) + 사용 가능 방법 목록 출력
|
||||
```
|
||||
|
||||
### _pending 검증
|
||||
|
||||
```text
|
||||
- 디렉토리 미존재: File::ensureDirectoryExists() 시도
|
||||
- 쓰기 불가: 경로, 소유자, 그룹, 권한 정보 포함 에러 출력
|
||||
```
|
||||
|
||||
### 패키지 검증
|
||||
|
||||
```text
|
||||
- 필수 파일: composer.json, app/ 디렉토리, config/app.php
|
||||
- config/app.php에 'version' 키 필수
|
||||
- 미충족 시 RuntimeException
|
||||
```
|
||||
|
||||
### Composer 실패
|
||||
|
||||
```text
|
||||
- exit code 비정상 → RuntimeException (마지막 5줄 출력 포함)
|
||||
```
|
||||
|
||||
### 로그 위치
|
||||
|
||||
| 로그 | 경로 | 내용 |
|
||||
|------|------|------|
|
||||
| 성공 로그 | `storage/logs/core_update_success_YYYYMMDD_HHMMSS.log` | 전체 실행 타임라인 |
|
||||
| 실패 로그 | `storage/logs/core_update_failed_YYYYMMDD_HHMMSS.log` | 실패까지의 실행 타임라인 |
|
||||
| 실패 보고서 | `storage/logs/core_update_failure_YYYYMMDD_HHMMSS.log` | 예외 상세 + 시스템 정보 |
|
||||
| 일반 로그 | `storage/logs/laravel.log` | Log 파사드 통한 기록 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 유지보수 모드
|
||||
|
||||
### 활성화 (Step 3)
|
||||
|
||||
```php
|
||||
Artisan::call('down', [
|
||||
'--secret' => $secret, // UUID 토큰 (bypass 접근용)
|
||||
'--retry' => 60, // Retry-After 헤더 (초)
|
||||
'--refresh' => 15, // 브라우저 자동 새로고침 (초)
|
||||
]);
|
||||
```
|
||||
|
||||
### Bypass 접근
|
||||
|
||||
```text
|
||||
URL: https://example.com/{secret}
|
||||
→ 쿠키 발급 → 유지보수 모드에서도 접근 가능
|
||||
```
|
||||
|
||||
### 해제
|
||||
|
||||
```text
|
||||
- 성공 시: Step 11에서 자동 해제 (php artisan up)
|
||||
- 실패 시: 유지보수 모드 유지 → 관리자에게 수동 해제 안내
|
||||
"이전 버전으로 사이트를 운영하려면: php artisan up"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 코어 vs 확장 업데이트 비교
|
||||
|
||||
| 항목 | 코어 | 모듈/플러그인 | 템플릿 |
|
||||
|------|------|-------------|--------|
|
||||
| **소스** | GitHub releases (zipball) | GitHub 또는 _bundled | GitHub 또는 _bundled |
|
||||
| **추출** | 폴백 체인 (ZipArchive → unzip) | ExtensionPendingHelper | ExtensionPendingHelper |
|
||||
| **백업** | CoreBackupHelper (선택) | ExtensionBackupHelper (선택) | ExtensionBackupHelper (선택) |
|
||||
| **유지보수 모드** | 전체 앱 down (secret 토큰) | 없음 | 없음 |
|
||||
| **Composer** | MD5 비교 → 변경 시만 실행 | 확장별 독립 vendor/ | 해당 없음 |
|
||||
| **파일 적용** | FilePermissionHelper (권한 보존) | ExtensionPendingHelper::copyToActive() | ExtensionPendingHelper::copyToActive() |
|
||||
| **vendor 처리** | 변경 시 전체 복사 | 확장별 composer install | 해당 없음 |
|
||||
| **마이그레이션** | 필수 (Step 9) | 선택 (확장별) | 없음 |
|
||||
| **동기화** | roles + permissions + menus + mail templates | roles + permissions + menus | 없음 |
|
||||
| **업그레이드 스텝** | `upgrades/Upgrade_X_Y_Z.php` | `{ext}/upgrades/Upgrade_X_Y_Z.php` | 없음 |
|
||||
| **네임스페이스** | `App\Upgrades` | `Modules\Vendor\Module\Upgrades` | 해당 없음 |
|
||||
| **실행 주체** | `CoreUpdateService` | `AbstractModule` | `TemplateManager` |
|
||||
| **커맨드** | `core:update` | `module:update {id}` | `template:update {id}` |
|
||||
| **레이아웃** | 해당 없음 | 자동 갱신 | 충돌 전략 (apply_new/keep_current) |
|
||||
| **롤백** | 전체 복원 + 유지보수 유지 | 확장별 복원 + 상태 복원 | 확장별 복원 |
|
||||
| **로깅** | 타임스탬프 성공/실패 로그 + 실패 보고서 | laravel.log | laravel.log |
|
||||
|
||||
---
|
||||
|
||||
## 참고 파일 위치
|
||||
|
||||
| 파일 | 경로 |
|
||||
|------|------|
|
||||
| CoreUpdateCommand | `app/Console/Commands/Core/CoreUpdateCommand.php` |
|
||||
| CoreCheckUpdatesCommand | `app/Console/Commands/Core/CoreCheckUpdatesCommand.php` |
|
||||
| CoreUpdateService | `app/Services/CoreUpdateService.php` |
|
||||
| CoreUpdateController | `app/Http/Controllers/Api/Admin/CoreUpdateController.php` |
|
||||
| CoreVersionChecker | `app/Extension/CoreVersionChecker.php` |
|
||||
| CoreBackupHelper | `app/Extension/Helpers/CoreBackupHelper.php` |
|
||||
| UpgradeStepInterface | `app/Contracts/Extension/UpgradeStepInterface.php` |
|
||||
| UpgradeContext | `app/Extension/UpgradeContext.php` |
|
||||
| 코어 업그레이드 디렉토리 | `upgrades/` |
|
||||
| 코어 설정 | `config/app.php` (version, update 섹션) |
|
||||
| 역할/권한/메뉴 정의 | `config/core.php` |
|
||||
@@ -0,0 +1,343 @@
|
||||
# Enum 사용 규칙
|
||||
|
||||
> **관련 문서**: [백엔드 가이드 인덱스](./index.md) | [검증 로직](./validation.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum)
|
||||
2. 위치: app/Enums/ 또는 modules/*/src/Enums/
|
||||
3. DB: enum 컬럼 또는 string + 검증
|
||||
4. FormRequest: Rule::enum(StatusEnum::class) 사용
|
||||
5. cases(): 모든 값 조회, tryFrom(): 안전한 변환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [핵심 원칙](#핵심-원칙)
|
||||
- [Enum 사용 대상](#enum-사용-대상)
|
||||
- [잘못된 예시](#잘못된-예시)
|
||||
- [올바른 예시](#올바른-예시)
|
||||
- [파일 위치](#파일-위치)
|
||||
- [데이터베이스 연동](#데이터베이스-연동)
|
||||
- [FormRequest 검증에서 사용](#formrequest-검증에서-사용)
|
||||
- [개발 체크리스트](#개발-체크리스트)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
상태, 타입, 분류 등 제한된 값 집합은 반드시 Enum으로 정의
|
||||
✅ 필수: PHP 8.1+ Backed Enum 사용
|
||||
```
|
||||
|
||||
**핵심 원칙**:
|
||||
|
||||
- **타입 안전성**: 문자열/정수 대신 Enum 사용으로 타입 안전성 확보
|
||||
- **자동완성**: IDE의 자동완성 지원으로 개발 생산성 향상
|
||||
- **유지보수**: 값 변경 시 한 곳에서만 수정
|
||||
- **유효성 검증**: 잘못된 값 입력 방지
|
||||
|
||||
---
|
||||
|
||||
## Enum 사용 대상
|
||||
|
||||
| 상황 | 예시 | Enum 사용 |
|
||||
|------|------|----------|
|
||||
| 상태 값 | active, inactive, pending | ✅ 필수 |
|
||||
| 타입 구분 | template, module, plugin | ✅ 필수 |
|
||||
| 고정 옵션 | admin, user, guest | ✅ 필수 |
|
||||
| 가변 데이터 | 사용자 입력, 동적 값 | ❌ 불필요 |
|
||||
|
||||
---
|
||||
|
||||
## 잘못된 예시
|
||||
|
||||
```php
|
||||
// ❌ 문자열 직접 사용 - 타입 안전성 없음
|
||||
class Module
|
||||
{
|
||||
public function setStatus(string $status): void
|
||||
{
|
||||
$this->status = $status; // 'actve' 오타 가능
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 상수로 정의 - 타입 체크 불가
|
||||
class ModuleStatus
|
||||
{
|
||||
public const ACTIVE = 'active';
|
||||
public const INACTIVE = 'inactive';
|
||||
}
|
||||
|
||||
$module->setStatus('invalid'); // 오류 없이 통과
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 올바른 예시
|
||||
|
||||
```php
|
||||
// ✅ Backed Enum 정의
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* 확장(모듈, 플러그인, 템플릿) 상태 Enum
|
||||
*/
|
||||
enum ExtensionStatus: string
|
||||
{
|
||||
/**
|
||||
* 활성화됨
|
||||
*/
|
||||
case Active = 'active';
|
||||
|
||||
/**
|
||||
* 비활성화됨
|
||||
*/
|
||||
case Inactive = 'inactive';
|
||||
|
||||
/**
|
||||
* 설치 중
|
||||
*/
|
||||
case Installing = 'installing';
|
||||
|
||||
/**
|
||||
* 제거 중
|
||||
*/
|
||||
case Uninstalling = 'uninstalling';
|
||||
|
||||
/**
|
||||
* 제거됨 (미설치 상태)
|
||||
*/
|
||||
case Uninstalled = 'uninstalled';
|
||||
|
||||
/**
|
||||
* 업데이트 중
|
||||
*/
|
||||
case Updating = 'updating';
|
||||
|
||||
/**
|
||||
* 모든 상태 값을 문자열 배열로 반환
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 타입 힌팅으로 안전하게 사용
|
||||
class Module
|
||||
{
|
||||
public function setStatus(ExtensionStatus $status): void
|
||||
{
|
||||
$this->status = $status->value;
|
||||
}
|
||||
}
|
||||
|
||||
$module->setStatus(ExtensionStatus::Active); // 타입 안전
|
||||
$module->setStatus('invalid'); // 컴파일 오류!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 파일 위치
|
||||
|
||||
```text
|
||||
app/Enums/ # 코어 Enum
|
||||
├── ExtensionStatus.php # 확장 상태
|
||||
├── LayoutSourceType.php # 레이아웃 소스 타입
|
||||
├── ScopeType.php # 권한 스코프 타입
|
||||
└── UserRole.php # 사용자 역할
|
||||
|
||||
modules/[vendor-module]/src/Enums/ # 모듈 Enum
|
||||
```
|
||||
|
||||
### ScopeType Enum — 권한 스코프
|
||||
|
||||
역할별 권한의 접근 범위를 정의합니다. `role_permissions` 피벗 테이블의 `scope_type` 컬럼에서 사용됩니다.
|
||||
|
||||
```php
|
||||
enum ScopeType: string
|
||||
{
|
||||
/** 본인 리소스만 접근 */
|
||||
case Self = 'self';
|
||||
|
||||
/** 소유 역할 공유 사용자의 리소스 접근 */
|
||||
case Role = 'role';
|
||||
}
|
||||
// null = 전체 접근 (제한 없음)
|
||||
```
|
||||
|
||||
**scope_type 값과 의미**:
|
||||
|
||||
| 값 | 의미 | 목록 필터링 | 상세 접근 체크 |
|
||||
| ---- | -------------- | ----------------------------------------- | -------------- |
|
||||
| `null` | 전체 접근 | 필터 미적용 | 항상 통과 |
|
||||
| `'self'` | 본인 리소스만 | `WHERE {owner_key} = {user_id}` | 소유자 일치 체크 |
|
||||
| `'role'` | 소유역할 범위 | `WHERE {owner_key} IN (역할 공유 사용자)` | 역할 공유 체크 |
|
||||
|
||||
**union 정책** (복수 역할 보유 시): `null`(전체) > `'role'`(소유역할) > `'self'`(본인) — 가장 넓은 범위 적용
|
||||
|
||||
> 상세: [permissions.md](../extension/permissions.md) scope_type 시스템 참조
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 연동
|
||||
|
||||
```php
|
||||
// 마이그레이션에서 Enum 값 사용
|
||||
use App\Enums\ExtensionStatus;
|
||||
|
||||
$table->string('status')->default(ExtensionStatus::Inactive->value)
|
||||
->comment('상태: ' . implode(', ', ExtensionStatus::values()));
|
||||
|
||||
// 모델에서 캐스팅
|
||||
protected $casts = [
|
||||
'status' => ExtensionStatus::class,
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FormRequest 검증에서 사용
|
||||
|
||||
```php
|
||||
use App\Enums\ExtensionStatus;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', Rule::enum(ExtensionStatus::class)],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enum 표준 헬퍼 메서드
|
||||
|
||||
G7에서 Enum에 추가하는 표준 헬퍼 메서드입니다.
|
||||
|
||||
### 필수 메서드
|
||||
|
||||
```php
|
||||
enum UserStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case Inactive = 'inactive';
|
||||
case Blocked = 'blocked';
|
||||
case Withdrawn = 'withdrawn';
|
||||
|
||||
/**
|
||||
* 모든 상태 값을 문자열 배열로 반환
|
||||
*
|
||||
* @return array<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
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return __('user.status.' . $this->value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 선택 메서드 (UI 연동 시)
|
||||
|
||||
```php
|
||||
/**
|
||||
* CSS variant 반환 (Badge, Tag 등에서 사용)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function variant(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Active => 'success',
|
||||
self::Inactive => 'warning',
|
||||
self::Blocked => 'danger',
|
||||
self::Withdrawn => 'secondary',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이콘 클래스 반환
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function icon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Active => 'fa-check-circle',
|
||||
self::Inactive => 'fa-pause-circle',
|
||||
self::Blocked => 'fa-ban',
|
||||
self::Withdrawn => 'fa-user-slash',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 메서드 요약
|
||||
|
||||
| 메서드 | 유형 | 필수 | 용도 |
|
||||
| ------ | ------ | ------ | ------ |
|
||||
| `values()` | static | ✅ | 마이그레이션 comment, 검증 등 |
|
||||
| `isValid()` | static | ✅ | 값 유효성 검증 |
|
||||
| `label()` | instance | ✅ | API 응답의 다국어 라벨 |
|
||||
| `variant()` | instance | 선택 | UI Badge/Tag 스타일 매핑 |
|
||||
| `icon()` | instance | 선택 | UI 아이콘 매핑 |
|
||||
|
||||
---
|
||||
|
||||
## 개발 체크리스트
|
||||
|
||||
- [ ] Backed Enum 사용 (string 또는 int)
|
||||
- [ ] 각 case에 한국어 PHPDoc 주석 추가
|
||||
- [ ] `values()` 헬퍼 메서드 구현
|
||||
- [ ] `isValid()` 헬퍼 메서드 구현 (필요시)
|
||||
- [ ] 마이그레이션 comment에 가능한 값 명시
|
||||
- [ ] 모델에서 Enum 캐스팅 설정
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [검증 로직](./validation.md) - FormRequest에서 Enum 검증
|
||||
- [데이터베이스 가이드](../database-guide.md) - 마이그레이션 규칙
|
||||
- [백엔드 가이드 인덱스](./index.md) - 전체 목차
|
||||
@@ -0,0 +1,322 @@
|
||||
# Custom Exception 다국어 처리
|
||||
|
||||
> 이 문서는 G7에서 Custom Exception 클래스의 다국어 처리 방법을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 예외 메시지 하드코딩 금지 → __() 함수 필수
|
||||
2. 메시지 위치: lang/ko/exceptions.php, lang/en/exceptions.php
|
||||
3. 파라미터 치환: __('exceptions.key', ['param' => $value])
|
||||
4. 코어 예외: exceptions.template_engine.* 형식
|
||||
5. 모듈 예외: vendor-module::exceptions.* 형식
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [핵심 원칙](#핵심-원칙)
|
||||
2. [다국어 파일 위치 및 구조](#다국어-파일-위치-및-구조)
|
||||
3. [파라미터 치환 패턴](#파라미터-치환-패턴)
|
||||
4. [템플릿 엔진 Custom Exception 예시](#템플릿-엔진-custom-exception-예시)
|
||||
5. [Custom Exception 개발 체크리스트](#custom-exception-개발-체크리스트)
|
||||
6. [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```
|
||||
필수: __() 함수 사용 (예외 메시지 하드코딩 금지)
|
||||
필수: __() 함수를 사용한 다국어 처리
|
||||
```
|
||||
|
||||
### 필수 규칙
|
||||
|
||||
- **모든 예외 메시지는 다국어 파일에 정의**: `/lang/ko/exceptions.php`, `/lang/en/exceptions.php`
|
||||
- **Exception 생성자에서 __() 함수 사용**: 하드코딩된 문자열 사용 금지
|
||||
- **파라미터 치환 지원**: `:trace`, `:max`, `:id` 등의 동적 값 지원
|
||||
- **일관성 유지**: 모든 커스텀 예외가 동일한 패턴 사용
|
||||
|
||||
---
|
||||
|
||||
## 다국어 파일 위치 및 구조
|
||||
|
||||
### 파일 위치
|
||||
|
||||
```
|
||||
/lang/ko/exceptions.php # 한국어 예외 메시지
|
||||
/lang/en/exceptions.php # 영어 예외 메시지
|
||||
```
|
||||
|
||||
### 파일 구조 예시
|
||||
|
||||
```php
|
||||
// /lang/ko/exceptions.php
|
||||
return [
|
||||
'circular_reference' => '레이아웃 순환 참조 감지: :trace',
|
||||
'max_depth_exceeded' => '레이아웃 중첩 깊이가 최대 허용 깊이(:max)를 초과했습니다.',
|
||||
'resource_not_found' => ':resource를 찾을 수 없습니다.',
|
||||
];
|
||||
|
||||
// /lang/en/exceptions.php
|
||||
return [
|
||||
'circular_reference' => 'Layout circular reference detected: :trace',
|
||||
'max_depth_exceeded' => 'Layout nesting depth exceeds maximum allowed depth (:max).',
|
||||
'resource_not_found' => ':resource not found.',
|
||||
];
|
||||
```
|
||||
|
||||
### 중첩 구조 (네임스페이스 그룹화)
|
||||
|
||||
```php
|
||||
// /lang/ko/exceptions.php
|
||||
return [
|
||||
'template' => [
|
||||
'circular_reference' => '레이아웃 순환 참조 감지: :trace',
|
||||
'max_depth_exceeded' => '레이아웃 중첩 깊이(:current)가 최대 허용 깊이(:max)를 초과했습니다.',
|
||||
'template_not_found' => '템플릿을 찾을 수 없습니다: :identifier',
|
||||
'layout_not_found' => '레이아웃을 찾을 수 없습니다: :layout_name',
|
||||
'component_not_found' => '컴포넌트를 찾을 수 없습니다: :component_name',
|
||||
'invalid_layout_structure' => '유효하지 않은 레이아웃 구조입니다.',
|
||||
],
|
||||
];
|
||||
|
||||
// /lang/en/exceptions.php
|
||||
return [
|
||||
'template' => [
|
||||
'circular_reference' => 'Layout circular reference detected: :trace',
|
||||
'max_depth_exceeded' => 'Layout nesting depth (:current) exceeds maximum allowed depth (:max).',
|
||||
'template_not_found' => 'Template not found: :identifier',
|
||||
'layout_not_found' => 'Layout not found: :layout_name',
|
||||
'component_not_found' => 'Component not found: :component_name',
|
||||
'invalid_layout_structure' => 'Invalid layout structure.',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 파라미터 치환 패턴
|
||||
|
||||
### 잘못된 예시 (DON'T)
|
||||
|
||||
```php
|
||||
// ❌ 하드코딩된 예외 메시지 - 금지
|
||||
class CircularReferenceException extends Exception
|
||||
{
|
||||
public function __construct(array $stack, string $currentLayout)
|
||||
{
|
||||
$stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
|
||||
$message = "레이아웃 순환 참조 감지: {$stackTrace}"; // 한국어만 지원, 다국어 불가
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 올바른 예시 (DO)
|
||||
|
||||
```php
|
||||
// ✅ 다국어 함수 사용 (파라미터 치환)
|
||||
class CircularReferenceException extends Exception
|
||||
{
|
||||
private array $stack;
|
||||
|
||||
public function __construct(array $stack, string $currentLayout)
|
||||
{
|
||||
$this->stack = $stack;
|
||||
|
||||
// ✅ 다국어 함수 사용 (파라미터 치환)
|
||||
$stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
|
||||
$message = __('exceptions.circular_reference', ['trace' => $stackTrace]);
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public function getStack(): array
|
||||
{
|
||||
return $this->stack;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 다중 파라미터 처리
|
||||
|
||||
```php
|
||||
class MaxDepthExceededException extends Exception
|
||||
{
|
||||
public function __construct(int $currentDepth, int $maxDepth)
|
||||
{
|
||||
// ✅ 다국어 함수 사용 (다중 파라미터)
|
||||
$message = __('exceptions.max_depth_exceeded', [
|
||||
'current' => $currentDepth,
|
||||
'max' => $maxDepth
|
||||
]);
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 엔진 Custom Exception 예시
|
||||
|
||||
### CircularReferenceException (레이아웃 순환 참조)
|
||||
|
||||
템플릿 레이아웃 상속 시 순환 참조가 발생하면 이 예외를 발생시킵니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Template;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 레이아웃 순환 참조 예외
|
||||
*/
|
||||
class CircularReferenceException extends Exception
|
||||
{
|
||||
private array $stack;
|
||||
|
||||
public function __construct(array $stack, string $currentLayout)
|
||||
{
|
||||
$this->stack = $stack;
|
||||
|
||||
// ✅ 다국어 함수 사용
|
||||
$stackTrace = implode(' → ', $stack) . " → {$currentLayout}";
|
||||
$message = __('exceptions.template.circular_reference', ['trace' => $stackTrace]);
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 순환 참조 스택 반환
|
||||
*/
|
||||
public function getStack(): array
|
||||
{
|
||||
return $this->stack;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MaxDepthExceededException (레이아웃 깊이 초과)
|
||||
|
||||
레이아웃 상속 깊이가 최대 허용 깊이(10)를 초과하면 이 예외를 발생시킵니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Template;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 레이아웃 최대 깊이 초과 예외
|
||||
*/
|
||||
class MaxDepthExceededException extends Exception
|
||||
{
|
||||
private int $currentDepth;
|
||||
private int $maxDepth;
|
||||
|
||||
public function __construct(int $currentDepth, int $maxDepth = 10)
|
||||
{
|
||||
$this->currentDepth = $currentDepth;
|
||||
$this->maxDepth = $maxDepth;
|
||||
|
||||
// ✅ 다국어 함수 사용 (다중 파라미터)
|
||||
$message = __('exceptions.template.max_depth_exceeded', [
|
||||
'current' => $currentDepth,
|
||||
'max' => $maxDepth
|
||||
]);
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 깊이 반환
|
||||
*/
|
||||
public function getCurrentDepth(): int
|
||||
{
|
||||
return $this->currentDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대 허용 깊이 반환
|
||||
*/
|
||||
public function getMaxDepth(): int
|
||||
{
|
||||
return $this->maxDepth;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service에서 사용 예시 (LayoutService)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Template;
|
||||
|
||||
use App\Exceptions\Template\CircularReferenceException;
|
||||
use App\Exceptions\Template\MaxDepthExceededException;
|
||||
|
||||
class LayoutService
|
||||
{
|
||||
private const MAX_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* 레이아웃 병합 (상속 체인 처리)
|
||||
*
|
||||
* @throws CircularReferenceException 순환 참조 발생 시
|
||||
* @throws MaxDepthExceededException 깊이 초과 시
|
||||
*/
|
||||
public function mergeLayouts(string $layoutName, array $stack = [], int $depth = 0): array
|
||||
{
|
||||
// 순환 참조 검사
|
||||
if (in_array($layoutName, $stack)) {
|
||||
throw new CircularReferenceException($stack, $layoutName);
|
||||
}
|
||||
|
||||
// 깊이 검사
|
||||
if ($depth > self::MAX_DEPTH) {
|
||||
throw new MaxDepthExceededException($depth, self::MAX_DEPTH);
|
||||
}
|
||||
|
||||
// 재귀적으로 레이아웃 병합
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Exception 개발 체크리스트
|
||||
|
||||
새로운 Custom Exception을 개발할 때 다음 항목을 확인하세요:
|
||||
|
||||
- [ ] `/lang/ko/exceptions.php`에 한국어 메시지 추가
|
||||
- [ ] `/lang/en/exceptions.php`에 영어 메시지 추가
|
||||
- [ ] Exception 생성자에서 모든 메시지는 `__()` 함수 사용
|
||||
- [ ] 동적 값은 파라미터 배열로 전달 (예: `['trace' => $stackTrace]`)
|
||||
- [ ] 두 언어 모두에서 테스트 수행
|
||||
- [ ] 예외 메시지가 사용자에게 노출될 경우 보안 정보 포함 금지
|
||||
|
||||
### 새 Exception 추가 절차
|
||||
|
||||
1. **다국어 키 정의**: 적절한 네임스페이스로 그룹화
|
||||
2. **Exception 클래스 생성**: `__()` 함수로 메시지 생성
|
||||
3. **필요한 속성 저장**: 디버깅이나 로깅에 필요한 값 보관
|
||||
4. **getter 메서드 제공**: 저장된 속성에 접근할 수 있도록 제공
|
||||
5. **테스트 작성**: 두 언어 환경에서 메시지 확인
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [validation.md](./validation.md) - Custom Rule 다국어 처리
|
||||
- [index.md](./index.md) - 백엔드 가이드 인덱스
|
||||
@@ -0,0 +1,395 @@
|
||||
# 미들웨어 등록 규칙
|
||||
|
||||
> **참조**: [백엔드 가이드 인덱스](index.md) | [인증 시스템](authentication.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 인증 필요 미들웨어 → 전역 등록 금지!
|
||||
2. 그룹 등록: appendToGroup('web'|'api', [...])
|
||||
3. 실행 순서: 전역 → 그룹(web/api) → 라우트 → 컨트롤러
|
||||
4. permission 미들웨어: scope_type 기반 접근 제어 (except/only/menu 옵션 폐기)
|
||||
5. scope 체크: Permission.resource_route_key + owner_key + role_permissions.scope_type
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [핵심 원칙](#핵심-원칙)
|
||||
- [미들웨어 실행 순서](#미들웨어-실행-순서)
|
||||
- [미들웨어 등록 방식](#미들웨어-등록-방식)
|
||||
- [올바른 등록 예시](#올바른-등록-예시)
|
||||
- [문제 상황과 해결책](#문제-상황과-해결책)
|
||||
- [디버깅 방법](#디버깅-방법)
|
||||
- [미들웨어 개발 체크리스트](#미들웨어-개발-체크리스트)
|
||||
- [관련 파일](#관련-파일)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
필수: 인증 필요 미들웨어는 appendToGroup('api') 사용 (전역 등록 금지)
|
||||
필수: appendToGroup('web'|'api', [...])으로 그룹 미들웨어에 등록
|
||||
```
|
||||
|
||||
**핵심 이해사항**:
|
||||
|
||||
- **미들웨어 실행 순서**: 전역 미들웨어는 web/api 그룹 미들웨어보다 **먼저** 실행됨
|
||||
- **인증 미들웨어 위치**: Sanctum 인증 미들웨어는 api 그룹에 등록되어 있음
|
||||
- **결과**: 전역 미들웨어에서 `Auth::check()`, `Auth::user()`를 호출하면 항상 `false`/`null` 반환
|
||||
|
||||
---
|
||||
|
||||
## 미들웨어 실행 순서
|
||||
|
||||
```
|
||||
요청 → 전역 미들웨어 → 그룹 미들웨어(web/api) → 라우트 미들웨어 → 컨트롤러
|
||||
↑ ↑
|
||||
Auth 불가능 Auth 가능
|
||||
(인증 전) (인증 후)
|
||||
```
|
||||
|
||||
### 실행 순서 상세
|
||||
|
||||
1. **전역 미들웨어** (`append()`, `prepend()`)
|
||||
- 모든 요청에 대해 가장 먼저 실행
|
||||
- 인증 처리 전이므로 `Auth::check()` = `false`
|
||||
|
||||
2. **그룹 미들웨어** (`appendToGroup('web'|'api', ...)`)
|
||||
- web 또는 api 그룹에 속한 라우트에서 실행
|
||||
- Sanctum 인증 미들웨어 이후 실행
|
||||
- `Auth::check()` 사용 가능
|
||||
|
||||
3. **라우트 미들웨어** (`alias()`)
|
||||
- 특정 라우트에만 적용
|
||||
- 가장 마지막에 실행
|
||||
|
||||
---
|
||||
|
||||
## 미들웨어 등록 방식
|
||||
|
||||
### 등록 방식 비교 표
|
||||
|
||||
| 방식 | 실행 시점 | Auth 사용 | 사용 사례 |
|
||||
|------|----------|----------|----------|
|
||||
| `append()` | 인증 **전** | ❌ 불가 | CORS, 로깅 등 |
|
||||
| `prepend()` | 인증 **전** (최우선) | ❌ 불가 | 보안 헤더 등 |
|
||||
| `appendToGroup('api', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 |
|
||||
| `appendToGroup('web', ...)` | 인증 **후** | ✅ 가능 | 사용자별 설정 |
|
||||
| `alias()` | 라우트 지정 시 | ✅ 가능 | 권한 체크 등 |
|
||||
|
||||
### append vs appendToGroup 차이
|
||||
|
||||
| 구분 | `append()` | `appendToGroup()` |
|
||||
|------|-----------|-------------------|
|
||||
| **등록 위치** | 전역 미들웨어 스택 | 특정 그룹 미들웨어 스택 |
|
||||
| **실행 시점** | 모든 요청의 최초 | 그룹 미들웨어 순서에 따름 |
|
||||
| **인증 상태** | 인증 전 | 인증 후 (Sanctum 이후) |
|
||||
| **적용 범위** | 모든 라우트 | 해당 그룹(web/api) 라우트만 |
|
||||
|
||||
---
|
||||
|
||||
## 올바른 등록 예시
|
||||
|
||||
### bootstrap/app.php
|
||||
|
||||
```php
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// ✅ SetLocale, SetTimezone은 인증 후 실행되어야 사용자 설정을 읽을 수 있음
|
||||
$localeTimezoneMiddleware = [
|
||||
\App\Http\Middleware\SetLocale::class,
|
||||
\App\Http\Middleware\SetTimezone::class,
|
||||
];
|
||||
$middleware->appendToGroup('web', $localeTimezoneMiddleware);
|
||||
$middleware->appendToGroup('api', $localeTimezoneMiddleware);
|
||||
|
||||
// 권한 관련 미들웨어 등록 (별칭)
|
||||
$middleware->alias([
|
||||
'admin' => \App\Http\Middleware\AdminMiddleware::class,
|
||||
'permission' => \App\Http\Middleware\PermissionMiddleware::class,
|
||||
'start.api.session' => \App\Http\Middleware\StartApiSession::class,
|
||||
]);
|
||||
})
|
||||
```
|
||||
|
||||
> **참고**: `EnsureFrontendRequestsAreStateful` (stateful)은 제거되었습니다. API 인증은 Bearer 토큰 전용이며, 세션은 `start.api.session` 미들웨어로 로그인/로그아웃 라우트에서만 생성됩니다. 상세: [authentication.md](authentication.md)
|
||||
|
||||
---
|
||||
|
||||
## permission 미들웨어 — scope_type 기반 접근 제어
|
||||
|
||||
> **변경 이력**: `except:`, `only:`, `menu:` 옵션은 scope_type 시스템으로 대체되어 **폐기**되었습니다. (2026-03-10)
|
||||
|
||||
### 문법
|
||||
|
||||
```text
|
||||
permission:{type},{permission}[,{requireAll}]
|
||||
```
|
||||
|
||||
옵션 파라미터 없이 권한 타입과 식별자만 전달합니다.
|
||||
|
||||
### 처리 흐름
|
||||
|
||||
```text
|
||||
1. 권한 타입 검증 (admin/user)
|
||||
2. 동적 파라미터 치환 ({slug} → 실제 값)
|
||||
3. 권한 체크 (인증/게스트)
|
||||
→ 권한 없음 → 403
|
||||
→ 권한 있음 → Step 4로 진행
|
||||
|
||||
4. scope_type 스코프 체크:
|
||||
a. Permission 조회 (static 캐시) → resource_route_key, owner_key
|
||||
b. resource_route_key가 null → 통과 (시스템 리소스)
|
||||
c. $request->route(resource_route_key) → Model resolve
|
||||
d. Model 없음 → 통과 (list 엔드포인트)
|
||||
e. 사용자의 effective scope 확인 (union 정책)
|
||||
f. scope=null → 통과 (전체 접근)
|
||||
g. scope='self' → $model->{owner_key} === $user->id → 아니면 403
|
||||
h. scope='role' → 리소스 소유자가 내 역할을 공유하는지 → 아니면 403
|
||||
```
|
||||
|
||||
### scope_type 값 정의
|
||||
|
||||
| 값 | 의미 | 상세 접근 체크 | 목록 필터링 |
|
||||
|---|---|---|---|
|
||||
| `null` | 전체 접근 (제한 없음) | 항상 통과 | 필터 미적용 |
|
||||
| `'self'` | 본인 리소스만 | `$model->{owner_key} === $user->id` | `WHERE {owner_key} = {user_id}` |
|
||||
| `'role'` | 내 역할 범위 리소스 | 리소스 소유자가 내 역할 공유 | `WHERE {owner_key} IN (역할 공유 사용자 IDs)` |
|
||||
|
||||
### union 정책 (복수 역할 보유 시)
|
||||
|
||||
```text
|
||||
우선순위: null(전체) > 'role'(소유역할) > 'self'(본인)
|
||||
|
||||
- 여러 역할 중 하나라도 scope_type=null → 전체 접근
|
||||
- 전부 non-null이면 가장 넓은 범위 적용 (role > self)
|
||||
- 예: 역할A(scope=self) + 역할B(scope=role) → role 적용
|
||||
```
|
||||
|
||||
### DB 구조
|
||||
|
||||
```text
|
||||
permissions 테이블:
|
||||
- resource_route_key VARCHAR(50) NULL — 라우트 파라미터명 (예: 'user', 'menu', 'product')
|
||||
- owner_key VARCHAR(50) NULL — 소유자 식별 컬럼 (예: 'id', 'created_by', 'user_id')
|
||||
|
||||
role_permissions 피벗:
|
||||
- scope_type ENUM('self', 'role') NULL DEFAULT NULL
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```php
|
||||
// 권한만 체크 (scope는 DB 설정에 따라 자동 적용)
|
||||
Route::get('{user}', [AdminUserController::class, 'show'])
|
||||
->middleware('permission:admin,core.users.read');
|
||||
|
||||
Route::put('{user}', [AdminUserController::class, 'update'])
|
||||
->middleware('permission:admin,core.users.update');
|
||||
|
||||
Route::put('{menu}', [AdminMenuController::class, 'update'])
|
||||
->middleware('permission:admin,core.menus.update');
|
||||
```
|
||||
|
||||
### 목록 엔드포인트 필터링
|
||||
|
||||
미들웨어는 모델 바인딩이 없는 목록 엔드포인트를 **통과**시킵니다.
|
||||
목록 필터링은 **Repository**에서 `PermissionHelper::applyPermissionScope()`로 처리합니다.
|
||||
|
||||
```php
|
||||
// Repository에서 한 줄로 적용
|
||||
$query = User::query();
|
||||
PermissionHelper::applyPermissionScope($query, 'core.users.read');
|
||||
```
|
||||
|
||||
### 관련 핵심 메서드
|
||||
|
||||
| 메서드 | 위치 | 용도 |
|
||||
|--------|------|------|
|
||||
| `PermissionHelper::checkScopeAccess()` | 미들웨어 (상세 접근) | 모델 바인딩된 리소스의 scope 체크 |
|
||||
| `PermissionHelper::applyPermissionScope()` | Repository (목록 필터링) | 쿼리에 scope WHERE 조건 추가 |
|
||||
| `User::getEffectiveScopeForPermission()` | 모델 | union 정책에 따른 effective scope 반환 |
|
||||
|
||||
### resource_route_key/owner_key가 null인 권한
|
||||
|
||||
시스템 리소스 (ActivityLog, Module, Plugin, Template, Permission, Settings 등)는 `resource_route_key`와 `owner_key`가 null이므로 scope 체크가 자동 스킵됩니다.
|
||||
|
||||
### 폐기된 옵션 (DEPRECATED)
|
||||
|
||||
```text
|
||||
아래 옵션들은 제거되었습니다. 사용 시 미들웨어가 인식하지 않습니다.
|
||||
|
||||
- except:self:{param} → scope_type='self'로 대체
|
||||
- except:owner:{param} → scope_type='self'로 대체
|
||||
- only:self:{param} → scope_type='self'로 대체
|
||||
- only:owner:{param} → scope_type='self'로 대체
|
||||
- menu:{slug} → 제거 (메뉴 접근 제어는 scope_type으로 불필요)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OptionalSanctumMiddleware (선택적 인증)
|
||||
|
||||
공개 API이면서 인증된 사용자에게는 추가 정보를 제공해야 하는 경우 사용합니다.
|
||||
|
||||
### 동작 흐름
|
||||
|
||||
```text
|
||||
요청 → Bearer 토큰 확인
|
||||
├── 토큰 없음 → guest로 통과
|
||||
├── 토큰 유효 → Sanctum 인증 진행 (인증된 사용자)
|
||||
├── 토큰 만료 → guest로 통과 (공개 페이지 접근 허용)
|
||||
└── 토큰 무효(위조) → 401 Unauthorized
|
||||
```
|
||||
|
||||
### 등록 방식
|
||||
|
||||
```php
|
||||
// bootstrap/app.php
|
||||
$middleware->alias([
|
||||
'optional.sanctum' => \App\Http\Middleware\OptionalSanctumMiddleware::class,
|
||||
]);
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```php
|
||||
// 레이아웃 API: 비회원도 접근 가능하지만, 인증 사용자에게는 권한 기반 UI 제공
|
||||
Route::middleware('optional.sanctum')
|
||||
->get('/layouts/{name}.json', [LayoutController::class, 'show']);
|
||||
```
|
||||
|
||||
### 일반 Sanctum과의 차이
|
||||
|
||||
| 상황 | `auth:sanctum` | `optional.sanctum` |
|
||||
| ------ | -------------- | -------------------- |
|
||||
| 토큰 없음 | 401 | guest 통과 |
|
||||
| 토큰 유효 | 인증 | 인증 |
|
||||
| 토큰 만료 | 401 | guest 통과 |
|
||||
| 토큰 무효 | 401 | 401 |
|
||||
|
||||
### 구현 파일
|
||||
|
||||
- `app/Http/Middleware/OptionalSanctumMiddleware.php`
|
||||
|
||||
---
|
||||
|
||||
## 문제 상황과 해결책
|
||||
|
||||
### ❌ 잘못된 예시 - 전역 미들웨어로 등록
|
||||
|
||||
```php
|
||||
// ❌ DON'T: 전역 미들웨어로 등록 - 인증 전에 실행됨
|
||||
$middleware->append([
|
||||
\App\Http\Middleware\SetLocale::class,
|
||||
\App\Http\Middleware\SetTimezone::class,
|
||||
]);
|
||||
|
||||
// SetTimezone 미들웨어 내부
|
||||
if (Auth::check()) { // 항상 false! Sanctum 인증 전이므로
|
||||
return Auth::user()->timezone;
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ 올바른 예시 - 그룹 미들웨어로 등록
|
||||
|
||||
```php
|
||||
// ✅ DO: 그룹 미들웨어로 등록 - 인증 후에 실행됨
|
||||
$middleware->appendToGroup('web', [
|
||||
\App\Http\Middleware\SetLocale::class,
|
||||
\App\Http\Middleware\SetTimezone::class,
|
||||
]);
|
||||
$middleware->appendToGroup('api', [
|
||||
\App\Http\Middleware\SetLocale::class,
|
||||
\App\Http\Middleware\SetTimezone::class,
|
||||
]);
|
||||
|
||||
// SetTimezone 미들웨어 내부
|
||||
if (Auth::check()) { // ✅ 정상 작동! Sanctum 인증 후이므로
|
||||
return Auth::user()->timezone;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디버깅 방법
|
||||
|
||||
미들웨어에서 인증 상태 확인이 필요할 때:
|
||||
|
||||
```php
|
||||
// 미들웨어 내부에 로그 추가
|
||||
\Log::info('Middleware debug', [
|
||||
'auth_check' => Auth::check(),
|
||||
'user_id' => Auth::id(),
|
||||
'user_timezone' => Auth::user()?->timezone,
|
||||
]);
|
||||
```
|
||||
|
||||
### 디버깅 결과 해석
|
||||
|
||||
| `auth_check` 값 | 의미 | 조치 |
|
||||
|----------------|------|------|
|
||||
| `false` | 인증 전에 미들웨어 실행됨 | `appendToGroup()`으로 변경 |
|
||||
| `true` | 정상적으로 인증 후 실행됨 | 문제 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 미들웨어 개발 체크리스트
|
||||
|
||||
### 신규 미들웨어 개발 시
|
||||
|
||||
- [ ] `Auth::check()` 또는 `Auth::user()` 사용 여부 확인
|
||||
- [ ] 인증 필요 시 `appendToGroup('web'|'api', ...)` 사용
|
||||
- [ ] 인증 불필요 시 `append()` 또는 `prepend()` 사용
|
||||
- [ ] 로그로 인증 상태 검증
|
||||
- [ ] web과 api 그룹 모두에 등록 필요 여부 확인
|
||||
|
||||
### 등록 위치 결정 가이드
|
||||
|
||||
```
|
||||
미들웨어에서 Auth 사용?
|
||||
├── YES → appendToGroup('web'|'api', ...)
|
||||
└── NO → append() 또는 prepend()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `bootstrap/app.php`: 미들웨어 등록
|
||||
- `app/Http/Middleware/PermissionMiddleware.php`: permission 미들웨어 (scope_type 체크 포함)
|
||||
- `app/Helpers/PermissionHelper.php`: checkScopeAccess, applyPermissionScope 메서드
|
||||
- `app/Models/User.php`: getEffectiveScopeForPermission (union 정책)
|
||||
- `app/Http/Middleware/StartApiSession.php`: API 세션 미들웨어 (로그인/로그아웃 전용)
|
||||
- `app/Http/Middleware/SetLocale.php`: 로케일 설정 미들웨어
|
||||
- `app/Http/Middleware/SetTimezone.php`: 타임존 설정 미들웨어
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [권한 시스템](../extension/permissions.md) - scope_type 시스템 상세, resource_route_key/owner_key 매핑
|
||||
- [인증 시스템](authentication.md) - Sanctum 인증 및 세션 처리
|
||||
- [서비스 프로바이더 안전성](service-provider.md) - 프로바이더 등록 규칙
|
||||
- [백엔드 가이드 인덱스](index.md) - 전체 가이드 목록
|
||||
|
||||
---
|
||||
|
||||
## 참고 이력
|
||||
|
||||
- [SetTimezone 미들웨어 리팩토링](../../history/20251127_1402_SetTimezone미들웨어리팩토링.md)
|
||||
|
||||
### SEO 미들웨어
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 클래스 | `App\Seo\SeoMiddleware` |
|
||||
| 별칭 | `seo` |
|
||||
| 등록 위치 | User catch-all 라우트 그룹에만 |
|
||||
| 금지 | 전역 등록 / Admin 라우트 부착 |
|
||||
|
||||
> 상세: [seo-system.md](seo-system.md)
|
||||
@@ -0,0 +1,185 @@
|
||||
# 알림 시스템 (Notification System)
|
||||
|
||||
> 그누보드7 알림 시스템의 아키텍처와 확장 가이드
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 모든 알림은 BaseNotification 상속 필수 (via() 보일러플레이트 제거)
|
||||
2. getHookPrefix() + getNotificationType() 구현 → 훅명 자동 생성
|
||||
3. toMail()은 각 서브클래스가 직접 구현 (Laravel 규약)
|
||||
4. 각 모듈은 자체 Mailable 클래스 소유 (AuthNotificationMail, BoardNotificationMail)
|
||||
5. 채널 확장: 플러그인이 Filter 훅으로 via() 채널 추가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
### BaseNotification 추상 클래스
|
||||
|
||||
```text
|
||||
BaseNotification (abstract)
|
||||
├── getHookPrefix(): string → 'core.auth', 'sirsoft-board', 'sirsoft-ecommerce'
|
||||
├── getNotificationType(): string → 'welcome', 'new_comment', 'order_confirmed'
|
||||
└── via(): array → HookManager::applyFilters() 자동 호출
|
||||
```
|
||||
|
||||
**위치**: `app/Notifications/BaseNotification.php`
|
||||
|
||||
**역할**: `via()` 메서드만 추상화. `toMail()`은 각 서브클래스가 직접 구현.
|
||||
|
||||
### 훅 이름 규칙
|
||||
|
||||
```text
|
||||
{hookPrefix}.notification.channels
|
||||
```
|
||||
|
||||
| 모듈 | hookPrefix | 훅명 |
|
||||
|------|-----------|------|
|
||||
| 코어 인증 | `core.auth` | `core.auth.notification.channels` |
|
||||
| 게시판 | `sirsoft-board` | `sirsoft-board.notification.channels` |
|
||||
| 이커머스 | `sirsoft-ecommerce` | `sirsoft-ecommerce.notification.channels` |
|
||||
|
||||
---
|
||||
|
||||
## 코어 알림 클래스
|
||||
|
||||
### 인증 알림 (app/Notifications/Auth/)
|
||||
|
||||
| 클래스 | hookPrefix | notificationType | Mailable |
|
||||
|--------|-----------|-----------------|----------|
|
||||
| WelcomeNotification | `core.auth` | `welcome` | AuthNotificationMail |
|
||||
| ResetPasswordNotification | `core.auth` | `reset_password` | AuthNotificationMail |
|
||||
| PasswordChangedNotification | `core.auth` | `password_changed` | AuthNotificationMail |
|
||||
|
||||
### 게시판 알림 (modules/sirsoft-board/src/Notifications/)
|
||||
|
||||
| 클래스 | hookPrefix | notificationType | Mailable |
|
||||
|--------|-----------|-----------------|----------|
|
||||
| NewCommentNotification | `sirsoft-board` | `new_comment` | BoardNotificationMail |
|
||||
| ReplyCommentNotification | `sirsoft-board` | `reply_comment` | BoardNotificationMail |
|
||||
| PostReplyNotification | `sirsoft-board` | `post_reply` | BoardNotificationMail |
|
||||
| PostActionNotification | `sirsoft-board` | `post_action` | BoardNotificationMail |
|
||||
| NewPostAdminNotification | `sirsoft-board` | `new_post_admin` | BoardNotificationMail |
|
||||
|
||||
---
|
||||
|
||||
## 새 알림 클래스 작성법
|
||||
|
||||
### 1. BaseNotification 상속
|
||||
|
||||
```php
|
||||
namespace Modules\Sirsoft\Ecommerce\Notifications;
|
||||
|
||||
use App\Notifications\BaseNotification;
|
||||
use Modules\Sirsoft\Ecommerce\Mail\EcommerceNotificationMail;
|
||||
|
||||
class OrderConfirmedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(
|
||||
private Order $order
|
||||
) {}
|
||||
|
||||
protected function getHookPrefix(): string
|
||||
{
|
||||
return 'sirsoft-ecommerce';
|
||||
}
|
||||
|
||||
protected function getNotificationType(): string
|
||||
{
|
||||
return 'order_confirmed';
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): EcommerceNotificationMail
|
||||
{
|
||||
return new EcommerceNotificationMail(
|
||||
type: 'order_confirmed',
|
||||
order: $this->order,
|
||||
recipient: $notifiable
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Mailable 클래스는 모듈별 소유
|
||||
|
||||
각 모듈은 자체 Mailable 클래스를 만듭니다. 데이터 형태가 모듈마다 근본적으로 다르기 때문입니다.
|
||||
|
||||
| 모듈 | Mailable | 주요 프로퍼티 |
|
||||
|------|----------|-------------|
|
||||
| 코어 인증 | AuthNotificationMail | type, recipient, actionUrl, extraData |
|
||||
| 게시판 | BoardNotificationMail | type, board, post, comment, recipient, commentAuthorName, actionType |
|
||||
| 이커머스 | (직접 구현) | type, order, recipient, ... |
|
||||
|
||||
---
|
||||
|
||||
## 채널 확장 (플러그인)
|
||||
|
||||
### Filter 훅으로 채널 추가
|
||||
|
||||
```php
|
||||
// plugins/sirsoft-site-notification/src/Listeners/SiteNotificationListener.php
|
||||
class SiteNotificationListener implements HookListenerInterface
|
||||
{
|
||||
public function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.auth.notification.channels' => [
|
||||
'method' => 'addDatabaseChannel',
|
||||
'type' => 'filter', // type: 'filter' 필수!
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function addDatabaseChannel(array $channels, string $type, object $notifiable): array
|
||||
{
|
||||
$channels[] = 'database';
|
||||
return $channels;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 커스텀 채널 클래스 (FCM, 카카오톡 등)
|
||||
|
||||
```php
|
||||
// via()에 FQCN 추가 → Laravel이 자동 resolve
|
||||
$channels[] = \Plugins\Sirsoft\Push\Channels\FcmChannel::class;
|
||||
```
|
||||
|
||||
커스텀 채널 클래스는 `send(object $notifiable, Notification $notification)` 메서드를 구현합니다.
|
||||
|
||||
---
|
||||
|
||||
## 테스트
|
||||
|
||||
### BaseNotificationTest
|
||||
|
||||
**위치**: `tests/Unit/Notifications/BaseNotificationTest.php`
|
||||
|
||||
| 테스트 | 검증 항목 |
|
||||
|--------|----------|
|
||||
| extends_laravel_notification | Notification 상속 확인 |
|
||||
| via_returns_mail_channel_by_default | 기본 ['mail'] 반환 |
|
||||
| hook_can_add_channels | Filter 훅으로 채널 추가 |
|
||||
| hook_receives_notification_type | 훅에 type 전달 확인 |
|
||||
| hook_receives_notifiable | 훅에 notifiable 전달 확인 |
|
||||
| hook_name_format | 훅 이름 형식 확인 |
|
||||
| board_module_hook_prefix | 모듈 훅 접두사 확인 |
|
||||
|
||||
### 기존 알림 테스트 (수정 없이 통과)
|
||||
|
||||
- `WelcomeNotificationTest` (6건)
|
||||
- `ResetPasswordNotificationTest` (7건)
|
||||
- `PasswordChangedNotificationTest` (6건)
|
||||
|
||||
---
|
||||
|
||||
## 금지 사항
|
||||
|
||||
```text
|
||||
❌ BaseNotification 없이 Notification 직접 상속
|
||||
❌ via()에서 HookManager 직접 호출 (BaseNotification이 처리)
|
||||
❌ Mailable 추상화 (모듈별 데이터 형태가 다름)
|
||||
❌ 훅 type: 'filter' 누락 (반환값 무시됨)
|
||||
```
|
||||
@@ -0,0 +1,595 @@
|
||||
# API 응답 규칙 (ResponseHelper)
|
||||
|
||||
> **관련 문서**: [index.md](index.md) | [controllers.md](controllers.md) | [api-resources.md](api-resources.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 모든 API 응답은 ResponseHelper 사용
|
||||
2. 인수 순서: success($messageKey, $data) - 메시지가 먼저!
|
||||
3. 데이터 소스 API: dataSource() 메서드 사용
|
||||
4. 페이지네이션: paginated() 또는 LengthAwarePaginator
|
||||
5. 에러: error($messageKey, $statusCode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [ResponseHelper 사용](#responsehelper-사용)
|
||||
2. [데이터 소스용 API](#데이터-소스용-api-)
|
||||
3. [응답 구조](#응답-구조)
|
||||
4. [프론트엔드 바인딩](#프론트엔드-바인딩)
|
||||
5. [구현 예시](#구현-예시)
|
||||
6. [인증 처리](#인증-처리)
|
||||
7. [성능 최적화](#성능-최적화)
|
||||
8. [에러 처리](#에러-처리)
|
||||
9. [페이지네이션 처리](#페이지네이션-처리-)
|
||||
10. [라우트/쿼리 파라미터 처리](#라우트쿼리-파라미터-처리)
|
||||
11. [체크리스트](#체크리스트)
|
||||
12. [안티 패턴 vs 모범 사례](#안티-패턴-vs-모범-사례)
|
||||
13. [HTTP 캐시 (ETag, 304 Not Modified)](#http-캐시-etag-304-not-modified)
|
||||
|
||||
---
|
||||
|
||||
## ResponseHelper 사용
|
||||
|
||||
### 규칙
|
||||
|
||||
- 모든 API 응답은 ResponseHelper 사용
|
||||
- 일관된 응답 구조 유지
|
||||
|
||||
### 메서드 시그니처
|
||||
|
||||
```
|
||||
주의: 인수 순서 확인!
|
||||
success($messageKey, $data) - 메시지가 먼저!
|
||||
❌ success($data, $messageKey) - 잘못된 순서
|
||||
```
|
||||
|
||||
```php
|
||||
// 성공 응답
|
||||
ResponseHelper::success(
|
||||
string $messageKey = 'messages.success', // 첫 번째: 메시지 키
|
||||
mixed $data = null, // 두 번째: 데이터
|
||||
int $statusCode = 200,
|
||||
array $messageParams = [],
|
||||
string $domain = 'core'
|
||||
);
|
||||
|
||||
// 오류 응답
|
||||
ResponseHelper::error(
|
||||
string $messageKey = 'messages.failed', // 첫 번째: 메시지 키
|
||||
int $statusCode = 400,
|
||||
mixed $errors = null,
|
||||
array $messageParams = [],
|
||||
string $domain = 'core'
|
||||
);
|
||||
|
||||
// 검증 오류
|
||||
ResponseHelper::validationError($errors, $messageKey = 'messages.validation_failed');
|
||||
|
||||
// 404 응답
|
||||
ResponseHelper::notFound($messageKey = 'messages.not_found');
|
||||
|
||||
// 401 응답
|
||||
ResponseHelper::unauthorized($messageKey = 'messages.unauthorized');
|
||||
|
||||
// 403 응답
|
||||
ResponseHelper::forbidden($messageKey = 'messages.forbidden');
|
||||
|
||||
// 500 응답
|
||||
ResponseHelper::serverError($messageKey = 'messages.error_occurred', $error = null);
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```php
|
||||
// ✅ 올바른 사용
|
||||
return ResponseHelper::success('messages.success', $data);
|
||||
return ResponseHelper::success('plugins.settings.updated', $settings);
|
||||
|
||||
// ❌ 잘못된 사용 - TypeError 발생!
|
||||
return ResponseHelper::success($data, 'messages.success'); // 인수 순서 틀림
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 소스용 API ⭐
|
||||
|
||||
**정의**: 템플릿 레이아웃 JSON의 `data_sources`에서 호출되는 API 엔드포인트
|
||||
|
||||
**목적**:
|
||||
- 프론트엔드 레이아웃 렌더링 시 필요한 데이터 제공
|
||||
- DataSourceManager를 통한 자동 fetch 지원
|
||||
- Progressive loading을 통한 빠른 초기 렌더링
|
||||
|
||||
### 필수 요구사항
|
||||
|
||||
```
|
||||
필수: 데이터 소스용 API는 아래 규칙 준수
|
||||
필수: ResponseHelper 사용
|
||||
✅ 필수: 인증 필요 시 auth_required: true 설정
|
||||
✅ 권장: 응답 시간 200ms 이내
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 응답 구조
|
||||
|
||||
데이터 소스용 API는 반드시 `ResponseHelper::success()`를 사용하여 다음 형태로 응답해야 합니다:
|
||||
|
||||
```php
|
||||
// 표준 응답 구조
|
||||
return ResponseHelper::success('messages.success', $data);
|
||||
|
||||
// 실제 응답 JSON
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
// 실제 데이터 (이 부분이 프론트엔드에서 {{dataSourceId.data}}로 바인딩됨)
|
||||
},
|
||||
"message": null,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 바인딩
|
||||
|
||||
```json
|
||||
{
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "admin_menu",
|
||||
"endpoint": "/api/admin/menus"
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"props": {
|
||||
"menu": "{{admin_menu.data}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
위 예시에서 `{{admin_menu.data}}`는 API 응답의 `data` 필드를 참조합니다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 예시
|
||||
|
||||
### 기본 컨트롤러 구현
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Services\MenuService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MenuController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private MenuService $menuService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 관리자 메뉴 목록 조회 (데이터 소스용)
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
// 1. 서비스에서 데이터 조회
|
||||
$menus = $this->menuService->getAdminMenus();
|
||||
|
||||
// 2. ResponseHelper로 응답 (필수)
|
||||
return ResponseHelper::success('messages.success', $menus);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API 리소스 사용 예시
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Auth;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Resources\Admin\UserResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends AdminBaseController
|
||||
{
|
||||
/**
|
||||
* 현재 인증된 사용자 정보 조회 (데이터 소스용)
|
||||
*/
|
||||
public function user(): JsonResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// API 리소스로 변환 후 ResponseHelper 사용
|
||||
return ResponseHelper::success('messages.success', new UserResource($user));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 인증 처리
|
||||
|
||||
데이터 소스에서 `auth_required: true`로 설정된 경우:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "current_user",
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/auth/user",
|
||||
"auth_required": true
|
||||
}
|
||||
```
|
||||
|
||||
백엔드에서는 라우트에 인증 미들웨어를 반드시 적용해야 합니다:
|
||||
|
||||
```php
|
||||
// routes/api.php
|
||||
Route::middleware(['auth:sanctum', 'type:admin'])->group(function () {
|
||||
Route::get('/admin/auth/user', [AuthController::class, 'user']);
|
||||
Route::get('/admin/menus', [MenuController::class, 'index']);
|
||||
Route::get('/admin/notifications', [NotificationController::class, 'index']);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
데이터 소스 API는 레이아웃 로딩 시 자동으로 호출되므로 성능이 중요합니다:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class MenuService
|
||||
{
|
||||
/**
|
||||
* 관리자 메뉴 조회 (캐싱 적용)
|
||||
*/
|
||||
public function getAdminMenus(): array
|
||||
{
|
||||
// 1. 캐시 사용 (권장)
|
||||
return Cache::remember('admin_menus', 3600, function () {
|
||||
return $this->repository->getActiveMenus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* N+1 쿼리 방지
|
||||
*/
|
||||
public function getMenusWithPermissions(): array
|
||||
{
|
||||
// 2. Eager Loading 사용
|
||||
return $this->repository
|
||||
->with(['permissions', 'children'])
|
||||
->where('active', true)
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 에러 처리
|
||||
|
||||
데이터 소스 API에서 에러 발생 시:
|
||||
|
||||
```php
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$data = $this->service->getData();
|
||||
return ResponseHelper::success('messages.success', $data);
|
||||
} catch (\Exception $e) {
|
||||
// 에러 로그 기록
|
||||
Log::error('Failed to fetch data source', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// 클라이언트에 에러 응답
|
||||
return ResponseHelper::error(
|
||||
__('messages.failed_to_load_data'),
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
프론트엔드에서는:
|
||||
- `loading_strategy: "progressive"`: 에러 발생 시에도 렌더링 계속 (빈 데이터)
|
||||
- `loading_strategy: "blocking"`: 에러 발생 시 빈 데이터로 렌더링
|
||||
- `DataSourceManagerOptions.onError` 콜백으로 에러 핸들링
|
||||
|
||||
---
|
||||
|
||||
## 페이지네이션 처리 ⭐
|
||||
|
||||
목록 API에서 페이지네이션 사용 시, 반드시 `pagination` 객체로 분리하여 반환:
|
||||
|
||||
```php
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = $request->input('per_page', 15);
|
||||
$page = $request->input('page', 1);
|
||||
|
||||
$users = $this->userService->getPaginatedUsers($perPage, $page);
|
||||
|
||||
// ✅ 표준 페이지네이션 응답 구조
|
||||
return ResponseHelper::success('messages.success', [
|
||||
'data' => $users->items(),
|
||||
'pagination' => [
|
||||
'total' => $users->total(),
|
||||
'from' => $users->firstItem() ?? 0,
|
||||
'to' => $users->lastItem() ?? 0,
|
||||
'per_page' => $users->perPage(),
|
||||
'current_page' => $users->currentPage(),
|
||||
'last_page' => $users->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### 프론트엔드 바인딩 경로
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "$t:admin.users.pagination_info|total={{users?.data?.pagination?.total ?? 0}}|from={{users?.data?.pagination?.from ?? 0}}|to={{users?.data?.pagination?.to ?? 0}}"
|
||||
}
|
||||
```
|
||||
|
||||
> **주의**: `data.pagination.total`이 올바른 경로입니다. `data.data.total`이 아님!
|
||||
|
||||
---
|
||||
|
||||
## 라우트/쿼리 파라미터 처리
|
||||
|
||||
데이터 소스에서 파라미터 바인딩:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "product_detail",
|
||||
"endpoint": "/api/admin/products/{{route.id}}",
|
||||
"params": {
|
||||
"include": "{{query.include}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
백엔드 컨트롤러:
|
||||
|
||||
```php
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$include = $request->input('include', []);
|
||||
|
||||
$product = $this->productService->getProductById($id, $include);
|
||||
|
||||
if (!$product) {
|
||||
return ResponseHelper::notFound('messages.product_not_found');
|
||||
}
|
||||
|
||||
return ResponseHelper::success('messages.success', $product);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
데이터 소스용 API 구현 시 반드시 확인:
|
||||
|
||||
```
|
||||
□ ResponseHelper::success() 사용
|
||||
□ auth_required: true 시 인증 미들웨어 적용
|
||||
□ 응답 시간 200ms 이내 (캐싱, Eager Loading 등)
|
||||
□ N+1 쿼리 방지
|
||||
□ API 리소스로 데이터 변환 (일관성)
|
||||
□ 에러 핸들링 (try-catch)
|
||||
□ 페이지네이션 표준 구조
|
||||
□ 로깅 (에러 발생 시)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 안티 패턴 vs 모범 사례
|
||||
|
||||
### 안티 패턴
|
||||
|
||||
```php
|
||||
// ❌ DON'T: ResponseHelper 미사용
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$data = $this->service->getData();
|
||||
return response()->json(['data' => $data]); // 구조 불일치
|
||||
}
|
||||
|
||||
// ❌ DON'T: 직접 배열 반환
|
||||
public function index(): array
|
||||
{
|
||||
return $this->service->getData(); // JsonResponse 타입 위반
|
||||
}
|
||||
|
||||
// ❌ DON'T: 인증 미들웨어 누락
|
||||
// auth_required: true인데 라우트에 인증 미들웨어 없음
|
||||
Route::get('/admin/menus', [MenuController::class, 'index']); // 취약점
|
||||
|
||||
// ❌ DON'T: 느린 응답 (500ms+)
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$data = $this->repository->all(); // N+1 쿼리 발생
|
||||
return ResponseHelper::success('messages.success', $data);
|
||||
}
|
||||
```
|
||||
|
||||
### 모범 사례
|
||||
|
||||
```php
|
||||
// ✅ DO: 완전한 구현 예시
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Resources\Admin\MenuResource;
|
||||
use App\Services\MenuService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MenuController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private MenuService $menuService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 관리자 메뉴 목록 조회
|
||||
*
|
||||
* 데이터 소스에서 사용:
|
||||
* - endpoint: /api/admin/menus
|
||||
* - auth_required: true
|
||||
* - loading_strategy: progressive
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
try {
|
||||
// 1. 서비스에서 캐시된 데이터 조회
|
||||
$menus = $this->menuService->getAdminMenus();
|
||||
|
||||
// 2. API 리소스로 변환 (일관성)
|
||||
$resource = MenuResource::collection($menus);
|
||||
|
||||
// 3. ResponseHelper로 응답 (필수)
|
||||
return ResponseHelper::success('messages.success', $resource);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// 4. 에러 로그 기록
|
||||
Log::error('Failed to load admin menus', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// 5. 에러 응답 (프론트엔드에서 처리)
|
||||
return ResponseHelper::error(
|
||||
__('messages.failed_to_load_menus'),
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP 캐시 (ETag, 304 Not Modified)
|
||||
|
||||
정적 데이터나 변경 빈도가 낮은 API에는 HTTP 캐시를 적용하여 전송 효율성을 높일 수 있습니다.
|
||||
|
||||
### successWithCache 사용
|
||||
|
||||
`BaseApiController`에서 제공하는 `successWithCache()` 메서드를 사용합니다:
|
||||
|
||||
```php
|
||||
// 캐시 헤더와 함께 성공 응답 반환
|
||||
return $this->successWithCache(
|
||||
'messages.success', // 메시지 키
|
||||
$data, // 응답 데이터
|
||||
3600 // 캐시 TTL (초, 기본: 1시간)
|
||||
);
|
||||
```
|
||||
|
||||
### 동작 방식
|
||||
|
||||
1. **첫 요청**: ETag 생성 → 200 OK + `ETag`, `Cache-Control` 헤더
|
||||
2. **재요청**: 클라이언트가 `If-None-Match` 헤더로 ETag 전송
|
||||
3. **ETag 일치**: 304 Not Modified (본문 없음, 헤더만)
|
||||
4. **ETag 불일치**: 새 데이터와 함께 200 OK
|
||||
|
||||
### 응답 헤더
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: max-age=3600, public
|
||||
ETag: "7d74afa3f6f8c8a3992772a87b3475b1"
|
||||
Vary: Accept-Encoding, Accept-Language
|
||||
```
|
||||
|
||||
### 적용 대상
|
||||
|
||||
| 적합 | 부적합 |
|
||||
| ---------------- | ------------------ |
|
||||
| 레이아웃 JSON | 실시간 데이터 |
|
||||
| 메뉴 목록 | 사용자별 데이터 |
|
||||
| 설정/환경값 | 자주 변경되는 목록 |
|
||||
| 번역 데이터 | 인증 정보 |
|
||||
|
||||
### PublicLayoutController 예시
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Public;
|
||||
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
|
||||
class PublicLayoutController extends PublicBaseController
|
||||
{
|
||||
private const CACHE_TTL = 3600;
|
||||
|
||||
public function serve(string $templateId, string $layoutName): JsonResponse|Response
|
||||
{
|
||||
// 서버 측 캐싱
|
||||
$mergedLayout = $this->cached(
|
||||
"layout.{$templateId}.{$layoutName}",
|
||||
fn () => $this->layoutService->loadAndMergeLayout($templateId, $layoutName),
|
||||
self::CACHE_TTL
|
||||
);
|
||||
|
||||
// HTTP 캐시 헤더 (ETag, Cache-Control) 포함 응답
|
||||
return $this->successWithCache(
|
||||
'messages.layout_served',
|
||||
$mergedLayout,
|
||||
self::CACHE_TTL
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 관련 메서드 (BaseApiController)
|
||||
|
||||
| 메서드 | 설명 |
|
||||
| ---------------------------------------------- | -------------------------------- |
|
||||
| `generateETag($data)` | 데이터 기반 ETag 생성 |
|
||||
| `isNotModified($etag)` | 클라이언트 캐시 유효성 확인 |
|
||||
| `notModifiedResponse($etag, $maxAge)` | 304 응답 반환 |
|
||||
| `successWithCache($message, $data, $maxAge)` | ETag + Cache-Control 포함 응답 |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](index.md) - 백엔드 가이드 인덱스
|
||||
- [controllers.md](controllers.md) - 컨트롤러 계층 구조
|
||||
- [api-resources.md](api-resources.md) - API 리소스 규칙
|
||||
- [routing.md](routing.md) - 라우트 네이밍 및 경로
|
||||
@@ -0,0 +1,277 @@
|
||||
# 라우트 네이밍 및 경로
|
||||
|
||||
> **상위 문서**: [백엔드 가이드 인덱스](./index.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 모든 라우트는 name() 필수: ->name('api.users.index')
|
||||
2. 접두사: api.*, web.*, vendor-module.*
|
||||
3. URL: /api/admin/*, /api/auth/*, /api/public/*
|
||||
4. 권한: permission: 또는 Middleware에서 체크
|
||||
5. REST 패턴: index, store, show, update, destroy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [라우트 이름 규칙](#라우트-이름-규칙)
|
||||
2. [URL 경로 규칙](#url-경로-규칙)
|
||||
3. [권한 체크](#권한-체크)
|
||||
4. [라우트 정의 예시](#라우트-정의-예시)
|
||||
5. [개발 체크리스트](#개발-체크리스트)
|
||||
|
||||
---
|
||||
|
||||
## 라우트 이름 규칙
|
||||
|
||||
### 필수 원칙
|
||||
|
||||
- **모든 라우트는 `name` 필수**
|
||||
- 일관된 네이밍 컨벤션 준수
|
||||
|
||||
### 접두사 규칙
|
||||
|
||||
| 타입 | 접두사 | 예시 |
|
||||
|------|--------|------|
|
||||
| API | `api.` | `api.users.index` |
|
||||
| WEB | `web.` | `web.dashboard` |
|
||||
| 모듈 | `[vendor-module].` | `sirsoft-ecommerce.products.index` |
|
||||
| 플러그인 | `[vendor-plugin].` | `sirsoft-payment.settings` |
|
||||
|
||||
### 조합 규칙
|
||||
|
||||
```php
|
||||
// 코어 API
|
||||
->name('api.users.index')
|
||||
->name('api.users.store')
|
||||
|
||||
// 모듈 API
|
||||
->name('api.sirsoft-ecommerce.products.index')
|
||||
->name('api.sirsoft-ecommerce.products.store')
|
||||
|
||||
// 플러그인 API
|
||||
->name('api.sirsoft-payment.transactions.index')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URL 경로 규칙
|
||||
|
||||
### 경로 패턴
|
||||
|
||||
| 타입 | 패턴 | 예시 |
|
||||
|------|------|------|
|
||||
| 코어 | `/admin/[기능명]` | `/admin/users` |
|
||||
| 모듈 | `/admin/[vendor-module]/[기능명]` | `/admin/sirsoft-ecommerce/products` |
|
||||
| 플러그인 | `/admin/[vendor-plugin]/[기능명]` | `/admin/sirsoft-payment/settings` |
|
||||
| 공개 API | `/api/[vendor-module]/[기능명]` | `/api/sirsoft-ecommerce/products` |
|
||||
|
||||
### 리소스 URL 규칙
|
||||
|
||||
```text
|
||||
# 목록 조회
|
||||
GET /admin/sirsoft-ecommerce/products
|
||||
|
||||
# 단일 조회
|
||||
GET /admin/sirsoft-ecommerce/products/{id}
|
||||
|
||||
# 생성
|
||||
POST /admin/sirsoft-ecommerce/products
|
||||
|
||||
# 수정
|
||||
PUT /admin/sirsoft-ecommerce/products/{id}
|
||||
|
||||
# 삭제
|
||||
DELETE /admin/sirsoft-ecommerce/products/{id}
|
||||
```
|
||||
|
||||
### 라이선스 API 라우트
|
||||
|
||||
코어 및 확장의 LICENSE 파일 내용을 반환하는 API 엔드포인트입니다.
|
||||
|
||||
```text
|
||||
GET /api/admin/license # 코어 LICENSE 반환
|
||||
GET /api/admin/modules/{identifier}/license # 모듈 LICENSE 반환
|
||||
GET /api/admin/plugins/{identifier}/license # 플러그인 LICENSE 반환
|
||||
GET /api/admin/templates/{identifier}/license # 템플릿 LICENSE 반환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 권한 체크
|
||||
|
||||
### 권한 체크 방식
|
||||
|
||||
```text
|
||||
주의: FormRequest의 authorize() 메서드 사용 금지
|
||||
필수: 라우트에 permission 미들웨어 체인
|
||||
```
|
||||
|
||||
### 권한 미들웨어 사용
|
||||
|
||||
```php
|
||||
// ✅ DO: 라우트에 permission 미들웨어 사용
|
||||
Route::get('/products', [ProductController::class, 'index'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.view')
|
||||
->name('api.sirsoft-ecommerce.products.index');
|
||||
|
||||
// ❌ DON'T: FormRequest에서 권한 체크
|
||||
class ProductRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// 이 방식 사용 금지
|
||||
return $this->user()->can('view-products');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 권한 네이밍 규칙
|
||||
|
||||
```text
|
||||
[vendor-module].[resource].[action]
|
||||
|
||||
예시:
|
||||
sirsoft-ecommerce.products.view
|
||||
sirsoft-ecommerce.products.create
|
||||
sirsoft-ecommerce.products.edit
|
||||
sirsoft-ecommerce.products.delete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 라우트 정의 예시
|
||||
|
||||
### 모듈 라우트 파일
|
||||
|
||||
```php
|
||||
// modules/sirsoft-ecommerce/src/routes/api.php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Sirsoft\Ecommerce\Controllers\Api\Admin\ProductController;
|
||||
|
||||
Route::prefix('admin/sirsoft-ecommerce')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
// 상품 관리 (권한 체크 포함)
|
||||
Route::get('/products', [ProductController::class, 'index'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.view')
|
||||
->name('api.sirsoft-ecommerce.products.index');
|
||||
|
||||
Route::post('/products', [ProductController::class, 'store'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.create')
|
||||
->name('api.sirsoft-ecommerce.products.store');
|
||||
|
||||
Route::get('/products/{id}', [ProductController::class, 'show'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.view')
|
||||
->name('api.sirsoft-ecommerce.products.show');
|
||||
|
||||
Route::put('/products/{id}', [ProductController::class, 'update'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.edit')
|
||||
->name('api.sirsoft-ecommerce.products.update');
|
||||
|
||||
Route::delete('/products/{id}', [ProductController::class, 'destroy'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.delete')
|
||||
->name('api.sirsoft-ecommerce.products.destroy');
|
||||
});
|
||||
```
|
||||
|
||||
### 코어 라우트 파일
|
||||
|
||||
```php
|
||||
// routes/api.php
|
||||
|
||||
use App\Http\Controllers\Api\Admin\UserController;
|
||||
|
||||
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
// 사용자 관리
|
||||
Route::get('/users', [UserController::class, 'index'])
|
||||
->middleware('permission:users.view')
|
||||
->name('api.users.index');
|
||||
|
||||
Route::post('/users', [UserController::class, 'store'])
|
||||
->middleware('permission:users.create')
|
||||
->name('api.users.store');
|
||||
});
|
||||
```
|
||||
|
||||
### 권한 바이패스 라우트 (except 옵션)
|
||||
|
||||
자기 자신 또는 소유자에 대해 권한 체크를 바이패스하는 라우트:
|
||||
|
||||
```php
|
||||
// routes/api.php
|
||||
|
||||
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
// 사용자 수정: 자기 자신은 core.users.update 권한 없이 수정 가능
|
||||
Route::put('/users/{user}', [UserController::class, 'update'])
|
||||
->middleware('permission:admin,core.users.update,except:self:user')
|
||||
->name('api.admin.users.update');
|
||||
|
||||
// 메뉴 수정: 소유자는 core.menus.update 권한 없이 수정 가능 (향후 적용 예시)
|
||||
Route::put('/menus/{menu}', [MenuController::class, 'update'])
|
||||
->middleware('permission:admin,core.menus.update,except:owner:menu')
|
||||
->name('api.admin.menus.update');
|
||||
});
|
||||
```
|
||||
|
||||
> 상세 문법: [middleware.md](middleware.md) "permission 미들웨어 except 옵션" 참조
|
||||
|
||||
### 공개 API 라우트
|
||||
|
||||
```php
|
||||
// modules/sirsoft-ecommerce/src/routes/api.php
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Controllers\Api\Public\ProductController;
|
||||
|
||||
Route::prefix('api/sirsoft-ecommerce')->group(function () {
|
||||
// 공개 상품 API (인증 불필요)
|
||||
Route::get('/products', [ProductController::class, 'index'])
|
||||
->name('api.sirsoft-ecommerce.public.products.index');
|
||||
|
||||
Route::get('/products/{id}', [ProductController::class, 'show'])
|
||||
->name('api.sirsoft-ecommerce.public.products.show');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 개발 체크리스트
|
||||
|
||||
### 라우트 정의 시 확인사항
|
||||
|
||||
- [ ] 라우트에 `name()` 메서드로 이름 지정
|
||||
- [ ] 적절한 접두사 사용 (api., web., vendor-module.)
|
||||
- [ ] URL 경로 규칙 준수 (/admin/[vendor-module]/[기능명])
|
||||
- [ ] 권한이 필요한 라우트에 `permission` 미들웨어 적용
|
||||
- [ ] 인증이 필요한 라우트에 `auth:sanctum` 미들웨어 적용
|
||||
- [ ] 관리자 라우트에 `admin` 미들웨어 적용
|
||||
- [ ] FormRequest의 `authorize()` 메서드에서 권한 체크하지 않음
|
||||
|
||||
### 라우트 테스트 확인사항
|
||||
|
||||
- [ ] 인증 없이 접근 시 401 반환
|
||||
- [ ] 권한 없이 접근 시 403 반환
|
||||
- [ ] 올바른 권한으로 접근 시 성공
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컨트롤러 계층 구조](./controllers.md) - 컨트롤러 네이밍 규칙
|
||||
- [미들웨어 등록 규칙](./middleware.md) - 미들웨어 실행 순서
|
||||
- [검증 로직 구현](./validation.md) - FormRequest 사용 규칙
|
||||
|
||||
### SEO 라우트
|
||||
|
||||
| URL | 메서드 | 라우트명 | 컨트롤러 | 비고 |
|
||||
|-----|--------|---------|---------|------|
|
||||
| /sitemap.xml | GET | web.sitemap | SitemapController@index | catch-all보다 위에 정의 |
|
||||
| /api/admin/seo/stats | GET | api.admin.seo.stats | SeoCacheController | 관리자 전용 |
|
||||
| /api/admin/seo/clear-cache | POST | api.admin.seo.clear-cache | SeoCacheController | 관리자 전용 |
|
||||
| /api/admin/seo/warmup | POST | api.admin.seo.warmup | SeoCacheController | 관리자 전용 |
|
||||
| /api/admin/seo/cached-urls | GET | api.admin.seo.cached-urls | SeoCacheController | 관리자 전용 |
|
||||
|
||||
> 상세: [seo-system.md](seo-system.md)
|
||||
@@ -0,0 +1,370 @@
|
||||
# Scout 검색 엔진 시스템 (Search System)
|
||||
|
||||
> **중요도**: 높음
|
||||
> **관련 문서**: [service-repository.md](service-repository.md) | [hooks.md](../extension/hooks.md) | [database-guide.md](../database-guide.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ngram 기반 검색 (기본 드라이버)
|
||||
2. FulltextSearchable 인터페이스: searchableColumns() + searchableWeights() 구현 필수
|
||||
3. LIKE fallback 자동 적용: FULLTEXT 미지원 DBMS(SQLite, PostgreSQL)에서 자동 전환
|
||||
4. 확장 포인트: core.search.engine_drivers 필터 훅으로 Meilisearch 등 커스텀 엔진 등록 가능
|
||||
5. AsUnicodeJson 캐스트: JSON 컬럼 FULLTEXT 검색 시 한글 \uXXXX 이스케이프 방지 필수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [아키텍처 개요](#아키텍처-개요)
|
||||
2. [FulltextSearchable 인터페이스](#fulltextsearchable-인터페이스)
|
||||
3. [검색 엔진 드라이버](#검색-엔진-드라이버)
|
||||
4. [확장 포인트](#확장-포인트)
|
||||
5. [마이그레이션](#마이그레이션)
|
||||
6. [AsUnicodeJson 캐스트](#asunicodejson-캐스트)
|
||||
7. [환경설정](#환경설정)
|
||||
8. [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 개요
|
||||
|
||||
G7은 **Laravel Scout**를 통해 검색 기능을 제공하며, 기본 검색 엔진으로 **DatabaseFulltextEngine**을 사용합니다.
|
||||
|
||||
**핵심 구조**:
|
||||
|
||||
```
|
||||
Controller/Service
|
||||
↓ Model::search($keyword)
|
||||
Laravel Scout (EngineManager)
|
||||
↓ SCOUT_DRIVER 기반 엔진 선택
|
||||
DatabaseFulltextEngine
|
||||
↓ FulltextSearchable 인터페이스 참조
|
||||
├── MySQL/MariaDB → MATCH...AGAINST IN BOOLEAN MODE
|
||||
└── SQLite/PostgreSQL → LIKE fallback
|
||||
```
|
||||
|
||||
**핵심 컴포넌트**:
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `app/Search/Contracts/FulltextSearchable.php` | 검색 대상 컬럼/가중치 정의 인터페이스 |
|
||||
| `app/Search/Engines/DatabaseFulltextEngine.php` | MySQL FULLTEXT + ngram Scout 엔진 |
|
||||
| `app/Providers/ScoutServiceProvider.php` | 엔진 등록 + 필터 훅 처리 |
|
||||
| `app/Casts/AsUnicodeJson.php` | FULLTEXT ngram용 UTF-8 JSON 캐스트 |
|
||||
| `config/scout.php` | Scout 설정 (드라이버, 큐, 소프트삭제 등) |
|
||||
|
||||
**설계 원칙**:
|
||||
|
||||
- MySQL 테이블 자체가 인덱스 소스 -- 외부 검색 서버 불필요
|
||||
- `update()`, `delete()`, `flush()`, `createIndex()`, `deleteIndex()`는 모두 **no-op** (MySQL이 자동 관리)
|
||||
- FULLTEXT 미지원 DBMS에서 LIKE fallback 자동 적용 (테스트 환경 SQLite 호환)
|
||||
|
||||
---
|
||||
|
||||
## FulltextSearchable 인터페이스
|
||||
|
||||
`App\Search\Contracts\FulltextSearchable` 인터페이스를 구현한 모델만 DatabaseFulltextEngine에서 검색됩니다.
|
||||
|
||||
### 필수 메서드
|
||||
|
||||
| 메서드 | 반환 타입 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `searchableColumns()` | `array<string>` | FULLTEXT 검색 대상 컬럼명 배열 |
|
||||
| `searchableWeights()` | `array<string, float>` | 컬럼별 검색 가중치 (높을수록 상위 노출) |
|
||||
|
||||
### 구현 예시 (Product 모델)
|
||||
|
||||
```php
|
||||
use App\Search\Contracts\FulltextSearchable;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class Product extends Model implements FulltextSearchable
|
||||
{
|
||||
use Searchable;
|
||||
|
||||
// FULLTEXT 검색 대상 컬럼
|
||||
public function searchableColumns(): array
|
||||
{
|
||||
return ['name', 'description'];
|
||||
}
|
||||
|
||||
// 컬럼별 가중치 (name 매칭이 description보다 2배 높은 점수)
|
||||
public function searchableWeights(): array
|
||||
{
|
||||
return [
|
||||
'name' => 2.0,
|
||||
'description' => 1.0,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 가중치 기반 스코어 계산
|
||||
|
||||
검색 결과는 `_ft_score` 가상 컬럼으로 관련성 점수가 부여됩니다:
|
||||
|
||||
```sql
|
||||
-- MySQL에서 생성되는 쿼리 (예시)
|
||||
SELECT products.*,
|
||||
(MATCH(`name`) AGAINST(? IN BOOLEAN MODE) * 2.0
|
||||
+ MATCH(`description`) AGAINST(? IN BOOLEAN MODE) * 1.0) as _ft_score
|
||||
FROM products
|
||||
WHERE (MATCH(`name`) AGAINST(? IN BOOLEAN MODE)
|
||||
OR MATCH(`description`) AGAINST(? IN BOOLEAN MODE))
|
||||
ORDER BY _ft_score DESC
|
||||
```
|
||||
|
||||
LIKE fallback 시 `_ft_score`는 항상 0 (관련성 순위 불가).
|
||||
|
||||
### 현재 FulltextSearchable 구현 모델
|
||||
|
||||
| 모듈 | 모델 | 검색 컬럼 |
|
||||
|------|------|----------|
|
||||
| sirsoft-ecommerce | Product | name, description |
|
||||
| sirsoft-ecommerce | Category | name |
|
||||
| sirsoft-ecommerce | Brand | name |
|
||||
| sirsoft-ecommerce | Coupon | name |
|
||||
| sirsoft-ecommerce | ProductCommonInfo | name |
|
||||
| sirsoft-board | Post | (모델 참조) |
|
||||
| sirsoft-page | Page | (모델 참조) |
|
||||
|
||||
---
|
||||
|
||||
## 검색 엔진 드라이버
|
||||
|
||||
### 기본 드라이버: mysql-fulltext
|
||||
|
||||
`DatabaseFulltextEngine`은 MySQL FULLTEXT + ngram 파서를 활용합니다:
|
||||
|
||||
- **MySQL 8.0+**: `MATCH...AGAINST IN BOOLEAN MODE` + **ngram 파서** (한글 2글자 토큰 분리)
|
||||
- **MariaDB**: `MATCH...AGAINST IN BOOLEAN MODE` + 기본 파서 (ngram 미지원)
|
||||
- **SQLite/PostgreSQL**: `LIKE %keyword%` fallback (개발/테스트 환경 호환)
|
||||
|
||||
### DBMS 지원 판단
|
||||
|
||||
```php
|
||||
// FULLTEXT 지원 여부 (MySQL, MariaDB만 true)
|
||||
DatabaseFulltextEngine::supportsFulltext();
|
||||
|
||||
// ngram 파서 지원 여부 (MySQL만 true, MariaDB는 false)
|
||||
DatabaseFulltextEngine::supportsNgramParser();
|
||||
```
|
||||
|
||||
### SCOUT_DRIVER 전환
|
||||
|
||||
`.env`에서 드라이버를 변경하면 즉시 적용됩니다:
|
||||
|
||||
```env
|
||||
# 기본값: MySQL FULLTEXT
|
||||
SCOUT_DRIVER=mysql-fulltext
|
||||
|
||||
# 플러그인에서 등록한 드라이버로 전환
|
||||
SCOUT_DRIVER=meilisearch
|
||||
```
|
||||
|
||||
### whereFulltext() 정적 헬퍼
|
||||
|
||||
Scout Builder를 사용할 수 없는 곳 (관계 검색, 서브쿼리 등)에서 FULLTEXT 조건을 직접 추가합니다:
|
||||
|
||||
```php
|
||||
use App\Search\Engines\DatabaseFulltextEngine;
|
||||
|
||||
// Repository에서 사용 예시
|
||||
$query = Post::query();
|
||||
DatabaseFulltextEngine::whereFulltext($query, 'content', $keyword);
|
||||
DatabaseFulltextEngine::whereFulltext($query, 'title', $keyword, 'or');
|
||||
```
|
||||
|
||||
DBMS에 따라 자동 분기:
|
||||
- MySQL/MariaDB: `WHERE MATCH(\`content\`) AGAINST(? IN BOOLEAN MODE)`
|
||||
- 그 외: `WHERE content LIKE '%keyword%'`
|
||||
|
||||
---
|
||||
|
||||
## 확장 포인트
|
||||
|
||||
### core.search.engine_drivers 필터 훅
|
||||
|
||||
`ScoutServiceProvider`에서 `core.search.engine_drivers` 필터 훅을 통해 플러그인이 추가 검색 엔진을 등록할 수 있습니다.
|
||||
|
||||
```php
|
||||
// 플러그인 ServiceProvider에서 등록
|
||||
use App\Extension\HookManager;
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
HookManager::addFilter('core.search.engine_drivers', function (array $drivers) {
|
||||
$drivers['meilisearch'] = \App\Search\Engines\MeilisearchEngine::class;
|
||||
return $drivers;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
등록 후 `.env`에서 `SCOUT_DRIVER=meilisearch`로 전환하면 해당 엔진이 사용됩니다.
|
||||
|
||||
### ScoutServiceProvider 동작 흐름
|
||||
|
||||
```php
|
||||
// app/Providers/ScoutServiceProvider.php
|
||||
|
||||
// 1. 기본 드라이버 맵
|
||||
$drivers = ['mysql-fulltext' => DatabaseFulltextEngine::class];
|
||||
|
||||
// 2. 필터 훅으로 플러그인 드라이버 수집
|
||||
$drivers = HookManager::applyFilters('core.search.engine_drivers', $drivers);
|
||||
|
||||
// 3. EngineManager에 모든 드라이버 등록
|
||||
$this->app->resolving(EngineManager::class, function (EngineManager $manager) use ($drivers) {
|
||||
foreach ($drivers as $name => $engineClass) {
|
||||
$manager->extend($name, fn () => $this->app->make($engineClass));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
### addFulltextIndex() 헬퍼
|
||||
|
||||
`DatabaseFulltextEngine::addFulltextIndex()`는 DBMS별 조건부 DDL을 처리합니다:
|
||||
|
||||
```php
|
||||
use App\Search\Engines\DatabaseFulltextEngine;
|
||||
|
||||
// 마이그레이션 up()에서 사용
|
||||
public function up(): void
|
||||
{
|
||||
DatabaseFulltextEngine::addFulltextIndex(
|
||||
'ecommerce_products', // 테이블명 (prefix 제외)
|
||||
'ft_ecommerce_products_name', // 인덱스명
|
||||
'name' // 대상 컬럼 (string 또는 array)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**DBMS별 동작**:
|
||||
|
||||
| DBMS | 생성되는 DDL |
|
||||
|------|-------------|
|
||||
| MySQL 8.0+ | `ALTER TABLE ... ADD FULLTEXT INDEX ... WITH PARSER ngram` |
|
||||
| MariaDB | `ALTER TABLE ... ADD FULLTEXT INDEX ...` (ngram 없음) |
|
||||
| SQLite/PostgreSQL | 스킵 (no-op) |
|
||||
|
||||
### 마이그레이션 down() 패턴
|
||||
|
||||
```php
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('ecommerce_products')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexes = array_column(Schema::getIndexes('ecommerce_products'), 'name');
|
||||
|
||||
Schema::table('ecommerce_products', function (Blueprint $table) use ($indexes) {
|
||||
if (in_array('ft_ecommerce_products_name', $indexes)) {
|
||||
$table->dropIndex('ft_ecommerce_products_name');
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 인덱스 네이밍 규칙
|
||||
|
||||
```
|
||||
ft_{테이블명}_{컬럼명}
|
||||
```
|
||||
|
||||
예: `ft_ecommerce_products_name`, `ft_ecommerce_products_description`
|
||||
|
||||
### 복합 컬럼 인덱스
|
||||
|
||||
```php
|
||||
// 여러 컬럼을 하나의 FULLTEXT 인덱스로 생성
|
||||
DatabaseFulltextEngine::addFulltextIndex(
|
||||
'posts',
|
||||
'ft_posts_title_content',
|
||||
['title', 'content'] // 배열 전달
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AsUnicodeJson 캐스트
|
||||
|
||||
### 문제
|
||||
|
||||
Laravel 기본 `array` 캐스트는 `json_encode()`로 한글을 `\uXXXX`로 이스케이프합니다:
|
||||
|
||||
```json
|
||||
// 기본 array 캐스트: \uXXXX 이스케이프
|
||||
{"ko": "\uc0c1\ud488\uba85"}
|
||||
|
||||
// AsUnicodeJson 캐스트: 실제 UTF-8
|
||||
{"ko": "상품명"}
|
||||
```
|
||||
|
||||
MySQL ngram 토크나이저는 **실제 UTF-8 문자**를 기준으로 토큰을 생성하므로, `\uXXXX` 이스케이프된 데이터에서는 한글 검색이 동작하지 않습니다.
|
||||
|
||||
### 사용법
|
||||
|
||||
```php
|
||||
use App\Casts\AsUnicodeJson;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
protected $casts = [
|
||||
'name' => AsUnicodeJson::class, // FULLTEXT 검색 대상 JSON 컬럼
|
||||
'description' => AsUnicodeJson::class, // FULLTEXT 검색 대상 JSON 컬럼
|
||||
'meta_keywords' => 'array', // 검색 대상 아닌 컬럼은 기본 캐스트 사용 가능
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 적용 대상
|
||||
|
||||
FULLTEXT 인덱스가 걸리는 JSON 타입 컬럼에는 반드시 `AsUnicodeJson` 캐스트를 사용합니다. 내부적으로 `JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES` 플래그를 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 환경설정
|
||||
|
||||
### config/scout.php 주요 설정
|
||||
|
||||
| 키 | 기본값 | 설명 |
|
||||
|----|--------|------|
|
||||
| `driver` | `mysql-fulltext` | 검색 엔진 드라이버 (`SCOUT_DRIVER` 환경변수) |
|
||||
| `prefix` | `''` | 인덱스 접두사 |
|
||||
| `queue` | `false` | 인덱스 동기화 큐 사용 여부 |
|
||||
| `soft_delete` | `true` | 소프트 삭제 레코드 필터링 |
|
||||
| `after_commit` | `false` | DB 트랜잭션 커밋 후 인덱스 동기화 |
|
||||
| `chunk.searchable` | `500` | 대량 인덱싱 시 청크 크기 |
|
||||
|
||||
### .env 설정
|
||||
|
||||
```env
|
||||
# 검색 엔진 드라이버 (기본: mysql-fulltext)
|
||||
SCOUT_DRIVER=mysql-fulltext
|
||||
|
||||
# 인덱스 접두사 (선택)
|
||||
SCOUT_PREFIX=
|
||||
|
||||
# 인덱스 동기화 큐 사용 (선택)
|
||||
SCOUT_QUEUE=false
|
||||
```
|
||||
|
||||
> **참고**: `mysql-fulltext` 드라이버는 MySQL 테이블 자체가 인덱스이므로 `SCOUT_QUEUE`, `SCOUT_PREFIX`는 실질적으로 사용되지 않습니다. 외부 검색 엔진(Meilisearch 등)으로 전환 시 의미가 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [Service-Repository 패턴](service-repository.md) - Repository에서 whereFulltext() 사용
|
||||
- [훅 시스템](../extension/hooks.md) - core.search.engine_drivers 필터 훅
|
||||
- [데이터베이스 가이드](../database-guide.md) - 마이그레이션 규칙
|
||||
- [플러그인 개발](../extension/plugin-development.md) - 검색 엔진 플러그인 개발
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,305 @@
|
||||
# 서비스 프로바이더 안전성
|
||||
|
||||
> **목차**: [index.md](./index.md) | [enum.md](./enum.md) | [controllers.md](./controllers.md) | [service-repository.md](./service-repository.md) | [validation.md](./validation.md) | [exceptions.md](./exceptions.md) | [api-resources.md](./api-resources.md) | [routing.md](./routing.md) | [response-helper.md](./response-helper.md) | [middleware.md](./middleware.md) | [authentication.md](./authentication.md) | **service-provider.md**
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. DB 접근 전 .env 파일 존재 확인 필수
|
||||
2. 테이블 존재 여부 Schema::hasTable() 체크
|
||||
3. 인스톨러 안정성: 마이그레이션 전에도 부팅 가능해야 함
|
||||
4. 조건 미충족 시 예외 대신 안전하게 스킵
|
||||
5. runningInConsole() + 'migrate' 명령 감지로 스킵
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [핵심 원칙](#핵심-원칙)
|
||||
2. [검증이 필요한 경우](#검증이-필요한-경우)
|
||||
3. [검증 체크리스트](#검증-체크리스트)
|
||||
4. [잘못된 예시](#잘못된-예시)
|
||||
5. [올바른 예시](#올바른-예시)
|
||||
6. [실행 흐름 다이어그램](#실행-흐름-다이어그램)
|
||||
7. [다른 서비스 프로바이더 적용 예시](#다른-서비스-프로바이더-적용-예시)
|
||||
8. [서비스 프로바이더 개발 체크리스트](#서비스-프로바이더-개발-체크리스트)
|
||||
9. [인스톨러 테스트 시나리오](#인스톨러-테스트-시나리오)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
필수: 서비스 프로바이더에서 DB 접근 전 .env + 테이블 존재 확인
|
||||
필수: .env 파일 및 테이블 존재 여부 확인 후 접근
|
||||
```
|
||||
|
||||
**핵심 원칙**:
|
||||
|
||||
- **인스톨러 안정성**: `.env` 파일이 없는 상태에서도 `composer install` 실행 가능해야 함
|
||||
- **단계적 초기화**: DB 테이블이 없는 상태(마이그레이션 전)에서도 애플리케이션 부팅 가능해야 함
|
||||
- **안전한 스킵**: 조건 미충족 시 예외 발생 대신 안전하게 건너뛰기
|
||||
|
||||
---
|
||||
|
||||
## 검증이 필요한 경우
|
||||
|
||||
| 상황 | 검증 항목 | 필요 이유 |
|
||||
|------|----------|----------|
|
||||
| DB 쿼리 실행 | `.env` 파일, 테이블 존재 | 인스톨러 실행 전 오류 방지 |
|
||||
| 모듈/플러그인 로드 | 관련 테이블 존재 | 마이그레이션 전 오류 방지 |
|
||||
| 설정값 로드 | `.env` 파일 존재 | 초기 설정 전 오류 방지 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 체크리스트
|
||||
|
||||
1. **`.env` 파일 존재 여부**: `File::exists(base_path('.env'))`
|
||||
2. **필수 테이블 존재 여부**: `Schema::hasTable('table_name')`
|
||||
3. **조건 미충족 시 안전하게 return**
|
||||
|
||||
---
|
||||
|
||||
## 잘못된 예시
|
||||
|
||||
```php
|
||||
// ❌ 검증 없이 DB 접근 - 인스톨러 실행 시 오류 발생
|
||||
class ModuleRouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
$this->routes(function () {
|
||||
$this->loadModuleRoutes();
|
||||
});
|
||||
}
|
||||
|
||||
protected function loadModuleRoutes(): void
|
||||
{
|
||||
$modulesPath = base_path('modules');
|
||||
|
||||
if (! File::exists($modulesPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ❌ .env 파일이나 테이블 확인 없이 DB 쿼리 실행
|
||||
$activeModules = Module::where('status', 'active')->get();
|
||||
|
||||
foreach ($activeModules as $module) {
|
||||
// 라우트 로드...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**오류 시나리오**:
|
||||
|
||||
```bash
|
||||
# 인스톨러 실행 중
|
||||
composer install
|
||||
↓
|
||||
package:discover 자동 실행
|
||||
↓
|
||||
ModuleRouteServiceProvider 로드
|
||||
↓
|
||||
Module::where() 실행 시도
|
||||
↓
|
||||
❌ SQLSTATE[HY000] [1045] Access denied (using password: NO)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 올바른 예시
|
||||
|
||||
### 1. .env 파일 체크 추가
|
||||
|
||||
```php
|
||||
// ✅ .env 파일 및 테이블 존재 확인
|
||||
class ModuleRouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
$this->routes(function () {
|
||||
$this->loadModuleRoutes();
|
||||
});
|
||||
}
|
||||
|
||||
protected function loadModuleRoutes(): void
|
||||
{
|
||||
// ✅ .env 파일이 없으면 스킵 (인스톨러 실행 전)
|
||||
if (! File::exists(base_path('.env'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
$modulesPath = base_path('modules');
|
||||
|
||||
if (! File::exists($modulesPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ 데이터베이스 테이블이 존재하지 않으면 스킵 (마이그레이션 전)
|
||||
if (! Schema::hasTable('modules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 안전하게 DB 접근
|
||||
$activeModules = Module::where('status', ExtensionStatus::Active->value)
|
||||
->pluck('identifier')
|
||||
->toArray();
|
||||
|
||||
foreach ($activeModules as $moduleIdentifier) {
|
||||
// 라우트 로드...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Service 클래스에서의 적용
|
||||
|
||||
```php
|
||||
class TemplateService
|
||||
{
|
||||
/**
|
||||
* 활성화된 모듈의 routes 데이터 로드
|
||||
*/
|
||||
private function loadActiveModulesRoutesData(): array
|
||||
{
|
||||
// ✅ .env 파일이 없으면 빈 배열 반환 (인스톨러 실행 전)
|
||||
if (! file_exists(base_path('.env'))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ✅ modules 테이블이 없으면 빈 배열 반환 (마이그레이션 전)
|
||||
if (! Schema::hasTable('modules')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 안전하게 모듈 데이터 로드
|
||||
$routes = [];
|
||||
$activeModules = $this->moduleManager->getActiveModules();
|
||||
|
||||
foreach ($activeModules as $module) {
|
||||
// routes.json 로드...
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실행 흐름 다이어그램
|
||||
|
||||
```
|
||||
인스톨러 단계 1: composer install
|
||||
├─ .env 없음 → ModuleRouteServiceProvider 스킵 ✅
|
||||
└─ package:discover 정상 완료
|
||||
|
||||
인스톨러 단계 2: .env 생성
|
||||
├─ DB 연결 정보 입력
|
||||
└─ .env 파일 생성 완료
|
||||
|
||||
인스톨러 단계 3: 마이그레이션
|
||||
├─ modules 테이블 없음 → ModuleRouteServiceProvider 스킵 ✅
|
||||
└─ 테이블 생성 완료
|
||||
|
||||
설치 완료 후:
|
||||
├─ .env 있음 ✅
|
||||
├─ modules 테이블 있음 ✅
|
||||
└─ ModuleRouteServiceProvider 정상 실행 ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다른 서비스 프로바이더 적용 예시
|
||||
|
||||
### PluginRouteServiceProvider
|
||||
|
||||
```php
|
||||
class PluginRouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected function loadPluginRoutes(): void
|
||||
{
|
||||
// ✅ 동일한 패턴 적용
|
||||
if (! File::exists(base_path('.env'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('plugins')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 플러그인 라우트 로드...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ConfigServiceProvider
|
||||
|
||||
```php
|
||||
class ConfigServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
// ✅ 동일한 패턴 적용
|
||||
if (! File::exists(base_path('.env'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('settings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 동적 설정 로드...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서비스 프로바이더 개발 체크리스트
|
||||
|
||||
- [ ] DB 접근이 필요한 경우 `.env` 파일 존재 확인
|
||||
- [ ] 특정 테이블 접근 시 `Schema::hasTable()` 확인
|
||||
- [ ] 조건 미충족 시 예외 발생 대신 `return`으로 안전하게 스킵
|
||||
- [ ] 인스톨러 환경에서 테스트 수행
|
||||
- [ ] `composer install` 단독 실행 테스트
|
||||
|
||||
---
|
||||
|
||||
## 인스톨러 테스트 시나리오
|
||||
|
||||
```bash
|
||||
# 1. .env 없이 composer install
|
||||
rm .env
|
||||
composer install
|
||||
# 예상: 정상 완료 (오류 없음)
|
||||
|
||||
# 2. .env 생성 후 애플리케이션 부팅
|
||||
cp .env.example .env
|
||||
php artisan config:clear
|
||||
# 예상: 정상 부팅 (modules 테이블 없어도 오류 없음)
|
||||
|
||||
# 3. 마이그레이션 후 정상 동작
|
||||
php artisan migrate
|
||||
# 예상: 모든 기능 정상 동작
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 이슈
|
||||
|
||||
- 인스톨러에서 `composer install` 시 DB 접근 오류
|
||||
- 마이그레이션 전 애플리케이션 부팅 실패
|
||||
- CI/CD 파이프라인에서 의존성 설치 오류
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [middleware.md](./middleware.md) - 미들웨어 등록 규칙
|
||||
- [authentication.md](./authentication.md) - 인증 및 세션 처리
|
||||
- [index.md](./index.md) - 백엔드 가이드 인덱스
|
||||
@@ -0,0 +1,972 @@
|
||||
# Service-Repository 패턴
|
||||
|
||||
> **백엔드 가이드** | [목차로 돌아가기](index.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지)
|
||||
2. CoreServiceProvider에서 Interface-구현체 바인딩
|
||||
3. Service에서 훅 실행: before_create → applyFilters → create → after_create
|
||||
4. 검증 로직은 FormRequest에서 (Service에 검증 금지)
|
||||
5. 다중 검색은 HasMultipleSearchFilters Trait 사용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [개요](#개요)
|
||||
- [Repository 인터페이스](#repository-인터페이스)
|
||||
- [Service 클래스](#service-클래스)
|
||||
- [트랜잭션 및 관계 삭제 패턴](#트랜잭션-및-관계-삭제-패턴)
|
||||
- [상태 변경 자동 처리](#상태-변경-자동-처리)
|
||||
- [Repository 클래스](#repository-클래스)
|
||||
- [다중 검색 필터 Trait](#다중-검색-필터-trait-hasmultiplesearchfilters)
|
||||
- [모듈에서 Repository 인터페이스 바인딩](#모듈에서-repository-인터페이스-바인딩)
|
||||
- [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
Service-Repository 패턴은 비즈니스 로직과 데이터 액세스 로직을 분리하는 아키텍처 패턴입니다.
|
||||
|
||||
```
|
||||
Controller → Request → Service → RepositoryInterface → Repository → Model
|
||||
```
|
||||
|
||||
| 계층 | 역할 |
|
||||
|------|------|
|
||||
| **Service** | 비즈니스 로직, 훅 실행, 트랜잭션 관리 |
|
||||
| **RepositoryInterface** | Repository 추상화 계약 정의 |
|
||||
| **Repository** | 데이터 액세스 구현, 쿼리 로직 캡슐화 |
|
||||
|
||||
---
|
||||
|
||||
## Repository 인터페이스
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
```
|
||||
필수: Repository 인터페이스를 통한 DI (구체 클래스 직접 타입힌트 금지)
|
||||
필수: Repository 인터페이스를 통한 DI
|
||||
✅ 필수: CoreServiceProvider에서 인터페이스-구현체 바인딩
|
||||
```
|
||||
|
||||
### 인터페이스 위치
|
||||
|
||||
```
|
||||
app/Contracts/Repositories/
|
||||
├── LayoutRepositoryInterface.php
|
||||
├── ModuleRepositoryInterface.php
|
||||
├── MenuRepositoryInterface.php
|
||||
├── RoleRepositoryInterface.php
|
||||
├── TemplateRepositoryInterface.php
|
||||
├── PluginRepositoryInterface.php
|
||||
├── UserRepositoryInterface.php
|
||||
├── PermissionRepositoryInterface.php
|
||||
├── LayoutVersionRepositoryInterface.php
|
||||
└── SystemConfigRepositoryInterface.php
|
||||
```
|
||||
|
||||
### 인터페이스 정의 패턴
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Repositories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
interface UserRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* ID로 사용자 조회
|
||||
*
|
||||
* @param int $id
|
||||
* @return User|null
|
||||
*/
|
||||
public function findById(int $id): ?User;
|
||||
|
||||
/**
|
||||
* 사용자 목록 페이지네이션 조회
|
||||
*
|
||||
* @param array $filters 검색 조건
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function getPaginated(array $filters = []): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* 사용자 생성
|
||||
*
|
||||
* @param array $data
|
||||
* @return User
|
||||
*/
|
||||
public function create(array $data): User;
|
||||
}
|
||||
```
|
||||
|
||||
### Repository 구현체 패턴
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||
use App\Models\User;
|
||||
|
||||
class UserRepository implements UserRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* ID로 사용자 조회
|
||||
*/
|
||||
public function findById(int $id): ?User
|
||||
{
|
||||
return User::find($id);
|
||||
}
|
||||
|
||||
// ... 인터페이스 메서드 구현
|
||||
}
|
||||
```
|
||||
|
||||
### Service Provider 바인딩
|
||||
|
||||
`app/Providers/CoreServiceProvider.php`에서 인터페이스와 구현체를 바인딩합니다:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||
use App\Repositories\UserRepository;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class CoreServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->registerRepositoryBindings();
|
||||
}
|
||||
|
||||
private function registerRepositoryBindings(): void
|
||||
{
|
||||
// bind() 사용 - Repository는 상태가 없으므로 매번 새 인스턴스
|
||||
$this->app->bind(UserRepositoryInterface::class, UserRepository::class);
|
||||
$this->app->bind(ModuleRepositoryInterface::class, ModuleRepository::class);
|
||||
// ... 나머지 Repository 바인딩
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 의존성 주입 패턴
|
||||
|
||||
#### Service에서 사용 (권장)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||
|
||||
class UserService
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepositoryInterface $userRepository
|
||||
) {}
|
||||
|
||||
public function getUser(int $id): ?User
|
||||
{
|
||||
return $this->userRepository->findById($id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Controller에서 사용
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||
|
||||
class UserController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepositoryInterface $userRepository
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Console Command에서 사용
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ListModuleCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private ModuleRepositoryInterface $moduleRepository
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 인터페이스 장점
|
||||
|
||||
| 장점 | 설명 |
|
||||
|------|------|
|
||||
| **테스트 용이성** | Mock 객체로 쉽게 대체 가능 |
|
||||
| **유연한 구현체 교체** | 바인딩만 변경하면 다른 구현체 사용 가능 |
|
||||
| **명확한 계약** | 인터페이스가 Repository의 공개 API 명세 역할 |
|
||||
| **의존성 역전** | 고수준 모듈이 저수준 모듈에 의존하지 않음 |
|
||||
|
||||
---
|
||||
|
||||
## Service 클래스
|
||||
|
||||
### 역할
|
||||
|
||||
- 비즈니스 로직 구현
|
||||
- 훅 실행 (before/after)
|
||||
- 트랜잭션 관리
|
||||
- 여러 리포지토리 조율
|
||||
|
||||
### 패턴
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use App\Hooks\HookManager;
|
||||
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private ProductRepositoryInterface $productRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 상품 생성
|
||||
*
|
||||
* @param array $data 상품 데이터
|
||||
* @return Product
|
||||
*/
|
||||
public function createProduct(array $data): Product
|
||||
{
|
||||
// Before 훅 - 데이터 검증, 전처리
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
|
||||
|
||||
// 필터 훅 - 데이터 변형
|
||||
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
|
||||
|
||||
// 비즈니스 로직 실행
|
||||
$product = $this->productRepository->create($data);
|
||||
|
||||
// After 훅 - 후처리, 알림, 캐시 등
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정
|
||||
*
|
||||
* @param int $id 상품 ID
|
||||
* @param array $data 수정할 데이터
|
||||
* @return Product
|
||||
*/
|
||||
public function updateProduct(int $id, array $data): Product
|
||||
{
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_update', $id, $data);
|
||||
|
||||
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_update_data', $data, $id);
|
||||
|
||||
$product = $this->productRepository->update($id, $data);
|
||||
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_update', $product, $data);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 목록 조회
|
||||
*
|
||||
* @param array $filters 검색 필터
|
||||
* @return Collection
|
||||
*/
|
||||
public function getProducts(array $filters = []): Collection
|
||||
{
|
||||
// Before 훅 - 검색 조건 전처리
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_list', $filters);
|
||||
|
||||
// 필터 훅 - 검색 조건 변형
|
||||
$filters = HookManager::applyFilters('sirsoft-ecommerce.product.filter_list_query', $filters);
|
||||
|
||||
$products = $this->productRepository->getAll($filters);
|
||||
|
||||
// 필터 훅 - 결과 데이터 변형
|
||||
$products = HookManager::applyFilters('sirsoft-ecommerce.product.filter_list_result', $products, $filters);
|
||||
|
||||
// After 훅 - 조회 후처리 (로깅, 캐싱 등)
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_list', $products, $filters);
|
||||
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 상세 조회
|
||||
*
|
||||
* @param int $id 상품 ID
|
||||
* @return Product|null
|
||||
*/
|
||||
public function getProduct(int $id): ?Product
|
||||
{
|
||||
// Before 훅 - 조회 전처리
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_show', $id);
|
||||
|
||||
$product = $this->productRepository->findById($id);
|
||||
|
||||
if ($product) {
|
||||
// 필터 훅 - 조회 결과 변형 (조회수 증가, 관련 데이터 추가 등)
|
||||
$product = HookManager::applyFilters('sirsoft-ecommerce.product.filter_show_result', $product);
|
||||
|
||||
// After 훅 - 조회 후처리
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_show', $product);
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 훅 실행 순서
|
||||
|
||||
```
|
||||
1. Before Action Hook → 사전 처리, 검증
|
||||
2. Filter Hook → 데이터 변형
|
||||
3. Repository 호출 → 실제 데이터 작업
|
||||
4. After Action Hook → 후처리, 알림, 캐시
|
||||
```
|
||||
|
||||
### 훅 네이밍 규칙
|
||||
|
||||
```
|
||||
[vendor-module].[entity].[action]_[timing]
|
||||
|
||||
예시:
|
||||
sirsoft-ecommerce.product.before_create
|
||||
sirsoft-ecommerce.product.after_update
|
||||
sirsoft-ecommerce.product.filter_create_data
|
||||
```
|
||||
|
||||
### 서비스 내부 조건부 권한 체크
|
||||
|
||||
미들웨어가 아닌 **서비스 내부**에서 추가적인 권한 체크가 필요한 경우 `PermissionHelper`를 사용합니다.
|
||||
대표적인 예: 역할(role) 변경처럼 데이터의 일부 필드만 별도 권한이 필요한 경우.
|
||||
|
||||
```php
|
||||
use App\Helpers\PermissionHelper;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
public function updateUser(User $user, array $data): User
|
||||
{
|
||||
$roleIds = $data['role_ids'] ?? null;
|
||||
unset($data['role_ids'], $data['roles']);
|
||||
|
||||
// 훅 실행 (생략)...
|
||||
|
||||
$user = $this->userRepository->update($user->id, $data);
|
||||
|
||||
// 역할 변경은 별도 권한으로 보호
|
||||
if ($roleIds !== null) {
|
||||
$authUser = Auth::user();
|
||||
|
||||
// 자기 자신의 역할은 항상 변경 불가 (보안)
|
||||
if ($authUser && $authUser->id === $user->id) {
|
||||
$roleIds = null;
|
||||
}
|
||||
|
||||
// core.permissions.update 권한 없으면 역할 변경 무시
|
||||
if ($roleIds !== null && ! PermissionHelper::check('core.permissions.update', $authUser)) {
|
||||
$roleIds = null;
|
||||
}
|
||||
|
||||
if ($roleIds !== null) {
|
||||
$user->roles()->sync($roleIds);
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
필수: 역할/권한 변경은 미들웨어 권한과 별개로 서비스에서 명시적 체크 필수
|
||||
필수: 자기 자신의 역할 변경은 항상 불가 (관리자 실수 방지)
|
||||
패턴: 민감 필드 분리 → 별도 권한 체크 → 권한 없으면 해당 필드 무시 (403이 아닌 무시)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 트랜잭션 및 관계 삭제 패턴
|
||||
|
||||
### CASCADE 방지 — 명시적 관계 삭제
|
||||
|
||||
```
|
||||
필수: Service에서 명시적 삭제 (DB CASCADE 의존 금지)
|
||||
필수: Service에서 모든 관계를 명시적으로 삭제 (훅/파일/로깅 보장)
|
||||
```
|
||||
|
||||
엔티티 삭제 시 관련된 모든 관계를 **Service에서 명시적으로** 제거합니다:
|
||||
|
||||
```php
|
||||
public function deleteUser(User $user): bool
|
||||
{
|
||||
// Before 훅
|
||||
HookManager::doAction('core.user.before_delete', $user);
|
||||
|
||||
// 원본 데이터 보관 (after 훅에서 사용)
|
||||
$userData = $user->toArray();
|
||||
|
||||
// 관계 명시적 해제 (CASCADE 의존 금지)
|
||||
$user->roles()->detach(); // 다대다 관계 해제
|
||||
$user->consents()->delete(); // 일대다 관계 삭제
|
||||
$user->tokens()->delete(); // 인증 토큰 삭제
|
||||
|
||||
// Attachment 삭제 (비즈니스 로직 통한 삭제)
|
||||
if ($user->avatarAttachment) {
|
||||
$this->attachmentService->delete($user->avatarAttachment->id);
|
||||
}
|
||||
|
||||
$result = $this->userRepository->delete($user);
|
||||
|
||||
// After 훅 (원본 데이터 전달)
|
||||
HookManager::doAction('core.user.after_delete', $userData);
|
||||
|
||||
return $result;
|
||||
}
|
||||
```
|
||||
|
||||
### 일괄 업데이트 트랜잭션 패턴
|
||||
|
||||
```php
|
||||
public function bulkUpdateStatus(array $ids, string $status): int
|
||||
{
|
||||
$statusEnum = UserStatus::from($status);
|
||||
|
||||
$updatedCount = DB::transaction(function () use ($ids, $statusEnum) {
|
||||
$count = User::whereIn('id', $ids)->update([
|
||||
'status' => $statusEnum->value,
|
||||
]);
|
||||
|
||||
// 트랜잭션 내에서 관계형 데이터 명시적 삭제
|
||||
if ($statusEnum !== UserStatus::Active) {
|
||||
PersonalAccessToken::where('tokenable_type', User::class)
|
||||
->whereIn('tokenable_id', $ids)
|
||||
->delete();
|
||||
}
|
||||
|
||||
return $count;
|
||||
});
|
||||
|
||||
// 트랜잭션 외부에서 훅 실행 (실패 시 롤백 방지)
|
||||
HookManager::doAction('core.user.after_bulk_update', $ids, $status, $updatedCount);
|
||||
|
||||
return $updatedCount;
|
||||
}
|
||||
```
|
||||
|
||||
### 핵심 규칙
|
||||
|
||||
| 원칙 | 설명 |
|
||||
| ------ | ------ |
|
||||
| **CASCADE 금지** | DB 외래키 CASCADE 대신 Service에서 명시적 삭제 |
|
||||
| **원본 데이터 보관** | 삭제 전 `toArray()`로 캡처 → after 훅에서 사용 |
|
||||
| **관계 유형별 처리** | `detach()` (다대다), `delete()` (일대다), Service 호출 (복합) |
|
||||
| **트랜잭션 내 훅 금지** | 훅은 트랜잭션 **외부**에서 실행 (롤백 시 훅 부작용 방지) |
|
||||
| **AttachmentService 사용** | 파일 삭제는 직접 DB 삭제 대신 AttachmentService 통해 처리 |
|
||||
|
||||
---
|
||||
|
||||
## 상태 변경 자동 처리
|
||||
|
||||
상태(status) 변경 시 관련 타임스탬프와 토큰을 자동으로 처리하는 패턴입니다.
|
||||
|
||||
### 상태 변경 타임스탬프 자동 설정
|
||||
|
||||
상태 변경 시 관련 타임스탬프를 `match` 표현식으로 자동 설정합니다:
|
||||
|
||||
```php
|
||||
// 상태 변경 감지 및 타임스탬프 자동 설정
|
||||
$oldStatus = $user->status;
|
||||
$newStatus = $data['status'] ?? null;
|
||||
|
||||
if ($newStatus && $newStatus !== $oldStatus) {
|
||||
$newStatusEnum = UserStatus::from($newStatus);
|
||||
$data = match ($newStatusEnum) {
|
||||
UserStatus::Blocked => array_merge($data, ['blocked_at' => now()]),
|
||||
UserStatus::Withdrawn => array_merge($data, ['withdrawn_at' => now()]),
|
||||
UserStatus::Active => array_merge($data, ['blocked_at' => null, 'withdrawn_at' => null]),
|
||||
UserStatus::Inactive => $data, // 타임스탬프 변경 없음
|
||||
};
|
||||
}
|
||||
|
||||
$this->userRepository->update($user, $data);
|
||||
```
|
||||
|
||||
### 상태 변경 시 토큰 자동 삭제
|
||||
|
||||
Active 외 상태로 변경 시 해당 사용자의 토큰을 삭제하여 즉시 로그아웃시킵니다:
|
||||
|
||||
```php
|
||||
// 단일 사용자: 상태가 Active 외로 변경되었으면 토큰 삭제
|
||||
if ($newStatus && $newStatus !== $oldStatus && $newStatus !== UserStatus::Active->value) {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
|
||||
// 일괄 업데이트: PersonalAccessToken 직접 삭제
|
||||
if ($statusEnum !== UserStatus::Active) {
|
||||
PersonalAccessToken::where('tokenable_type', User::class)
|
||||
->whereIn('tokenable_id', $ids)
|
||||
->delete();
|
||||
}
|
||||
```
|
||||
|
||||
### 패턴 요약
|
||||
|
||||
| 원칙 | 설명 |
|
||||
| ---------- | ---------- |
|
||||
| **변경 감지** | `$oldStatus !== $newStatus` 비교 후 처리 (불필요한 업데이트 방지) |
|
||||
| **match 표현식** | Enum case별 타임스탬프 자동 매핑 (`array_merge`로 기존 데이터 보존) |
|
||||
| **Active 복귀 시 초기화** | `blocked_at`, `withdrawn_at` 등 관련 타임스탬프를 `null`로 리셋 |
|
||||
| **토큰 자동 삭제** | 비활성 상태 전환 시 즉시 로그아웃 (단일: `tokens()->delete()`, 일괄: `PersonalAccessToken` 직접 삭제) |
|
||||
|
||||
---
|
||||
|
||||
## Repository 클래스
|
||||
|
||||
### 역할
|
||||
|
||||
- 데이터 액세스 추상화
|
||||
- 쿼리 로직 캡슐화
|
||||
- N+1 문제 방지 (Eager Loading)
|
||||
|
||||
### 데이터베이스 독립성 원칙
|
||||
|
||||
```
|
||||
필수: 표준 SQL만 사용 (MySQL 전용 함수 금지)
|
||||
필수: Laravel 쿼리빌더 또는 Eloquent 문법 사용
|
||||
✅ 필수: 데이터베이스 추상화 계층을 통한 쿼리 작성
|
||||
```
|
||||
|
||||
특정 데이터베이스에 의존하는 Raw 쿼리를 사용하면 다른 데이터베이스로 마이그레이션이 어려워집니다.
|
||||
|
||||
#### JSON 컬럼 처리
|
||||
|
||||
```php
|
||||
// ❌ DON'T: MySQL 전용 함수 사용
|
||||
$query->whereRaw("JSON_SEARCH(name, 'one', ?) IS NOT NULL", ["%{$keyword}%"]);
|
||||
$query->orderByRaw("JSON_EXTRACT(name, '$.\"$locale\"') $sortOrder");
|
||||
|
||||
// ✅ DO: Laravel JSON 문법 사용
|
||||
$locales = config('app.translatable_locales', ['ko', 'en']);
|
||||
foreach ($locales as $locale) {
|
||||
$query->orWhere("name->{$locale}", 'like', "%{$keyword}%");
|
||||
}
|
||||
$query->orderBy("name->{$locale}", $sortOrder);
|
||||
```
|
||||
|
||||
#### 허용되는 Raw 쿼리
|
||||
|
||||
복잡한 집계나 Laravel이 지원하지 않는 기능에 한해 Raw 쿼리를 허용하되, 가능한 표준 SQL을 사용합니다:
|
||||
|
||||
```php
|
||||
// ✅ 표준 SQL 집계 (대부분의 DB에서 호환)
|
||||
$query->selectRaw('sales_status, COUNT(*) as count');
|
||||
$query->whereColumn('stock_quantity', '<=', 'safe_stock_quantity');
|
||||
```
|
||||
|
||||
### 패턴
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Repositories;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ProductRepository
|
||||
{
|
||||
/**
|
||||
* 모든 상품 조회
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Product::with(['category', 'images'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 상품 조회
|
||||
*
|
||||
* @param int $id
|
||||
* @return Product|null
|
||||
*/
|
||||
public function findById(int $id): ?Product
|
||||
{
|
||||
return Product::with(['category', 'images'])->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 생성
|
||||
*
|
||||
* @param array $data
|
||||
* @return Product
|
||||
*/
|
||||
public function create(array $data): Product
|
||||
{
|
||||
return Product::create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정
|
||||
*
|
||||
* @param int $id
|
||||
* @param array $data
|
||||
* @return Product
|
||||
*/
|
||||
public function update(int $id, array $data): Product
|
||||
{
|
||||
$product = $this->findById($id);
|
||||
$product->update($data);
|
||||
return $product->fresh();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Eager Loading으로 N+1 방지
|
||||
|
||||
```php
|
||||
// ❌ DON'T: N+1 문제 발생
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Product::all(); // 관계 로딩 없음
|
||||
}
|
||||
|
||||
// ✅ DO: Eager Loading 사용
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Product::with(['category', 'images'])->get();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다중 검색 필터 Trait (HasMultipleSearchFilters)
|
||||
|
||||
목록 API에서 다중 검색 조건을 지원해야 할 때 `HasMultipleSearchFilters` Trait을 사용합니다.
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`app/Repositories/Concerns/HasMultipleSearchFilters.php`
|
||||
|
||||
### 지원 연산자
|
||||
|
||||
| 연산자 | 설명 | SQL 변환 |
|
||||
|--------|------|----------|
|
||||
| `like` (기본) | 부분 일치 | `LIKE %value%` |
|
||||
| `eq` | 정확히 일치 | `= value` |
|
||||
| `starts_with` | 시작 일치 | `LIKE value%` |
|
||||
| `ends_with` | 끝 일치 | `LIKE %value` |
|
||||
|
||||
### Trait 제공 메서드
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `applyMultipleSearchFilters()` | 다중 검색 조건을 AND로 적용 |
|
||||
| `applySearchFilter()` | 개별 검색 필터 적용 (연산자 처리) |
|
||||
| `applyOrSearchAcrossFields()` | 단일 검색어로 여러 필드 OR 검색 |
|
||||
|
||||
### Repository에서 사용
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Repositories\Concerns\HasMultipleSearchFilters;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class UserRepository
|
||||
{
|
||||
use HasMultipleSearchFilters;
|
||||
|
||||
/**
|
||||
* 검색 가능한 필드 목록 (보안을 위해 허용 필드 명시)
|
||||
*/
|
||||
private const SEARCHABLE_FIELDS = ['name', 'email', 'username'];
|
||||
|
||||
/**
|
||||
* 필터링 및 페이지네이션 적용
|
||||
*/
|
||||
public function getPaginatedUsers(array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = User::query();
|
||||
$this->applyFilters($query, $filters);
|
||||
return $query->paginate($filters['per_page'] ?? 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리에 필터 조건 적용
|
||||
*/
|
||||
private function applyFilters(Builder $query, array $filters): void
|
||||
{
|
||||
// 다중 검색 조건 적용
|
||||
if (! empty($filters['filters']) && is_array($filters['filters'])) {
|
||||
$this->applyMultipleSearchFilters($query, $filters['filters'], self::SEARCHABLE_FIELDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### FormRequest 검증 규칙
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\User;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UserListRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 검색 가능한 필드 목록 (보안을 위해 허용 필드 명시)
|
||||
*/
|
||||
public const SEARCHABLE_FIELDS = ['name', 'email', 'username'];
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
// 'all'은 전체 필드 검색을 의미하는 특수 값
|
||||
$searchableFields = implode(',', array_merge(['all'], self::SEARCHABLE_FIELDS));
|
||||
|
||||
return [
|
||||
// 다중 검색 조건
|
||||
'filters' => 'nullable|array|max:10',
|
||||
'filters.*.field' => "required_with:filters|string|in:{$searchableFields}",
|
||||
'filters.*.value' => 'required_with:filters|string|max:255',
|
||||
'filters.*.operator' => 'nullable|string|in:like,eq,starts_with,ends_with',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 전 데이터 전처리
|
||||
*
|
||||
* 프론트엔드에서 빈 필터가 전송되는 경우를 처리합니다.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// value가 비어있는 filters 자동 제거
|
||||
$filters = $this->filters;
|
||||
if (is_array($filters)) {
|
||||
$filters = array_filter($filters, function ($filter) {
|
||||
return ! empty($filter['value']);
|
||||
});
|
||||
// 인덱스 재정렬
|
||||
$filters = array_values($filters);
|
||||
$this->merge(['filters' => $filters ?: null]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 빈 필터 자동 제거 패턴
|
||||
|
||||
프론트엔드(DataSourceManager)에서 파라미터 치환 시 빈 값이 전송될 수 있습니다. 이를 처리하기 위해 `prepareForValidation()`에서 빈 필터를 자동 제거합니다:
|
||||
|
||||
| 상황 | 처리 방식 |
|
||||
|------|----------|
|
||||
| `filters[0][value]`가 빈 문자열 | 해당 filter 항목 제거 |
|
||||
| 모든 filters가 제거됨 | `filters`를 `null`로 설정 |
|
||||
| 유효한 필터만 존재 | 인덱스 재정렬 후 검증 진행 |
|
||||
|
||||
### 'all' 필드 검색
|
||||
|
||||
`field`가 `'all'`인 경우 모든 SEARCHABLE_FIELDS를 OR 조건으로 검색합니다. HasMultipleSearchFilters Trait에서 이를 처리합니다.
|
||||
|
||||
### API 사용 예시
|
||||
|
||||
```bash
|
||||
# 단일 필터
|
||||
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍
|
||||
|
||||
# 다중 필터 (AND 조건)
|
||||
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍&filters[1][field]=email&filters[1][value]=example
|
||||
|
||||
# 연산자 지정
|
||||
GET /api/admin/users?filters[0][field]=name&filters[0][value]=홍길동&filters[0][operator]=eq
|
||||
```
|
||||
|
||||
### 다른 Repository에서 재사용
|
||||
|
||||
```php
|
||||
class ProductRepository
|
||||
{
|
||||
use HasMultipleSearchFilters;
|
||||
|
||||
private const SEARCHABLE_FIELDS = ['name', 'sku', 'description'];
|
||||
|
||||
// 동일한 패턴으로 검색 구현
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모듈에서 Repository 인터페이스 바인딩
|
||||
|
||||
모듈 내에서 Repository를 사용할 때도 반드시 인터페이스를 통해 DI해야 합니다.
|
||||
|
||||
### 모듈 Repository 구조
|
||||
|
||||
```text
|
||||
modules/sirsoft-ecommerce/src/
|
||||
├── Contracts/
|
||||
│ └── Repositories/
|
||||
│ └── ProductRepositoryInterface.php
|
||||
├── Repositories/
|
||||
│ └── ProductRepository.php
|
||||
├── Services/
|
||||
│ └── ProductService.php
|
||||
└── Providers/
|
||||
└── EcommerceServiceProvider.php
|
||||
```
|
||||
|
||||
### 모듈 인터페이스 정의
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Contracts\Repositories;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface ProductRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Product;
|
||||
public function getPaginated(array $filters = []): LengthAwarePaginator;
|
||||
public function create(array $data): Product;
|
||||
public function update(int $id, array $data): Product;
|
||||
public function delete(int $id): bool;
|
||||
}
|
||||
```
|
||||
|
||||
### 모듈 ServiceProvider에서 바인딩
|
||||
|
||||
모듈의 ServiceProvider에서 인터페이스와 구현체를 바인딩합니다:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;
|
||||
|
||||
class EcommerceServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->registerRepositoryBindings();
|
||||
}
|
||||
|
||||
private function registerRepositoryBindings(): void
|
||||
{
|
||||
$this->app->bind(ProductRepositoryInterface::class, ProductRepository::class);
|
||||
// 추가 Repository 바인딩...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 모듈 Service에서 인터페이스 사용
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private ProductRepositoryInterface $productRepository // ✅ 인터페이스 타입힌트
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 올바른 사용 vs 잘못된 사용
|
||||
|
||||
```php
|
||||
// ❌ DON'T: 구체 클래스 직접 타입힌트
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private ProductRepository $productRepository // 구체 클래스 직접 의존
|
||||
) {}
|
||||
}
|
||||
|
||||
// ✅ DO: 인터페이스 타입힌트
|
||||
use Modules\Sirsoft\Ecommerce\Contracts\Repositories\ProductRepositoryInterface;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private ProductRepositoryInterface $productRepository // 인터페이스에 의존
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 핵심 원칙 요약
|
||||
|
||||
| 원칙 | 설명 |
|
||||
|------|------|
|
||||
| **인터페이스 의존** | Service/Controller는 Repository 인터페이스에 의존 |
|
||||
| **ServiceProvider 바인딩** | 인터페이스-구현체 매핑은 ServiceProvider에서 수행 |
|
||||
| **테스트 용이성** | Mock 객체로 쉽게 대체 가능 |
|
||||
| **유연한 교체** | 바인딩만 변경하면 다른 구현체 사용 가능 |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [컨트롤러 계층 구조](controllers.md) - Controller에서 Service 사용
|
||||
- [검증 로직 구현](validation.md) - FormRequest 검증 규칙
|
||||
- [index.md](index.md) - 백엔드 가이드 전체 목차
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,211 @@
|
||||
# 그누보드7 자주 쓰는 명령어 치트시트
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. _bundled에서 레이아웃 JSON만 수정 → 확장 업데이트(--force)만 실행 (빌드 불필요)
|
||||
2. _bundled에서 TSX/TS 수정 → build 필수 + 확장 업데이트(--force) 필요
|
||||
3. 프론트엔드 테스트 → PowerShell 래퍼 필수 (powershell -Command)
|
||||
4. 백엔드 테스트 → Bash 직접 실행 (php artisan test)
|
||||
5. 코드 스타일 → vendor/bin/pint --dirty
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빌드 vs 확장 업데이트
|
||||
|
||||
```
|
||||
_bundled에서 레이아웃 JSON만 수정한 경우 → 빌드 불필요, 확장 업데이트(--force)만 필요
|
||||
_bundled에서 TSX/TS 파일 수정한 경우 → 빌드 후 확장 업데이트(--force) 필요
|
||||
```
|
||||
|
||||
| 수정 파일 유형 | 필요한 작업 |
|
||||
|---------------|-------------|
|
||||
| `*.json` (레이아웃만) | `{type}:update {id} --force` 실행 |
|
||||
| `*.tsx`, `*.ts` + `*.json` | `{type}:build` + `{type}:update {id} --force` |
|
||||
| `*.tsx`, `*.ts`만 | `{type}:build` + `{type}:update {id} --force` |
|
||||
| `modules/**/resources/js/**/*.ts` (핸들러) | `module:build` + `module:update {id} --force` |
|
||||
| `templates/**/src/**/*.tsx` (컴포넌트) | `template:build` + `template:update {id} --force` |
|
||||
|
||||
---
|
||||
|
||||
## 확장 업데이트 (_bundled → 활성 반영)
|
||||
|
||||
```bash
|
||||
# 템플릿 업데이트 (templates/_bundled/** → templates/**)
|
||||
php artisan template:update sirsoft-admin_basic --force
|
||||
|
||||
# 모듈 업데이트 (modules/_bundled/** → modules/**)
|
||||
php artisan module:update sirsoft-ecommerce --force
|
||||
|
||||
# 플러그인 업데이트 (plugins/_bundled/** → plugins/**)
|
||||
php artisan plugin:update sirsoft-payment --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빌드 (Artisan 명령어 사용)
|
||||
|
||||
> **기본 빌드 경로**: `_bundled` 디렉토리. `--active` 옵션으로 활성 디렉토리 빌드 가능.
|
||||
> `--watch` 모드는 자동으로 활성 디렉토리 사용 (실시간 브라우저 확인).
|
||||
> 빌드 후 활성 반영: `module:update` / `template:update` / `plugin:update` 커맨드 사용.
|
||||
|
||||
```bash
|
||||
# 코어 템플릿 엔진 (resources/js/core/template-engine/**)
|
||||
php artisan core:build # 기본: 템플릿 엔진만 빌드
|
||||
php artisan core:build --full # 전체 빌드 (npm run build)
|
||||
php artisan core:build --watch # 파일 감시 모드
|
||||
|
||||
# 모듈 빌드 (기본: _bundled)
|
||||
php artisan module:build sirsoft-ecommerce # _bundled에서 빌드
|
||||
php artisan module:build --all # 모든 _bundled 모듈 빌드
|
||||
php artisan module:build sirsoft-ecommerce --watch # 활성에서 watch
|
||||
php artisan module:build sirsoft-ecommerce --active # 활성에서 빌드
|
||||
|
||||
# 템플릿 빌드 (기본: _bundled)
|
||||
php artisan template:build sirsoft-admin_basic # _bundled에서 빌드
|
||||
php artisan template:build --all # 모든 _bundled 템플릿 빌드
|
||||
php artisan template:build sirsoft-admin_basic --watch # 활성에서 watch
|
||||
php artisan template:build sirsoft-admin_basic --active # 활성에서 빌드
|
||||
|
||||
# 플러그인 빌드 (기본: _bundled)
|
||||
php artisan plugin:build sirsoft-payment # _bundled에서 빌드
|
||||
php artisan plugin:build --all # 모든 _bundled 플러그인 빌드
|
||||
php artisan plugin:build sirsoft-payment --watch # 활성에서 watch
|
||||
php artisan plugin:build sirsoft-payment --active # 활성에서 빌드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 테스트
|
||||
|
||||
```powershell
|
||||
# 프론트엔드 (PowerShell 래퍼 필수)
|
||||
powershell -Command "npm run test:run"
|
||||
powershell -Command "npm run test:run -- DataGrid"
|
||||
powershell -Command "npm run test:run -- template-engine"
|
||||
```
|
||||
|
||||
```bash
|
||||
# 백엔드
|
||||
php artisan test
|
||||
php artisan test --filter=TestName
|
||||
|
||||
# _bundled 확장 테스트 (활성 디렉토리 복사 불필요)
|
||||
php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests
|
||||
php vendor/bin/phpunit --filter=TestName modules/_bundled/sirsoft-ecommerce/tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 코드 스타일
|
||||
|
||||
```bash
|
||||
vendor/bin/pint --dirty
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
```bash
|
||||
php artisan make:migration create_[table]_table
|
||||
php artisan migrate
|
||||
php artisan migrate:rollback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 확장 시스템 Artisan
|
||||
|
||||
```bash
|
||||
# 코어 업데이트
|
||||
php artisan core:check-updates # 코어 업데이트 확인
|
||||
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
|
||||
|
||||
# 모듈
|
||||
php artisan module:list
|
||||
php artisan module:install [identifier]
|
||||
php artisan module:activate [identifier]
|
||||
php artisan module:deactivate [identifier]
|
||||
php artisan module:uninstall [identifier]
|
||||
php artisan module:composer-install [identifier?] [--all]
|
||||
php artisan module:cache-clear [identifier?]
|
||||
php artisan module:seed [identifier] [--sample] [--count=key=value]
|
||||
php artisan module:check-updates [identifier?]
|
||||
php artisan module:update [identifier] [--force]
|
||||
|
||||
# 플러그인
|
||||
php artisan plugin:list
|
||||
php artisan plugin:install [identifier]
|
||||
php artisan plugin:activate [identifier]
|
||||
php artisan plugin:deactivate [identifier]
|
||||
php artisan plugin:uninstall [identifier]
|
||||
php artisan plugin:composer-install [identifier?] [--all]
|
||||
php artisan plugin:cache-clear [identifier?]
|
||||
php artisan plugin:seed [identifier] [--sample] [--count=key=value]
|
||||
php artisan plugin:check-updates [identifier?]
|
||||
php artisan plugin:update [identifier] [--force]
|
||||
|
||||
# 템플릿
|
||||
php artisan template:list
|
||||
php artisan template:install [identifier]
|
||||
php artisan template:activate [identifier]
|
||||
php artisan template:deactivate [identifier]
|
||||
php artisan template:uninstall [identifier]
|
||||
php artisan template:cache-clear
|
||||
php artisan template:check-updates [identifier?]
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force]
|
||||
|
||||
# Composer 의존성 (모듈/플러그인별 독립 vendor/)
|
||||
php artisan extension:composer-install # 모든 모듈+플러그인
|
||||
|
||||
# 오토로드
|
||||
php artisan extension:update-autoload
|
||||
```
|
||||
|
||||
### SEO Artisan 커맨드
|
||||
|
||||
```bash
|
||||
# SEO
|
||||
php artisan seo:warmup # SEO 캐시 워밍업
|
||||
php artisan seo:warmup --layout=shop/show # 특정 레이아웃만
|
||||
php artisan seo:clear # 전체 SEO 캐시 삭제
|
||||
php artisan seo:clear --layout=home # 특정 레이아웃만
|
||||
php artisan seo:stats # 캐시 통계 출력
|
||||
php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치)
|
||||
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 확장 업데이트 (CLI + API)
|
||||
|
||||
```bash
|
||||
# 코어 업데이트
|
||||
php artisan core:check-updates # 코어 업데이트 확인
|
||||
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
|
||||
|
||||
# CLI (Artisan 커맨드)
|
||||
php artisan module:check-updates [identifier?] # 모듈 업데이트 확인
|
||||
php artisan module:update [identifier] [--force] # 모듈 업데이트 실행
|
||||
php artisan plugin:check-updates [identifier?] # 플러그인 업데이트 확인
|
||||
php artisan plugin:update [identifier] [--force] # 플러그인 업데이트 실행
|
||||
php artisan template:check-updates [identifier?] # 템플릿 업데이트 확인
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] # 템플릿 업데이트 실행
|
||||
|
||||
# API 엔드포인트
|
||||
POST /api/admin/core-update/check # 코어 업데이트 확인
|
||||
GET /api/admin/core-update/changelog # 코어 변경 로그 조회
|
||||
POST /api/admin/modules/check-updates # 모듈 업데이트 확인
|
||||
POST /api/admin/modules/{moduleName}/update # 모듈 업데이트 실행
|
||||
POST /api/admin/plugins/check-updates # 플러그인 업데이트 확인
|
||||
POST /api/admin/plugins/{pluginName}/update # 플러그인 업데이트 실행
|
||||
POST /api/admin/templates/check-updates # 템플릿 업데이트 확인
|
||||
GET /api/admin/templates/{templateName}/check-modified-layouts # 수정된 레이아웃 확인
|
||||
POST /api/admin/templates/{templateName}/update # 템플릿 업데이트 실행
|
||||
```
|
||||
|
||||
> 상세: [extension/extension-update-system.md](extension/extension-update-system.md)
|
||||
|
||||
---
|
||||
@@ -0,0 +1,849 @@
|
||||
# 그누보드7 데이터베이스 개발 가이드
|
||||
|
||||
> 이 문서는 그누보드7의 데이터베이스 마이그레이션, 시더, 다국어 처리 규칙을 상세히 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 마이그레이션: 한국어 comment 필수, down() 구현 필수
|
||||
2. 네이밍: create_[table]_table, add_[col]_to_[table]_table
|
||||
3. 로케일: config('app.supported_locales') 사용, 하드코딩 금지
|
||||
4. 다국어 필드: JSON 구조 {"ko": "한글", "en": "English"}
|
||||
5. 시더: 콘솔 메시지 필수, truncate() 대신 delete() 사용
|
||||
6. 모듈/플러그인 down(): 테이블/컬럼/인덱스 존재 확인 후 삭제 (방어적 코딩 필수)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [마이그레이션](#마이그레이션)
|
||||
- [시더](#시더)
|
||||
- [로케일 설정](#로케일-설정)
|
||||
- [다국어 (Backend)](#다국어-backend)
|
||||
- [코어 테이블 다국어 지원](#코어-테이블-다국어-지원)
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
**규칙**:
|
||||
- 한국어 comment 필수
|
||||
- enum/boolean은 값 설명 포함
|
||||
- 외래키 제약조건 명시
|
||||
- down() 메서드 구현
|
||||
|
||||
**파일명 네이밍 규칙**:
|
||||
|
||||
- **테이블 생성**: `create_[table_name]_table`
|
||||
- **컬럼 추가**: `add_[column1]_and_[column2]_to_[table_name]_table`
|
||||
- 단일 컬럼: `add_[column_name]_to_[table_name]_table`
|
||||
- 다중 컬럼: 모든 컬럼명을 `_and_`로 연결
|
||||
- **컬럼 삭제**: `remove_[column1]_and_[column2]_from_[table_name]_table`
|
||||
- 단일 컬럼: `remove_[column_name]_from_[table_name]_table`
|
||||
- 다중 컬럼: 모든 컬럼명을 `_and_`로 연결
|
||||
- **컬럼 변경**: `modify_[column1]_and_[column2]_in_[table_name]_table`
|
||||
- 단일 컬럼: `modify_[column_name]_in_[table_name]_table`
|
||||
- 다중 컬럼: 모든 컬럼명을 `_and_`로 연결
|
||||
- **복합 변경** (추가+삭제+변경): `update_[main_feature]_fields_in_[table_name]_table`
|
||||
- 예: `update_user_profile_fields_in_users_table`
|
||||
|
||||
**예시**:
|
||||
|
||||
```bash
|
||||
# 단일 컬럼 추가
|
||||
add_email_to_users_table
|
||||
|
||||
# 다중 컬럼 추가
|
||||
add_first_name_and_last_name_and_phone_to_users_table
|
||||
|
||||
# 단일 컬럼 삭제
|
||||
remove_nickname_from_users_table
|
||||
|
||||
# 다중 컬럼 삭제
|
||||
remove_old_status_and_legacy_flag_from_products_table
|
||||
|
||||
# 단일 컬럼 변경
|
||||
modify_price_in_products_table
|
||||
|
||||
# 다중 컬럼 변경
|
||||
modify_name_and_description_in_categories_table
|
||||
|
||||
# 복합 변경 (3개 이상의 컬럼 또는 추가/삭제/변경 혼합)
|
||||
update_template_version_fields_in_templates_table
|
||||
```
|
||||
|
||||
**패턴**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 마이그레이션 실행
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('products', function (Blueprint $table) {
|
||||
$table->id()->comment('상품 ID');
|
||||
$table->string('name')->comment('상품명');
|
||||
$table->text('description')->comment('상품 설명');
|
||||
$table->decimal('price', 10, 2)->comment('상품 가격');
|
||||
$table->foreignId('category_id')
|
||||
->comment('카테고리 ID')
|
||||
->constrained('product_categories')
|
||||
->onDelete('cascade');
|
||||
$table->boolean('is_active')
|
||||
->default(true)
|
||||
->comment('활성화 여부 (1: 활성화, 0: 비활성화)');
|
||||
$table->enum('status', ['draft', 'published', 'archived'])
|
||||
->default('draft')
|
||||
->comment('상품 상태 (draft: 임시저장, published: 게시, archived: 보관)');
|
||||
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
|
||||
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// 인덱스
|
||||
$table->index('category_id');
|
||||
$table->index('is_active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 롤백
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('products');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 모듈/플러그인 마이그레이션 down() 메서드
|
||||
|
||||
```
|
||||
주의: 모듈/플러그인은 삭제 시 마이그레이션 롤백이 빈번하게 발생
|
||||
필수: down() 메서드에서 테이블/컬럼/인덱스 존재 여부 확인 후 삭제
|
||||
✅ 필수: Laravel 11+ 에서는 Schema::getIndexes() 사용 (getDoctrineSchemaManager 미지원)
|
||||
✅ 필수: NOT NULL 변경 시 NULL 데이터 존재 여부 확인
|
||||
```
|
||||
|
||||
#### 왜 방어적 코딩이 필요한가?
|
||||
|
||||
모듈/플러그인 삭제 시 `delete_data: true` 옵션으로 마이그레이션 롤백이 실행됩니다. 이때:
|
||||
|
||||
| 문제 상황 | 발생 원인 | 결과 |
|
||||
|----------|----------|------|
|
||||
| 테이블 미존재 | 이전 롤백 실패, 수동 삭제 | SQL 오류 발생 |
|
||||
| 인덱스 미존재 | 부분 롤백, 스키마 불일치 | SQL 오류 발생 |
|
||||
| NULL 데이터 존재 | NOT NULL 변경 시도 | SQL 오류 발생 |
|
||||
|
||||
**오류 발생 시 해당 마이그레이션 롤백이 실패하고, 이후 마이그레이션도 롤백되지 않습니다.**
|
||||
|
||||
#### down() 메서드 작성 규칙
|
||||
|
||||
**1. 테이블 생성 마이그레이션 (create_*_table)**
|
||||
|
||||
```php
|
||||
// ✅ DO: dropIfExists 사용 (기본 패턴)
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('products');
|
||||
}
|
||||
|
||||
// ❌ DON'T: drop 사용 (테이블 미존재 시 오류)
|
||||
public function down(): void
|
||||
{
|
||||
Schema::drop('products'); // 금지
|
||||
}
|
||||
```
|
||||
|
||||
**2. 컬럼/인덱스 추가 마이그레이션 (add_*_to_*_table)**
|
||||
|
||||
```php
|
||||
// ✅ DO: 테이블, 인덱스, 컬럼 존재 여부 확인 후 삭제
|
||||
public function down(): void
|
||||
{
|
||||
// 1. 테이블 존재 여부 확인
|
||||
if (! Schema::hasTable('ecommerce_product_images')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 인덱스 존재 여부 확인 (Laravel 11+)
|
||||
$tableName = Schema::getConnection()->getTablePrefix().'ecommerce_product_images';
|
||||
$indexes = Schema::getIndexes('ecommerce_product_images');
|
||||
$indexNames = array_column($indexes, 'name');
|
||||
|
||||
Schema::table('ecommerce_product_images', function (Blueprint $table) use ($tableName, $indexNames) {
|
||||
// 인덱스 존재 시에만 삭제
|
||||
if (in_array($tableName.'_temp_key_index', $indexNames)) {
|
||||
$table->dropIndex(['temp_key']);
|
||||
}
|
||||
|
||||
// 컬럼 존재 시에만 삭제
|
||||
if (Schema::hasColumn('ecommerce_product_images', 'temp_key')) {
|
||||
$table->dropColumn('temp_key');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ DON'T: 존재 여부 확인 없이 삭제 (금지)
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ecommerce_product_images', function (Blueprint $table) {
|
||||
$table->dropIndex(['temp_key']); // 인덱스 미존재 시 오류
|
||||
$table->dropColumn('temp_key'); // 컬럼 미존재 시 오류
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**3. nullable 변경 마이그레이션**
|
||||
|
||||
```php
|
||||
// ✅ DO: NULL 데이터 존재 여부 확인 후 NOT NULL 변경
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasColumn('products', 'category_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// NULL 데이터가 있으면 NOT NULL 변경 불가
|
||||
$hasNullData = DB::table('products')
|
||||
->whereNull('category_id')
|
||||
->exists();
|
||||
|
||||
if (! $hasNullData) {
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('category_id')
|
||||
->nullable(false)
|
||||
->comment('카테고리 ID')
|
||||
->change();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ DON'T: NULL 데이터 확인 없이 NOT NULL 변경 (금지)
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
// NULL 데이터 존재 시 SQL 오류 발생
|
||||
$table->unsignedBigInteger('category_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### down() 메서드 체크리스트
|
||||
|
||||
모듈/플러그인 마이그레이션 `down()` 작성 시 반드시 확인:
|
||||
|
||||
```
|
||||
□ 1. 테이블 존재 여부: Schema::hasTable() 사용
|
||||
□ 2. 컬럼 존재 여부: Schema::hasColumn() 사용
|
||||
□ 3. 인덱스 존재 여부: Schema::getIndexes() 사용 (Laravel 11+)
|
||||
□ 4. NOT NULL 변경 시: NULL 데이터 존재 여부 확인
|
||||
□ 5. 외래키 삭제 시: 외래키 존재 여부 확인
|
||||
□ 6. dropIfExists() 사용: 테이블 삭제 시
|
||||
```
|
||||
|
||||
#### Laravel 버전별 인덱스 확인 방법
|
||||
|
||||
```php
|
||||
// Laravel 11+ (권장)
|
||||
$indexes = Schema::getIndexes('table_name');
|
||||
$indexNames = array_column($indexes, 'name');
|
||||
if (in_array('index_name', $indexNames)) {
|
||||
// 인덱스 존재
|
||||
}
|
||||
|
||||
// Laravel 10 이하 (Doctrine DBAL 필요)
|
||||
$sm = Schema::getConnection()->getDoctrineSchemaManager();
|
||||
$indexes = $sm->listTableIndexes($tableName);
|
||||
if (isset($indexes['index_name'])) {
|
||||
// 인덱스 존재
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 외래 키 및 삭제 정책
|
||||
|
||||
```
|
||||
필수: Service에서 명시적 삭제 (DB CASCADE에 의존한 삭제 금지)
|
||||
필수: 어플리케이션(Service)에서 명시적으로 연관 데이터 삭제
|
||||
✅ 필수: 삭제 순서 보장, 훅 실행, 로깅, 파일 삭제 등 어플리케이션 로직 처리
|
||||
```
|
||||
|
||||
#### 왜 DB CASCADE에 의존하면 안 되는가?
|
||||
|
||||
DB CASCADE를 사용하면 다음 문제가 발생합니다:
|
||||
|
||||
| 문제 | 설명 |
|
||||
|------|------|
|
||||
| **훅 미실행** | `before_delete`, `after_delete` 훅이 실행되지 않음 |
|
||||
| **파일 미삭제** | Storage에 저장된 파일이 남음 (이미지, 첨부파일 등) |
|
||||
| **로깅 불가** | 삭제 이력을 추적할 수 없음 |
|
||||
| **순서 미보장** | 삭제 순서를 제어할 수 없음 |
|
||||
| **디버깅 어려움** | 어떤 데이터가 삭제되었는지 파악 어려움 |
|
||||
|
||||
#### 마이그레이션 작성 규칙
|
||||
|
||||
**마이그레이션에서 CASCADE 설정은 허용**되지만, **삭제 시에는 어플리케이션에서 명시적으로 처리**해야 합니다:
|
||||
|
||||
```php
|
||||
// ✅ 마이그레이션: CASCADE 설정 가능 (안전망 역할)
|
||||
$table->foreignId('product_id')
|
||||
->comment('상품 ID')
|
||||
->constrained('products')
|
||||
->onDelete('cascade'); // DB 레벨 안전망
|
||||
|
||||
// ✅ 마이그레이션: RESTRICT 설정 (삭제 차단이 필요한 경우)
|
||||
$table->foreignId('product_id')
|
||||
->comment('상품 ID')
|
||||
->constrained('products')
|
||||
->restrictOnDelete(); // 참조 중이면 삭제 불가
|
||||
```
|
||||
|
||||
#### Service 삭제 메서드 작성 규칙
|
||||
|
||||
**모든 연관 데이터는 Service에서 명시적으로 삭제**해야 합니다:
|
||||
|
||||
```php
|
||||
// ✅ DO: 명시적 삭제 (필수)
|
||||
public function delete(Product $product): bool
|
||||
{
|
||||
HookManager::doAction('module.entity.before_delete', $product);
|
||||
|
||||
return DB::transaction(function () use ($product) {
|
||||
// 1. 파일 물리적 삭제 (Storage)
|
||||
$this->deleteProductImageFiles($product);
|
||||
|
||||
// 2. 연관 데이터 명시적 삭제 (순서 중요)
|
||||
$product->images()->delete();
|
||||
$product->options()->delete();
|
||||
$product->additionalOptions()->delete();
|
||||
$product->labelAssignments()->delete();
|
||||
$product->logs()->delete();
|
||||
$product->notice()->delete();
|
||||
$product->categories()->detach(); // 중간 테이블
|
||||
|
||||
// 3. 메인 레코드 삭제
|
||||
$result = $this->repository->delete($product);
|
||||
|
||||
HookManager::doAction('module.entity.after_delete', $product);
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ DON'T: CASCADE에 의존 (금지)
|
||||
public function delete(Product $product): bool
|
||||
{
|
||||
// 연관 데이터 삭제 없이 바로 삭제 - 금지!
|
||||
return $this->repository->delete($product);
|
||||
}
|
||||
```
|
||||
|
||||
#### 삭제 메서드 체크리스트
|
||||
|
||||
Service에서 `delete()` 메서드 작성 시 반드시 확인:
|
||||
|
||||
```
|
||||
□ 1. 훅 실행 (before_delete, after_delete)
|
||||
□ 2. DB 트랜잭션 사용
|
||||
□ 3. 파일 삭제 (Storage에 저장된 이미지, 첨부파일 등)
|
||||
□ 4. 모든 HasMany 관계 명시적 삭제
|
||||
□ 5. 모든 HasOne 관계 명시적 삭제
|
||||
□ 6. 모든 BelongsToMany 중간 테이블 detach
|
||||
□ 7. TODO 주석으로 미구현 연관 데이터 명시
|
||||
□ 8. 메인 레코드 삭제
|
||||
```
|
||||
|
||||
#### 예외: RESTRICT 사용 케이스
|
||||
|
||||
비즈니스 규칙상 삭제를 차단해야 하는 경우 `RESTRICT`를 사용합니다:
|
||||
|
||||
```php
|
||||
// 주문 이력이 있는 상품은 삭제 불가
|
||||
$table->foreignId('product_id')
|
||||
->constrained('products')
|
||||
->restrictOnDelete(); // ✅ 적절한 사용
|
||||
|
||||
// Service에서 삭제 전 체크
|
||||
public function checkCanDelete(Product $product): array
|
||||
{
|
||||
$ordersCount = OrderOption::where('product_id', $product->id)->count();
|
||||
|
||||
return [
|
||||
'canDelete' => $ordersCount === 0,
|
||||
'reason' => $ordersCount > 0
|
||||
? __('messages.has_order_history', ['count' => $ordersCount])
|
||||
: null,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 시더
|
||||
|
||||
**규칙**:
|
||||
- 콘솔 메시지 (시작/완료/삭제 건수) 필수
|
||||
- 메서드 분리 (delete*, create*)
|
||||
- `truncate()` 대신 `delete()` 사용
|
||||
- PHPDoc 주석 한국어
|
||||
|
||||
#### 시더 디렉토리 구조
|
||||
|
||||
```
|
||||
설치 필수 시더와 샘플(개발용) 시더는 반드시 분리
|
||||
✅ 설치 시더: database/seeders/ 루트에 배치
|
||||
✅ 샘플 시더: database/seeders/Sample/ 하위에 배치
|
||||
✅ 기본 실행(db:seed, module:seed, plugin:seed)은 설치 시더만 실행
|
||||
✅ --sample 옵션 시에만 샘플 시더 추가 실행
|
||||
```
|
||||
|
||||
**디렉토리 구조**:
|
||||
```
|
||||
database/seeders/
|
||||
├── DatabaseSeeder.php # 설치 시더 호출 + 조건부 샘플 호출
|
||||
├── AdminUserSeeder.php # 설치 필수
|
||||
├── RolePermissionSeeder.php # 설치 필수
|
||||
└── Sample/ # 샘플(개발용) 시더
|
||||
├── DummyUserSeeder.php
|
||||
└── TemplateSeeder.php
|
||||
```
|
||||
|
||||
**`HasSampleSeeders` 트레이트** (`app/Traits/HasSampleSeeders.php`):
|
||||
- DatabaseSeeder에서 `use HasSampleSeeders;` 사용
|
||||
- `shouldIncludeSample()`: `--sample` 옵션 여부 확인
|
||||
- 커맨드에서 `setIncludeSample()` 호출로 전파
|
||||
|
||||
**DatabaseSeeder 패턴**:
|
||||
```php
|
||||
use App\Traits\HasSampleSeeders;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use HasSampleSeeders;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// 설치 필수 시더 (항상 실행)
|
||||
$this->call([
|
||||
AdminUserSeeder::class,
|
||||
]);
|
||||
|
||||
// 샘플 시더 (--sample 옵션 시에만 실행)
|
||||
if ($this->shouldIncludeSample()) {
|
||||
$this->call([
|
||||
Sample\DummyUserSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**실행 명령어**:
|
||||
```bash
|
||||
# 설치 시더만 (기본)
|
||||
php artisan db:seed
|
||||
php artisan module:seed sirsoft-ecommerce
|
||||
|
||||
# 설치 + 샘플
|
||||
php artisan db:seed --sample
|
||||
php artisan module:seed sirsoft-ecommerce --sample
|
||||
|
||||
# 개별 샘플 시더
|
||||
php artisan db:seed --class="Database\Seeders\Sample\DummyUserSeeder"
|
||||
php artisan module:seed sirsoft-ecommerce --class=Sample\\ProductSeeder
|
||||
```
|
||||
|
||||
**패턴**:
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders\Core;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\User;
|
||||
use App\Models\Role;
|
||||
|
||||
class AdminUserSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* 기본 관리자 정보
|
||||
*/
|
||||
private array $defaultAdminUser = [
|
||||
'name' => '관리자',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => 'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* 시더 실행
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('기본 관리자 사용자 생성을 시작합니다.');
|
||||
|
||||
// 기존 데이터 삭제
|
||||
$this->deleteExistingUsers();
|
||||
|
||||
// 새 데이터 생성
|
||||
$this->createAdminUser($this->defaultAdminUser);
|
||||
|
||||
$this->command->info('기본 관리자 사용자가 성공적으로 생성되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 사용자 삭제
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function deleteExistingUsers(): void
|
||||
{
|
||||
$deletedCount = User::where('email', $this->defaultAdminUser['email'])->delete();
|
||||
|
||||
if ($deletedCount > 0) {
|
||||
$this->command->warn("기존 관리자 사용자 {$deletedCount}건을 삭제했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 사용자 생성
|
||||
*
|
||||
* @param array $userData 사용자 데이터
|
||||
* @return void
|
||||
*/
|
||||
private function createAdminUser(array $userData): void
|
||||
{
|
||||
$adminRole = Role::where('name', 'admin')->first();
|
||||
|
||||
$user = User::create([
|
||||
'name' => $userData['name'],
|
||||
'email' => $userData['email'],
|
||||
'password' => bcrypt($userData['password']),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$user->roles()->attach($adminRole->id);
|
||||
|
||||
$this->command->info("관리자 사용자가 생성되었습니다: {$user->email}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 로케일 설정
|
||||
|
||||
```
|
||||
MANDATORY: supported_locales와 translatable_locales는 config/app.php에서 관리
|
||||
✅ 필수: 하드코딩 금지, config() 함수 사용 필수
|
||||
```
|
||||
|
||||
#### supported_locales vs translatable_locales
|
||||
|
||||
**위치**: `config/app.php`
|
||||
|
||||
```php
|
||||
// config/app.php
|
||||
'supported_locales' => ['ko', 'en'], // UI 언어 전환에 사용
|
||||
'translatable_locales' => ['ko', 'en'], // 다국어 필드 데이터 저장에 사용
|
||||
```
|
||||
|
||||
#### supported_locales
|
||||
|
||||
**용도**: 시스템에서 지원하는 모든 UI 언어 목록
|
||||
|
||||
**사용처**:
|
||||
- `SetLocale` 미들웨어: 사용자 언어 검증
|
||||
- 템플릿 엔진: 언어 선택자 옵션 생성 (`$locales` 전역 변수)
|
||||
- 프론트엔드: `template.json`의 `locales` 필드 (템플릿별 지원 언어)
|
||||
|
||||
**규칙**:
|
||||
- 반드시 `config('app.supported_locales')`로 접근
|
||||
- 하드코딩(`['ko', 'en']`) 금지
|
||||
- 번역 파일(`/lang/{locale}/`)이 존재해야 함
|
||||
|
||||
**예시**:
|
||||
```php
|
||||
// ✅ DO: config 함수 사용
|
||||
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
|
||||
if (in_array($locale, $supportedLocales)) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ❌ DON'T: 하드코딩
|
||||
if (in_array($locale, ['ko', 'en'])) { // 금지
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### translatable_locales
|
||||
|
||||
**용도**: 다국어 필드(JSON 타입)에서 허용하는 언어 목록
|
||||
|
||||
**사용처**:
|
||||
- 다국어 테이블 필드 (`permissions.name`, `roles.description` 등)
|
||||
- FormRequest 검증 (예: `UpdateModuleRequest`)
|
||||
- Model Accessor/Mutator (`getLocalizedName()` 등)
|
||||
|
||||
**규칙**:
|
||||
- 반드시 `config('app.translatable_locales')`로 접근
|
||||
- 번역 파일이 없어도 데이터 저장은 허용 (DB 데이터만)
|
||||
- 새 언어 추가 시 이 배열에 언어 코드 추가
|
||||
|
||||
**예시**:
|
||||
```php
|
||||
// ✅ DO: FormRequest에서 사용
|
||||
public function rules(): array
|
||||
{
|
||||
$locales = config('app.translatable_locales', ['ko', 'en']);
|
||||
$rules = [];
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$rules["name.{$locale}"] = ['required', 'string'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
// ✅ DO: Model에서 사용
|
||||
public function getLocalizedName(?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? app()->getLocale();
|
||||
$translatable = config('app.translatable_locales', ['ko', 'en']);
|
||||
|
||||
// Locale별 폴백 처리
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 두 설정의 차이점
|
||||
|
||||
| 구분 | supported_locales | translatable_locales |
|
||||
|------|------------------|---------------------|
|
||||
| **용도** | UI 언어 전환 | 다국어 필드 데이터 |
|
||||
| **번역 파일** | 필수 (`/lang/{locale}/`) | 선택 (없어도 됨) |
|
||||
| **사용 예** | 언어 선택자, 미들웨어 | DB JSON 필드, FormRequest |
|
||||
| **검증 위치** | 런타임 (미들웨어) | 데이터 입력 시 (FormRequest) |
|
||||
|
||||
#### 새 언어 추가 절차
|
||||
|
||||
1. **`config/app.php` 업데이트**:
|
||||
```php
|
||||
'supported_locales' => ['ko', 'en', 'ja'], // 일본어 추가
|
||||
'translatable_locales' => ['ko', 'en', 'ja'],
|
||||
```
|
||||
|
||||
2. **번역 파일 생성** (supported_locales에 추가한 경우):
|
||||
```bash
|
||||
mkdir -p lang/ja
|
||||
# 모든 번역 파일 복사 및 번역
|
||||
```
|
||||
|
||||
3. **템플릿 메타데이터 업데이트**:
|
||||
```json
|
||||
// templates/sirsoft-admin_basic/template.json
|
||||
{
|
||||
"locales": ["ko", "en", "ja"]
|
||||
}
|
||||
```
|
||||
|
||||
### 다국어 (Backend)
|
||||
|
||||
**기본 지원 언어**:
|
||||
- **한국어 (ko)**: 기본 언어
|
||||
- **영어 (en)**: 필수 지원 언어
|
||||
|
||||
**규칙**:
|
||||
- 모든 다국어 파일은 `ko`, `en` 두 언어를 필수로 제공
|
||||
- 새로운 기능 추가 시 반드시 두 언어 모두 작성
|
||||
- **언어 목록은 `config('app.supported_locales')`에서 관리**
|
||||
|
||||
**위치**:
|
||||
- 코어: `/lang/{ko,en}/`
|
||||
- 모듈: `/modules/[vendor-module]/src/lang/{ko,en}/`
|
||||
- 플러그인: `/plugins/[vendor-plugin]/src/lang/{ko,en}/`
|
||||
|
||||
**네이밍**:
|
||||
- 코어: 접두사 없음
|
||||
- 모듈: `[vendor-module]::` 접두사 (예: `sirsoft-ecommerce::`)
|
||||
- 플러그인: `[vendor-plugin]::` 접두사
|
||||
|
||||
**예시**:
|
||||
```php
|
||||
// modules/_bundled/sirsoft-ecommerce/src/lang/ko/products.php
|
||||
|
||||
return [
|
||||
'title' => '상품 관리',
|
||||
'create' => '상품 생성',
|
||||
'edit' => '상품 수정',
|
||||
'delete_confirm' => '이 상품을 삭제하시겠습니까?',
|
||||
'messages' => [
|
||||
'created' => '상품이 생성되었습니다.',
|
||||
'updated' => '상품이 수정되었습니다.',
|
||||
'deleted' => '상품이 삭제되었습니다.',
|
||||
],
|
||||
];
|
||||
|
||||
// 사용
|
||||
__('sirsoft-ecommerce::products.title');
|
||||
__('sirsoft-ecommerce::products.messages.created');
|
||||
```
|
||||
|
||||
### 코어 테이블 다국어 지원
|
||||
|
||||
```
|
||||
MANDATORY: permissions, roles, menus, modules, plugins 테이블 다국어 필수
|
||||
✅ 필수: JSON 구조 사용 {"ko": "한국어", "en": "English"}
|
||||
```
|
||||
|
||||
**지원 테이블 및 필드**:
|
||||
|
||||
| 테이블 | 다국어 필드 |
|
||||
|--------|------------|
|
||||
| permissions | name, description |
|
||||
| roles | name, description |
|
||||
| menus | name |
|
||||
| modules | name, description |
|
||||
| plugins | name, description |
|
||||
|
||||
**JSON 구조**:
|
||||
```json
|
||||
{
|
||||
"ko": "한국어 텍스트",
|
||||
"en": "English text"
|
||||
}
|
||||
```
|
||||
|
||||
**모델 사용법**:
|
||||
|
||||
```php
|
||||
// 현재 로케일의 이름 반환
|
||||
$permission->getLocalizedName(); // 현재 로케일
|
||||
$permission->getLocalizedName('ko'); // 한국어
|
||||
$permission->getLocalizedName('en'); // 영어
|
||||
|
||||
// Accessor 사용
|
||||
$permission->localized_name; // 현재 로케일
|
||||
$permission->localized_description; // 현재 로케일
|
||||
|
||||
// 예시
|
||||
app()->setLocale('ko');
|
||||
echo $permission->getLocalizedName(); // "사용자 조회"
|
||||
|
||||
app()->setLocale('en');
|
||||
echo $permission->getLocalizedName(); // "View Users"
|
||||
```
|
||||
|
||||
**시더 작성 규칙**:
|
||||
|
||||
```php
|
||||
// ✅ DO: 다국어 배열 구조
|
||||
Permission::create([
|
||||
'identifier' => 'users.read',
|
||||
'name' => [
|
||||
'ko' => '사용자 조회',
|
||||
'en' => 'View Users',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '사용자 목록을 조회할 수 있습니다.',
|
||||
'en' => 'Can view user list.',
|
||||
],
|
||||
]);
|
||||
|
||||
Menu::create([
|
||||
'name' => [
|
||||
'ko' => '대시보드',
|
||||
'en' => 'Dashboard',
|
||||
],
|
||||
'slug' => 'dashboard',
|
||||
]);
|
||||
|
||||
// ❌ DON'T: 문자열 사용 (레거시, 권장하지 않음)
|
||||
Permission::create([
|
||||
'identifier' => 'users.read',
|
||||
'name' => '사용자 조회', // 지양
|
||||
]);
|
||||
```
|
||||
|
||||
**ModuleInterface, PluginInterface 다국어 지원**:
|
||||
|
||||
```php
|
||||
// ✅ DO: 다국어 배열 반환 (권장)
|
||||
public function getName(): array
|
||||
{
|
||||
return [
|
||||
'ko' => '이커머스 모듈',
|
||||
'en' => 'Ecommerce Module',
|
||||
];
|
||||
}
|
||||
|
||||
public function getDescription(): array
|
||||
{
|
||||
return [
|
||||
'ko' => '온라인 쇼핑몰 기능을 제공합니다.',
|
||||
'en' => 'Provides online shopping features.',
|
||||
];
|
||||
}
|
||||
|
||||
// 역호환: 문자열 반환 (자동 변환됨)
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Ecommerce Module'; // 자동으로 ['ko' => '...', 'en' => '...']로 변환
|
||||
}
|
||||
```
|
||||
|
||||
**FormRequest 역호환성 처리**:
|
||||
|
||||
```php
|
||||
// UpdateModuleRequest.php 예시
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$locales = config('app.translatable_locales', ['ko', 'en']);
|
||||
|
||||
// name이 문자열로 들어온 경우 배열로 자동 변환
|
||||
if ($this->has('name') && is_string($this->name)) {
|
||||
$nameArray = [];
|
||||
foreach ($locales as $locale) {
|
||||
$nameArray[$locale] = $this->name;
|
||||
}
|
||||
$this->merge(['name' => $nameArray]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**폴백 동작**:
|
||||
|
||||
1. 요청한 로케일에 번역이 있으면 반환
|
||||
2. 없으면 'ko' (기본 로케일)로 폴백
|
||||
3. 'ko'도 없으면 'en'으로 폴백
|
||||
4. 둘 다 없으면 빈 문자열 반환
|
||||
|
||||
**엣지 케이스**:
|
||||
|
||||
```php
|
||||
// 한국어만 있는 경우
|
||||
$permission->name = ['ko' => '테스트'];
|
||||
$permission->getLocalizedName('en'); // "테스트" (ko로 폴백)
|
||||
|
||||
// 영어만 있는 경우
|
||||
$role->name = ['en' => 'Test'];
|
||||
$role->getLocalizedName('ko'); // "Test" (en으로 폴백)
|
||||
|
||||
// 빈 번역
|
||||
$menu->name = ['ko' => '메뉴', 'en' => ''];
|
||||
$menu->getLocalizedName('en'); // "" (빈 문자열 반환)
|
||||
|
||||
// NULL 값
|
||||
$module->description = null;
|
||||
$module->getLocalizedDescription(); // "" (빈 문자열 반환)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# 그누보드7 확장 시스템 개발 가이드
|
||||
|
||||
> 이 문서는 그누보드7의 확장 시스템 전체 개요와 각 상세 문서에 대한 링크를 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
필수: /modules, /plugins 디렉토리 스캔으로 자동 발견 (composer.json 하드코딩 금지)
|
||||
필수: 모든 확장 작업은 Artisan 커맨드로 수행
|
||||
✅ 필수: 훅 시스템을 통한 코어 수정 최소화
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 확장 시스템 개요
|
||||
|
||||
G7은 **동적 로딩** 기반의 확장 시스템을 제공합니다:
|
||||
|
||||
- **모듈**: 독립적인 비즈니스 기능 단위 (예: 이커머스, 블로그)
|
||||
- **플러그인**: 특정 기능 확장 (예: 결제, 배송)
|
||||
- **템플릿**: UI 렌더링 및 레이아웃 정의 (Admin, User)
|
||||
- **훅 시스템**: 코어 수정 없이 로직 확장
|
||||
|
||||
---
|
||||
|
||||
## 문서 구조
|
||||
|
||||
### 훅 시스템
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [hooks.md](hooks.md) | Action/Filter Hook, 리스너 구현, 우선순위 |
|
||||
|
||||
### 모듈 개발
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [module-basics.md](module-basics.md) | AbstractModule, 디렉토리 구조, 네이밍 규칙 |
|
||||
| [module-routing.md](module-routing.md) | API/Web 라우트, 자동 Prefix, routes.json |
|
||||
| [module-layouts.md](module-layouts.md) | 레이아웃 등록, 오버라이드, 상속 |
|
||||
| [module-commands.md](module-commands.md) | Artisan 커맨드 (install, activate, uninstall) |
|
||||
| [module-i18n.md](module-i18n.md) | 백엔드/프론트엔드 다국어 분리 |
|
||||
|
||||
### 레이아웃 확장
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [layout-extensions.md](layout-extensions.md) | 동적 UI 주입 (Overlay, Extension Point), 템플릿 오버라이드 |
|
||||
|
||||
### 플러그인 개발
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [plugin-development.md](plugin-development.md) | PluginInterface, 격리성, 의존성 원칙 |
|
||||
|
||||
### 확장 관리자
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [extension-manager.md](extension-manager.md) | Composer 오토로드, 서비스 등록 |
|
||||
|
||||
### 템플릿 시스템
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [template-basics.md](template-basics.md) | Admin/User 템플릿, 메타데이터, 버전 히스토리 |
|
||||
| [template-routing.md](template-routing.md) | 라우트 정의, 언어 파일 경로 규칙 |
|
||||
| [template-security.md](template-security.md) | API 서빙, 파일 확장자 화이트리스트 |
|
||||
| [template-caching.md](template-caching.md) | 캐시 계층, 무효화 전략 |
|
||||
| [template-commands.md](template-commands.md) | Artisan 커맨드 (install, activate) |
|
||||
|
||||
### 권한 및 메뉴 시스템
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [permissions.md](permissions.md) | Role, Permission, 자동 관리 |
|
||||
| [menus.md](menus.md) | 메뉴 권한, 시더 |
|
||||
|
||||
---
|
||||
|
||||
## 확장 타입별 네이밍 규칙
|
||||
|
||||
| 타입 | 디렉토리명 | 네임스페이스 | Composer 경로 |
|
||||
|------|-----------|-------------|---------------|
|
||||
| 모듈 | `vendor-module` | `Modules\Vendor\Module\` | `modules/vendor-module` |
|
||||
| 플러그인 | `vendor-plugin` | `Plugins\Vendor\Plugin\` | `plugins/vendor-plugin` |
|
||||
| 템플릿 | `vendor-template` | - | `templates/vendor-template` |
|
||||
|
||||
**예시**:
|
||||
- 모듈: `sirsoft-ecommerce` → `Modules\Sirsoft\Ecommerce\`
|
||||
- 플러그인: `sirsoft-payment` → `Plugins\Sirsoft\Payment\`
|
||||
- 템플릿: `sirsoft-admin_basic`
|
||||
|
||||
---
|
||||
|
||||
## 훅 네이밍 규칙
|
||||
|
||||
```
|
||||
[vendor-module].[entity].[action]_[timing]
|
||||
```
|
||||
|
||||
**타이밍 접미사**:
|
||||
- `before_*`: 작업 실행 전
|
||||
- `after_*`: 작업 실행 후
|
||||
- `on_*`: 특정 이벤트 발생 시
|
||||
- `filter_*`: 데이터 필터링
|
||||
|
||||
**예시**:
|
||||
```
|
||||
sirsoft-ecommerce.product.before_create
|
||||
sirsoft-ecommerce.product.after_update
|
||||
sirsoft-ecommerce.product.filter_create_data
|
||||
sirsoft-ecommerce.order.on_payment_success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 Artisan 커맨드
|
||||
|
||||
### 모듈 관련
|
||||
|
||||
```bash
|
||||
php artisan module:list # 모듈 목록 조회
|
||||
php artisan module:install [identifier] # 모듈 설치
|
||||
php artisan module:activate [identifier] # 모듈 활성화
|
||||
php artisan module:deactivate [identifier] # 모듈 비활성화
|
||||
php artisan module:uninstall [identifier] # 모듈 제거
|
||||
php artisan module:cache-clear # 모듈 캐시 정리
|
||||
```
|
||||
|
||||
### 템플릿 관련
|
||||
|
||||
```bash
|
||||
php artisan template:list # 템플릿 목록 조회
|
||||
php artisan template:install [identifier] # 템플릿 설치
|
||||
php artisan template:activate [identifier] # 템플릿 활성화
|
||||
php artisan template:deactivate [identifier] # 템플릿 비활성화
|
||||
php artisan template:uninstall [identifier] # 템플릿 제거
|
||||
php artisan template:cache-clear # 템플릿 캐시 정리
|
||||
```
|
||||
|
||||
### 확장 관리자
|
||||
|
||||
```bash
|
||||
php artisan extension:update-autoload # Composer 오토로드 업데이트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작 가이드
|
||||
|
||||
### 새 모듈 개발 시
|
||||
|
||||
1. [module-basics.md](module-basics.md) - 기본 구조 이해
|
||||
2. [module-routing.md](module-routing.md) - 라우트 설정
|
||||
3. [module-i18n.md](module-i18n.md) - 다국어 지원
|
||||
4. [module-layouts.md](module-layouts.md) - 레이아웃 등록 (필요시)
|
||||
5. [hooks.md](hooks.md) - 훅 리스너 구현 (필요시)
|
||||
|
||||
### 새 플러그인 개발 시
|
||||
|
||||
1. [plugin-development.md](plugin-development.md) - 플러그인 구조 이해
|
||||
2. [hooks.md](hooks.md) - 훅 리스너 구현
|
||||
|
||||
### 템플릿 커스터마이징 시
|
||||
|
||||
1. [template-basics.md](template-basics.md) - 템플릿 타입 이해
|
||||
2. [template-routing.md](template-routing.md) - 라우트/언어 파일
|
||||
3. [template-security.md](template-security.md) - 보안 규칙
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [AGENTS.md](../../../AGENTS.md) - 프로젝트 개발 가이드
|
||||
- [backend/](../backend/) - 백엔드 개발 가이드
|
||||
- [frontend/](../frontend/) - 프론트엔드 개발 가이드
|
||||
- [database-guide.md](../database-guide.md) - 데이터베이스 가이드
|
||||
@@ -0,0 +1,213 @@
|
||||
# Changelog 규칙 (Changelog Rules)
|
||||
|
||||
> 코어 및 확장(모듈, 플러그인, 템플릿)의 변경사항을 CHANGELOG.md에 기록하는 규정
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가)
|
||||
2. 형식: Keep a Changelog 표준 (## [버전] - 날짜 / ### 카테고리 / - 항목)
|
||||
3. 허용 카테고리: Added, Changed, Deprecated, Removed, Fixed, Security
|
||||
4. 확장 위치: 각 확장의 루트 디렉토리 (modules/_bundled/vendor-module/CHANGELOG.md)
|
||||
5. 코어 위치: 프로젝트 루트 /CHANGELOG.md (코어 버전 변경 시 필수)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [CHANGELOG.md 작성 규칙](#1-changelogmd-작성-규칙)
|
||||
2. [카테고리 정의](#2-카테고리-정의)
|
||||
3. [파일 위치](#3-파일-위치)
|
||||
4. [ChangelogParser 헬퍼](#4-changelogparser-헬퍼)
|
||||
5. [API 엔드포인트](#5-api-엔드포인트)
|
||||
6. [관리 화면 표시](#6-관리-화면-표시)
|
||||
7. [템플릿 예시](#7-템플릿-예시)
|
||||
|
||||
---
|
||||
|
||||
## 1. CHANGELOG.md 작성 규칙
|
||||
|
||||
### 필수 사항
|
||||
|
||||
```text
|
||||
확장 버전 업 시 CHANGELOG.md에 변경사항 기록 필수
|
||||
미기록 시 버전 업 작업 불완전으로 간주
|
||||
```
|
||||
|
||||
- **형식**: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 표준 준수
|
||||
- **버전 관리**: [Semantic Versioning](https://semver.org/lang/ko/) 준수
|
||||
- **작성 언어**: 한국어 (확장 대상이 한국어 사용자)
|
||||
- **날짜 형식**: YYYY-MM-DD (ISO 8601)
|
||||
- **최신 버전**: 파일 상단에 위치 (역순)
|
||||
|
||||
### 버전 섹션 형식
|
||||
|
||||
```markdown
|
||||
## [버전] - YYYY-MM-DD
|
||||
```
|
||||
|
||||
예: `## [0.1.2] - 2026-02-25`
|
||||
|
||||
### 항목 형식
|
||||
|
||||
```markdown
|
||||
### 카테고리
|
||||
- 변경사항 설명
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 카테고리 정의
|
||||
|
||||
| 카테고리 | 설명 | 사용 시점 |
|
||||
|----------|------|----------|
|
||||
| `Added` | 새 기능 추가 | 새로운 기능, 화면, API 엔드포인트 추가 |
|
||||
| `Changed` | 기존 기능 변경 | 기존 동작 수정, 성능 개선, UI 변경 |
|
||||
| `Deprecated` | 향후 제거 예정 | 향후 삭제될 기능 경고 |
|
||||
| `Removed` | 기능 제거 | 기존 기능/API 삭제 |
|
||||
| `Fixed` | 버그 수정 | 기존 버그, 오류 수정 |
|
||||
| `Security` | 보안 취약점 수정 | 보안 관련 수정 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 파일 위치
|
||||
|
||||
### 확장 (모듈/플러그인/템플릿)
|
||||
|
||||
```text
|
||||
modules/_bundled/vendor-module/CHANGELOG.md # 모듈 (예: sirsoft-board)
|
||||
plugins/_bundled/vendor-plugin/CHANGELOG.md # 플러그인 (예: sirsoft-tosspayments)
|
||||
templates/_bundled/vendor-template/CHANGELOG.md # 템플릿 (예: sirsoft-admin_basic)
|
||||
```
|
||||
|
||||
- _bundled 디렉토리에서 작업 (활성 디렉토리 직접 수정 금지)
|
||||
- 업데이트 프로세스를 통해 활성 디렉토리로 복사
|
||||
|
||||
### 코어
|
||||
|
||||
```text
|
||||
/CHANGELOG.md # 프로젝트 루트
|
||||
```
|
||||
|
||||
- **코어 버전 변경 시 CHANGELOG.md에 변경사항 기록 필수** (미기록 시 버전 업 불가)
|
||||
- 확장과 동일한 Keep a Changelog 형식 사용
|
||||
- 코어 버전 변경 대상 파일: `config/app.php`, `.env.example`, `.env.testing`
|
||||
- 버전 변경 시 반드시 CHANGELOG.md에 해당 버전 섹션 추가 후 버전 파일 수정
|
||||
|
||||
---
|
||||
|
||||
## 4. ChangelogParser 헬퍼
|
||||
|
||||
**파일**: `app/Extension/Helpers/ChangelogParser.php`
|
||||
|
||||
### 주요 메서드
|
||||
|
||||
| 메서드 | 설명 | 반환 |
|
||||
|--------|------|------|
|
||||
| `parse(string $filePath)` | CHANGELOG.md 전체 파싱 | `array` (버전별 구조화 배열) |
|
||||
| `getVersionRange(string $filePath, string $from, string $to)` | 특정 범위 엔트리 추출 | `array` (from 초과 ~ to 이하) |
|
||||
| `resolveChangelogPath(string $basePath, string $identifier, ?string $source)` | 소스별 경로 결정 | `?string` |
|
||||
|
||||
### 반환 형식
|
||||
|
||||
```php
|
||||
[
|
||||
[
|
||||
'version' => '0.1.2',
|
||||
'date' => '2026-02-25',
|
||||
'categories' => [
|
||||
['name' => 'Added', 'items' => ['새 기능 A']],
|
||||
['name' => 'Fixed', 'items' => ['버그 B 수정']],
|
||||
],
|
||||
],
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 엔드포인트
|
||||
|
||||
```text
|
||||
GET /api/admin/modules/{identifier}/changelog
|
||||
GET /api/admin/plugins/{identifier}/changelog
|
||||
GET /api/admin/templates/{identifier}/changelog
|
||||
```
|
||||
|
||||
### 쿼리 파라미터
|
||||
|
||||
| 파라미터 | 설명 | 기본값 |
|
||||
|----------|------|--------|
|
||||
| `source` | CHANGELOG.md 읽을 위치 | `active` |
|
||||
| `from_version` | 이 버전 초과 항목만 반환 | - |
|
||||
| `to_version` | 이 버전 이하만 반환 | - |
|
||||
|
||||
### 사용 시나리오
|
||||
|
||||
- **상세 모달**: `GET .../changelog` (전체 조회)
|
||||
- **업데이트 모달**: `GET .../changelog?source=bundled&from_version=0.1.1&to_version=0.1.2` (변경분)
|
||||
|
||||
---
|
||||
|
||||
## 6. 관리 화면 표시
|
||||
|
||||
### 업데이트 모달
|
||||
|
||||
- **전역 상태**: `_global.updateChangelog` (3개 확장 타입 공통)
|
||||
- **표시 조건**: `updateChangelog && updateChangelog.length > 0`
|
||||
- **표시 위치**: 버전 정보 아래, 외부 링크(github_changelog_url) 위
|
||||
- **스타일**: blue 배경 (`bg-blue-50 dark:bg-blue-900/20`)
|
||||
- **우선순위**: 인라인 changelog > 외부 링크 (둘 다 있으면 둘 다 표시)
|
||||
|
||||
### 상세 모달
|
||||
|
||||
- **전역 상태**: `_global.moduleChangelog`, `_global.pluginChangelog`, `_global.templateChangelog` (타입별)
|
||||
- **섹션 ID**: `changelog_section`
|
||||
- **아이콘**: `clock-rotate-left`
|
||||
- **empty state**: `_global.xxxChangelog`가 비어있으면 "변경 내역 정보가 없습니다." 표시
|
||||
|
||||
### 메인 리스트 액션 흐름
|
||||
|
||||
```text
|
||||
# 업데이트 버튼 클릭 시:
|
||||
setState(selectedX) → apiCall(changelog?source=bundled&from_version=...&to_version=...)
|
||||
→ onSuccess: setState(updateChangelog) + openModal(update_modal)
|
||||
→ onError: setState(updateChangelog=[]) + openModal(update_modal)
|
||||
|
||||
# 상세 보기 버튼 클릭 시:
|
||||
setState(selectedX) → apiCall(changelog)
|
||||
→ onSuccess: setState(xChangelog) + openModal(detail_modal)
|
||||
→ onError: setState(xChangelog=[]) + openModal(detail_modal)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 템플릿 예시
|
||||
|
||||
새 확장을 만들 때 아래 템플릿을 사용합니다:
|
||||
|
||||
```markdown
|
||||
# Changelog
|
||||
|
||||
이 프로젝트의 모든 주요 변경사항을 기록합니다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [0.1.0] - YYYY-MM-DD
|
||||
|
||||
### Added
|
||||
- 초기 기능 구현
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `app/Extension/Helpers/ChangelogParser.php` — Changelog 파서
|
||||
- `app/Http/Requests/Extension/ChangelogRequest.php` — FormRequest 검증
|
||||
- `app/Http/Controllers/Api/Admin/ModuleController.php` — `changelog()` 메서드
|
||||
- `app/Http/Controllers/Api/Admin/PluginController.php` — `changelog()` 메서드
|
||||
- `app/Http/Controllers/Api/Admin/TemplateController.php` — `changelog()` 메서드
|
||||
- `app/Services/ModuleService.php` — `getModuleChangelog()` 메서드
|
||||
- `app/Services/PluginService.php` — `getPluginChangelog()` 메서드
|
||||
- `app/Services/TemplateService.php` — `getTemplateChangelog()` 메서드
|
||||
@@ -0,0 +1,349 @@
|
||||
# ExtensionManager (확장 관리자)
|
||||
|
||||
> **소스**: extension-guide.md에서 분리
|
||||
> **관련 문서**: [module-basics.md](module-basics.md), [plugin-development.md](plugin-development.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. composer.json 수정 없음 - 런타임 오토로드 방식 사용
|
||||
2. 캐시 파일: bootstrap/cache/autoload-extensions.php (gitignore 대상)
|
||||
3. 설치/삭제 시 자동 갱신: updateComposerAutoload() → generateAutoloadFile()
|
||||
4. 수동 갱신: php artisan extension:update-autoload
|
||||
5. _bundled/_pending 디렉토리 자동 제외 (str_starts_with($name, '_') → skip)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [런타임 오토로드 방식](#런타임-오토로드-방식)
|
||||
3. [Composer 의존성 관리](#composer-의존성-관리)
|
||||
4. [식별자 검증 규칙](#식별자-검증-규칙-validextensionidentifier)
|
||||
5. [Artisan 커맨드](#artisan-커맨드)
|
||||
6. [서비스 등록](#서비스-등록)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
모듈과 플러그인에서 공통으로 사용되는 기능을 담당하는 관리자 클래스입니다. 주로 오토로드 설정 관리를 담당합니다.
|
||||
|
||||
**주요 역할**:
|
||||
|
||||
- 런타임 오토로드 캐시 파일 생성
|
||||
- 모듈/플러그인 설치 시 PSR-4 네임스페이스 등록
|
||||
- Composer ClassLoader에 동적 등록
|
||||
|
||||
---
|
||||
|
||||
## 런타임 오토로드 방식
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
```
|
||||
중요: composer.json을 수정하지 않음
|
||||
✅ 결정: 런타임 오토로드 방식 사용 (캐시 파일 기반)
|
||||
✅ 장점: 버전 관리 충돌 없음, composer dump-autoload 불필요
|
||||
```
|
||||
|
||||
### 동작 방식
|
||||
|
||||
```
|
||||
모듈/플러그인 설치/삭제 시
|
||||
↓
|
||||
ExtensionManager::updateComposerAutoload() 호출
|
||||
↓
|
||||
bootstrap/cache/autoload-extensions.php 파일 생성 (gitignore 대상)
|
||||
↓
|
||||
public/index.php 또는 artisan 실행 시
|
||||
↓
|
||||
$loader->addPsr4()로 런타임에 네임스페이스 등록
|
||||
```
|
||||
|
||||
### 캐시 파일 구조
|
||||
|
||||
```php
|
||||
// bootstrap/cache/autoload-extensions.php
|
||||
|
||||
return [
|
||||
'psr4' => [
|
||||
"Modules\\Sirsoft\\Ecommerce\\" => "modules/sirsoft-ecommerce/src/",
|
||||
"Plugins\\Sirsoft\\Payment\\" => "plugins/sirsoft-payment/src/",
|
||||
],
|
||||
'classmap' => [
|
||||
"modules/sirsoft-ecommerce/module.php",
|
||||
"plugins/sirsoft-payment/plugin.php",
|
||||
],
|
||||
'files' => [],
|
||||
'vendor_autoloads' => [
|
||||
"modules/sirsoft-ecommerce/vendor/autoload.php",
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
- `vendor_autoloads`: 모듈/플러그인의 `vendor/autoload.php` 경로 목록 (Composer 의존성이 설치된 경우만 포함)
|
||||
|
||||
### 중요 규칙
|
||||
|
||||
```
|
||||
설치된 모듈/플러그인만 캐시 파일에 반영됩니다
|
||||
_bundled/_pending 디렉토리는 활성 확장으로 인식하지 않습니다
|
||||
```
|
||||
|
||||
- 디렉토리에 파일이 있어도 **DB에 설치 기록이 없으면 반영되지 않음**
|
||||
- identifier는 디렉토리명과 동일 (예: `sirsoft-sample`, `sirsoft-payment`)
|
||||
- `_`로 시작하는 디렉토리(`_bundled`, `_pending`)는 디렉토리 스캔 시 자동 제외됨
|
||||
- 캐시 파일에는 활성 디렉토리 경로만 포함 (`_bundled/` 경로 미포함)
|
||||
|
||||
### 수행 작업
|
||||
|
||||
1. `ModuleRepository`와 `PluginRepository`를 통해 설치된 확장 목록 조회
|
||||
2. 각 모듈/플러그인의 `composer.json`에서 PSR-4 오토로드 정보 수집
|
||||
3. `bootstrap/cache/autoload-extensions.php` 파일 생성
|
||||
4. `module.php`, `plugin.php` 파일을 `classmap`에 추가
|
||||
|
||||
### 로딩 진입점
|
||||
|
||||
**public/index.php** 및 **artisan**에서 `ExtensionManager::registerExtensionAutoload($loader)` 호출:
|
||||
|
||||
```text
|
||||
registerExtensionAutoload($loader)
|
||||
↓ 1. PSR-4 네임스페이스 등록 ($loader->addPsr4())
|
||||
↓ 2. Classmap 파일 로드 (module.php, plugin.php → require_once)
|
||||
↓ 3. Files 로드 (헬퍼 함수 등 → require_once)
|
||||
↓ 4. Vendor autoloads 로드 (모듈/플러그인의 vendor/autoload.php → require_once)
|
||||
```
|
||||
|
||||
**로딩 순서가 중요합니다**:
|
||||
|
||||
| 순서 | 유형 | 설명 |
|
||||
| ---------- | ---------- | ---------- |
|
||||
| 1 | PSR-4 | 확장의 `src/` 디렉토리 네임스페이스를 Composer ClassLoader에 등록 |
|
||||
| 2 | Classmap | `module.php`, `plugin.php` 엔트리 파일을 즉시 로드 |
|
||||
| 3 | Files | 헬퍼 함수 등 자동 로드 파일 |
|
||||
| 4 | Vendor autoloads | 각 확장의 `vendor/autoload.php`를 `require_once`하여 외부 패키지 로드 |
|
||||
|
||||
- 캐시 파일 미존재 시 아무 작업도 하지 않고 return (설치 전 상태)
|
||||
- 경로가 존재하지 않는 항목은 자동 스킵 (`is_dir()` / `file_exists()` 체크)
|
||||
|
||||
---
|
||||
|
||||
## Composer 의존성 관리
|
||||
|
||||
모듈/플러그인이 외부 Composer 패키지(예: `stripe/stripe-php`)를 필요로 할 때, 각 확장의 `composer.json`에 `require`로 정의하고 확장별 독립 `vendor/` 디렉토리에 설치합니다.
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
```text
|
||||
중요: 루트 composer.json에 모듈/플러그인 패키지 추가 금지
|
||||
✅ 결정: 각 확장의 composer.json → 확장별 vendor/ 디렉토리에 독립 설치
|
||||
✅ 장점: 확장 간 의존성 충돌 방지, 삭제 시 깔끔한 정리
|
||||
```
|
||||
|
||||
### 동작 방식
|
||||
|
||||
```text
|
||||
모듈/플러그인 설치 시 (Phase 4.5)
|
||||
↓
|
||||
hasComposerDependencies() → 외부 패키지 존재 여부 확인
|
||||
↓ (존재 시)
|
||||
runComposerInstall() → 확장 디렉토리에서 composer install 실행
|
||||
↓
|
||||
modules/{name}/vendor/ 또는 plugins/{name}/vendor/ 생성
|
||||
↓
|
||||
updateComposerAutoload() → autoload-extensions.php에 vendor_autoloads 등록
|
||||
↓
|
||||
public/index.php / artisan → vendor/autoload.php를 require_once
|
||||
```
|
||||
|
||||
### 주요 메서드
|
||||
|
||||
| 메서드 | 시그니처 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `hasComposerDependencies` | `(string $type, string $dirName): bool` | 외부 패키지 의존성 존재 여부 (`php`, `ext-*` 제외) |
|
||||
| `getComposerDependencies` | `(string $type, string $dirName): array` | 외부 패키지 목록 반환 |
|
||||
| `runComposerInstall` | `(string $type, string $dirName, bool $noDev, ?Command $command): bool` | 확장 디렉토리에서 `composer install` 실행 |
|
||||
| `detectDuplicatePackages` | `(): array` | 여러 확장에서 중복 사용되는 패키지 감지 |
|
||||
| `isComposerUnchanged` | `(string $stagingPath, string $activePath): bool` | 스테이징/활성 디렉토리의 composer 변경 여부 확인 |
|
||||
|
||||
### Composer 의존성 변경 감지 (isComposerUnchanged)
|
||||
|
||||
업데이트 시 불필요한 `composer install`을 스킵하기 위해 `composer.json`과 `composer.lock`을 이중 해시 비교합니다:
|
||||
|
||||
```text
|
||||
isComposerUnchanged(stagingPath, activePath)
|
||||
↓ 활성 디렉토리 또는 vendor/ 미존재 → false (설치 필요)
|
||||
↓ composer.json 한쪽 미존재 → false (변경으로 간주)
|
||||
↓ md5_file(staging/composer.json) !== md5_file(active/composer.json) → false
|
||||
↓ composer.lock 존재 여부 불일치 → false
|
||||
↓ md5_file(staging/composer.lock) !== md5_file(active/composer.lock) → false
|
||||
↓ 모두 일치 → true (스킵 가능)
|
||||
```
|
||||
|
||||
- **이중 비교**: `composer.json`(의존성 선언) + `composer.lock`(실제 설치 버전) 모두 확인
|
||||
- **false 반환 = 설치 필요**: 하나라도 변경되었으면 `composer install` 실행
|
||||
- **true 반환 = 스킵 가능**: 기존 `vendor/` 디렉토리 재사용
|
||||
|
||||
### runComposerInstall 환경변수
|
||||
|
||||
웹 서버 환경에서 Composer 실행 시 시스템 환경변수가 부족하므로, `install-worker.php` 패턴과 동일하게 환경변수를 명시적으로 구성합니다.
|
||||
|
||||
| 환경변수 | 출처/설정값 | 용도 |
|
||||
|----------|-----------|------|
|
||||
| PATH, SystemRoot | `getenv()`으로 시스템에서 복사 | Composer 바이너리 탐색 |
|
||||
| TEMP, TMP | 시스템에서 복사, 불가 시 `storage/temp` | 임시 파일 (Windows 특이) |
|
||||
| APPDATA, LOCALAPPDATA, USERPROFILE | `getenv()`으로 시스템에서 복사 | Windows 사용자 경로 |
|
||||
| COMPOSER_HOME | `storage/composer` | Composer 캐시 디렉토리 |
|
||||
| HOME | `storage/composer` | Unix-style 홈 디렉토리 |
|
||||
|
||||
### 삭제 시 vendor 정리
|
||||
|
||||
`InspectsUninstallData` trait이 제공하는 메서드:
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `getVendorDirectoryInfo(string $type, string $dirName): ?array` | vendor/ 디렉토리 크기 정보 조회 (삭제 모달 표시용) |
|
||||
| `deleteVendorDirectory(string $type, string $dirName): void` | vendor/ 디렉토리 및 composer.lock 삭제 |
|
||||
|
||||
- **삭제 시점**: `uninstallModule`/`uninstallPlugin`에서 `$deleteData === true`일 때 실행
|
||||
- **실패 처리**: 삭제 실패 시 예외를 catch하고 로그만 남김 (프로세스 중단하지 않음)
|
||||
|
||||
---
|
||||
|
||||
## Artisan 커맨드
|
||||
|
||||
### extension:update-autoload
|
||||
|
||||
모듈과 플러그인의 오토로드 캐시 파일을 생성합니다.
|
||||
|
||||
```bash
|
||||
php artisan extension:update-autoload
|
||||
```
|
||||
|
||||
**사용 시점**:
|
||||
|
||||
- 모듈/플러그인 디렉토리를 수동으로 추가/삭제한 경우
|
||||
- 오토로드 캐시 파일이 손상된 경우
|
||||
- 개발 중 오토로드 문제 디버깅
|
||||
|
||||
**출력 예시**:
|
||||
|
||||
```bash
|
||||
$ php artisan extension:update-autoload
|
||||
확장 오토로드 파일을 생성합니다...
|
||||
오토로드 파일이 성공적으로 생성되었습니다.
|
||||
→ bootstrap/cache/autoload-extensions.php
|
||||
```
|
||||
|
||||
### extension:composer-install
|
||||
|
||||
모든 모듈과 플러그인의 Composer 의존성을 일괄 설치합니다.
|
||||
|
||||
```bash
|
||||
php artisan extension:composer-install
|
||||
```
|
||||
|
||||
**옵션**:
|
||||
|
||||
- `--no-dev`: dev 의존성 제외
|
||||
|
||||
**사용 시점**:
|
||||
|
||||
- 프로젝트 초기 설정 시 모든 확장의 의존성을 한번에 설치
|
||||
- 배포 환경 구성 시
|
||||
|
||||
**동작**:
|
||||
|
||||
- `module:composer-install --all`과 `plugin:composer-install --all`을 순차 호출
|
||||
|
||||
---
|
||||
|
||||
## 식별자 검증 규칙 (ValidExtensionIdentifier)
|
||||
|
||||
모듈/플러그인/템플릿 설치 시 `ValidExtensionIdentifier` 규칙으로 식별자 형식을 공통 검증합니다:
|
||||
|
||||
| 규칙 | 설명 | 예시 |
|
||||
| ---------- | ---------- | ---------- |
|
||||
| **하이픈 1차 구분** | 최소 2개 부분 필수 (vendor-name) | `sirsoft-board` ✅ / `sirsoftboard` ❌ |
|
||||
| **언더스코어 2차 구분** | 이름 내 단어 구분 (선택적) | `sirsoft-daum_postcode` ✅ |
|
||||
| **영문 소문자 + 숫자 + `_`만** | 대문자, 특수문자 불가 | `Sirsoft-Board` ❌ / `sirsoft-my@module` ❌ |
|
||||
| **각 단어 첫 글자 숫자 불가** | 하이픈/언더스코어로 구분된 각 단어 기준 | `sirsoft-2shop` ❌ / `sirsoft-board2` ✅ |
|
||||
| **빈 부분 불가** | 연속 하이픈/언더스코어, 양끝 하이픈/언더스코어 금지 | `sirsoft--board` ❌ / `-sirsoft-board` ❌ |
|
||||
| **최대 255자** | 길이 제한 | - |
|
||||
|
||||
### 유효/무효 예시
|
||||
|
||||
```text
|
||||
✅ sirsoft-board → Sirsoft\Board
|
||||
✅ sirsoft-daum_postcode → Sirsoft\DaumPostcode
|
||||
✅ sirsoft-board2 → Sirsoft\Board2
|
||||
✅ vendor-my_module_name → Vendor\MyModuleName
|
||||
|
||||
❌ sirsoftboard → 하이픈 없음 (최소 2부분 필수)
|
||||
❌ sirsoft-2shop → '2shop' 첫 글자 숫자
|
||||
❌ Sirsoft-Board → 대문자 사용
|
||||
❌ sirsoft-my@module → 특수문자 '@'
|
||||
❌ sirsoft--board → 연속 하이픈 (빈 부분)
|
||||
❌ sirsoft-board_ → 끝 언더스코어 (빈 단어)
|
||||
```
|
||||
|
||||
### Artisan 커맨드에서의 검증
|
||||
|
||||
`module:install`, `plugin:install`, `template:install` 등에서 `ExtensionManager::validateIdentifierFormat()`을 호출하여 잘못된 식별자 입력 시 `InvalidArgumentException`을 발생시킵니다.
|
||||
|
||||
### 관련 파일
|
||||
|
||||
- `app/Rules/ValidExtensionIdentifier.php` — 검증 규칙 구현
|
||||
- `ExtensionManager::validateIdentifierFormat()` — Artisan 커맨드용 래퍼
|
||||
|
||||
---
|
||||
|
||||
## 서비스 등록
|
||||
|
||||
`CoreServiceProvider`에서 싱글톤으로 등록됩니다.
|
||||
|
||||
```php
|
||||
// app/Providers/CoreServiceProvider.php
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
// 확장 매니저 등록 (모듈/플러그인 공통 기능)
|
||||
$this->app->singleton(ExtensionManager::class, function ($app) {
|
||||
return new ExtensionManager;
|
||||
});
|
||||
|
||||
// 모듈 매니저에 주입
|
||||
$this->app->singleton(ModuleManager::class, function ($app) {
|
||||
return new ModuleManager($app->make(ExtensionManager::class));
|
||||
});
|
||||
|
||||
// 플러그인 매니저에 주입
|
||||
$this->app->singleton(PluginManager::class, function ($app) {
|
||||
return new PluginManager($app->make(ExtensionManager::class));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 의존성 관계
|
||||
|
||||
```
|
||||
ExtensionManager (독립)
|
||||
↓
|
||||
ModuleManager ← ExtensionManager 주입
|
||||
↓
|
||||
PluginManager ← ExtensionManager 주입
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [모듈 개발 기초](module-basics.md) - 모듈 구조 및 설정
|
||||
- [플러그인 개발](plugin-development.md) - 플러그인 구조 및 설정
|
||||
- [모듈 Artisan 커맨드](module-commands.md) - 모듈 관련 커맨드
|
||||
- [확장 업데이트 시스템](extension-update-system.md) - _bundled/_pending 및 업데이트
|
||||
- [인덱스](index.md) - 확장 시스템 전체 문서 목록
|
||||
@@ -0,0 +1,746 @@
|
||||
# 확장 업데이트 시스템 (Extension Update System)
|
||||
|
||||
> 모듈, 플러그인, 템플릿의 버전 업데이트 감지, 실행, 롤백을 관리하는 시스템
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여)
|
||||
2. _bundled = Git 추적 선탑재 소스, _pending = 설치 시 임시 스테이징만
|
||||
3. 상태 가드: ExtensionStatusGuard.assertNotInProgress(ExtensionStatus, identifier)
|
||||
4. 업그레이드 콜백: upgrades/ 디렉토리에 Upgrade_X_Y_Z.php (UpgradeStepInterface 구현)
|
||||
5. 역할/권한/메뉴 동기화: user_overrides로 사용자 커스터마이징 보존 (개별 식별자 보호)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [_bundled vs _pending 디렉토리](#1-_bundled-vs-_pending-디렉토리)
|
||||
2. [업데이트 감지](#2-업데이트-감지)
|
||||
3. [업데이트 실행 흐름](#3-업데이트-실행-흐름)
|
||||
4. [ExtensionStatusGuard](#4-extensionstatusguard)
|
||||
5. [ExtensionBackupHelper](#5-extensionbackuphelper)
|
||||
6. [ExtensionPendingHelper](#6-extensionpendinghelper)
|
||||
7. [UpgradeStepInterface + UpgradeContext](#7-upgradestepinterface--upgradecontext)
|
||||
8. [번들 디렉토리 개발 워크플로우](#8-번들-디렉토리-개발-워크플로우-critical---mandatory)
|
||||
9. [개발자 버전 업데이트 가이드](#9-개발자-버전-업데이트-가이드)
|
||||
10. [템플릿 레이아웃 충돌 전략](#10-템플릿-레이아웃-충돌-전략)
|
||||
11. [Artisan 커맨드](#11-artisan-커맨드)
|
||||
12. [API 엔드포인트](#12-api-엔드포인트)
|
||||
13. [역할/권한/메뉴 동기화](#13-역할권한메뉴-동기화)
|
||||
14. [SettingsMigrator](#14-settingsmigrator)
|
||||
15. [큐 워커 재시작 + 스케줄 등록](#15-큐-워커-재시작--스케줄-등록)
|
||||
|
||||
---
|
||||
|
||||
## 1. _bundled vs _pending 디렉토리
|
||||
|
||||
| 속성 | `_bundled/` | `_pending/` |
|
||||
|------|------------|-------------|
|
||||
| **용도** | 선탑재 확장 소스 (배포 포함) | 외부 다운로드/업로드 스테이징 |
|
||||
| **Git 추적** | O (추적됨) | X (`.gitignore` 제외) |
|
||||
| **수명** | 항상 존재 | 설치 완료 후 삭제 가능 |
|
||||
| **업데이트 감지** | **참여** (GitHub 다음 우선순위) | **미참여** |
|
||||
| **사용 시점** | 설치 + 업데이트 소스 | 설치 시 스테이징만 |
|
||||
| **디렉토리 구조** | `modules/_bundled/vendor-module/` | `modules/_pending/vendor-module/` |
|
||||
|
||||
### 디렉토리 자동 제외
|
||||
|
||||
`_`로 시작하는 디렉토리는 ExtensionManager에서 활성 확장으로 인식하지 않습니다:
|
||||
|
||||
```php
|
||||
// str_starts_with($name, '_') → skip
|
||||
// _bundled, _pending 모두 자동 제외
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 업데이트 감지
|
||||
|
||||
### 우선순위 (2단계)
|
||||
|
||||
```
|
||||
1. GitHub (github_url이 설정된 경우) → GitHub API로 최신 버전 조회
|
||||
2. _bundled (선탑재 소스) → _bundled/{identifier}/manifest.json에서 버전 비교
|
||||
```
|
||||
|
||||
> **주의**: `_pending` 디렉토리는 업데이트 감지에 **참여하지 않습니다**.
|
||||
|
||||
### 감지 결과
|
||||
|
||||
```php
|
||||
[
|
||||
'update_available' => true,
|
||||
'update_source' => 'github' | 'bundled', // pending 없음
|
||||
'latest_version' => '1.2.0',
|
||||
'current_version' => '1.0.0',
|
||||
]
|
||||
```
|
||||
|
||||
### check-updates API 흐름
|
||||
|
||||
1. Service 레이어에서 훅 발행: `core.{type}.before_check_updates`
|
||||
2. Manager의 `checkAll{Type}ForUpdates()` 호출
|
||||
3. 각 확장별 `check{Type}Update($identifier)` 실행
|
||||
4. DB에 `update_available`, `latest_version`, `update_source`, `github_changelog_url` 갱신
|
||||
5. 훅 발행: `core.{type}.after_check_updates`
|
||||
|
||||
### GitHub 아카이브 다운로드 및 해석
|
||||
|
||||
GitHub 소스 업데이트 시 아카이브 URL 해석 → 다운로드 → 압축 해제 과정을 거칩니다:
|
||||
|
||||
**v접두사 자동 감지** (`resolveGithubArchiveUrl`):
|
||||
|
||||
```text
|
||||
1. "v{version}" 태그로 HEAD 요청 시도 (예: v1.2.0)
|
||||
2. 실패 시 "{version}" 태그로 HEAD 요청 시도 (예: 1.2.0)
|
||||
3. HTTP 200/302 → 유효한 URL 반환
|
||||
4. 모두 실패 → null 반환 (업데이트 불가)
|
||||
```
|
||||
|
||||
**아카이브 추출 전략** (2단계 폴백):
|
||||
|
||||
| 순서 | 방식 | 조건 |
|
||||
| ---------- | ---------- | ---------- |
|
||||
| 1 | `ZipArchive` (PHP zip 확장) | `class_exists(ZipArchive::class)` |
|
||||
| 2 | `unzip` CLI 명령어 | ZipArchive 미사용 시 폴백 |
|
||||
|
||||
**GitHub 아카이브 디렉토리 구조**: GitHub zipball은 `owner-repo-hash/` 형태의 루트 디렉토리를 포함합니다. `ZipInstallHelper::findAndValidateManifest()`가 1단계 하위 디렉토리까지 자동 검색하여 매니페스트 파일을 찾습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 업데이트 실행 흐름
|
||||
|
||||
### 모듈/플러그인 업데이트 (10단계)
|
||||
|
||||
```
|
||||
1. [가드] ExtensionStatusGuard::assertNotInProgress()
|
||||
2. [백업] ExtensionBackupHelper::createBackup()
|
||||
3. [상태] status → Updating
|
||||
4. [파일] 소스에서 활성 디렉토리로 복사
|
||||
- github: 다운로드 후 복사
|
||||
- bundled: ExtensionPendingHelper::copyToActive()
|
||||
5. [마이그레이션] runMigrations()
|
||||
6. [DB 트랜잭션] 버전/메타데이터 갱신
|
||||
- Roles, Permissions, Menus 동기화 (→ [13. 역할/권한/메뉴 동기화](#13-역할권한메뉴-동기화))
|
||||
7. [레이아웃] 활성 상태였으면 레이아웃 갱신
|
||||
- registerModuleLayouts() + registerLayoutExtensions() + refreshModuleLayouts()
|
||||
8. [오토로드] Composer autoload 갱신
|
||||
9. [업그레이드] runUpgradeSteps($module, $fromVersion, $toVersion)
|
||||
10. [마무리] 이전 상태 복원 + 백업 삭제 + 캐시 무효화
|
||||
```
|
||||
|
||||
### 템플릿 업데이트 (차이점)
|
||||
|
||||
- **마이그레이션**: 없음 (템플릿은 DB 스키마 미사용)
|
||||
- **업그레이드 스텝**: 없음
|
||||
- **레이아웃 전략**: `layout_strategy` 파라미터로 충돌 처리 (→ [10. 레이아웃 충돌 전략](#10-템플릿-레이아웃-충돌-전략))
|
||||
- **Roles/Permissions/Menus**: 없음
|
||||
|
||||
### 에러 발생 시
|
||||
|
||||
```
|
||||
1. ExtensionBackupHelper::restoreFromBackup() → 파일 원복
|
||||
2. status → 이전 상태 복원
|
||||
3. 에러 로깅 (upgrade 채널)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. ExtensionStatusGuard
|
||||
|
||||
> 파일: `app/Extension/Helpers/ExtensionStatusGuard.php`
|
||||
|
||||
### 시그니처
|
||||
|
||||
```php
|
||||
public static function assertNotInProgress(ExtensionStatus $status, string $identifier): void
|
||||
```
|
||||
|
||||
### 차단 매트릭스
|
||||
|
||||
| 현재 상태 | 설치 | 삭제 | 활성화 | 비활성화 | 업데이트 |
|
||||
|----------|------|------|--------|---------|---------|
|
||||
| Active | - | O | - | O | O |
|
||||
| Inactive | - | O | O | - | O |
|
||||
| **Installing** | **차단** | **차단** | **차단** | **차단** | **차단** |
|
||||
| **Uninstalling** | **차단** | **차단** | **차단** | **차단** | **차단** |
|
||||
| **Updating** | **차단** | **차단** | **차단** | **차단** | **차단** |
|
||||
| Uninstalled | O | - | - | - | - |
|
||||
|
||||
- `Installing`, `Uninstalling`, `Updating` 상태에서는 모든 작업이 `\RuntimeException`으로 차단됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. ExtensionBackupHelper
|
||||
|
||||
> 파일: `app/Extension/Helpers/ExtensionBackupHelper.php`
|
||||
|
||||
### 메서드
|
||||
|
||||
```php
|
||||
// 백업 생성 → storage/app/extension_backups/{type}/{identifier}_{timestamp}/
|
||||
public static function createBackup(string $type, string $identifier): string
|
||||
|
||||
// 백업에서 복원 (copyToActive의 원자적 교체 사용)
|
||||
public static function restoreFromBackup(string $type, string $identifier, string $backupPath): void
|
||||
|
||||
// 백업 삭제 (경로 미존재 시 무시)
|
||||
public static function deleteBackup(string $backupPath): void
|
||||
```
|
||||
|
||||
- `$type`: `'modules'`, `'plugins'`, `'templates'`
|
||||
- 타임스탬프 형식: `Ymd_His` (예: `20260224_143052`)
|
||||
- 파일 작업: `Illuminate\Support\Facades\File` 사용
|
||||
|
||||
---
|
||||
|
||||
## 6. ExtensionPendingHelper
|
||||
|
||||
> 파일: `app/Extension/Helpers/ExtensionPendingHelper.php`
|
||||
|
||||
### 메서드 (8개)
|
||||
|
||||
```php
|
||||
// _pending 확장 목록 로드
|
||||
public static function loadPendingExtensions(string $basePath, string $manifestName): array
|
||||
|
||||
// _bundled 확장 목록 로드
|
||||
public static function loadBundledExtensions(string $basePath, string $manifestName): array
|
||||
|
||||
// _pending/_bundled → 활성 디렉토리로 원자적 교체 (rename→rename→delete 패턴)
|
||||
public static function copyToActive(string $sourcePath, string $targetPath): void
|
||||
|
||||
// 확장 디렉토리 삭제 (경로 미존재 시 무시)
|
||||
public static function deleteExtensionDirectory(string $basePath, string $identifier): void
|
||||
|
||||
// _pending 디렉토리에 존재 여부
|
||||
public static function isPending(string $basePath, string $identifier): bool
|
||||
|
||||
// _bundled 디렉토리에 존재 여부
|
||||
public static function isBundled(string $basePath, string $identifier): bool
|
||||
|
||||
// _pending 경로 반환
|
||||
public static function getPendingPath(string $basePath, string $identifier): string
|
||||
|
||||
// _bundled 경로 반환
|
||||
public static function getBundledPath(string $basePath, string $identifier): string
|
||||
```
|
||||
|
||||
- `$basePath`: `base_path('modules')`, `base_path('plugins')`, `base_path('templates')`
|
||||
- `$manifestName`: `'module.json'`, `'plugin.json'`, `'template.json'`
|
||||
|
||||
### copyToActive 원자적 교체 패턴
|
||||
|
||||
`copyToActive()`는 기존 대상 디렉토리가 있을 때 **rename→rename→delete** 패턴으로 원자적 교체를 수행합니다.
|
||||
임시 디렉토리(`_updating_*`, `_old_*`)는 **`_pending/` 하위**에 생성되므로, 잔존 시에도 오토로드에 포함되지 않습니다:
|
||||
|
||||
```text
|
||||
1. source → _pending/{id}_updating_{uid} (File::copyDirectory) # _pending/ 하위 임시 경로에 복사
|
||||
2. target → _pending/{id}_old_{uid} (File::moveDirectory) # 기존을 _pending/ 하위 _old로 rename
|
||||
3. _pending/{id}_updating_{uid} → target (File::moveDirectory) # 임시를 활성으로 rename
|
||||
4. _pending/{id}_old_{uid} 삭제 (File::deleteDirectory) # 이전 버전 삭제 (실패해도 무해)
|
||||
```
|
||||
|
||||
| 실패 시점 | 결과 |
|
||||
| --------- | ---- |
|
||||
| 1단계 실패 (복사) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시 디렉토리 정리 후 예외 |
|
||||
| 2단계 실패 (rename) | 기존 디렉토리 **온전히 보존**, `_pending/` 내 임시만 정리 |
|
||||
| 3단계 실패 (rename) | `_pending/` 내 _old를 원래 위치로 **롤백**, 예외 |
|
||||
|
||||
> **Windows 참고**: `deleteDirectory` 직후 같은 이름으로 `rename`이 실패하는 타이밍 이슈가 있어, delete→copy 대신 rename→rename→delete 패턴을 사용합니다.
|
||||
> **오토로드 안전성**: 임시 디렉토리가 `_pending/` 하위에 생성되므로, IDE 잠금 등으로 잔존하더라도 `str_starts_with($name, '_')` 필터에 의해 오토로드에서 자동 제외됩니다.
|
||||
|
||||
이 메서드는 다음 위치에서 공통으로 사용됩니다:
|
||||
|
||||
- `ExtensionBackupHelper::restoreFromBackup()` — 백업 복원
|
||||
- `ModuleManager::downloadModuleUpdate()` — GitHub 다운로드 교체
|
||||
- `PluginManager::downloadPluginUpdate()` — 동일
|
||||
- `TemplateManager::downloadTemplateUpdate()` — 동일
|
||||
|
||||
---
|
||||
|
||||
## 7. UpgradeStepInterface + UpgradeContext
|
||||
|
||||
### UpgradeStepInterface
|
||||
|
||||
> 파일: `app/Contracts/Extension/UpgradeStepInterface.php`
|
||||
|
||||
```php
|
||||
interface UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void;
|
||||
}
|
||||
```
|
||||
|
||||
### UpgradeContext
|
||||
|
||||
> 파일: `app/Extension/UpgradeContext.php`
|
||||
|
||||
```php
|
||||
// readonly 속성
|
||||
public readonly LoggerInterface $logger; // upgrade 로그 채널
|
||||
public readonly string $fromVersion; // 업그레이드 시작 버전
|
||||
public readonly string $toVersion; // 업그레이드 목표 버전
|
||||
public readonly string $currentStep; // 현재 실행 중인 스텝 버전
|
||||
|
||||
// 메서드
|
||||
public function table(string $table): string // DB 프리픽스 적용 (raw SQL용)
|
||||
public function withCurrentStep(string $stepVersion): self // 불변 복제
|
||||
```
|
||||
|
||||
> **raw SQL에서 테이블명 사용 시 `$context->table()` 필수**
|
||||
> - `Schema::hasColumn('users', ...)`, `DB::table('users')` → 프리픽스 자동 적용 (불필요)
|
||||
> - `DB::selectOne("SHOW COLUMNS FROM ...")` 등 raw SQL → **`$context->table('users')` 필수**
|
||||
|
||||
### 업그레이드 스텝 작성 예시
|
||||
|
||||
```php
|
||||
// modules/vendor-module/upgrades/Upgrade_1_1_0.php
|
||||
namespace Modules\Vendor\Module\Upgrades;
|
||||
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\UpgradeContext;
|
||||
|
||||
class Upgrade_1_1_0 implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$context->logger->info("Upgrading from {$context->fromVersion} to {$context->toVersion}");
|
||||
|
||||
// 데이터 마이그레이션 등 버전 특화 작업
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 정적 메뉴/권한 제거 시 cleanup 사용 예시
|
||||
|
||||
확장이 새 버전에서 기존 정적 메뉴/권한을 제거해야 하는 경우, UpgradeStep에서 cleanup helper를 명시적으로 호출합니다.
|
||||
|
||||
> **주의**: `cleanupStaleMenus()`/`cleanupStalePermissions()`는 정적 정의 기반으로만 판단하므로, 확장이 런타임에 동적으로 생성한 메뉴/권한도 삭제됩니다. 동적 메뉴/권한이 있는 확장에서는 삭제 대상을 직접 특정하는 것이 안전합니다.
|
||||
|
||||
```php
|
||||
// upgrades/Upgrade_2_0_0.php — 정적 메뉴 정리 예시
|
||||
use App\Extension\Helpers\ExtensionMenuSyncHelper;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
|
||||
class Upgrade_2_0_0 implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$menuHelper = app(ExtensionMenuSyncHelper::class);
|
||||
$menuHelper->cleanupStaleMenus(
|
||||
ExtensionOwnerType::Module,
|
||||
'vendor-module',
|
||||
['menu-slug-1', 'menu-slug-2'], // 유지할 slug 목록
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 자동 발견 규칙
|
||||
|
||||
- 디렉토리: `{extensionPath}/upgrades/`
|
||||
- 파일 패턴: `Upgrade_X_Y_Z.php` (언더스코어 → 점으로 변환: `1_1_0` → `1.1.0`)
|
||||
- 실행 순서: `fromVersion < stepVersion <= toVersion` 범위의 스텝만 자연 정렬 순 실행
|
||||
|
||||
---
|
||||
|
||||
## 8. 번들 디렉토리 개발 워크플로우
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
```text
|
||||
필수: 확장(모듈/플러그인/템플릿) 수정/개발은 _bundled 디렉토리에서만 작업
|
||||
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스(update 커맨드) 사용
|
||||
이유: 활성 디렉토리 직접 수정 시 _bundled와 불일치 → 업데이트 시 변경 유실
|
||||
이유: 업데이트 프로세스를 통해야 마이그레이션, 권한/메뉴 동기화, 레이아웃 갱신 등이 정상 수행됨
|
||||
```
|
||||
|
||||
### 개발 워크플로우 (필수 절차)
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 1. _bundled 디렉토리에서 코드 수정/개발 │
|
||||
│ 예: modules/_bundled/sirsoft-ecommerce/src/Services/... │
|
||||
│ │
|
||||
│ 2. manifest 버전 올리기 (module.json / plugin.json / template.json) │
|
||||
│ 예: "version": "1.0.0" → "1.1.0" │
|
||||
│ │
|
||||
│ 3. 업그레이드 스텝 작성 (조건부 - DB/설정 변경 시) │
|
||||
│ 예: upgrades/Upgrade_1_1_0.php │
|
||||
│ │
|
||||
│ 4. 업데이트 커맨드로 활성 디렉토리에 반영 │
|
||||
│ php artisan module:update sirsoft-ecommerce │
|
||||
│ php artisan plugin:update sirsoft-payment │
|
||||
│ php artisan template:update sirsoft-admin_basic │
|
||||
│ │
|
||||
│ 5. 업데이트 결과 검증 (테스트 실행) │
|
||||
│ php artisan test --filter=관련테스트 │
|
||||
│ powershell -Command "npm run test:run -- 관련테스트" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 왜 활성 디렉토리 직접 수정이 금지되는가?
|
||||
|
||||
| 문제 | 설명 |
|
||||
|------|------|
|
||||
| **변경 유실** | 다음 업데이트 시 _bundled 소스로 덮어쓰기되어 활성 디렉토리의 직접 수정 사항이 사라짐 |
|
||||
| **동기화 누락** | 권한/역할/메뉴 동기화, 레이아웃 갱신, 마이그레이션 실행 등 업데이트 프로세스의 후처리가 수행되지 않음 |
|
||||
| **Git 추적 불가** | 활성 디렉토리는 .gitignore 대상이므로 변경 사항이 Git에 기록되지 않음 |
|
||||
| **환경 불일치** | _bundled(Git 추적)와 활성 디렉토리(Git 미추적)가 불일치하여 다른 환경에서 재현 불가 |
|
||||
| **업데이트 감지 불가** | 활성 디렉토리만 수정하고 _bundled를 미갱신하면 check-updates에서 변경을 감지하지 못함 |
|
||||
|
||||
### 확장 타입별 업데이트 커맨드
|
||||
|
||||
| 확장 타입 | 업데이트 확인 | 업데이트 실행 | 강제 재설치 |
|
||||
| -------- | ----------- | ----------- | ---------- |
|
||||
| 모듈 | `php artisan module:check-updates sirsoft-ecommerce` | `php artisan module:update sirsoft-ecommerce` | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| 플러그인 | `php artisan plugin:check-updates sirsoft-payment` | `php artisan plugin:update sirsoft-payment` | `php artisan plugin:update sirsoft-payment --force` |
|
||||
| 템플릿 | `php artisan template:check-updates sirsoft-admin_basic` | `php artisan template:update sirsoft-admin_basic` | `php artisan template:update sirsoft-admin_basic --force` |
|
||||
|
||||
### 예외: 초기 개발 (아직 _bundled에 미등록)
|
||||
|
||||
신규 확장을 처음 개발할 때는 활성 디렉토리에서 직접 작업할 수 있습니다. 단, **최초 개발 완료 후 _bundled에 반영하는 시점부터** 이 규칙이 적용됩니다.
|
||||
|
||||
```text
|
||||
허용: 신규 확장 초기 개발 시 활성 디렉토리에서 직접 작업
|
||||
전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업
|
||||
전환 후: 활성 디렉토리 직접 수정 금지 → 업데이트 프로세스만 사용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 개발자 버전 업데이트 가이드
|
||||
|
||||
### 코드 변경 시 버전/업그레이드 필수 규칙
|
||||
|
||||
```text
|
||||
필수: 확장(모듈/플러그인/템플릿)의 코드를 변경한 경우 아래 절차를 수행해야 합니다.
|
||||
버전 변경 없이 코드만 수정하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다.
|
||||
```
|
||||
|
||||
#### 필수 체크리스트
|
||||
|
||||
| # | 항목 | 모듈 | 플러그인 | 템플릿 |
|
||||
|---|------|------|---------|--------|
|
||||
| 1 | manifest 버전 올리기 (`module.json` / `plugin.json` / `template.json`) | ✅ 필수 | ✅ 필수 | ✅ 필수 |
|
||||
| 2 | `_bundled` 디렉토리 동기화 (활성 디렉토리와 동일하게) | ✅ 필수 | ✅ 필수 | ✅ 필수 |
|
||||
| 3 | 업그레이드 스텝 작성 (`upgrades/Upgrade_X_Y_Z.php`) | 조건부 | 조건부 | 해당 없음 |
|
||||
| 4 | DB 마이그레이션 추가 (`database/migrations/`) | 조건부 | 조건부 | 해당 없음 |
|
||||
|
||||
#### 업그레이드 스텝이 필요한 경우 (모듈/플러그인만)
|
||||
|
||||
다음 중 하나라도 해당하면 `upgrades/Upgrade_X_Y_Z.php`를 작성해야 합니다:
|
||||
|
||||
| 변경 유형 | 업그레이드 스텝 필요 | 예시 |
|
||||
|----------|-------------------|------|
|
||||
| DB 스키마 변경 | ✅ (+ 마이그레이션) | 컬럼 추가/삭제, 테이블 구조 변경 |
|
||||
| 환경설정 구조 변경 | ✅ (SettingsMigrator 사용) | 설정 키 이름 변경, 새 카테고리 추가 |
|
||||
| 기존 데이터 마이그레이션 | ✅ | 데이터 형식 변환, 기본값 채우기 |
|
||||
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | 새 권한 추가, 메뉴 순서 변경 |
|
||||
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | 기존 메뉴/권한 삭제 |
|
||||
| 레이아웃 JSON 변경 | ❌ (자동 갱신) | 레이아웃 UI 수정, 새 레이아웃 추가 |
|
||||
| PHP 코드만 변경 (API 호환) | ❌ | 버그 수정, 성능 개선 |
|
||||
|
||||
#### 버전 올리기 않아도 되는 경우
|
||||
|
||||
- `_bundled` 디렉토리에 아직 반영되지 않은 개발 중 변경 (활성 디렉토리에서만 작업)
|
||||
- 테스트 파일만 변경
|
||||
- 규정 문서만 변경
|
||||
|
||||
### 새 버전 배포 절차
|
||||
|
||||
1. **manifest 버전 수정**: `module.json` / `plugin.json` / `template.json`의 `version` 필드 변경
|
||||
2. **업그레이드 스텝 작성** (필요 시): `upgrades/Upgrade_X_Y_Z.php` 생성
|
||||
3. **_bundled 업데이트**: Git 저장소의 `_bundled/{identifier}/` 디렉토리 갱신
|
||||
4. **GitHub 릴리스** (선택): `github_url` 설정 시 GitHub releases로 자동 감지
|
||||
|
||||
### manifest 수정 항목
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"github_changelog_url": "https://github.com/vendor/module/releases/tag/engine-v1.2.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 템플릿 레이아웃 충돌 전략
|
||||
|
||||
템플릿 업데이트 시 관리자가 수정한 레이아웃이 있으면 충돌이 발생합니다.
|
||||
|
||||
### 사전 확인 API
|
||||
|
||||
```
|
||||
GET /api/admin/templates/{templateName}/check-modified-layouts
|
||||
```
|
||||
|
||||
응답:
|
||||
```json
|
||||
{
|
||||
"has_modified": true,
|
||||
"modified_layouts": [
|
||||
{ "id": 1, "name": "dashboard", "updated_at": "2026-02-24T10:00:00Z" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 충돌 해결 전략
|
||||
|
||||
| 전략 | 동작 |
|
||||
|------|------|
|
||||
| `apply_new` (기본값) | 새 버전의 레이아웃으로 덮어쓰기 (관리자 수정 사항 유실) |
|
||||
| `keep_current` | 기존 레이아웃 유지 (새 버전 레이아웃 미적용) |
|
||||
|
||||
업데이트 API에 `layout_strategy` 파라미터로 전달:
|
||||
|
||||
```
|
||||
POST /api/admin/templates/{templateName}/update
|
||||
Body: { "layout_strategy": "apply_new" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Artisan 커맨드
|
||||
|
||||
| 커맨드 | 설명 |
|
||||
|--------|------|
|
||||
| `module:check-updates [identifier?]` | 모듈 업데이트 확인 (전체 또는 단일) |
|
||||
| `module:update [identifier] [--force]` | 모듈 업데이트 실행 |
|
||||
| `plugin:check-updates [identifier?]` | 플러그인 업데이트 확인 (전체 또는 단일) |
|
||||
| `plugin:update [identifier] [--force]` | 플러그인 업데이트 실행 |
|
||||
| `template:check-updates [identifier?]` | 템플릿 업데이트 확인 (전체 또는 단일) |
|
||||
| `template:update [identifier] [--layout-strategy=overwrite] [--force]` | 템플릿 업데이트 실행 |
|
||||
|
||||
> 단일 `check-updates`는 `checkXxxUpdate()` 호출 후 커맨드에서 DB 갱신 (`updateByIdentifier()`)
|
||||
> 템플릿 `update`만 `--layout-strategy` 옵션 지원 (`overwrite` | `keep`)
|
||||
> `--force`: 버전 비교를 건너뛰고 강제 재설치 (파일 손상, 수동 수정 복원 시 사용)
|
||||
|
||||
---
|
||||
|
||||
## 12. API 엔드포인트
|
||||
|
||||
### 모듈
|
||||
|
||||
| 메서드 | 경로 | 권한 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| POST | `/api/admin/modules/check-updates` | `core.modules.install` | 모듈 업데이트 일괄 확인 |
|
||||
| POST | `/api/admin/modules/{moduleName}/update` | `core.modules.install` | 모듈 업데이트 실행 |
|
||||
|
||||
### 플러그인
|
||||
|
||||
| 메서드 | 경로 | 권한 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| POST | `/api/admin/plugins/check-updates` | `core.plugins.install` | 플러그인 업데이트 일괄 확인 |
|
||||
| POST | `/api/admin/plugins/{pluginName}/update` | `core.plugins.install` | 플러그인 업데이트 실행 |
|
||||
|
||||
### 템플릿
|
||||
|
||||
| 메서드 | 경로 | 권한 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| POST | `/api/admin/templates/check-updates` | `core.templates.install` | 템플릿 업데이트 일괄 확인 |
|
||||
| GET | `/api/admin/templates/{templateName}/check-modified-layouts` | `core.templates.read` | 수정된 레이아웃 확인 |
|
||||
| POST | `/api/admin/templates/{templateName}/update` | `core.templates.install` | 템플릿 업데이트 실행 |
|
||||
|
||||
### 훅 (Hook)
|
||||
|
||||
| 훅 이름 | 시점 |
|
||||
|---------|------|
|
||||
| `core.modules.before_check_updates` | 모듈 업데이트 확인 전 |
|
||||
| `core.modules.after_check_updates` | 모듈 업데이트 확인 후 |
|
||||
| `core.modules.before_update` | 모듈 업데이트 실행 전 |
|
||||
| `core.modules.after_update` | 모듈 업데이트 실행 후 |
|
||||
| `core.plugins.before_check_updates` | 플러그인 업데이트 확인 전 |
|
||||
| `core.plugins.after_check_updates` | 플러그인 업데이트 확인 후 |
|
||||
| `core.plugins.before_update` | 플러그인 업데이트 실행 전 |
|
||||
| `core.plugins.after_update` | 플러그인 업데이트 실행 후 |
|
||||
| `core.templates.before_check_updates` | 템플릿 업데이트 확인 전 |
|
||||
| `core.templates.after_check_updates` | 템플릿 업데이트 확인 후 |
|
||||
| `core.templates.before_version_update` | 템플릿 업데이트 실행 전 |
|
||||
| `core.templates.after_version_update` | 템플릿 업데이트 실행 후 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 역할/권한/메뉴 동기화
|
||||
|
||||
> 파일: `app/Extension/Helpers/ExtensionRoleSyncHelper.php`, `app/Extension/Helpers/ExtensionMenuSyncHelper.php`
|
||||
|
||||
업데이트 실행 흐름 6단계에서 Roles, Permissions, Menus를 동기화할 때, 사용자 커스터마이징을 보존하면서 안전하게 갱신합니다.
|
||||
|
||||
### user_overrides 패턴
|
||||
|
||||
각 모델(Role, Menu)에 `user_overrides` JSON 컬럼이 존재합니다. 유저가 수정한 필드명 또는 개별 식별자를 기록하고, 확장 재설치/업데이트 시 **기록된 항목만 보호**합니다.
|
||||
|
||||
```text
|
||||
유저 수정 시: Listener가 user_overrides에 변경 내용 자동 기록
|
||||
- 필드 변경 (name, icon 등) → 필드명 기록: ["name", "icon"]
|
||||
- 권한 변경 → 개별 권한 식별자 기록: ["sirsoft-board.boards.delete"]
|
||||
- 역할 변경 → 개별 역할 식별자 기록: ["editor"]
|
||||
- 순서 변경 → "order" 기록: ["order"]
|
||||
|
||||
확장 업데이트 시: user_overrides에 기록된 항목만 보호, 나머지 정상 갱신
|
||||
```
|
||||
|
||||
### 역할/권한 동기화 (ExtensionRoleSyncHelper)
|
||||
|
||||
- **syncRole()**: `user_overrides`에 기록된 필드(name, description)만 건너뛰고 나머지 갱신
|
||||
- **syncPermission()**: 항상 확장 정의값으로 덮어쓰기 (Permission은 user_overrides 없음)
|
||||
- **syncRolePermissions()**: `user_overrides`에 기록된 **개별 권한 식별자**만 보호, 나머지 DB 기반 diff로 attach/detach
|
||||
- **cleanupStalePermissions()**: 확장에서 제거된 권한 삭제 + 역할 연결 해제 (**자동 호출 폐기 #135** — UpgradeStep에서 명시적 호출 전용)
|
||||
|
||||
### 역할-권한 할당 동기화 (syncAllRoleAssignments)
|
||||
|
||||
```text
|
||||
주의: DB 기반 diff + 개별 권한 식별자 보호
|
||||
|
||||
유저가 admin에서 boards.delete 해제 → user_overrides = ["sirsoft-board.boards.delete"]
|
||||
확장 v1.1 업데이트:
|
||||
|
||||
→ DB에서 현재 확장 권한 조회
|
||||
→ user_overrides에서 권한 식별자만 필터 (필드명 "name" 등 제외)
|
||||
→ 보호된 권한(boards.delete)은 attach/detach 계산에서 제외
|
||||
→ 나머지 권한은 정의 기준으로 정상 동기화
|
||||
```
|
||||
|
||||
| 시나리오 | 동작 |
|
||||
| -------- | ---- |
|
||||
| 최초 설치 (user_overrides=[]) | 정의된 모든 권한 attach |
|
||||
| 업데이트 (보호 권한 있음) | 보호 권한 제외, 나머지 diff 동기화 |
|
||||
| 확장이 새 권한 추가 | 비보호 → attach, 보호 → 건너뜀 |
|
||||
| 확장에서 권한 제거 | 비보호 → detach, 보호 → 건너뜀 |
|
||||
| 유저 수동 추가 할당 (비확장 권한) | 영향 없음 (확장 권한만 대상) |
|
||||
|
||||
### 메뉴 동기화 (ExtensionMenuSyncHelper)
|
||||
|
||||
- **비교 필드**: `user_overrides`에 기록된 필드만 건너뜀 (name, icon, order, url 중)
|
||||
- **항상 확장 정의값**: parent_id, is_active (사용자 보존 대상 아님)
|
||||
- **syncMenuRecursive()**: 재귀 구조 처리 + 다국어 name 역호환
|
||||
- **cleanupStaleMenus()**: 자식 포함 cascade 삭제 (**자동 호출 폐기 #135** — UpgradeStep에서 명시적 호출 전용)
|
||||
- **메뉴-역할 할당**: `user_overrides`에 기록된 **개별 역할 식별자**만 보호
|
||||
|
||||
```text
|
||||
예시: 유저가 name과 editor 역할을 변경
|
||||
→ user_overrides = ["name", "editor"]
|
||||
|
||||
모듈 v1.1 업데이트:
|
||||
→ name: user_overrides에 있음 → 보존
|
||||
→ order: user_overrides에 없음 → 새 값 반영
|
||||
→ icon: user_overrides에 없음 → 새 값 반영
|
||||
→ 역할 editor: 보호 → 유저 설정 유지
|
||||
→ 역할 admin: 비보호 → 정상 동기화
|
||||
```
|
||||
|
||||
### user_overrides 기록 리스너
|
||||
|
||||
| 리스너 | 구독 훅 | 기록 내용 |
|
||||
| ------ | ------- | --------- |
|
||||
| `RoleUserOverridesListener` | `core.role.before_update` | 변경된 필드명 (name, description) |
|
||||
| `RoleUserOverridesListener` | `core.role.after_sync_permissions` | 변경된 개별 권한 식별자 |
|
||||
| `MenuUserOverridesListener` | `core.menu.before_update` | 변경된 필드명 (name, icon, order, url) |
|
||||
| `MenuUserOverridesListener` | `core.menu.after_update_order` | 순서 변경된 메뉴에 "order" |
|
||||
| `MenuUserOverridesListener` | `core.menu.after_sync_roles` | 변경된 개별 역할 식별자 |
|
||||
|
||||
---
|
||||
|
||||
## 14. SettingsMigrator
|
||||
|
||||
> 파일: `app/Extension/Helpers/SettingsMigrator.php`
|
||||
|
||||
UpgradeStep 내에서 환경설정 파일을 안전하게 마이그레이션하는 유틸리티 클래스.
|
||||
|
||||
### 사용법
|
||||
|
||||
```php
|
||||
class Upgrade_1_3_0 implements UpgradeStepInterface
|
||||
{
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$result = SettingsMigrator::forModule('sirsoft-ecommerce')
|
||||
->addField('shipping.tracking_enabled', false)
|
||||
->renameField('order_settings.auto_cancel_days', 'order_settings.pending_cancel_days')
|
||||
->removeField('seo.deprecated_field')
|
||||
->addCategory('notifications', ['email_enabled' => true])
|
||||
->apply();
|
||||
|
||||
$context->logger->info('Settings migrated', $result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 오퍼레이션
|
||||
|
||||
| 메서드 | 동작 |
|
||||
|--------|------|
|
||||
| `addField(path, default)` | 새 필드 추가 (이미 존재하면 **스킵** — 사용자 값 보존) |
|
||||
| `renameField(old, new)` | 필드 이름 변경 (이전 키 값을 새 키로 이동) |
|
||||
| `removeField(path)` | 필드 제거 (존재하지 않으면 no-op) |
|
||||
| `transformField(path, callable)` | 값 변환 (존재하지 않으면 스킵) |
|
||||
| `addCategory(category, defaults)` | 새 카테고리 파일 생성 (모듈 전용) |
|
||||
| `apply()` | 모든 변환 실행, `['applied' => int, 'skipped' => int, 'errors' => []]` 반환 |
|
||||
|
||||
### 경로 규칙 (dot notation)
|
||||
|
||||
- 모듈: `'category.field'` → `storage/app/modules/{id}/settings/{category}.json` 내 `field` 키
|
||||
- 플러그인: `'field'` → `storage/app/plugins/{id}/settings/setting.json` 내 `field` 키
|
||||
|
||||
---
|
||||
|
||||
## 15. 큐 워커 재시작 + 스케줄 등록
|
||||
|
||||
### 큐 워커 재시작
|
||||
|
||||
> 파일: `app/Listeners/ExtensionUpdateQueueListener.php`
|
||||
|
||||
확장 업데이트 성공 시 `Artisan::call('queue:restart')` 자동 실행. 코드 변경 후 큐 워커가 이전 코드로 실행되는 문제를 방지합니다.
|
||||
|
||||
- 구독 훅: `core.modules.after_update`, `core.plugins.after_update` (priority 100)
|
||||
- 실패 시: warning 로그만 기록 (예외 전파 안 함)
|
||||
- 자동 등록: `CoreServiceProvider::registerCoreHookListeners()` 스캔
|
||||
|
||||
### 확장 스케줄 등록
|
||||
|
||||
> 파일: `routes/console.php`
|
||||
|
||||
활성 모듈/플러그인의 `getSchedules()` 메서드를 읽어 Laravel 스케줄러에 자동 등록합니다.
|
||||
|
||||
```php
|
||||
// module.php에서 정의
|
||||
public function getSchedules(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'command' => 'sirsoft-ecommerce:sync-stock',
|
||||
'schedule' => 'hourly', // 또는 cron: '*/30 * * * *'
|
||||
'description' => '재고 동기화',
|
||||
'enabled_config' => 'sirsoft-ecommerce.stock.auto_sync',
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
- cron expression vs 메서드명 자동 분기
|
||||
- `enabled_config`: 확장 설정값으로 조건부 활성화
|
||||
|
||||
---
|
||||
|
||||
## 참고 파일 위치
|
||||
|
||||
| 파일 | 경로 |
|
||||
|------|------|
|
||||
| ExtensionStatusGuard | `app/Extension/Helpers/ExtensionStatusGuard.php` |
|
||||
| ExtensionBackupHelper | `app/Extension/Helpers/ExtensionBackupHelper.php` |
|
||||
| ExtensionPendingHelper | `app/Extension/Helpers/ExtensionPendingHelper.php` |
|
||||
| ExtensionRoleSyncHelper | `app/Extension/Helpers/ExtensionRoleSyncHelper.php` |
|
||||
| ExtensionMenuSyncHelper | `app/Extension/Helpers/ExtensionMenuSyncHelper.php` |
|
||||
| SettingsMigrator | `app/Extension/Helpers/SettingsMigrator.php` |
|
||||
| ExtensionUpdateQueueListener | `app/Listeners/ExtensionUpdateQueueListener.php` |
|
||||
| UpgradeStepInterface | `app/Contracts/Extension/UpgradeStepInterface.php` |
|
||||
| UpgradeContext | `app/Extension/UpgradeContext.php` |
|
||||
| ExtensionStatus | `app/Enums/ExtensionStatus.php` |
|
||||
| ModuleManager | `app/Extension/ModuleManager.php` |
|
||||
| PluginManager | `app/Extension/PluginManager.php` |
|
||||
| TemplateManager | `app/Extension/TemplateManager.php` |
|
||||
| logging.php (upgrade 채널) | `config/logging.php` |
|
||||
@@ -0,0 +1,885 @@
|
||||
# 훅 시스템 (Hook System)
|
||||
|
||||
> G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시)
|
||||
2. Filter 훅: applyFilters() - 데이터 변형 (type => 'filter' 필수!)
|
||||
3. 네이밍: [vendor-module].[entity].[action]_[timing]
|
||||
4. 우선순위: 1-5(높음), 10(기본), 15+(낮음)
|
||||
5. Service 패턴: before_* → filter_* → 로직 → after_*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [개요](#개요)
|
||||
- [훅 타입](#훅-타입)
|
||||
- [훅 네이밍 규칙](#훅-네이밍-규칙)
|
||||
- [리스너 구현](#리스너-구현)
|
||||
- [코어 리스너 자동 발견](#코어-리스너-자동-발견)
|
||||
- [우선순위 가이드라인](#우선순위-가이드라인)
|
||||
- [모듈에 리스너 등록](#모듈에-리스너-등록)
|
||||
- [Service에서 훅 사용 패턴](#service에서-훅-사용-패턴)
|
||||
- [필터 훅 리스너 예시](#필터-훅-리스너-예시)
|
||||
- [훅 권한 시스템](#훅-권한-시스템)
|
||||
- [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
G7의 훅 시스템은 WordPress 스타일로 구현되어 코어 수정 없이 비즈니스 로직을 확장할 수 있습니다.
|
||||
|
||||
**핵심 파일 위치**: `app/Extension/HookManager.php`
|
||||
|
||||
---
|
||||
|
||||
## 훅 타입
|
||||
|
||||
### 1. Action Hooks (액션 훅)
|
||||
|
||||
**목적**: 특정 시점에 부가 작업 실행
|
||||
|
||||
**사용 예시**:
|
||||
- 이메일 발송
|
||||
- 활동 로그 기록 (→ [활동 로그 시스템](../backend/activity-log.md) 참조)
|
||||
- 캐시 무효화
|
||||
- 알림 전송
|
||||
|
||||
**패턴**:
|
||||
|
||||
```php
|
||||
// Service에서 훅 실행
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);
|
||||
```
|
||||
|
||||
### 2. Filter Hooks (필터 훅)
|
||||
|
||||
**목적**: 데이터 변형 및 가공
|
||||
|
||||
**사용 예시**:
|
||||
- 가격 계산
|
||||
- 데이터 포맷 변경
|
||||
- 권한 확인
|
||||
|
||||
**패턴**:
|
||||
|
||||
```php
|
||||
// Service에서 필터 적용
|
||||
$finalPrice = HookManager::applyFilters('sirsoft-ecommerce.product.calculate_price', $basePrice, $product);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 훅 네이밍 규칙
|
||||
|
||||
### 표준 패턴
|
||||
|
||||
```text
|
||||
[vendor-module].[entity].[action]_[timing]
|
||||
```
|
||||
|
||||
### 타이밍 접미사
|
||||
|
||||
| 접미사 | 설명 |
|
||||
| ---------- | ------------------ |
|
||||
| `before_*` | 작업 실행 전 |
|
||||
| `after_*` | 작업 실행 후 |
|
||||
| `on_*` | 특정 이벤트 발생 시 |
|
||||
| `filter_*` | 데이터 변형용 필터 |
|
||||
|
||||
### 예시
|
||||
|
||||
```text
|
||||
# 생성/수정/삭제 (변경 작업)
|
||||
sirsoft-ecommerce.product.before_create
|
||||
sirsoft-ecommerce.product.after_create
|
||||
sirsoft-ecommerce.product.after_update
|
||||
sirsoft-ecommerce.product.filter_create_data
|
||||
|
||||
# 조회 작업
|
||||
sirsoft-ecommerce.product.before_list
|
||||
sirsoft-ecommerce.product.after_list
|
||||
sirsoft-ecommerce.product.filter_list_query
|
||||
sirsoft-ecommerce.product.filter_list_result
|
||||
sirsoft-ecommerce.product.before_show
|
||||
sirsoft-ecommerce.product.after_show
|
||||
sirsoft-ecommerce.product.filter_show_result
|
||||
|
||||
# 이벤트/기타
|
||||
sirsoft-ecommerce.order.on_payment_success
|
||||
sirsoft-ecommerce.product.calculate_price
|
||||
|
||||
# 코어 훅
|
||||
core.attachment.download
|
||||
core.attachment.update
|
||||
core.attachment.delete
|
||||
|
||||
# FormRequest Validation Rules 훅 (Filter)
|
||||
core.user.create_validation_rules
|
||||
core.user.update_validation_rules
|
||||
core.role.store_validation_rules
|
||||
core.role.update_validation_rules
|
||||
core.permission.store_validation_rules
|
||||
core.permission.update_validation_rules
|
||||
core.menu.store_validation_rules
|
||||
core.menu.update_validation_rules
|
||||
|
||||
# Layout Extension 훅
|
||||
core.layout_extension.before_apply
|
||||
core.layout_extension.after_apply
|
||||
|
||||
# SEO 렌더링 훅 (Filter)
|
||||
core.seo.filter_context
|
||||
core.seo.filter_meta
|
||||
core.seo.filter_view_data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 리스너 구현
|
||||
|
||||
### HookListenerInterface 사용
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProductCacheInvalidationListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* 구독할 훅 목록 반환
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
// Action 훅: type 생략 시 기본값 'action'
|
||||
'sirsoft-ecommerce.product.after_create' => ['method' => 'handleProductChange', 'priority' => 10],
|
||||
'sirsoft-ecommerce.product.after_update' => ['method' => 'handleProductChange', 'priority' => 10],
|
||||
'sirsoft-ecommerce.product.after_delete' => ['method' => 'handleProductChange', 'priority' => 10],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 변경 시 캐시 무효화
|
||||
*
|
||||
* @param mixed ...$args
|
||||
* @return void
|
||||
*/
|
||||
public function handleProductChange(...$args): void
|
||||
{
|
||||
// 모든 상품 목록 캐시 삭제
|
||||
Cache::forget('products.all');
|
||||
Cache::forget('products.active');
|
||||
|
||||
// 특정 상품 캐시 삭제
|
||||
if (isset($args[0]->id)) {
|
||||
Cache::forget("product.{$args[0]->id}");
|
||||
}
|
||||
|
||||
Log::info('상품 캐시가 무효화되었습니다.', [
|
||||
'product_id' => $args[0]->id ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 코어 리스너 자동 발견
|
||||
|
||||
코어 애플리케이션의 훅 리스너는 `app/Listeners/` 디렉토리에서 **자동 발견**됩니다. `HookListenerInterface`를 구현한 클래스는 별도 등록 없이 자동으로 HookManager에 등록됩니다.
|
||||
|
||||
### 자동 발견 조건
|
||||
|
||||
| 조건 | 설명 |
|
||||
|------|------|
|
||||
| 위치 | `app/Listeners/` 및 모든 하위 디렉토리 |
|
||||
| 인터페이스 | `HookListenerInterface` 구현 필수 |
|
||||
| 등록 방식 | `CoreServiceProvider::boot()`에서 재귀 스캔 |
|
||||
|
||||
### 디렉토리 구조 예시
|
||||
|
||||
```text
|
||||
app/Listeners/
|
||||
├── ActivityLogListener.php ✅ 자동 발견
|
||||
├── CoreActivityLogListener.php ✅ 자동 발견
|
||||
├── ExtensionCompatibilityAlertListener.php ✅ 자동 발견
|
||||
├── Dashboard/
|
||||
│ ├── DashboardModuleListener.php ✅ 자동 발견 (하위 디렉토리)
|
||||
│ └── DashboardStatsListener.php ✅ 자동 발견 (하위 디렉토리)
|
||||
└── UserLogin/
|
||||
└── UpdateLastLoginListener.php ✅ 자동 발견 (하위 디렉토리)
|
||||
```
|
||||
|
||||
### CoreServiceProvider 구현
|
||||
|
||||
```php
|
||||
// app/Providers/CoreServiceProvider.php
|
||||
|
||||
private function registerCoreHookListeners(): void
|
||||
{
|
||||
$listenersPath = app_path('Listeners');
|
||||
|
||||
if (! is_dir($listenersPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 재귀적으로 모든 PHP 파일 스캔
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($listenersPath, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 파일 경로에서 클래스명 추출
|
||||
$relativePath = str_replace($listenersPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
|
||||
$relativePath = str_replace('.php', '', $relativePath);
|
||||
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
|
||||
$listenerClass = 'App\\Listeners\\'.$relativePath;
|
||||
|
||||
// 클래스 존재 여부 확인
|
||||
if (! class_exists($listenerClass)) {
|
||||
Log::warning("코어 훅 리스너 클래스를 찾을 수 없습니다: {$listenerClass}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// HookListenerInterface 구현 여부 확인
|
||||
if (! in_array(HookListenerInterface::class, class_implements($listenerClass))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->registerCoreHookListener($listenerClass);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 장점
|
||||
|
||||
| 장점 | 설명 |
|
||||
|------|------|
|
||||
| 자동 활성화 | 리스너 파일 생성만으로 훅 구독 시작 |
|
||||
| ServiceProvider 수정 불필요 | 새 리스너 추가 시 코드 변경 없음 |
|
||||
| 모듈/플러그인과 동일 패턴 | 일관된 아키텍처 |
|
||||
| 메모리 효율 | 클로저 래퍼로 지연 인스턴스 생성 |
|
||||
|
||||
### 모듈/플러그인과의 차이점
|
||||
|
||||
| 항목 | 코어 리스너 | 모듈/플러그인 리스너 |
|
||||
|------|------------|-------------------|
|
||||
| 발견 방식 | 디렉토리 재귀 스캔 | `getHookListeners()` 메서드 |
|
||||
| 위치 | `app/Listeners/**/*.php` | `modules/**/Listeners/`, `plugins/**/Listeners/` |
|
||||
| 등록 주체 | `CoreServiceProvider` | `ModuleServiceProvider`, `PluginServiceProvider` |
|
||||
|
||||
---
|
||||
|
||||
## 우선순위 가이드라인
|
||||
|
||||
**우선순위 범위**: 1 ~ 100 (낮을수록 먼저 실행)
|
||||
|
||||
| 우선순위 | 용도 | 예시 |
|
||||
| -------- | ---------- | ------------------ |
|
||||
| 1-5 | 매우 높음 | 데이터 검증, 보안 체크 |
|
||||
| 10 | 기본값 | 일반 리스너 |
|
||||
| 15-20 | 낮음 | 알림, 로깅 |
|
||||
| 25+ | 매우 낮음 | 분석, 통계 |
|
||||
|
||||
### 정렬 메커니즘
|
||||
|
||||
HookManager는 `ksort()`를 사용하여 우선순위별 정렬 후 실행합니다:
|
||||
|
||||
| 규칙 | 설명 |
|
||||
| ---- | ---- |
|
||||
| 정렬 방식 | `ksort()` — 숫자 오름차순 (낮을수록 먼저 실행) |
|
||||
| 기본값 | `$priority = 10` (`addAction`, `addFilter` 공통) |
|
||||
| 동일 우선순위 | 등록 순서대로 실행 (FIFO — 배열 push 순서 유지) |
|
||||
|
||||
```php
|
||||
// 우선순위 5 → 10 → 20 순서로 실행
|
||||
HookManager::addAction('order.created', $securityCheck, 5);
|
||||
HookManager::addAction('order.created', $businessLogic, 10);
|
||||
HookManager::addAction('order.created', $notification, 20);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모듈에 리스너 등록
|
||||
|
||||
### module.php에서 등록
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce;
|
||||
|
||||
use App\Contracts\Extension\ModuleInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\ProductCacheInvalidationListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\OrderNotificationListener;
|
||||
|
||||
class Module implements ModuleInterface
|
||||
{
|
||||
// ... 기타 메서드들 ...
|
||||
|
||||
/**
|
||||
* 훅 리스너 목록 반환
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getHookListeners(): array
|
||||
{
|
||||
return [
|
||||
ProductCacheInvalidationListener::class,
|
||||
OrderNotificationListener::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service에서 훅 사용 패턴
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\ProductRepository;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
public function __construct(
|
||||
private ProductRepository $productRepository
|
||||
) {}
|
||||
|
||||
public function createProduct(array $data): Product
|
||||
{
|
||||
// 1. Before 훅 - 데이터 검증, 전처리
|
||||
HookManager::doAction('sirsoft-ecommerce.product.before_create', $data);
|
||||
|
||||
// 2. 필터 훅 - 데이터 변형
|
||||
$data = HookManager::applyFilters('sirsoft-ecommerce.product.filter_create_data', $data);
|
||||
|
||||
// 3. 비즈니스 로직 실행
|
||||
$product = $this->productRepository->create($data);
|
||||
|
||||
// 4. After 훅 - 후처리, 알림, 캐시 등
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_create', $product, $data);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
public function calculateProductPrice(Product $product): float
|
||||
{
|
||||
$basePrice = $product->price;
|
||||
|
||||
// 필터 훅으로 가격 계산 (할인, 세금 등)
|
||||
$finalPrice = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.product.calculate_price',
|
||||
$basePrice,
|
||||
$product
|
||||
);
|
||||
|
||||
return $finalPrice;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 필터 훅 리스너 예시
|
||||
|
||||
### `type` 필드 사용법
|
||||
|
||||
`getSubscribedHooks()` 메서드에서 `type` 필드를 사용하여 훅 타입을 명시해야 합니다.
|
||||
|
||||
| type 값 | 설명 | 기본값 |
|
||||
|---------|------|--------|
|
||||
| `action` | Action 훅 - 반환값 없음 | ✅ (생략 시 기본값) |
|
||||
| `filter` | Filter 훅 - 반환값 필수 | |
|
||||
|
||||
**중요**: Filter 훅 리스너는 반드시 `'type' => 'filter'`를 명시해야 합니다. 명시하지 않으면 Action 훅으로 처리되어 **반환값이 무시**됩니다.
|
||||
|
||||
### Filter 훅 리스너 구현
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
|
||||
class ProductPriceCalculationListener implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
// Filter 훅은 반드시 'type' => 'filter' 명시
|
||||
'sirsoft-ecommerce.product.calculate_price' => [
|
||||
'method' => 'applyDiscounts',
|
||||
'priority' => 10,
|
||||
'type' => 'filter', // 필수!
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 할인 적용
|
||||
*
|
||||
* @param float $price 기본 가격
|
||||
* @param Product $product 상품 객체
|
||||
* @return float 할인 적용된 가격
|
||||
*/
|
||||
public function applyDiscounts(float $price, $product): float
|
||||
{
|
||||
// 10% 할인 적용 (예시)
|
||||
if ($product->category->name === 'Special') {
|
||||
$price = $price * 0.9;
|
||||
}
|
||||
|
||||
return $price; // 반드시 값을 반환해야 함
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Action과 Filter 혼합 사용
|
||||
|
||||
하나의 리스너에서 Action 훅과 Filter 훅을 함께 구독할 수 있습니다:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Models\User;
|
||||
|
||||
class UserNotificationSettingsListener implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
// Filter 훅: 데이터 변형 (type 필수)
|
||||
'core.user.filter_create_data' => [
|
||||
'method' => 'filterCreateData',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
],
|
||||
'core.user.filter_update_data' => [
|
||||
'method' => 'filterUpdateData',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
],
|
||||
'core.user.filter_resource_data' => [
|
||||
'method' => 'filterResourceData',
|
||||
'priority' => 10,
|
||||
'type' => 'filter',
|
||||
],
|
||||
|
||||
// Action 훅: 부가 작업 (type 생략 가능)
|
||||
'core.user.after_create' => [
|
||||
'method' => 'afterCreate',
|
||||
'priority' => 10,
|
||||
// type 생략 = 'action'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 데이터 필터: 모듈 필드 추출 후 제거
|
||||
*
|
||||
* @param array $data 요청 데이터
|
||||
* @return array 모듈 필드가 제거된 데이터
|
||||
*/
|
||||
public function filterCreateData(array $data): array
|
||||
{
|
||||
// 모듈 필드 추출 및 임시 저장
|
||||
$moduleData = $this->extractModuleData($data);
|
||||
session(['module_data' => $moduleData]);
|
||||
|
||||
// 모듈 필드 제거 후 반환
|
||||
return $this->removeModuleFields($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 후 액션: 임시 저장된 데이터로 모듈 데이터 저장
|
||||
*
|
||||
* @param User $user 생성된 사용자
|
||||
* @param array $originalData 원본 요청 데이터
|
||||
* @return void
|
||||
*/
|
||||
public function afterCreate(User $user, array $originalData): void
|
||||
{
|
||||
$moduleData = session('module_data', []);
|
||||
session()->forget('module_data');
|
||||
|
||||
if (!empty($moduleData)) {
|
||||
$this->saveModuleData($user->id, $moduleData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 필터: 모듈 데이터 병합
|
||||
*
|
||||
* @param array $data API 응답 데이터
|
||||
* @param User $user 조회 대상 사용자
|
||||
* @return array 모듈 데이터가 병합된 응답
|
||||
*/
|
||||
public function filterResourceData(array $data, User $user): array
|
||||
{
|
||||
$moduleData = $this->getModuleData($user->id);
|
||||
return array_merge($data, $moduleData);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SEO 훅 활용 예시 — 리뷰 플러그인이 JSON-LD에 리뷰 데이터 주입
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Reviews\Listeners;
|
||||
|
||||
use App\Extension\Contracts\HookListenerInterface;
|
||||
|
||||
class SeoReviewMetaListener implements HookListenerInterface
|
||||
{
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.seo.filter_meta' => [
|
||||
'method' => 'injectReviewJsonLd',
|
||||
'priority' => 10,
|
||||
'type' => 'filter', // 필수!
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 메타에 리뷰 JSON-LD를 주입합니다.
|
||||
*
|
||||
* @param array $meta 메타 태그 배열
|
||||
* @param array $hookMeta 훅 메타 정보 (layoutName, moduleIdentifier 등)
|
||||
* @return array 수정된 메타 배열
|
||||
*/
|
||||
public function injectReviewJsonLd(array $meta, array $hookMeta): array
|
||||
{
|
||||
// 상품 상세 페이지에서만 동작
|
||||
if ($hookMeta['moduleIdentifier'] !== 'sirsoft-ecommerce') {
|
||||
return $meta;
|
||||
}
|
||||
|
||||
$context = $hookMeta['context'] ?? [];
|
||||
$productId = $context['route']['id'] ?? null;
|
||||
if (! $productId) {
|
||||
return $meta;
|
||||
}
|
||||
|
||||
// 리뷰 데이터 조회 및 JSON-LD 주입
|
||||
$reviews = $this->getReviewAggregate($productId);
|
||||
if ($reviews && $meta['jsonLd']) {
|
||||
$jsonLd = json_decode($meta['jsonLd'], true);
|
||||
$jsonLd['aggregateRating'] = [
|
||||
'@type' => 'AggregateRating',
|
||||
'ratingValue' => $reviews['average'],
|
||||
'reviewCount' => $reviews['count'],
|
||||
];
|
||||
$meta['jsonLd'] = json_encode($jsonLd, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
return $meta;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 훅 타입별 동작 비교
|
||||
|
||||
| 구분 | Action 훅 | Filter 훅 |
|
||||
|------|----------|-----------|
|
||||
| `type` 필드 | 생략 가능 (`'action'` 기본값) | **필수** (`'type' => 'filter'`) |
|
||||
| 반환값 | 무시됨 | **필수** (다음 필터로 전달) |
|
||||
| HookManager 메서드 | `doAction()` | `applyFilters()` |
|
||||
| 사용 목적 | 부가 작업 (로그, 알림, 캐시 등) | 데이터 변형 (필터링, 병합, 제거 등) |
|
||||
|
||||
---
|
||||
|
||||
## 훅 내부 메커니즘
|
||||
|
||||
### 중복 실행 방지 (가드 플래그)
|
||||
|
||||
HookManager는 `$dispatching` 배열을 사용하여 동일 훅의 중복 실행을 방지합니다. Laravel Event 시스템과의 하이브리드 동작에서 발생할 수 있는 무한 루프를 차단합니다.
|
||||
|
||||
```text
|
||||
doAction('hook.name')
|
||||
→ $dispatching['hook.name'] = true (가드 설정)
|
||||
→ Event::dispatch('hook.name')
|
||||
→ 리스너 실행 중 doAction('hook.name') 재호출 시
|
||||
→ $dispatching 체크 → 스킵 (중복 방지)
|
||||
→ unset($dispatching['hook.name']) (가드 해제)
|
||||
```
|
||||
|
||||
### addAction/addFilter 내부 동작
|
||||
|
||||
```text
|
||||
addAction() → Event::listen() 등록 + 리스너 배열 관리
|
||||
addFilter() → Event::listen() 등록 + 리스너 배열 관리 (동일)
|
||||
removeAction() / removeFilter() → Event::forget() + 배열에서 제거
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 훅 권한 시스템
|
||||
|
||||
G7은 훅 실행 시 **권한 체크**를 지원합니다. `permission_hooks` 테이블을 통해 특정 훅을 실행하려면 어떤 권한이 필요한지 매핑할 수 있습니다.
|
||||
|
||||
### 하이브리드 권한 체계
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 권한 체크 레이어 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1단계: permission_hooks (기능 레벨) │
|
||||
│ → 'core.attachment.download' 훅 실행 권한 │
|
||||
│ → 이 기능 자체를 사용할 수 있는가? │
|
||||
│ → 미매핑 시 모든 사용자 허용 │
|
||||
│ → 매핑 시 해당 퍼미션 보유자만 허용 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 2단계: role_* (리소스 레벨) │
|
||||
│ → 특정 파일/메뉴에 대한 접근 권한 │
|
||||
│ → 이 리소스를 읽을/수정할/삭제할 수 있는가? │
|
||||
│ → 현행 로직 유지 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### permission_hooks 테이블
|
||||
|
||||
```php
|
||||
Schema::create('permission_hooks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('permission_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('hook_name')->comment('훅 이름 (예: core.attachment.download)');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['permission_id', 'hook_name']);
|
||||
$table->index('hook_name');
|
||||
});
|
||||
```
|
||||
|
||||
### PermissionHook 모델
|
||||
|
||||
```php
|
||||
// app/Models/PermissionHook.php
|
||||
|
||||
class PermissionHook extends Model
|
||||
{
|
||||
protected $fillable = ['permission_id', 'hook_name'];
|
||||
|
||||
public function permission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Permission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 훅에 매핑된 권한 ID 목록 조회
|
||||
*/
|
||||
public static function getPermissionsForHook(string $hookName): array
|
||||
{
|
||||
return static::where('hook_name', $hookName)
|
||||
->pluck('permission_id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅에 권한이 매핑되어 있는지 확인
|
||||
*/
|
||||
public static function hasPermissionMapping(string $hookName): bool
|
||||
{
|
||||
return static::where('hook_name', $hookName)->exists();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HookManager의 권한 체크 메서드
|
||||
|
||||
```php
|
||||
// app/Extension/HookManager.php
|
||||
|
||||
class HookManager
|
||||
{
|
||||
/**
|
||||
* 훅 실행 전 권한 체크
|
||||
*
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 사용자 (null이면 비로그인)
|
||||
* @return bool 실행 허용 여부
|
||||
*/
|
||||
public static function checkHookPermission(string $hookName, ?User $user): bool
|
||||
{
|
||||
// 1. 훅에 매핑된 권한이 없으면 모든 사용자 허용
|
||||
if (!PermissionHook::hasPermissionMapping($hookName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 비로그인 사용자는 권한 매핑된 훅 실행 불가
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 관리자는 모든 훅 실행 가능
|
||||
if ($user->hasRole('admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. 사용자의 역할에 해당 권한이 있는지 확인
|
||||
$requiredPermissionIds = PermissionHook::getPermissionsForHook($hookName);
|
||||
|
||||
return $user->roles()
|
||||
->whereHas('permissions', function ($query) use ($requiredPermissionIds) {
|
||||
$query->whereIn('permissions.id', $requiredPermissionIds);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 체크 후 Action 실행
|
||||
*
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 사용자
|
||||
* @param mixed ...$args 훅 인자
|
||||
* @return bool 실행 성공 여부
|
||||
*/
|
||||
public static function doActionWithPermission(string $hookName, ?User $user, ...$args): bool
|
||||
{
|
||||
if (!static::checkHookPermission($hookName, $user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static::doAction($hookName, ...$args);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 체크 후 Filter 적용
|
||||
*
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 사용자
|
||||
* @param mixed $value 필터링할 값
|
||||
* @param mixed ...$args 추가 인자
|
||||
* @return mixed 필터링된 값 (권한 없으면 원본 반환)
|
||||
*/
|
||||
public static function applyFiltersWithPermission(string $hookName, ?User $user, $value, ...$args)
|
||||
{
|
||||
if (!static::checkHookPermission($hookName, $user)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return static::applyFilters($hookName, $value, ...$args);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 훅에 권한 매핑하기
|
||||
|
||||
```php
|
||||
// 시더 또는 관리자 기능에서
|
||||
|
||||
// 'attachment.download' 권한을 'core.attachment.download' 훅에 매핑
|
||||
$permission = Permission::where('identifier', 'attachment.download')->first();
|
||||
|
||||
PermissionHook::create([
|
||||
'permission_id' => $permission->id,
|
||||
'hook_name' => 'core.attachment.download',
|
||||
]);
|
||||
```
|
||||
|
||||
### 권한 매핑 해제 (모든 사용자 허용)
|
||||
|
||||
```php
|
||||
PermissionHook::where('hook_name', 'core.attachment.download')->delete();
|
||||
```
|
||||
|
||||
### Service에서 사용 예시
|
||||
|
||||
```php
|
||||
// app/Services/AttachmentService.php
|
||||
|
||||
public function download(int $id, ?User $user): ?Attachment
|
||||
{
|
||||
// 1단계: 기능 레벨 권한 체크 (permission_hooks)
|
||||
if (!HookManager::checkHookPermission('core.attachment.download', $user)) {
|
||||
throw new AuthorizationException(__('attachment.download_permission_denied'));
|
||||
}
|
||||
|
||||
$attachment = $this->repository->findById($id);
|
||||
|
||||
// 2단계: 리소스 레벨 권한 체크 (role_attachments)
|
||||
if (!$this->checkResourcePermission($user, $attachment, AttachmentPermissionType::Read)) {
|
||||
throw new AuthorizationException(__('attachment.resource_access_denied'));
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
```
|
||||
|
||||
### 권한 체크 흐름 요약
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ checkHookPermission() 흐름 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. permission_hooks에 훅 매핑 확인 │
|
||||
│ → 매핑 없음: 모든 사용자 허용 (return true) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 2. 비로그인 사용자 │
|
||||
│ → 매핑 있으면 거부 (return false) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 3. 관리자(admin) 역할 │
|
||||
│ → 모든 훅 실행 허용 (return true) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 4. 사용자 역할의 권한 확인 │
|
||||
│ → 매핑된 권한 보유 여부 반환 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### SEO 캐시 무효화 리스너
|
||||
|
||||
모듈의 CRUD 훅에서 SEO 캐시를 무효화하는 패턴:
|
||||
|
||||
| 훅 | 리스너 메서드 | 무효화 대상 |
|
||||
|----|-------------|-----------|
|
||||
| `[module].product.after_create` | `onProductChange` | 해당 URL + 목록/카테고리 |
|
||||
| `[module].product.after_update` | `onProductUpdate` | 해당 URL + 목록 |
|
||||
| `[module].*.after_delete` | `onProductChange` | 전체 관련 캐시 |
|
||||
|
||||
캐시 무효화 시 `Cache::forget('seo:sitemap')`도 함께 호출
|
||||
|
||||
> 구현 상세: [seo-system.md](../backend/seo-system.md)
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [모듈 개발 기초](module-basics.md) - 모듈에서 훅 리스너 등록
|
||||
- [Service-Repository 패턴](../backend/service-repository.md) - Service에서 훅 실행 패턴
|
||||
- [활동 로그 시스템](../backend/activity-log.md) - 활동 로그 Listener 작성 패턴, Per-Item Bulk 로깅 규칙
|
||||
- [권한 시스템](permissions.md) - Permission, Role 체계
|
||||
- [확장 시스템 인덱스](index.md) - 전체 확장 시스템 개요
|
||||
@@ -0,0 +1,682 @@
|
||||
# 레이아웃 확장 시스템 (Layout Extensions)
|
||||
|
||||
> 모듈/플러그인이 기존 레이아웃에 동적으로 UI를 주입하는 시스템입니다.
|
||||
|
||||
## 목차
|
||||
|
||||
- [개요](#개요)
|
||||
- [핵심 원칙](#핵심-원칙)
|
||||
- [확장 타입](#확장-타입)
|
||||
- [Extension 파일 위치](#extension-파일-위치)
|
||||
- [Overlay 확장](#overlay-확장)
|
||||
- [Extension Point 확장](#extension-point-확장)
|
||||
- [템플릿 오버라이드](#템플릿-오버라이드)
|
||||
- [모듈 활성화/비활성화 동작](#모듈-활성화비활성화-동작)
|
||||
- [우선순위](#우선순위)
|
||||
- [데이터 소스 병합](#데이터-소스-병합)
|
||||
- [모달 병합](#모달-병합)
|
||||
- [Overlay 레이아웃 섹션 병합](#overlay-레이아웃-섹션-병합)
|
||||
- [관련 파일](#관련-파일)
|
||||
- [문제 해결](#문제-해결)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
Layout Extension 시스템은 모듈/플러그인이 코어 또는 다른 확장의 레이아웃에 UI 컴포넌트를 동적으로 주입할 수 있게 해줍니다.
|
||||
|
||||
**주요 특징**:
|
||||
- 코어 레이아웃 수정 없이 UI 확장
|
||||
- 모듈 비활성화 시 자동으로 확장 UI 숨김
|
||||
- 템플릿에서 모듈 확장 오버라이드 가능
|
||||
- 우선순위 기반 렌더링 순서 제어
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
필수: layout_extensions 통한 동적 주입 사용 (코어 레이아웃에 모듈 UI 하드코딩 금지)
|
||||
필수: Layout Extension 시스템을 통한 동적 UI 주입
|
||||
필수: 모듈 비활성화 시 관련 UI 자동 숨김
|
||||
✅ 필수: 확장 컴포넌트에 ExtensionBadge 표시 (관리자 UI)
|
||||
필수: 플러그인은 완전한 레이아웃 등록 불가 → 확장 지점(layout_extensions)만 사용
|
||||
(예외: settings.json 환경설정 레이아웃은 registerPluginLayouts()로 등록)
|
||||
필수: 모듈만 완전한 레이아웃 등록 가능 (admin/user 모두)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 확장 타입
|
||||
|
||||
| 타입 | 키 | 설명 | 사용 시점 |
|
||||
|------|-----|------|----------|
|
||||
| **Overlay** | `target_layout` | 기존 레이아웃의 특정 컴포넌트 ID를 찾아 주입 | 특정 위치에 삽입/추가 |
|
||||
| **Extension Point** | `extension_point` | 레이아웃에 사전 정의된 확장 포인트에 주입 | 확장용 영역이 미리 정의된 경우 |
|
||||
|
||||
---
|
||||
|
||||
## Extension 파일 위치
|
||||
|
||||
### 모듈
|
||||
|
||||
```
|
||||
modules/
|
||||
└── vendor-module/
|
||||
└── resources/
|
||||
└── extensions/ ← 확장 정의 디렉토리
|
||||
└── *.json ← 확장 JSON 파일
|
||||
```
|
||||
|
||||
### 플러그인
|
||||
|
||||
```
|
||||
plugins/
|
||||
└── vendor-plugin/
|
||||
└── resources/
|
||||
└── extensions/
|
||||
└── *.json
|
||||
```
|
||||
|
||||
### 템플릿 오버라이드
|
||||
|
||||
```
|
||||
templates/
|
||||
└── vendor-template/
|
||||
└── extensions/
|
||||
└── {module-identifier}/ ← 오버라이드 대상 모듈
|
||||
└── *.json ← 오버라이드 JSON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overlay 확장
|
||||
|
||||
기존 레이아웃의 특정 컴포넌트를 찾아 UI를 주입합니다.
|
||||
|
||||
### JSON 스키마
|
||||
|
||||
```json
|
||||
{
|
||||
"target_layout": "admin_user_form",
|
||||
"injections": [
|
||||
{
|
||||
"target_id": "section_marketing_consent",
|
||||
"position": "append",
|
||||
"components": [...]
|
||||
}
|
||||
],
|
||||
"data_sources": [...],
|
||||
"priority": 100
|
||||
}
|
||||
```
|
||||
|
||||
### 필드 설명
|
||||
|
||||
| 필드 | 필수 | 타입 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `target_layout` | ✅ | string | 확장할 대상 레이아웃 이름 |
|
||||
| `injections` | ✅ | array | 주입 정의 배열 |
|
||||
| `injections[].target_id` | ✅ | string | 주입 대상 컴포넌트 ID |
|
||||
| `injections[].position` | ✅ | string | 주입 위치 (아래 참조) |
|
||||
| `injections[].components` | ✅ | array | 주입할 컴포넌트 배열 |
|
||||
| `data_sources` | ❌ | array | 추가 데이터 소스 (선택) |
|
||||
| `priority` | ❌ | number | 우선순위 (기본값: 100, 낮을수록 먼저) |
|
||||
|
||||
### position 옵션
|
||||
|
||||
| 값 | 설명 | 다이어그램 |
|
||||
|-----|------|----------|
|
||||
| `prepend` | 타겟 **앞에** 형제로 삽입 | `[NEW] [TARGET] [siblings...]` |
|
||||
| `append` | 타겟 **뒤에** 형제로 삽입 | `[TARGET] [NEW] [siblings...]` |
|
||||
| `prepend_child` | 타겟 children **맨 앞에** 삽입 | `TARGET { [NEW] [children...] }` |
|
||||
| `append_child` | 타겟 children **맨 뒤에** 삽입 | `TARGET { [children...] [NEW] }` |
|
||||
| `replace` | 타겟 **완전 교체** | `[NEW]` (기존 타겟 제거) |
|
||||
| `inject_props` | 타겟 컴포넌트의 **props에 값 주입** | `TARGET.props ← injection.props` |
|
||||
|
||||
### inject_props 상세
|
||||
|
||||
`inject_props`는 기존 컴포넌트의 props에 값을 주입하는 특수 position입니다. `components` 필드 대신 `props` 필드를 사용합니다.
|
||||
|
||||
#### 병합 전략
|
||||
|
||||
| 전략 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| `_append` | 배열 끝에 추가 | `tabs._append: [newTab]` → 기존 tabs 뒤에 추가 |
|
||||
| `_prepend` | 배열 앞에 추가 | `tabs._prepend: [newTab]` → 기존 tabs 앞에 추가 |
|
||||
| `_merge` | 객체 병합 (shallow) | `style._merge: {color: 'red'}` → 기존 style에 병합 |
|
||||
| (직접 값) | 스칼라 덮어쓰기 | `disabled: true` → 기존 값 대체 |
|
||||
|
||||
#### inject_props 예시: 탭에 항목 추가
|
||||
|
||||
```json
|
||||
{
|
||||
"target_layout": "admin_user_detail",
|
||||
"injections": [
|
||||
{
|
||||
"target_id": "user_detail_tabs",
|
||||
"position": "inject_props",
|
||||
"props": {
|
||||
"tabs": {
|
||||
"_append": [
|
||||
{
|
||||
"id": "ext_verification",
|
||||
"label": "$t:admin.users.form.sections.verification_info",
|
||||
"iconName": "shield"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"target_id": "extension_tab_content",
|
||||
"position": "append_child",
|
||||
"components": [
|
||||
{
|
||||
"id": "ext_verification_content",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_global.activeUserDetailTab || query.tab || 'basic') === 'ext_verification'}}",
|
||||
"children": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"priority": 100
|
||||
}
|
||||
```
|
||||
|
||||
#### 주의사항
|
||||
|
||||
- `inject_props`일 때 `components` 필드는 무시됩니다
|
||||
- 대상 prop이 표현식 문자열인 경우 `_append`/`_prepend`/`_merge` 적용이 불가하며, 경고 로그가 기록됩니다
|
||||
- `_append`와 `_merge` 등 여러 전략을 하나의 prop에 혼합 사용할 수 없습니다 (첫 번째 매칭 전략 우선)
|
||||
|
||||
### 예시: 사용자 폼에 알림 설정 추가
|
||||
|
||||
```json
|
||||
{
|
||||
"target_layout": "admin_user_form",
|
||||
"injections": [
|
||||
{
|
||||
"target_id": "section_marketing_consent",
|
||||
"position": "append",
|
||||
"components": [
|
||||
{
|
||||
"id": "section_notification_settings",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center justify-between mb-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "text-lg font-semibold text-gray-900 dark:text-white"
|
||||
},
|
||||
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "ExtensionBadge",
|
||||
"props": {
|
||||
"type": "module",
|
||||
"identifier": "sirsoft-board",
|
||||
"installedModules": "{{_global.installedModules}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "checkbox",
|
||||
"name": "notify_post_complete",
|
||||
"checked": "{{_local.form?.notify_post_complete ?? false}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"priority": 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extension Point 확장
|
||||
|
||||
레이아웃에 미리 정의된 확장 포인트에 컴포넌트를 주입합니다.
|
||||
|
||||
### 지원 위치
|
||||
|
||||
Extension Point는 레이아웃의 다음 섹션에서 사용할 수 있습니다:
|
||||
|
||||
| 위치 | 지원 | 설명 |
|
||||
|----------------|------|--------------------------------------|
|
||||
| `components` | ✅ | 메인 컴포넌트 트리 (기본) |
|
||||
| `modals` | ✅ | 모달 내부 컴포넌트 트리 (v1.17.0+) |
|
||||
|
||||
```text
|
||||
v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
|
||||
✅ v1.17.0+: components와 modals 양쪽 모두 재귀적으로 extension_point 처리
|
||||
```
|
||||
|
||||
### 레이아웃에서 Extension Point 정의
|
||||
|
||||
```json
|
||||
{
|
||||
"layout_name": "admin_user_form",
|
||||
"components": [
|
||||
{
|
||||
"id": "user_form_container",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "extension_point",
|
||||
"name": "user_form_additional_fields",
|
||||
"default": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 모듈에서 Extension Point에 주입
|
||||
|
||||
```json
|
||||
{
|
||||
"extension_point": "user_form_additional_fields",
|
||||
"mode": "replace",
|
||||
"components": [
|
||||
{
|
||||
"id": "custom_field_section",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [...]
|
||||
}
|
||||
],
|
||||
"data_sources": [...],
|
||||
"priority": 100
|
||||
}
|
||||
```
|
||||
|
||||
### JSON 스키마
|
||||
|
||||
| 필드 | 필수 | 타입 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `extension_point` | ✅ | string | 확장 포인트 이름 |
|
||||
| `mode` | ❌ | string | 주입 모드 (아래 참조, 기본값: `append`) |
|
||||
| `components` | ❌ | array | 주입할 컴포넌트 배열 |
|
||||
| `data_sources` | ❌ | array | 추가 데이터 소스 |
|
||||
| `modals` | ❌ | array | 호스트 레이아웃에 병합할 모달 배열 |
|
||||
| `scripts` | ❌ | array | 추가 스크립트 |
|
||||
| `priority` | ❌ | number | 우선순위 (기본값: 100) |
|
||||
|
||||
### mode 옵션
|
||||
|
||||
| 값 | 설명 | 동작 |
|
||||
| ----- | ------ | ------ |
|
||||
| `append` | default **뒤에** 추가 (기본값) | `[default...] [NEW]` |
|
||||
| `prepend` | default **앞에** 추가 | `[NEW] [default...]` |
|
||||
| `replace` | default **완전 교체** | `[NEW]` (default 제거) |
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 오버라이드
|
||||
|
||||
템플릿은 모듈/플러그인의 확장을 오버라이드하여 커스터마이징할 수 있습니다.
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```
|
||||
templates/_bundled/sirsoft-admin_basic/
|
||||
└── extensions/
|
||||
└── sirsoft-board/ ← 오버라이드 대상 모듈
|
||||
└── user-notification-settings.json ← 오버라이드 파일
|
||||
```
|
||||
|
||||
### 오버라이드 파일명 규칙
|
||||
|
||||
모듈의 원본 확장 파일명과 **동일한 이름**을 사용합니다.
|
||||
|
||||
```
|
||||
모듈 원본: modules/_bundled/sirsoft-board/resources/extensions/user-notification-settings.json
|
||||
템플릿 오버라이드: templates/_bundled/sirsoft-admin_basic/extensions/sirsoft-board/user-notification-settings.json
|
||||
```
|
||||
|
||||
### 오버라이드 우선순위
|
||||
|
||||
```
|
||||
1. 템플릿 오버라이드 (source_type = Template) ← 가장 높은 우선순위
|
||||
2. 플러그인 확장 (source_type = Plugin)
|
||||
3. 모듈 확장 (source_type = Module) ← 가장 낮은 우선순위
|
||||
```
|
||||
|
||||
### 오버라이드 예시
|
||||
|
||||
모듈의 기본 확장을 템플릿에서 스타일 변경:
|
||||
|
||||
```json
|
||||
{
|
||||
"target_layout": "admin_user_form",
|
||||
"injections": [
|
||||
{
|
||||
"target_id": "section_marketing_consent",
|
||||
"position": "append",
|
||||
"components": [
|
||||
{
|
||||
"id": "section_notification_settings",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg p-6 mb-6"
|
||||
},
|
||||
"children": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"priority": 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모듈 활성화/비활성화 동작
|
||||
|
||||
### 활성화 시
|
||||
|
||||
1. `resources/extensions/*.json` 파일 스캔
|
||||
2. `template_layout_extensions` 테이블에 등록 (또는 기존 레코드 복원)
|
||||
3. 레이아웃 캐시 무효화
|
||||
|
||||
### 비활성화 시
|
||||
|
||||
1. 해당 모듈의 확장 레코드 Soft Delete
|
||||
2. **렌더링 시 비활성화된 모듈의 확장 자동 스킵**
|
||||
3. 레이아웃 캐시 무효화
|
||||
|
||||
### 템플릿 오버라이드와 모듈 비활성화
|
||||
|
||||
**중요**: 모듈이 비활성화되면, 해당 모듈을 오버라이드하는 템플릿 확장도 함께 숨김 처리됩니다.
|
||||
|
||||
```
|
||||
sirsoft-board 모듈 비활성화
|
||||
↓
|
||||
sirsoft-board의 모든 확장 숨김
|
||||
↓
|
||||
sirsoft-board를 오버라이드하는 템플릿 확장도 숨김
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 우선순위
|
||||
|
||||
### priority 필드
|
||||
|
||||
- 기본값: 100
|
||||
- 낮을수록 먼저 렌더링
|
||||
- 같은 위치에 여러 확장이 있을 경우 순서 결정
|
||||
|
||||
```json
|
||||
{
|
||||
"target_layout": "admin_user_form",
|
||||
"priority": 50,
|
||||
"injections": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### 우선순위 가이드라인
|
||||
|
||||
| 범위 | 용도 | 예시 |
|
||||
|------|------|------|
|
||||
| 1-25 | 필수 정보 | 보안 경고, 중요 알림 |
|
||||
| 50-75 | 주요 기능 | 핵심 모듈 UI |
|
||||
| 100 | 기본값 | 일반 확장 |
|
||||
| 150+ | 보조 정보 | 분석, 통계 |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 소스 병합
|
||||
|
||||
확장에서 정의한 `data_sources`는 호스트 레이아웃의 데이터 소스에 병합됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"target_layout": "admin_user_form",
|
||||
"injections": [...],
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "notification_settings",
|
||||
"type": "api",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/modules/sirsoft-board/admin/users/{{route.id}}/notification-settings",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모달 병합
|
||||
|
||||
Extension Point 확장에서 정의한 `modals`는 호스트 레이아웃의 `modals` 배열에 자동 병합됩니다.
|
||||
|
||||
### 왜 modals 섹션을 사용해야 하는가?
|
||||
|
||||
Extension Point의 children으로 주입된 인라인 Modal(`show` prop 기반)은 정상 동작하지 않습니다. 그누보드7 Modal 시스템에서 안정적으로 동작하려면 `modals` 섹션에 등록하고 `openModal`/`closeModal` 핸들러로 제어해야 합니다.
|
||||
|
||||
> 상세 모달 규칙: [modal-usage.md](../frontend/modal-usage.md)
|
||||
|
||||
### Extension JSON에서 modals 정의
|
||||
|
||||
```json
|
||||
{
|
||||
"extension_point": "shop_checkout_extensions",
|
||||
"modals": [
|
||||
{
|
||||
"id": "payment_error_modal",
|
||||
"type": "composite",
|
||||
"name": "Modal",
|
||||
"props": {
|
||||
"title": "$t:plugin.payment_error_title",
|
||||
"size": "small"
|
||||
},
|
||||
"children": [...]
|
||||
}
|
||||
],
|
||||
"priority": 100
|
||||
}
|
||||
```
|
||||
|
||||
### 핸들러에서 모달 열기
|
||||
|
||||
Extension으로 병합된 모달은 커스텀 핸들러에서 `G7Core.modal.open()`으로 열 수 있습니다:
|
||||
|
||||
```typescript
|
||||
const G7Core = (window as any).G7Core;
|
||||
G7Core?.modal?.open?.('payment_error_modal');
|
||||
```
|
||||
|
||||
또는 레이아웃 JSON 액션에서:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "openModal",
|
||||
"target": "payment_error_modal"
|
||||
}
|
||||
```
|
||||
|
||||
### 병합 동작
|
||||
|
||||
- 여러 확장의 `modals`가 하나의 호스트 레이아웃에 모두 병합됩니다
|
||||
- 호스트 레이아웃에 기존 `modals`가 있으면 확장 모달이 뒤에 추가됩니다
|
||||
- 확장에 `modals`가 없으면 호스트 레이아웃에 영향 없음 (하위 호환)
|
||||
|
||||
### 주의사항
|
||||
|
||||
```text
|
||||
modals 섹션 모달에 show prop 사용 금지 (무시됨)
|
||||
모달 ID는 호스트 레이아웃의 기존 모달과 중복되지 않도록 플러그인 prefix 사용 권장
|
||||
✅ 모달 닫기 시 setState → closeModal 순서 유지 (순서 중요)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overlay 레이아웃 섹션 병합
|
||||
|
||||
Overlay 확장에서 `computed`, `state`, `modals` 섹션을 정의하면 호스트 레이아웃에 자동 병합됩니다.
|
||||
|
||||
### 지원 섹션
|
||||
|
||||
| 섹션 | Extension Point | Overlay | 설명 |
|
||||
|------|:---:|:---:|------|
|
||||
| `data_sources` | ✅ | ✅ | API 데이터 소스 추가 |
|
||||
| `scripts` | ✅ | ✅ | 커스텀 핸들러 스크립트 추가 |
|
||||
| `modals` | ✅ | ✅ | 모달 컴포넌트 추가 |
|
||||
| `computed` | ✅ | ✅ | 계산된 값 추가 |
|
||||
| `state` | ✅ | ✅ | 초기 상태 추가 |
|
||||
|
||||
### 예시: Overlay에서 computed와 state 추가
|
||||
|
||||
```json
|
||||
{
|
||||
"target_layout": "admin_user_detail",
|
||||
"injections": [...],
|
||||
"computed": {
|
||||
"verificationStatus": "{{user?.data?.identity_verified ? 'verified' : 'pending'}}"
|
||||
},
|
||||
"state": {
|
||||
"showVerificationHistory": false
|
||||
},
|
||||
"modals": [
|
||||
{
|
||||
"id": "ext_verification_modal",
|
||||
"type": "composite",
|
||||
"name": "Modal",
|
||||
"props": { "title": "인증 이력" },
|
||||
"children": [...]
|
||||
}
|
||||
],
|
||||
"priority": 100
|
||||
}
|
||||
```
|
||||
|
||||
### 병합 동작
|
||||
|
||||
- 여러 overlay의 같은 섹션은 priority 오름차순으로 순차 병합됩니다
|
||||
- 동일 키 충돌 시 후순위 overlay가 덮어씁니다
|
||||
- 해당 섹션이 없는 overlay는 기존 레이아웃에 영향 없음 (하위 호환)
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 오버라이드 vs 확장 오버라이드
|
||||
|
||||
| 구분 | 레이아웃 오버라이드 | 확장 오버라이드 |
|
||||
|------|-------------------|---------------|
|
||||
| 대상 | 모듈이 등록한 완전한 레이아웃 | 확장 지점/Overlay로 주입된 UI |
|
||||
| 위치 | `templates/{t}/layouts/overrides/{module}/*.json` | `templates/{t}/extensions/{module}/*.json` |
|
||||
| DB 테이블 | `template_layouts` | `template_layout_extensions` |
|
||||
| 해석 서비스 | `LayoutResolverService` | `LayoutExtensionService` |
|
||||
| version_constraint | 지원 | 지원 |
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 백엔드
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `app/Services/LayoutExtensionService.php` | 확장 적용 핵심 서비스 |
|
||||
| `app/Models/LayoutExtension.php` | 확장 모델 (DB 테이블) |
|
||||
| `app/Repositories/LayoutExtensionRepository.php` | 확장 Repository |
|
||||
| `app/Enums/LayoutExtensionType.php` | 확장 타입 Enum (ExtensionPoint, Overlay) |
|
||||
| `app/Enums/LayoutSourceType.php` | 출처 타입 Enum (Module, Plugin, Template) |
|
||||
|
||||
### 데이터베이스
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `template_layout_extensions` | 확장 등록 정보 저장 |
|
||||
|
||||
### 주요 컬럼
|
||||
|
||||
```
|
||||
template_layout_extensions
|
||||
├── template_id # 템플릿 ID
|
||||
├── extension_type # ExtensionPoint | Overlay
|
||||
├── target_name # 확장 포인트명 또는 대상 레이아웃명
|
||||
├── source_type # Module | Plugin | Template
|
||||
├── source_identifier # 모듈/플러그인/템플릿 식별자
|
||||
├── override_target # 오버라이드 대상 (템플릿 오버라이드 시)
|
||||
├── content # 확장 JSON 내용
|
||||
├── priority # 우선순위
|
||||
├── is_active # 활성 상태
|
||||
└── deleted_at # Soft Delete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 확장이 표시되지 않음
|
||||
|
||||
**확인 사항**:
|
||||
1. 모듈/플러그인이 활성화되어 있는지 확인
|
||||
2. `target_layout` 또는 `extension_point` 이름이 정확한지 확인
|
||||
3. `target_id`가 대상 레이아웃에 존재하는지 확인
|
||||
4. JSON 문법 오류 확인
|
||||
|
||||
**캐시 초기화**:
|
||||
```bash
|
||||
php artisan cache:clear
|
||||
```
|
||||
|
||||
### 오버라이드가 적용되지 않음
|
||||
|
||||
**확인 사항**:
|
||||
1. 템플릿 오버라이드 파일 경로 확인
|
||||
- `templates/{template}/extensions/{module-identifier}/{filename}.json`
|
||||
2. 파일명이 원본과 동일한지 확인
|
||||
3. `template_layout_extensions` 테이블에서 `source_type = 'template'` 레코드 확인
|
||||
|
||||
### 모듈 비활성화 후에도 확장 표시됨
|
||||
|
||||
**원인**: 캐시 문제
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
php artisan cache:clear
|
||||
```
|
||||
|
||||
### 여러 확장의 순서가 예상과 다름
|
||||
|
||||
**해결**: `priority` 값 조정 (낮을수록 먼저 렌더링)
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [module-layouts.md](module-layouts.md) - 모듈 레이아웃 등록
|
||||
- [hooks.md](hooks.md) - 훅 시스템
|
||||
- [layout-json.md](../frontend/layout-json.md) - 레이아웃 JSON 스키마
|
||||
- [components.md](../frontend/components.md) - 컴포넌트 개발 규칙
|
||||
@@ -0,0 +1,393 @@
|
||||
# 메뉴 시스템
|
||||
|
||||
> G7의 메뉴 권한 시스템과 메뉴 시더 작성 규칙을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 구조: User → Role → role_menus 피벗 → Menu
|
||||
2. 권한: MenuPermissionType (read, write, delete)
|
||||
3. 모든 메뉴: role_menus에 명시적 역할 권한 필요 (코어/확장 구분 없음)
|
||||
4. 자동 부여: 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할 자동 부여
|
||||
5. 시더: Menu::create() + grantAdminRoleToMenus() 패턴
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [메뉴 권한 아키텍처](#메뉴-권한-아키텍처)
|
||||
2. [role_menus 피벗 테이블](#role_menus-피벗-테이블)
|
||||
3. [MenuPermissionType Enum](#menupermissiontype-enum)
|
||||
4. [Menu 모델의 HasRoleBasedAccess 구현](#menu-모델의-hasrolebasedaccess-구현)
|
||||
5. [메뉴 시더에서 권한 부여](#메뉴-시더에서-권한-부여)
|
||||
6. [메뉴 권한 확인 방법](#메뉴-권한-확인-방법)
|
||||
7. [권한 체계 요약](#권한-체계-요약)
|
||||
|
||||
---
|
||||
|
||||
## 메뉴 권한 아키텍처
|
||||
|
||||
G7에서 메뉴 권한은 **역할 기반(Role-based)**으로 관리됩니다.
|
||||
|
||||
```
|
||||
User → Role → role_menus → Menu
|
||||
(pivot)
|
||||
```
|
||||
|
||||
### 코어 메뉴 vs 모듈 메뉴
|
||||
|
||||
| 메뉴 유형 | 식별 | 기본 정책 |
|
||||
|----------|------|----------|
|
||||
| 코어 메뉴 | `extension_type = 'core'` | role_menus에 명시적 역할 권한 필요 |
|
||||
| 모듈 메뉴 | `extension_type = 'module'` | 확장 활성 상태 + 명시적 역할 권한 필요 |
|
||||
| 플러그인 메뉴 | `extension_type = 'plugin'` | 확장 활성 상태 + 명시적 역할 권한 필요 |
|
||||
| 사용자 생성 메뉴 | `extension_type = NULL` | role_menus에 명시적 역할 권한 필요 |
|
||||
|
||||
---
|
||||
|
||||
## role_menus 피벗 테이블
|
||||
|
||||
### 테이블 구조
|
||||
|
||||
```php
|
||||
Schema::create('role_menus', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('menu_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('permission_type', ['read', 'write', 'delete'])->default('read');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['role_id', 'menu_id', 'permission_type'], 'uk_role_menu_permission');
|
||||
});
|
||||
```
|
||||
|
||||
### 설계 원칙
|
||||
|
||||
- **레코드 존재 = 권한 허용**: `is_allowed` 컬럼 없이 레코드 존재 여부로 판단
|
||||
- **복합 유니크 인덱스**: 동일 역할-메뉴에 여러 권한 타입 부여 가능
|
||||
- **Cascade Delete**: 역할 또는 메뉴 삭제 시 자동 정리
|
||||
|
||||
---
|
||||
|
||||
## MenuPermissionType Enum
|
||||
|
||||
```php
|
||||
// app/Enums/MenuPermissionType.php
|
||||
|
||||
enum MenuPermissionType: string
|
||||
{
|
||||
case Read = 'read';
|
||||
case Write = 'write';
|
||||
case Delete = 'delete';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Read => '읽기/접근',
|
||||
self::Write => '수정',
|
||||
self::Delete => '삭제',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 권한 타입 설명
|
||||
|
||||
| 타입 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| Read | `read` | 메뉴 조회/접근 권한 |
|
||||
| Write | `write` | 메뉴 관련 데이터 수정 권한 |
|
||||
| Delete | `delete` | 메뉴 관련 데이터 삭제 권한 |
|
||||
|
||||
---
|
||||
|
||||
## Menu 모델의 HasRoleBasedAccess 구현
|
||||
|
||||
`Menu` 모델은 `HasRoleBasedAccess` 인터페이스를 구현하여 역할 기반 접근 제어를 지원합니다.
|
||||
|
||||
### 인터페이스 구현
|
||||
|
||||
```php
|
||||
// app/Models/Menu.php
|
||||
|
||||
class Menu extends Model implements HasRoleBasedAccess
|
||||
{
|
||||
/**
|
||||
* 접근 가능한 역할들 (권한 타입 포함)
|
||||
*/
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'role_menus')
|
||||
->withPivot('permission_type')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 권한 타입을 가진 역할들 조회
|
||||
*/
|
||||
public function rolesWithPermission(BackedEnum|string $permissionType): BelongsToMany
|
||||
{
|
||||
$type = $permissionType instanceof BackedEnum
|
||||
? $permissionType->value
|
||||
: $permissionType;
|
||||
|
||||
return $this->belongsToMany(Role::class, 'role_menus')
|
||||
->withPivot('permission_type')
|
||||
->withTimestamps()
|
||||
->wherePivot('permission_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할 권한이 설정되어 있는지 확인
|
||||
*/
|
||||
public function hasRolePermissions(): bool
|
||||
{
|
||||
return $this->roles()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 리소스 소유자의 ID 반환
|
||||
*/
|
||||
public function getOwnerId(): ?int
|
||||
{
|
||||
return $this->created_by;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### scopeAccessibleBy 쿼리 스코프
|
||||
|
||||
```php
|
||||
/**
|
||||
* 사용자에게 접근 가능한 메뉴들만 조회하는 스코프
|
||||
*
|
||||
* 권한 체크 로직:
|
||||
* 1. 관리자는 모든 메뉴 접근 가능
|
||||
* 2. 코어/사용자 생성 메뉴: role_menus에 명시적 역할 권한 필요
|
||||
* 3. 모듈/플러그인 메뉴: 확장이 활성화되어 있고 역할 권한이 있어야 접근 가능
|
||||
*
|
||||
* 메뉴 생성 시 관리자 역할 + 생성자/설치자 역할이 자동 부여되므로
|
||||
* role_menus 레코드가 없는 메뉴는 누구에게도 보이지 않습니다.
|
||||
*/
|
||||
public function scopeAccessibleBy(Builder $query, User $user): Builder
|
||||
{
|
||||
if ($user->hasRole('admin')) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$userRoleIds = $user->roles()->pluck('roles.id')->toArray();
|
||||
|
||||
return $query->where(function (Builder $query) use ($userRoleIds) {
|
||||
// 코어/사용자 생성 메뉴: 명시적 역할 권한 필요
|
||||
$query->where(function (Builder $q) use ($userRoleIds) {
|
||||
$q->where(function ($q2) {
|
||||
$q2->whereNull('extension_type')
|
||||
->orWhere('extension_type', 'core');
|
||||
})
|
||||
->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) {
|
||||
$roleQ->whereIn('roles.id', $userRoleIds)
|
||||
->where('role_menus.permission_type', MenuPermissionType::Read->value);
|
||||
});
|
||||
});
|
||||
})->orWhere(function (Builder $query) use ($userRoleIds) {
|
||||
// 모듈/플러그인 메뉴 (확장이 활성화되어 있고 역할 권한이 있는 경우)
|
||||
$query->whereIn('extension_type', ['module', 'plugin'])
|
||||
->whereHas('roles', function (Builder $roleQ) use ($userRoleIds) {
|
||||
$roleQ->whereIn('roles.id', $userRoleIds)
|
||||
->where('role_menus.permission_type', MenuPermissionType::Read->value);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 메뉴 시더에서 권한 부여
|
||||
|
||||
### 예시: EcommerceMenuSeeder
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\Menu;
|
||||
use App\Models\Role;
|
||||
use App\Models\Module;
|
||||
use App\Enums\MenuPermissionType;
|
||||
|
||||
class EcommerceMenuSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$this->command->info('이커머스 메뉴 생성을 시작합니다.');
|
||||
|
||||
$module = Module::where('identifier', 'sirsoft-ecommerce')->first();
|
||||
|
||||
// 메뉴 생성
|
||||
$ecommerceMenu = Menu::create([
|
||||
'name' => ['ko' => '이커머스', 'en' => 'Ecommerce'],
|
||||
'slug' => 'sirsoft-ecommerce',
|
||||
'url' => '/admin/sirsoft-ecommerce',
|
||||
'icon' => 'fa-shopping-cart',
|
||||
'module_id' => $module->id,
|
||||
]);
|
||||
|
||||
// 권한 부여
|
||||
$this->grantMenuPermissions($module->id);
|
||||
|
||||
$this->command->info('이커머스 메뉴가 생성되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할에 메뉴 권한 부여
|
||||
*/
|
||||
private function grantMenuPermissions(int $moduleId): void
|
||||
{
|
||||
$adminRole = Role::where('identifier', 'admin')->first();
|
||||
$managerRole = Role::where('identifier', 'sirsoft-ecommerce.manager')->first();
|
||||
|
||||
$ecommerceMenus = Menu::where('module_id', $moduleId)->get();
|
||||
|
||||
foreach ($ecommerceMenus as $menu) {
|
||||
// 관리자: 모든 권한
|
||||
$adminRole->menus()->attach($menu->id, [
|
||||
'permission_type' => MenuPermissionType::Read->value,
|
||||
]);
|
||||
$adminRole->menus()->attach($menu->id, [
|
||||
'permission_type' => MenuPermissionType::Write->value,
|
||||
]);
|
||||
$adminRole->menus()->attach($menu->id, [
|
||||
'permission_type' => MenuPermissionType::Delete->value,
|
||||
]);
|
||||
|
||||
// 매니저: 읽기/쓰기 권한만
|
||||
if ($managerRole) {
|
||||
$managerRole->menus()->attach($menu->id, [
|
||||
'permission_type' => MenuPermissionType::Read->value,
|
||||
]);
|
||||
$managerRole->menus()->attach($menu->id, [
|
||||
'permission_type' => MenuPermissionType::Write->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->command->info("역할에 {$ecommerceMenus->count()}개의 메뉴 권한을 부여했습니다.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### syncWithoutDetaching 사용 (기존 권한 유지)
|
||||
|
||||
```php
|
||||
// 기존 권한을 유지하면서 새 권한 추가
|
||||
$role->menus()->syncWithoutDetaching([
|
||||
$menuId => ['permission_type' => 'read'],
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 메뉴 권한 확인 방법
|
||||
|
||||
### 1. User 모델에서 확인
|
||||
|
||||
```php
|
||||
// 기본 (read 권한)
|
||||
$user->hasMenuPermission($menuId);
|
||||
|
||||
// 특정 권한 타입
|
||||
$user->hasMenuPermission($menuId, 'write');
|
||||
$user->hasMenuPermission($menuId, MenuPermissionType::Delete);
|
||||
```
|
||||
|
||||
### 2. 쿼리 스코프로 필터링
|
||||
|
||||
```php
|
||||
// 사용자가 접근 가능한 메뉴 조회
|
||||
$accessibleMenus = Menu::accessibleBy($user)->get();
|
||||
|
||||
// 계층 구조와 함께 조회
|
||||
$accessibleMenus = Menu::accessibleBy($user)
|
||||
->whereNull('parent_id')
|
||||
->with(['children' => function ($query) use ($user) {
|
||||
$query->accessibleBy($user);
|
||||
}])
|
||||
->get();
|
||||
```
|
||||
|
||||
### 3. Role 모델에서 확인
|
||||
|
||||
```php
|
||||
// 역할에 메뉴 권한 부여
|
||||
$role->menus()->attach($menuId, ['permission_type' => 'read']);
|
||||
|
||||
// 역할의 모든 메뉴 조회
|
||||
$role->menus()->get();
|
||||
|
||||
// 특정 권한 타입의 메뉴만 조회
|
||||
$role->menus()->wherePivot('permission_type', 'write')->get();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 권한 체계 요약
|
||||
|
||||
### 권한 체크 흐름
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. 관리자(admin) 역할 확인 │
|
||||
│ → admin이면 모든 메뉴 접근 허용 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 2. 코어/사용자 생성 메뉴 (extension_type = 'core' 또는 NULL)│
|
||||
│ → role_menus에 명시적 역할 권한 필요 │
|
||||
│ → 메뉴 생성 시 관리자+생성자 역할 자동 부여 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 3. 모듈/플러그인 메뉴 (extension_type = 'module'/'plugin') │
|
||||
│ → 확장이 활성화(active) 상태여야 함 │
|
||||
│ → role_menus에 명시적 권한 필요 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 권한 타입별 정리
|
||||
|
||||
| 권한 타입 | 용도 | 예시 |
|
||||
|----------|------|------|
|
||||
| `read` | 메뉴 표시, 페이지 접근 | 네비게이션 표시 |
|
||||
| `write` | 데이터 생성/수정 | 상품 등록, 수정 |
|
||||
| `delete` | 데이터 삭제 | 상품 삭제 |
|
||||
|
||||
### 동적 메뉴 보존
|
||||
|
||||
모듈/플러그인이 런타임에 동적으로 생성한 메뉴(예: 게시판 모듈의 개별 게시판 메뉴)는 확장 업데이트 시 자동으로 보존됩니다.
|
||||
|
||||
- `cleanupStaleMenus()`/`cleanupStalePermissions()`의 **자동 호출은 폐기**되었습니다
|
||||
- 정적 메뉴/권한의 추가·수정은 업데이트 시 자동 동기화됩니다
|
||||
- 정적 메뉴/권한을 **제거**해야 하는 경우, UpgradeStep에서 cleanup helper를 명시적으로 호출하세요
|
||||
- 상세: [extension-update-system.md](./extension-update-system.md) "정적 메뉴/권한 제거 시 cleanup 사용 예시" 참조
|
||||
|
||||
### Deprecated 항목
|
||||
|
||||
다음 항목들은 더 이상 사용하지 않습니다:
|
||||
|
||||
| 항목 | 대체 방법 |
|
||||
|------|----------|
|
||||
| `menu_permissions` 테이블 | `role_menus` 피벗 테이블 |
|
||||
| `MenuPermission` 모델 | `Role::menus()` 관계 |
|
||||
| `is_allowed` 컬럼 | 레코드 존재 여부로 판단 |
|
||||
| `User::menuPermissions()` | `User::hasMenuPermission()` |
|
||||
| `module_id` 컬럼 | `extension_type` + `extension_identifier` |
|
||||
| `module_identifier` 컬럼 | `extension_identifier` |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [permissions.md](./permissions.md) - 권한 시스템 (Role, Permission, 리소스 레벨 권한)
|
||||
- [module-basics.md](./module-basics.md) - 모듈 기본 구조
|
||||
- [index.md](./index.md) - 확장 시스템 인덱스
|
||||
@@ -0,0 +1,483 @@
|
||||
# 모듈 프론트엔드 에셋 시스템
|
||||
|
||||
> 이 문서는 G7의 모듈 프론트엔드 에셋 로딩 시스템을 다룹니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. module.json에 에셋 매니페스트 정의 (js, css, loading strategy)
|
||||
2. Vite IIFE 빌드로 dist/에 번들 생성
|
||||
3. TemplateApp 초기화 시 자동 로드 (global 전략)
|
||||
4. 핸들러 네이밍: {module-identifier}.{handler-name}
|
||||
5. 빌드 명령: php artisan module:build [identifier] (기본: _bundled, --active로 활성)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [개요](#개요)
|
||||
- [에셋 매니페스트 스키마](#에셋-매니페스트-스키마)
|
||||
- [모듈 프론트엔드 구조](#모듈-프론트엔드-구조)
|
||||
- [핸들러 등록](#핸들러-등록)
|
||||
- [빌드 및 배포](#빌드-및-배포)
|
||||
- [에셋 로딩 전략](#에셋-로딩-전략)
|
||||
- [외부 라이브러리](#외부-라이브러리)
|
||||
- [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
모듈/플러그인은 자체 프론트엔드 에셋(JS, CSS, 이미지, 폰트 등)을 포함할 수 있습니다.
|
||||
이 시스템은 활성화된 모듈의 에셋을 동적으로 로드하여 ActionDispatcher에 핸들러를 등록합니다.
|
||||
|
||||
### 작동 원리
|
||||
|
||||
```
|
||||
1. admin.blade.php 렌더링
|
||||
└─ window.G7Config.moduleAssets 주입
|
||||
|
||||
2. TemplateApp.init()
|
||||
└─ ModuleAssetLoader.loadActiveExtensionAssets()
|
||||
├─ CSS 로드 (병렬)
|
||||
└─ JS 로드 (순차)
|
||||
└─ 각 모듈의 initModule() 실행
|
||||
└─ ActionDispatcher.registerHandler()
|
||||
|
||||
3. 레이아웃 렌더링
|
||||
└─ 모듈 핸들러 사용 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 에셋 매니페스트 스키마
|
||||
|
||||
### module.json (통합 스키마)
|
||||
|
||||
모듈 루트에 `module.json` 파일을 생성합니다. 메타데이터와 에셋 설정이 하나의 파일에 통합되어 있습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"identifier": "sirsoft-ecommerce",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"description": {
|
||||
"ko": "상품 및 주문 관리를 위한 이커머스 모듈",
|
||||
"en": "E-commerce module for product and order management"
|
||||
},
|
||||
"g7_version": ">=1.0.0",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
},
|
||||
"github_url": null,
|
||||
"github_changelog_url": null,
|
||||
"assets": {
|
||||
"js": {
|
||||
"entry": "resources/js/index.ts",
|
||||
"output": "dist/js/module.iife.js"
|
||||
},
|
||||
"css": {
|
||||
"entry": "resources/css/main.css",
|
||||
"output": "dist/css/module.css"
|
||||
},
|
||||
"handlers": true,
|
||||
"static": "resources/assets/"
|
||||
},
|
||||
"loading": {
|
||||
"strategy": "global",
|
||||
"priority": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **참고**: `identifier`, `vendor`는 디렉토리명에서 자동 추론되므로 생략 가능합니다.
|
||||
> `name`, `version`, `description`은 AbstractModule에서 자동 파싱됩니다.
|
||||
|
||||
### 스키마 설명
|
||||
|
||||
#### 메타데이터 필드
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `identifier` | `string` | 선택 | 모듈 식별자 (디렉토리명에서 자동 추론) |
|
||||
| `vendor` | `string` | 선택 | 벤더명 (identifier에서 자동 추론) |
|
||||
| `name` | `string\|object` | 필수 | 모듈명 (다국어: `{"ko": "...", "en": "..."}`) |
|
||||
| `version` | `string` | 필수 | 시맨틱 버전 (예: `1.0.0`) |
|
||||
| `description` | `string\|object` | 필수 | 모듈 설명 (다국어 지원) |
|
||||
| `g7_version` | `string` | 선택 | 그누보드7 코어 버전 제약 (예: `>=1.0.0`) |
|
||||
| `dependencies` | `object` | 선택 | 모듈/플러그인 의존성 |
|
||||
| `github_url` | `string\|null` | 선택 | GitHub 저장소 URL (업데이트 감지용) |
|
||||
| `github_changelog_url` | `string\|null` | 선택 | GitHub 변경 이력 URL |
|
||||
|
||||
#### 에셋 필드
|
||||
|
||||
| 필드 | 설명 |
|
||||
|------|------|
|
||||
| `assets.js.entry` | JS 소스 엔트리 포인트 |
|
||||
| `assets.js.output` | 빌드된 JS 출력 경로 |
|
||||
| `assets.css.entry` | CSS 소스 엔트리 포인트 |
|
||||
| `assets.css.output` | 빌드된 CSS 출력 경로 |
|
||||
| `assets.handlers` | 핸들러 포함 여부 |
|
||||
| `assets.static` | 정적 에셋 소스 디렉토리 |
|
||||
| `loading.strategy` | 로딩 전략 (global, layout, lazy) |
|
||||
| `loading.priority` | 로드 우선순위 (낮을수록 먼저) |
|
||||
|
||||
---
|
||||
|
||||
## 모듈 프론트엔드 구조
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```
|
||||
modules/_bundled/sirsoft-ecommerce/
|
||||
├── module.json ← 에셋 매니페스트
|
||||
├── package.json ← npm 패키지 정의
|
||||
├── vite.config.ts ← Vite 빌드 설정
|
||||
├── tsconfig.json ← TypeScript 설정
|
||||
├── dist/ ← 빌드 출력 (git ignore)
|
||||
│ ├── js/module.iife.js
|
||||
│ ├── css/module.css
|
||||
│ └── assets/
|
||||
│ ├── fonts/
|
||||
│ └── images/
|
||||
├── resources/
|
||||
│ ├── js/ ← JS 소스
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── types.ts
|
||||
│ │ └── handlers/
|
||||
│ │ ├── index.ts
|
||||
│ │ └── updateProductField.ts
|
||||
│ ├── css/main.css ← CSS 소스
|
||||
│ └── assets/ ← 정적 에셋 소스
|
||||
│ ├── fonts/
|
||||
│ └── images/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "sirsoft-ecommerce",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### vite.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'resources/js/index.ts'),
|
||||
name: 'SirsoftEcommerce',
|
||||
fileName: 'module',
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: 'dist',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'js/[name].iife.js',
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name?.endsWith('.css')) {
|
||||
return 'css/[name][extname]';
|
||||
}
|
||||
return 'assets/[name][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
emptyOutDir: true,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'resources/js'),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핸들러 등록
|
||||
|
||||
### 모듈 엔트리 파일 (index.ts)
|
||||
|
||||
```typescript
|
||||
import '../css/main.css';
|
||||
import { handlerMap } from './handlers';
|
||||
|
||||
const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
|
||||
|
||||
export function initModule(): void {
|
||||
const registerHandlers = () => {
|
||||
const actionDispatcher = (window as any).G7Core?.getActionDispatcher?.();
|
||||
|
||||
if (actionDispatcher) {
|
||||
Object.entries(handlerMap).forEach(([name, handler]) => {
|
||||
const fullName = `${MODULE_IDENTIFIER}.${name}`;
|
||||
actionDispatcher.registerHandler(fullName, handler);
|
||||
});
|
||||
console.log(`[Module:${MODULE_IDENTIFIER}] Handlers registered`);
|
||||
} else {
|
||||
// ActionDispatcher 초기화 대기
|
||||
setTimeout(registerHandlers, 100);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
registerHandlers();
|
||||
} else {
|
||||
window.addEventListener('load', registerHandlers);
|
||||
}
|
||||
}
|
||||
|
||||
// IIFE 빌드 시 즉시 실행
|
||||
initModule();
|
||||
```
|
||||
|
||||
### 핸들러 정의
|
||||
|
||||
```typescript
|
||||
// handlers/updateProductField.ts
|
||||
import type { ActionContext } from '@/types';
|
||||
|
||||
interface UpdateProductFieldParams {
|
||||
productId: string | number;
|
||||
field: string;
|
||||
value: string | number | boolean;
|
||||
stateKey?: string;
|
||||
}
|
||||
|
||||
export function updateProductFieldHandler(
|
||||
params: UpdateProductFieldParams,
|
||||
context: ActionContext
|
||||
): void {
|
||||
const { productId, field, value, stateKey = 'products' } = params;
|
||||
|
||||
// setState를 통해 상태 업데이트
|
||||
if (context.setState) {
|
||||
context.setState({
|
||||
[stateKey]: {
|
||||
_modified: {
|
||||
[productId]: { [field]: value }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// handlers/index.ts
|
||||
import { updateProductFieldHandler } from './updateProductField';
|
||||
import { updateOptionFieldHandler } from './updateOptionField';
|
||||
import type { ActionHandler } from '@/types';
|
||||
|
||||
export const handlerMap: Record<string, ActionHandler> = {
|
||||
updateProductField: updateProductFieldHandler,
|
||||
updateOptionField: updateOptionFieldHandler,
|
||||
};
|
||||
```
|
||||
|
||||
### 레이아웃에서 핸들러 사용
|
||||
|
||||
```json
|
||||
{
|
||||
"component": "Input",
|
||||
"props": {
|
||||
"type": "number",
|
||||
"value": "{{row.stock_quantity}}"
|
||||
},
|
||||
"events": {
|
||||
"onBlur": {
|
||||
"handler": "sirsoft-ecommerce.updateProductField",
|
||||
"params": {
|
||||
"productId": "{{row.id}}",
|
||||
"field": "stock_quantity",
|
||||
"value": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빌드 및 배포
|
||||
|
||||
### Artisan 커맨드
|
||||
|
||||
```bash
|
||||
# _bundled에서 빌드 (기본값)
|
||||
php artisan module:build sirsoft-ecommerce
|
||||
|
||||
# 모든 _bundled 모듈 빌드
|
||||
php artisan module:build --all
|
||||
|
||||
# 프로덕션 빌드 (_bundled)
|
||||
php artisan module:build sirsoft-ecommerce --production
|
||||
|
||||
# 파일 감시 모드 (활성 디렉토리에서 자동 실행)
|
||||
php artisan module:build sirsoft-ecommerce --watch
|
||||
|
||||
# 활성 디렉토리에서 빌드
|
||||
php artisan module:build sirsoft-ecommerce --active
|
||||
|
||||
# 빌드 후 활성 디렉토리 반영
|
||||
php artisan module:update sirsoft-ecommerce
|
||||
```
|
||||
|
||||
### npm 스크립트
|
||||
|
||||
```bash
|
||||
# 모듈 디렉토리에서 직접 실행
|
||||
cd modules/_bundled/sirsoft-ecommerce
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# 개발 모드 (파일 감시)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 에셋 서빙 API
|
||||
|
||||
빌드된 에셋은 다음 API를 통해 서빙됩니다:
|
||||
|
||||
```
|
||||
GET /api/modules/assets/{identifier}/{path}
|
||||
|
||||
예시:
|
||||
/api/modules/assets/sirsoft-ecommerce/dist/js/module.iife.js
|
||||
/api/modules/assets/sirsoft-ecommerce/dist/css/module.css
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 에셋 로딩 전략
|
||||
|
||||
### global (기본값)
|
||||
|
||||
앱 초기화 시 자동으로 로드됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"loading": {
|
||||
"strategy": "global",
|
||||
"priority": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- TemplateApp.init()에서 자동 로드
|
||||
- 모든 페이지에서 핸들러 사용 가능
|
||||
- 우선순위(priority)가 낮을수록 먼저 로드
|
||||
|
||||
### layout (향후 지원)
|
||||
|
||||
특정 레이아웃에서만 로드됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"loading": {
|
||||
"strategy": "layout"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 레이아웃의 scripts 섹션에서 명시적 로드
|
||||
- 해당 레이아웃 진입 시 로드
|
||||
|
||||
### lazy (향후 지원)
|
||||
|
||||
필요할 때 동적으로 로드됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"loading": {
|
||||
"strategy": "lazy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 핸들러 호출 시점에 로드
|
||||
- 초기 로딩 시간 최적화
|
||||
|
||||
---
|
||||
|
||||
## 외부 라이브러리
|
||||
|
||||
외부 CDN 스크립트를 조건부로 로드할 수 있습니다.
|
||||
|
||||
### module.json 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"assets": {
|
||||
"external": [
|
||||
{
|
||||
"src": "https://cdn.example.com/chart.js",
|
||||
"id": "chartjs-cdn",
|
||||
"if": "{{_global.settings.useCharts}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 레이아웃 scripts 섹션
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": [
|
||||
{
|
||||
"src": "https://cdn.example.com/lib.js",
|
||||
"id": "external-lib",
|
||||
"if": "{{_global.modules['sirsoft-ecommerce'].useExternalLib}}",
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AbstractModule 에셋 메서드
|
||||
|
||||
AbstractModule은 에셋 관련 헬퍼 메서드를 제공합니다:
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `getAssets()` | module.json의 assets 섹션 반환 |
|
||||
| `getAssetLoadingConfig()` | loading 설정 반환 (strategy, priority) |
|
||||
| `hasAssets()` | 에셋 정의 존재 여부 |
|
||||
| `getBuiltAssetPaths()` | 빌드된 에셋 경로 반환 (js, css) |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [module-basics.md](./module-basics.md) - 모듈 개발 기초
|
||||
- [hooks.md](./hooks.md) - 훅 시스템 (핸들러와 연계)
|
||||
- [module-commands.md](./module-commands.md) - 모듈 Artisan 커맨드
|
||||
- [../frontend/components.md](../frontend/components.md) - 컴포넌트 핸들러 호출
|
||||
@@ -0,0 +1,629 @@
|
||||
# 모듈 개발 기초
|
||||
|
||||
> 이 문서는 G7의 모듈 개발 기초를 다룹니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 디렉토리: vendor-module (예: sirsoft-ecommerce)
|
||||
2. 네임스페이스: Modules\Vendor\Module\ (예: Modules\Sirsoft\Ecommerce\)
|
||||
3. AbstractModule 상속 권장 (ModuleInterface 직접 구현 가능)
|
||||
4. 필수: module.json (메타데이터 SSoT), Module.php, composer.json, routes/admin.json
|
||||
5. getName()/getVersion()/getDescription()은 module.json에서 자동 파싱 (하드코딩 불필요)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [모듈 네이밍 규칙](#모듈-네이밍-규칙)
|
||||
- [AbstractModule 상속 (권장)](#abstractmodule-상속-권장)
|
||||
- [ModuleInterface 직접 구현 (레거시)](#moduleinterface-직접-구현-레거시)
|
||||
- [composer.json 작성법](#composerjson-모듈)
|
||||
- [모듈 디렉토리 구조](#모듈-디렉토리-구조)
|
||||
- [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 모듈 네이밍 규칙
|
||||
|
||||
### 디렉토리명
|
||||
|
||||
`vendor-module` 형식 (GitHub 스타일)
|
||||
|
||||
- 소문자 사용
|
||||
- 하이픈(-): vendor와 module 구분
|
||||
- 언더스코어(_): 모듈명 내 단어 구분 (선택적)
|
||||
- 예: `sirsoft-ecommerce`, `sirsoft-order_management`, `johndoe-blog`
|
||||
|
||||
### 네임스페이스
|
||||
|
||||
`Modules\Vendor\Module\` 형식
|
||||
|
||||
- PascalCase 사용
|
||||
- 백슬래시(\) 구분
|
||||
- 예: `Modules\Sirsoft\Ecommerce\`, `Modules\Sirsoft\OrderManagement\`
|
||||
|
||||
### 디렉토리명 → 네임스페이스 변환 규칙
|
||||
|
||||
```text
|
||||
필수: 변환 규칙 이해
|
||||
|
||||
하이픈(-) → 네임스페이스 구분자(\)
|
||||
언더스코어(_) → PascalCase 결합
|
||||
|
||||
예시:
|
||||
sirsoft-ecommerce → Sirsoft\Ecommerce
|
||||
sirsoft-order_management → Sirsoft\OrderManagement
|
||||
vendor-my_module_name → Vendor\MyModuleName
|
||||
```
|
||||
|
||||
### Composer 패키지명
|
||||
|
||||
`modules/vendor-module`
|
||||
|
||||
- 예: `modules/sirsoft-ecommerce`, `modules/sirsoft-order_management`
|
||||
|
||||
### 네이밍 요약표
|
||||
|
||||
| 항목 | 형식 | 예시 |
|
||||
|------|------|------|
|
||||
| 디렉토리명 | `vendor-module` | `sirsoft-ecommerce`, `sirsoft-order_management` |
|
||||
| 네임스페이스 | `Modules\Vendor\Module\` | `Modules\Sirsoft\Ecommerce\`, `Modules\Sirsoft\OrderManagement\` |
|
||||
| Composer 패키지명 | `modules/vendor-module` | `modules/sirsoft-ecommerce`, `modules/sirsoft-order_management` |
|
||||
|
||||
### 식별자 검증 규칙
|
||||
|
||||
모듈/플러그인/템플릿 공통 식별자 검증 규칙은 [extension-manager.md](./extension-manager.md#식별자-검증-규칙-validextensionidentifier)를 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## AbstractModule 상속 (권장)
|
||||
|
||||
모듈 개발자는 `AbstractModule`을 상속받아 필수 메서드만 구현하면 됩니다.
|
||||
`getIdentifier()`와 `getVendor()`는 디렉토리명에서 자동으로 추론됩니다.
|
||||
|
||||
### manifest 자동 파싱 메서드 (오버라이드 불필요)
|
||||
|
||||
`module.json`에서 자동으로 값을 읽어오는 메서드입니다. 하드코딩 오버라이드가 불필요합니다.
|
||||
|
||||
| 메서드 | 반환 타입 | 소스 (module.json) | 기본값 |
|
||||
|--------|----------|-------------------|--------|
|
||||
| `getName()` | `string\|array` | `name` | identifier |
|
||||
| `getVersion()` | `string` | `version` | `'0.0.0'` |
|
||||
| `getDescription()` | `string\|array` | `description` | `''` |
|
||||
| `getRequiredCoreVersion()` | `?string` | `g7_version` | `null` |
|
||||
| `getLicense()` | `?string` | `license` | `null` |
|
||||
| `getGithubUrl()` | `?string` | `github_url` | `null` |
|
||||
| `getAssets()` | `array` | `assets` | `[]` |
|
||||
| `getAssetLoadingConfig()` | `array` | `loading` | strategy: global, priority: 100 |
|
||||
|
||||
> **`license` 필드**: `module.json`에 `"license": "MIT"` 등의 라이선스 정보를 포함합니다. 이 값은 API 리소스의 `license` 필드로 노출됩니다. 또한 각 모듈 루트에 `LICENSE` 파일을 포함하여 라이선스 전문을 제공해야 합니다.
|
||||
|
||||
### 자동 추론 메서드 (final - 오버라이드 불가)
|
||||
|
||||
| 메서드 | 반환 타입 | 설명 |
|
||||
|--------|----------|------|
|
||||
| `getIdentifier()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft-ecommerce`) |
|
||||
| `getVendor()` | `string` | 디렉토리명에서 자동 추론 (예: `sirsoft`) |
|
||||
|
||||
### 기본값 제공 메서드 (필요시 오버라이드)
|
||||
|
||||
| 메서드 | 기본값 | 설명 |
|
||||
|--------|--------|------|
|
||||
| `install()` | `true` | 설치 로직 |
|
||||
| `uninstall()` | `true` | 제거 로직 |
|
||||
| `activate()` | `true` | 활성화 로직 |
|
||||
| `deactivate()` | `true` | 비활성화 로직 |
|
||||
| `getDynamicTables()` | `[]` | 런타임 동적 생성 테이블 목록 (언인스톨 시 Manager가 삭제) |
|
||||
| `getRoutes()` | 자동 탐지 | `src/routes/api.php`, `src/routes/web.php` |
|
||||
| `getMigrations()` | 자동 탐지 | `database/migrations` 디렉토리 |
|
||||
| `getViews()` | `[]` | 뷰 파일 |
|
||||
| `getPermissions()` | `[]` | 권한 목록 (resource_route_key, owner_key, roles scope_type 지원) |
|
||||
| `getRoles()` | `[]` | 역할 목록 |
|
||||
| `getConfig()` | `[]` | 설정 |
|
||||
| `getAdminMenus()` | `[]` | 관리자 메뉴 |
|
||||
| `getHookListeners()` | `[]` | 훅 리스너 |
|
||||
| `getDependencies()` | `[]` | 의존성 |
|
||||
| `getMetadata()` | `[]` | 메타데이터 |
|
||||
| `upgrades()` | `[]` | 업그레이드 스텝 (`upgrades/` 디렉토리 자동 발견) |
|
||||
|
||||
### 간결한 모듈 구현 예시
|
||||
|
||||
> **참고**: `getName()`, `getVersion()`, `getDescription()`은 `module.json`에서 자동 파싱되므로 오버라이드가 불필요합니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce;
|
||||
|
||||
use App\Extension\AbstractModule;
|
||||
|
||||
class Module extends AbstractModule
|
||||
{
|
||||
// getName(), getVersion(), getDescription()은 module.json에서 자동 파싱
|
||||
// 별도 오버라이드 불필요
|
||||
|
||||
/**
|
||||
* 역할 목록 (필요시 오버라이드)
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'identifier' => 'sirsoft-ecommerce.manager',
|
||||
'name' => [
|
||||
'ko' => '이커머스 관리자',
|
||||
'en' => 'Ecommerce Manager',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '이커머스 모듈 관리 권한을 가진 역할',
|
||||
'en' => 'Role with ecommerce module management permissions',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 목록 (필요시 오버라이드)
|
||||
*
|
||||
* - resource_route_key: 라우트 파라미터명 (scope 체크용, 소유자 개념 없으면 생략)
|
||||
* - owner_key: 모델의 소유자 식별 컬럼명 (scope 체크용, 소유자 개념 없으면 생략)
|
||||
* - roles: 문자열 배열 또는 {role, scope_type} 객체 배열
|
||||
*/
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'identifier' => 'sirsoft-ecommerce.products.view',
|
||||
'name' => [
|
||||
'ko' => '상품 조회',
|
||||
'en' => 'View Products',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '상품 목록 및 상세 정보를 조회할 수 있습니다',
|
||||
'en' => 'Can view product list and details',
|
||||
],
|
||||
'resource_route_key' => 'product', // 라우트 파라미터명
|
||||
'owner_key' => 'created_by', // 소유자 컬럼
|
||||
// roles에 scope_type 지정 가능 (null=전체, 'self'=본인, 'role'=소유역할)
|
||||
'roles' => [
|
||||
['role' => 'admin', 'scope_type' => null],
|
||||
['role' => 'sirsoft-ecommerce.manager', 'scope_type' => 'role'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'identifier' => 'sirsoft-ecommerce.products.create',
|
||||
'name' => [
|
||||
'ko' => '상품 생성',
|
||||
'en' => 'Create Products',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '새로운 상품을 생성할 수 있습니다',
|
||||
'en' => 'Can create new products',
|
||||
],
|
||||
'resource_route_key' => 'product',
|
||||
'owner_key' => 'created_by',
|
||||
'roles' => ['admin'], // 문자열 배열도 허용 (scope_type=null 기본값)
|
||||
],
|
||||
[
|
||||
'identifier' => 'sirsoft-ecommerce.orders.view',
|
||||
'name' => [
|
||||
'ko' => '주문 조회',
|
||||
'en' => 'View Orders',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '주문 목록 및 상세 정보를 조회할 수 있습니다',
|
||||
'en' => 'Can view order list and details',
|
||||
],
|
||||
'resource_route_key' => 'order',
|
||||
'owner_key' => 'user_id',
|
||||
'roles' => ['admin', 'sirsoft-ecommerce.manager'],
|
||||
],
|
||||
[
|
||||
'identifier' => 'sirsoft-ecommerce.categories.view',
|
||||
'name' => [
|
||||
'ko' => '카테고리 조회',
|
||||
'en' => 'View Categories',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '카테고리 목록을 조회할 수 있습니다',
|
||||
'en' => 'Can view category list',
|
||||
],
|
||||
// resource_route_key/owner_key 생략 = 소유자 개념 없음 (scope 체크 스킵)
|
||||
'roles' => ['admin'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 메뉴 정의 (필요시 오버라이드)
|
||||
*/
|
||||
public function getAdminMenus(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => [
|
||||
'ko' => '이커머스',
|
||||
'en' => 'Ecommerce',
|
||||
],
|
||||
'slug' => 'ecommerce',
|
||||
'url' => '/admin/ecommerce',
|
||||
'icon' => 'fa-shopping-cart',
|
||||
'order' => 20,
|
||||
'children' => [
|
||||
[
|
||||
'name' => [
|
||||
'ko' => '상품 관리',
|
||||
'en' => 'Product Management',
|
||||
],
|
||||
'slug' => 'sirsoft-ecommerce-products',
|
||||
'url' => '/admin/ecommerce/products',
|
||||
'icon' => 'fa-box',
|
||||
'order' => 1,
|
||||
'permission' => 'sirsoft-ecommerce.products.view',
|
||||
],
|
||||
[
|
||||
'name' => [
|
||||
'ko' => '주문 관리',
|
||||
'en' => 'Order Management',
|
||||
],
|
||||
'slug' => 'sirsoft-ecommerce-orders',
|
||||
'url' => '/admin/ecommerce/orders',
|
||||
'icon' => 'fa-receipt',
|
||||
'order' => 2,
|
||||
'permission' => 'sirsoft-ecommerce.orders.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 리스너 목록 (필요시 오버라이드)
|
||||
*/
|
||||
public function getHookListeners(): array
|
||||
{
|
||||
return [
|
||||
\Modules\Sirsoft\Ecommerce\Listeners\ProductCacheInvalidationListener::class,
|
||||
\Modules\Sirsoft\Ecommerce\Listeners\OrderNotificationListener::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 메타데이터 (필요시 오버라이드)
|
||||
*/
|
||||
public function getMetadata(): array
|
||||
{
|
||||
return [
|
||||
'author' => 'sirsoft',
|
||||
'license' => 'MIT',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ModuleInterface 직접 구현 (레거시)
|
||||
|
||||
> **참고**: 특별한 경우가 아니라면 `AbstractModule` 상속을 권장합니다.
|
||||
|
||||
`ModuleInterface`를 직접 구현할 경우, 모든 메서드를 직접 구현해야 합니다:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce;
|
||||
|
||||
use App\Contracts\Extension\ModuleInterface;
|
||||
|
||||
class Module implements ModuleInterface
|
||||
{
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return 'sirsoft-ecommerce';
|
||||
}
|
||||
|
||||
public function getVendor(): string
|
||||
{
|
||||
return 'sirsoft';
|
||||
}
|
||||
|
||||
// ... 모든 메서드 직접 구현 필요
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## composer.json (모듈)
|
||||
|
||||
### 기본 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "E-commerce module for Gnuboard7 platform by sirsoft",
|
||||
"type": "library",
|
||||
"authors": [
|
||||
{
|
||||
"name": "sirsoft",
|
||||
"email": "contact@sirsoft.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Sirsoft\\Ecommerce\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 외부 패키지 의존성
|
||||
|
||||
모듈이 외부 Composer 패키지를 필요로 하는 경우, `require`에 추가합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"stripe/stripe-php": "^13.0",
|
||||
"intervention/image": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Sirsoft\\Ecommerce\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
주의: 루트 composer.json에 모듈 패키지 추가 금지
|
||||
모듈 설치 시 자동으로 composer install 실행 (Phase 4.5)
|
||||
modules/{identifier}/vendor/ 디렉토리에 독립 설치
|
||||
수동 설치: php artisan module:composer-install [identifier]
|
||||
```
|
||||
|
||||
- `php`와 `ext-*` 패키지는 외부 의존성으로 간주되지 않음 (composer install 트리거 안 함)
|
||||
- 설치된 패키지는 `vendor_autoloads`를 통해 런타임에 자동 로드됨
|
||||
- 상세: [extension-manager.md](extension-manager.md) "Composer 의존성 관리" 참조
|
||||
|
||||
### Factory/Seeder 사용 시 추가 설정
|
||||
|
||||
```
|
||||
필수: database/factories/ 또는 database/seeders/ 사용 시 autoload 등록
|
||||
등록하지 않으면 테스트에서 "Class not found" 오류 발생
|
||||
```
|
||||
|
||||
Factory 또는 Seeder를 사용하는 모듈은 **반드시** composer.json에 해당 경로를 등록해야 합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "modules/sirsoft-ecommerce",
|
||||
"description": "E-commerce module for Gnuboard7 platform by sirsoft",
|
||||
"type": "library",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Sirsoft\\Ecommerce\\": "src/",
|
||||
"Modules\\Sirsoft\\Ecommerce\\Database\\Seeders\\": "database/seeders/",
|
||||
"Modules\\Sirsoft\\Ecommerce\\Database\\Factories\\": "database/factories/"
|
||||
}
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### autoload 설정 후 갱신 (필수)
|
||||
|
||||
composer.json 수정 후 반드시 autoload 갱신:
|
||||
|
||||
```bash
|
||||
php artisan extension:update-autoload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모듈 디렉토리 구조
|
||||
|
||||
```
|
||||
modules/_bundled/sirsoft-ecommerce/
|
||||
├── module.json # 메타데이터 (이름, 버전, 설명 등 SSoT)
|
||||
├── module.php # ModuleInterface 구현
|
||||
├── LICENSE # 라이선스 전문 (MIT)
|
||||
├── composer.json # 오토로딩 + 외부 패키지 의존성 설정
|
||||
├── package.json # npm 패키지 정의 (에셋 모듈만)
|
||||
├── vite.config.ts # Vite 빌드 설정 (에셋 모듈만)
|
||||
├── tsconfig.json # TypeScript 설정 (에셋 모듈만)
|
||||
├── vendor/ # Composer 의존성 (자동 생성, gitignore 대상)
|
||||
├── dist/ # 프론트엔드 빌드 출력 (에셋 모듈만, gitignore 대상)
|
||||
│ ├── js/module.iife.js
|
||||
│ └── css/module.css
|
||||
├── upgrades/ # 버전 업그레이드 스텝 (UpgradeStepInterface 구현)
|
||||
│ └── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 로직
|
||||
├── config/
|
||||
│ └── ecommerce.php # 모듈 설정
|
||||
├── database/
|
||||
│ ├── factories/ # 테스트용 Factory
|
||||
│ ├── migrations/ # 마이그레이션
|
||||
│ └── seeders/ # 시더
|
||||
│ ├── DatabaseSeeder.php # 메인 시더 (설치 + 조건부 샘플)
|
||||
│ └── Sample/ # 샘플(개발용) 시더
|
||||
├── lang/ # 다국어 파일 (PHP 배열)
|
||||
│ ├── en/
|
||||
│ └── ko/
|
||||
├── resources/ # 리소스 파일
|
||||
│ ├── js/ # 프론트엔드 JS 소스 (에셋 모듈만)
|
||||
│ │ ├── index.ts # 엔트리 포인트
|
||||
│ │ └── handlers/ # 커스텀 핸들러
|
||||
│ ├── css/ # CSS 소스 (에셋 모듈만)
|
||||
│ │ └── main.css
|
||||
│ ├── lang/ # 프론트엔드 다국어 (JSON)
|
||||
│ │ ├── ko.json # 한국어 프론트엔드 다국어
|
||||
│ │ └── en.json # 영어 프론트엔드 다국어
|
||||
│ ├── layouts/ # 레이아웃 JSON
|
||||
│ │ ├── admin/ # admin 템플릿 레이아웃
|
||||
│ │ └── user/ # user 템플릿 레이아웃 (선택)
|
||||
│ ├── routes/ # 프론트엔드 라우트 정의
|
||||
│ │ ├── admin.json # admin 템플릿 전용 라우트
|
||||
│ │ └── user.json # user 템플릿 전용 라우트 (선택)
|
||||
│ └── views/ # 뷰 파일 (Blade 템플릿 등)
|
||||
├── src/
|
||||
│ ├── Contracts/ # 인터페이스
|
||||
│ ├── Http/ # HTTP 계층
|
||||
│ │ ├── Controllers/ # 컨트롤러
|
||||
│ │ │ └── Api/
|
||||
│ │ │ └── Admin/
|
||||
│ │ ├── Requests/ # FormRequest 클래스
|
||||
│ │ └── Resources/ # API 리소스
|
||||
│ ├── Listeners/ # 훅 리스너
|
||||
│ ├── Models/ # 모델
|
||||
│ ├── Providers/ # 프로바이더
|
||||
│ ├── Repositories/ # 리포지토리
|
||||
│ ├── Seo/ # SEO Sitemap 기여자, 캐시 무효화 리스너
|
||||
│ ├── routes/ # 라우트 (src 하위)
|
||||
│ │ ├── api.php
|
||||
│ │ └── web.php
|
||||
│ └── Services/ # 서비스
|
||||
└── tests/ # 테스트 파일
|
||||
├── Feature/ # 기능 테스트
|
||||
└── Unit/ # 단위 테스트
|
||||
```
|
||||
|
||||
### 디렉토리 설명
|
||||
|
||||
| 디렉토리 | 설명 |
|
||||
|----------|------|
|
||||
| `module.json` | 메타데이터 SSoT (이름, 버전, 설명, 의존성, 라이선스 등) |
|
||||
| `module.php` | ModuleInterface 구현 (진입점) |
|
||||
| `LICENSE` | 라이선스 전문 (MIT 등) — API 엔드포인트 `GET /api/admin/modules/{identifier}/license`로 제공 |
|
||||
| `composer.json` | PSR-4 오토로딩 + 외부 패키지 의존성 설정 |
|
||||
| `package.json` | npm 패키지 정의 (에셋 모듈만 해당) |
|
||||
| `vite.config.ts` | Vite IIFE 빌드 설정 (에셋 모듈만 해당) |
|
||||
| `vendor/` | Composer 의존성 디렉토리 (자동 생성, gitignore 대상) |
|
||||
| `dist/` | 프론트엔드 빌드 출력 (에셋 모듈만 해당, gitignore 대상) |
|
||||
| `config/` | 모듈별 설정 파일 |
|
||||
| `database/factories/` | 테스트용 Factory (autoload 등록 필수) |
|
||||
| `database/migrations/` | 데이터베이스 마이그레이션 |
|
||||
| `database/seeders/` | 데이터베이스 시더 (autoload 등록 필수). 설치 시더는 루트, 샘플 시더는 `Sample/` 하위 |
|
||||
| `lang/` | 백엔드 다국어 (PHP 배열) |
|
||||
| `resources/js/` | 프론트엔드 JS/TS 소스 — 핸들러 정의 등 (에셋 모듈만 해당) |
|
||||
| `resources/css/` | CSS 소스 (에셋 모듈만 해당) |
|
||||
| `resources/lang/` | 프론트엔드 다국어 (JSON) |
|
||||
| `resources/layouts/` | 레이아웃 JSON (admin/, user/ 하위) |
|
||||
| `resources/routes/admin.json` | admin 템플릿 전용 프론트엔드 라우트 |
|
||||
| `resources/routes/user.json` | user 템플릿 전용 프론트엔드 라우트 (선택) |
|
||||
| `src/Http/Controllers/` | 컨트롤러 |
|
||||
| `src/Http/Requests/` | FormRequest 클래스 |
|
||||
| `src/Http/Resources/` | API 리소스 |
|
||||
| `src/Listeners/` | 훅 리스너 |
|
||||
| `src/Models/` | Eloquent 모델 |
|
||||
| `src/Repositories/` | 리포지토리 |
|
||||
| `src/Seo/` | SEO Sitemap 기여자, 캐시 무효화 리스너 |
|
||||
| `src/Services/` | 비즈니스 로직 서비스 |
|
||||
| `src/routes/` | API/Web 라우트 |
|
||||
| `tests/` | 테스트 파일 |
|
||||
|
||||
---
|
||||
|
||||
## 번들 디렉토리 작업 규칙
|
||||
|
||||
```text
|
||||
필수: 모듈 수정/개발은 _bundled 디렉토리에서만 작업
|
||||
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용
|
||||
```
|
||||
|
||||
### 개발 워크플로우
|
||||
|
||||
```text
|
||||
1. modules/_bundled/{identifier}/ 에서 코드 수정
|
||||
2. _bundled에서 직접 테스트 실행으로 검증 (활성 디렉토리 복사 불필요)
|
||||
3. module.json 버전 올리기
|
||||
4. php artisan module:update {identifier} 로 활성 디렉토리에 프로덕션 반영
|
||||
```
|
||||
|
||||
### _bundled 직접 테스트 실행
|
||||
|
||||
_bundled에서 바로 PHPUnit/Vitest 테스트를 실행할 수 있습니다. `tests/bootstrap.php`가 _bundled 코드를 활성 디렉토리보다 우선 로드합니다.
|
||||
|
||||
```bash
|
||||
# 백엔드 테스트 (_bundled에서 직접 실행)
|
||||
php vendor/bin/phpunit modules/_bundled/{identifier}/tests
|
||||
php vendor/bin/phpunit --filter=TestName modules/_bundled/{identifier}/tests
|
||||
|
||||
# 프론트엔드 테스트
|
||||
cd modules/_bundled/{identifier}
|
||||
powershell -Command "npm run test:run"
|
||||
```
|
||||
|
||||
> 상세: [testing-guide.md](../testing-guide.md) "_bundled 확장 직접 테스트" 참조
|
||||
|
||||
### 왜 활성 디렉토리 직접 수정이 금지되는가?
|
||||
|
||||
- 활성 디렉토리는 `.gitignore` 대상 → Git에 변경 기록 불가
|
||||
- 다음 업데이트 시 `_bundled` 소스로 덮어쓰기 → 직접 수정 사항 유실
|
||||
- 업데이트 프로세스 미수행 시 마이그레이션/권한 동기화/레이아웃 갱신 누락
|
||||
|
||||
### 예외: 초기 개발 (아직 _bundled에 미등록)
|
||||
|
||||
```text
|
||||
✅ 허용: 신규 모듈 초기 개발 시 활성 디렉토리에서 직접 작업
|
||||
전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업
|
||||
```
|
||||
|
||||
> 상세: [extension-update-system.md](./extension-update-system.md) "번들 디렉토리 개발 워크플로우" 참조
|
||||
|
||||
---
|
||||
|
||||
## 코드 변경 시 버전/업그레이드 필수
|
||||
|
||||
```text
|
||||
필수: 모듈 코드를 변경한 경우 버전을 올리고 필요 시 업그레이드 스텝을 작성해야 합니다.
|
||||
버전 변경 없이 _bundled에 반영하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다.
|
||||
```
|
||||
|
||||
### 필수 작업
|
||||
|
||||
1. **`module.json` 버전 올리기**: `version` 필드를 Semantic Versioning에 따라 증가
|
||||
2. **`_bundled` 동기화**: `modules/_bundled/{identifier}/` 디렉토리에 변경 사항 반영
|
||||
3. **업그레이드 스텝 작성** (조건부): DB 스키마/환경설정 구조/데이터 마이그레이션이 필요한 경우
|
||||
|
||||
### 업그레이드 스텝 작성 기준
|
||||
|
||||
| 변경 유형 | 업그레이드 스텝 필요 | 비고 |
|
||||
|----------|-------------------|------|
|
||||
| DB 스키마 변경 | ✅ (+ 마이그레이션) | 컬럼/테이블 구조 변경 |
|
||||
| 환경설정 구조 변경 | ✅ (SettingsMigrator) | 설정 키 이름/구조 변경 |
|
||||
| 기존 데이터 변환 | ✅ | 데이터 형식 변환, 기본값 |
|
||||
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | Module.php에서 정의 |
|
||||
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | #135: 동적 메뉴/권한 보존 |
|
||||
| 레이아웃 JSON 변경 | ❌ (자동 갱신) | refresh-layout에서 처리 |
|
||||
| PHP 코드만 변경 | ❌ | 버전만 올리면 됨 |
|
||||
|
||||
> 상세: [extension-update-system.md](./extension-update-system.md) "개발자 버전 업데이트 가이드" 참조
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](./index.md) - 확장 시스템 개요
|
||||
- [hooks.md](./hooks.md) - 훅 시스템
|
||||
- [module-routing.md](./module-routing.md) - 모듈 라우트 규칙
|
||||
- [module-layouts.md](./module-layouts.md) - 모듈 레이아웃 시스템
|
||||
- [module-assets.md](./module-assets.md) - 모듈 프론트엔드 에셋 시스템
|
||||
- [module-commands.md](./module-commands.md) - 모듈 Artisan 커맨드
|
||||
- [module-i18n.md](./module-i18n.md) - 모듈 다국어
|
||||
- [활동 로그 시스템](../backend/activity-log.md) - 활동 로그 Listener, DescriptionResolver, Per-Item Bulk 로깅 규칙
|
||||
- [extension-update-system.md](./extension-update-system.md) - 확장 업데이트 시스템
|
||||
- [permissions.md](./permissions.md) - 권한 시스템
|
||||
- [menus.md](./menus.md) - 메뉴 시스템
|
||||
@@ -0,0 +1,548 @@
|
||||
# 모듈 Artisan 커맨드
|
||||
|
||||
> **관련 문서**: [index.md](index.md) | [module-basics.md](module-basics.md) | [module-routing.md](module-routing.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 목록: php artisan module:list
|
||||
2. 설치: php artisan module:install [identifier] (Composer 의존성 자동 설치 포함)
|
||||
3. 활성화: php artisan module:activate [identifier]
|
||||
4. 비활성화: php artisan module:deactivate [identifier]
|
||||
5. Composer: php artisan module:composer-install [identifier] (수동 의존성 설치)
|
||||
6. 삭제: php artisan module:uninstall [identifier] (--delete-data 시 vendor/ 삭제 포함)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [핵심 원칙](#핵심-원칙)
|
||||
- [사용 가능한 커맨드](#사용-가능한-커맨드)
|
||||
- [커맨드 구현 규칙](#커맨드-구현-규칙)
|
||||
- [커맨드 테스트](#커맨드-테스트)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
### 모듈 Artisan 커맨드 규칙
|
||||
|
||||
```
|
||||
필수: 모듈 관련 모든 작업은 Artisan 커맨드로 수행
|
||||
✅ 규칙: 직접 DB 조작 또는 파일 시스템 변경 금지
|
||||
```
|
||||
|
||||
모듈의 설치, 활성화, 비활성화, 삭제 등 모든 생명주기 관리는 반드시 Artisan 커맨드를 통해 수행해야 합니다. 이를 통해:
|
||||
- 일관된 상태 관리
|
||||
- 적절한 이벤트 발생
|
||||
- 권한/메뉴 등 연관 데이터 관리
|
||||
- 로그 기록
|
||||
|
||||
---
|
||||
|
||||
## 사용 가능한 커맨드
|
||||
|
||||
### 모듈 관리 커맨드
|
||||
|
||||
#### 모듈 목록 조회
|
||||
|
||||
```bash
|
||||
php artisan module:list
|
||||
```
|
||||
|
||||
**옵션**:
|
||||
- `--status=active`: 활성화된 모듈만 표시
|
||||
- `--status=inactive`: 비활성화된 모듈만 표시
|
||||
- `--status=uninstalled`: 미설치 모듈만 표시
|
||||
|
||||
**출력 예시**:
|
||||
```
|
||||
+--------------------+----------+---------+
|
||||
| Identifier | Version | Status |
|
||||
+--------------------+----------+---------+
|
||||
| sirsoft-ecommerce | 1.0.0 | active |
|
||||
| sirsoft-blog | 1.2.0 | inactive|
|
||||
| vendor-sample | 0.1.0 | - |
|
||||
+--------------------+----------+---------+
|
||||
```
|
||||
|
||||
#### 모듈 설치
|
||||
|
||||
```bash
|
||||
php artisan module:install [identifier]
|
||||
```
|
||||
|
||||
**수행 작업**:
|
||||
- `_pending/_bundled` 디렉토리에서 활성 디렉토리로 복사 (ExtensionPendingHelper)
|
||||
- `modules` 테이블에 모듈 등록
|
||||
- 역할(Role) 등록
|
||||
- 권한(Permission) 등록
|
||||
- 메뉴(Menu) 등록
|
||||
- 마이그레이션 실행 (있는 경우)
|
||||
- 시더 실행 (있는 경우)
|
||||
- Composer 의존성 설치 (외부 패키지가 있는 경우, Phase 4.5)
|
||||
|
||||
**예시**:
|
||||
```bash
|
||||
php artisan module:install sirsoft-ecommerce
|
||||
```
|
||||
|
||||
#### 모듈 활성화
|
||||
|
||||
```bash
|
||||
php artisan module:activate [identifier]
|
||||
```
|
||||
|
||||
**수행 작업**:
|
||||
- `modules.status`를 `active`로 변경
|
||||
- soft deleted된 레이아웃 복원
|
||||
- 모듈 레이아웃 등록 (admin 템플릿에)
|
||||
- 모듈의 라우트 활성화
|
||||
- 모듈의 훅 리스너 활성화
|
||||
- **템플릿 언어/routes 캐시 무효화**
|
||||
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
|
||||
|
||||
#### 모듈 비활성화
|
||||
|
||||
```bash
|
||||
php artisan module:deactivate [identifier]
|
||||
```
|
||||
|
||||
**수행 작업**:
|
||||
- `modules.status`를 `inactive`로 변경
|
||||
- 모듈 레이아웃 soft delete
|
||||
- 모듈의 라우트 비활성화
|
||||
- 모듈의 훅 리스너 비활성화
|
||||
- **템플릿 언어/routes 캐시 무효화**
|
||||
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
|
||||
|
||||
#### 모듈 삭제
|
||||
|
||||
```bash
|
||||
php artisan module:uninstall [identifier]
|
||||
```
|
||||
|
||||
**옵션**:
|
||||
- `--force`: 확인 프롬프트 없이 즉시 삭제
|
||||
- `--delete-data`: 데이터도 함께 삭제 (동적 테이블 + 마이그레이션 롤백 + 스토리지)
|
||||
|
||||
**수행 작업**:
|
||||
- 확인 프롬프트 표시 (--force 미사용 시)
|
||||
- `--delete-data` 사용 시 (트랜잭션 외부, DDL):
|
||||
- `getDynamicTables()` 반환 테이블 삭제 (Manager가 처리)
|
||||
- 마이그레이션 롤백 (정적 테이블 삭제)
|
||||
- 모듈 스토리지 디렉토리 삭제 (`storage/app/modules/{identifier}/`)
|
||||
- Composer vendor/ 디렉토리 및 composer.lock 삭제
|
||||
- 역할(Role) 삭제
|
||||
- 권한(Permission) 삭제
|
||||
- 메뉴(Menu) 삭제
|
||||
- 레이아웃(Layout) 영구 삭제
|
||||
- 활성 디렉토리 삭제 (`_bundled` 원본은 보존)
|
||||
- `modules` 테이블에서 완전 삭제 (hard delete)
|
||||
- **템플릿 언어/routes 캐시 무효화**
|
||||
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
|
||||
|
||||
**예시**:
|
||||
```bash
|
||||
# 확인 프롬프트 표시
|
||||
php artisan module:uninstall sirsoft-ecommerce
|
||||
|
||||
# 강제 삭제
|
||||
php artisan module:uninstall sirsoft-ecommerce --force
|
||||
```
|
||||
|
||||
#### 모듈 Composer 의존성 설치
|
||||
|
||||
```bash
|
||||
php artisan module:composer-install [identifier]
|
||||
```
|
||||
|
||||
**옵션**:
|
||||
|
||||
- `--all`: 모든 설치된 모듈의 Composer 의존성 설치
|
||||
- `--no-dev`: dev 의존성 제외
|
||||
|
||||
**예시**:
|
||||
|
||||
```bash
|
||||
# 특정 모듈 의존성 설치
|
||||
php artisan module:composer-install sirsoft-ecommerce
|
||||
|
||||
# 모든 모듈 의존성 설치
|
||||
php artisan module:composer-install --all
|
||||
|
||||
# dev 의존성 제외
|
||||
php artisan module:composer-install sirsoft-ecommerce --no-dev
|
||||
```
|
||||
|
||||
**수행 작업**:
|
||||
|
||||
- 모듈의 `composer.json`에 외부 패키지(`require`)가 있는지 확인
|
||||
- `modules/{identifier}/` 디렉토리에서 `composer install` 실행
|
||||
- `modules/{identifier}/vendor/` 디렉토리 생성
|
||||
- 오토로드 캐시 파일 갱신 (`vendor_autoloads` 반영)
|
||||
|
||||
**사용 시점**:
|
||||
|
||||
- 모듈 설치 시 자동 실행되지만, 수동으로 재설치가 필요한 경우
|
||||
- vendor/ 디렉토리가 손상되거나 삭제된 경우
|
||||
- 새로운 패키지를 composer.json에 추가한 후
|
||||
|
||||
#### 모듈 업데이트 확인
|
||||
|
||||
```bash
|
||||
php artisan module:check-updates [identifier?]
|
||||
```
|
||||
|
||||
**인수**:
|
||||
- `identifier` (선택): 특정 모듈만 확인
|
||||
|
||||
**기능**:
|
||||
|
||||
- **전체 확인** (인수 없음): 설치된 모든 모듈의 업데이트 가용 여부를 테이블로 출력
|
||||
- 감지 우선순위: GitHub > `_bundled` (2단계, `_pending` 미참여)
|
||||
- DB에 `update_available`, `latest_version`, `update_source` 갱신
|
||||
- **단일 확인** (인수 있음): 특정 모듈의 업데이트 가용 여부 확인
|
||||
- `checkModuleUpdate()`는 DB 갱신 안 함 → 커맨드에서 `updateByIdentifier()` 직접 호출
|
||||
|
||||
**예시**:
|
||||
```bash
|
||||
# 모든 모듈 업데이트 확인
|
||||
php artisan module:check-updates
|
||||
|
||||
# 특정 모듈만 확인
|
||||
php artisan module:check-updates sirsoft-ecommerce
|
||||
```
|
||||
|
||||
#### 모듈 업데이트
|
||||
|
||||
```bash
|
||||
php artisan module:update [identifier] [--force]
|
||||
```
|
||||
|
||||
**인수**:
|
||||
- `identifier` (필수): 업데이트할 모듈 식별자
|
||||
|
||||
**옵션**:
|
||||
- `--force`: 버전 비교 없이 강제 업데이트 (파일 손상, 수동 수정 복원 시 사용)
|
||||
|
||||
**수행 작업**:
|
||||
- 업데이트 가용 여부 확인 (없으면 info + SUCCESS, `--force` 시 무시하고 진행)
|
||||
- 확인 프롬프트 → 업데이트 실행
|
||||
- 상태 가드 확인 → 백업 → status: Updating → 파일 교체 → 마이그레이션 → 업그레이드 스텝 → 복원
|
||||
- 실패 시 백업 복원 안내
|
||||
|
||||
**예시**:
|
||||
```bash
|
||||
php artisan module:update sirsoft-ecommerce
|
||||
php artisan module:update sirsoft-ecommerce --force # 강제 재설치
|
||||
```
|
||||
|
||||
#### 모듈 캐시 초기화
|
||||
|
||||
```bash
|
||||
php artisan module:cache-clear
|
||||
```
|
||||
|
||||
**옵션**:
|
||||
- `[identifier]`: 특정 모듈만 캐시 삭제
|
||||
|
||||
**예시**:
|
||||
```bash
|
||||
# 모든 모듈 캐시 삭제
|
||||
php artisan module:cache-clear
|
||||
|
||||
# 특정 모듈 캐시만 삭제
|
||||
php artisan module:cache-clear sirsoft-ecommerce
|
||||
```
|
||||
|
||||
#### 모듈 프론트엔드 에셋 빌드
|
||||
|
||||
```bash
|
||||
php artisan module:build [identifier]
|
||||
```
|
||||
|
||||
> **기본 빌드 경로**: `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
|
||||
> 활성 디렉토리 반영은 `module:update` 커맨드로만 수행.
|
||||
|
||||
**옵션**:
|
||||
|
||||
- `--all`: 모든 모듈의 에셋 빌드 (_bundled 기준, --active 시 활성 기준)
|
||||
- `--watch`: 파일 변경 감시 모드 (자동으로 활성 디렉토리 사용)
|
||||
- `--production`: 프로덕션 최적화 빌드
|
||||
- `--active`: 활성 디렉토리에서 빌드 (_bundled 대신)
|
||||
|
||||
**예시**:
|
||||
```bash
|
||||
# _bundled에서 빌드 (기본값)
|
||||
php artisan module:build sirsoft-ecommerce
|
||||
|
||||
# 모든 _bundled 모듈 빌드
|
||||
php artisan module:build --all
|
||||
|
||||
# 프로덕션 빌드 (_bundled)
|
||||
php artisan module:build sirsoft-ecommerce --production
|
||||
|
||||
# 파일 감시 모드 (활성 디렉토리에서 자동 실행)
|
||||
php artisan module:build sirsoft-ecommerce --watch
|
||||
|
||||
# 활성 디렉토리에서 빌드
|
||||
php artisan module:build sirsoft-ecommerce --active
|
||||
|
||||
# 빌드 후 활성 디렉토리 반영
|
||||
php artisan module:update sirsoft-ecommerce
|
||||
```
|
||||
|
||||
**수행 작업**:
|
||||
|
||||
- 빌드 경로 결정: _bundled 우선 → 활성 폴백 (--active 시 활성만)
|
||||
- npm 의존성 설치 (node_modules 없는 경우)
|
||||
- Vite를 통한 IIFE 번들 빌드
|
||||
- `dist/js/module.iife.js` 및 `dist/css/module.css` 생성
|
||||
- 빌드 결과 파일 크기 출력
|
||||
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
|
||||
- 주의: `--watch` 모드에서는 캐시 버전이 증가하지 않음
|
||||
|
||||
**참고**: [module-assets.md](./module-assets.md) 문서에서 에셋 빌드 설정 상세 참조
|
||||
|
||||
#### 모듈 시더 실행
|
||||
|
||||
```bash
|
||||
php artisan module:seed [identifier]
|
||||
```
|
||||
|
||||
**옵션**:
|
||||
|
||||
- `--class=SeederClass`: 특정 시더 클래스만 실행
|
||||
- `--sample`: 샘플 데이터 시더도 함께 실행 (기본: 설치 시더만)
|
||||
- `--count=key=value`: 시더에 카운트 옵션 전달 (반복 가능)
|
||||
- `--force`: 프로덕션 환경에서 강제 실행
|
||||
|
||||
**예시**:
|
||||
|
||||
```bash
|
||||
# 단일 모듈 시더 실행 (설치 시더만)
|
||||
php artisan module:seed sirsoft-ecommerce
|
||||
|
||||
# 설치 + 샘플 시더 실행
|
||||
php artisan module:seed sirsoft-ecommerce --sample
|
||||
|
||||
# 모든 활성 모듈 시더 실행
|
||||
php artisan module:seed
|
||||
|
||||
# 특정 시더 클래스 실행
|
||||
php artisan module:seed sirsoft-ecommerce --class=Sample\\ProductSeeder
|
||||
|
||||
# 샘플 + 카운트 옵션
|
||||
php artisan module:seed sirsoft-ecommerce --sample --count=products=1000
|
||||
|
||||
# 프로덕션 환경에서 강제 실행
|
||||
php artisan module:seed sirsoft-ecommerce --force
|
||||
```
|
||||
|
||||
**수행 작업**:
|
||||
|
||||
- 활성화된 모듈만 실행 가능 (비활성 모듈은 에러)
|
||||
- `modules/{identifier}/database/seeders/DatabaseSeeder.php` 실행
|
||||
- 기본 실행 시 설치 필수 시더만 실행, `--sample` 옵션 시 Sample/ 하위 시더도 실행
|
||||
- `--class` 옵션 사용 시 해당 시더만 실행
|
||||
- `--sample` 옵션은 `HasSampleSeeders` 트레이트를 통해 시더에 전파
|
||||
|
||||
**주의사항**:
|
||||
|
||||
- 활성화되지 않은 모듈은 시더 실행 불가
|
||||
- 프로덕션 환경에서는 `--force` 옵션 필수
|
||||
- 샘플 시더는 `database/seeders/Sample/` 하위 디렉토리에 위치
|
||||
|
||||
---
|
||||
|
||||
## 커맨드 구현 규칙
|
||||
|
||||
### 1. 다국어 메시지 필수
|
||||
|
||||
```php
|
||||
// ✅ DO: 다국어 메시지 사용
|
||||
$this->info(__('modules.commands.install.success', ['module' => $identifier]));
|
||||
|
||||
// ❌ DON'T: 하드코딩 메시지
|
||||
$this->info('모듈 설치 성공');
|
||||
```
|
||||
|
||||
### 2. 로그 기록 필수
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
// 성공 로그
|
||||
Log::info(__('modules.commands.install.success', ['module' => $identifier]));
|
||||
|
||||
// 에러 로그
|
||||
Log::error('모듈 설치 실패', [
|
||||
'module' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
```
|
||||
|
||||
### 3. 예외 처리
|
||||
|
||||
```php
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
// 커맨드 로직
|
||||
|
||||
$this->info('✅ ' . __('modules.commands.install.success', ['module' => $identifier]));
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('❌ ' . $e->getMessage());
|
||||
Log::error('모듈 설치 실패', [
|
||||
'module' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 상세 정보 출력
|
||||
|
||||
```php
|
||||
// 모듈 정보 출력
|
||||
$this->info(' - ' . __('modules.commands.install.version', ['version' => $module->version]));
|
||||
$this->info(' - ' . __('modules.commands.install.permissions_created', ['count' => $permissionsCount]));
|
||||
$this->info(' - ' . __('modules.commands.install.menus_created', ['count' => $menusCount]));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 커맨드 테스트
|
||||
|
||||
### Feature 테스트 작성 필수
|
||||
|
||||
모든 커맨드에 대해 Feature 테스트를 작성해야 합니다.
|
||||
|
||||
#### 설치 테스트
|
||||
|
||||
```php
|
||||
public function test_can_install_module_via_command(): void
|
||||
{
|
||||
// Arrange
|
||||
$identifier = 'sirsoft-sample';
|
||||
|
||||
// Act
|
||||
$this->artisan('module:install', ['identifier' => $identifier])
|
||||
->assertSuccessful();
|
||||
|
||||
// Assert
|
||||
$this->assertDatabaseHas('modules', [
|
||||
'identifier' => $identifier,
|
||||
'status' => ExtensionStatus::Inactive->value,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
#### 삭제 확인 프롬프트 테스트
|
||||
|
||||
```php
|
||||
public function test_uninstall_requires_confirmation(): void
|
||||
{
|
||||
// Arrange
|
||||
$module = Module::factory()->create(['identifier' => 'test-module']);
|
||||
|
||||
// Act & Assert
|
||||
$this->artisan('module:uninstall', ['identifier' => 'test-module'])
|
||||
->expectsConfirmation(
|
||||
__('modules.commands.uninstall.confirm', ['module' => 'test-module']),
|
||||
'no'
|
||||
)
|
||||
->assertSuccessful();
|
||||
|
||||
// Module should still exist
|
||||
$this->assertDatabaseHas('modules', ['identifier' => 'test-module']);
|
||||
}
|
||||
```
|
||||
|
||||
#### 활성화/비활성화 테스트
|
||||
|
||||
```php
|
||||
public function test_can_activate_module(): void
|
||||
{
|
||||
// Arrange
|
||||
$module = Module::factory()->create([
|
||||
'identifier' => 'test-module',
|
||||
'status' => ExtensionStatus::Inactive,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$this->artisan('module:activate', ['identifier' => 'test-module'])
|
||||
->assertSuccessful();
|
||||
|
||||
// Assert
|
||||
$this->assertDatabaseHas('modules', [
|
||||
'identifier' => 'test-module',
|
||||
'status' => ExtensionStatus::Active->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_deactivate_module(): void
|
||||
{
|
||||
// Arrange
|
||||
$module = Module::factory()->create([
|
||||
'identifier' => 'test-module',
|
||||
'status' => ExtensionStatus::Active,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$this->artisan('module:deactivate', ['identifier' => 'test-module'])
|
||||
->assertSuccessful();
|
||||
|
||||
// Assert
|
||||
$this->assertDatabaseHas('modules', [
|
||||
'identifier' => 'test-module',
|
||||
'status' => ExtensionStatus::Inactive->value,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모듈 업데이트 (API + CLI)
|
||||
|
||||
모듈 업데이트는 **API 엔드포인트** 또는 **Artisan 커맨드**를 통해 수행됩니다.
|
||||
|
||||
### Artisan 커맨드
|
||||
|
||||
```bash
|
||||
# 업데이트 확인
|
||||
php artisan module:check-updates # 전체
|
||||
php artisan module:check-updates [identifier] # 단일
|
||||
|
||||
# 업데이트 실행
|
||||
php artisan module:update [identifier]
|
||||
```
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
```text
|
||||
POST /api/admin/modules/check-updates
|
||||
POST /api/admin/modules/{moduleName}/update
|
||||
```
|
||||
|
||||
- 감지 우선순위: GitHub > `_bundled` (2단계, `_pending` 미참여)
|
||||
- 상태 가드 확인 → 백업 → status: Updating → 파일 교체 → 마이그레이션 → 업그레이드 스텝 → 복원
|
||||
- 에러 발생 시 자동 롤백 (백업에서 복원)
|
||||
|
||||
> 상세: [extension-update-system.md](extension-update-system.md)
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [모듈 개발 기초](module-basics.md) - AbstractModule, 디렉토리 구조
|
||||
- [모듈 라우트 규칙](module-routing.md) - 라우트 네이밍, 자동 Prefix
|
||||
- [모듈 레이아웃 시스템](module-layouts.md) - 레이아웃 등록, 오버라이드
|
||||
- [권한 시스템](permissions.md) - 모듈 Role/Permission 자동 관리
|
||||
- [확장 업데이트 시스템](extension-update-system.md) - 업데이트 감지/실행 전체 흐름
|
||||
@@ -0,0 +1,336 @@
|
||||
# 모듈 다국어 시스템
|
||||
|
||||
> **관련 문서**: [index.md](index.md) | [module-basics.md](module-basics.md) | [module-routing.md](module-routing.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 백엔드: /lang/{locale}/*.php → __('vendor-module::key')
|
||||
2. 프론트엔드: /resources/lang/{locale}.json → $t:key
|
||||
3. JSON에 moduleIdentifier 포함 금지! (자동 병합됨)
|
||||
4. 지원 언어: ko, en 필수
|
||||
5. 키 충돌 방지: 모듈별 고유 prefix 사용 권장
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [핵심 원칙](#핵심-원칙)
|
||||
- [백엔드 다국어](#백엔드-다국어)
|
||||
- [프론트엔드 다국어](#프론트엔드-다국어)
|
||||
- [레이아웃 JSON에서 사용](#레이아웃-json에서-사용)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
### 다국어 파일 구조
|
||||
|
||||
```
|
||||
중요: 백엔드와 프론트엔드 다국어 파일 경로 구분
|
||||
✅ 필수: resources/lang/*.json 파일에 moduleIdentifier 없이 작성
|
||||
```
|
||||
|
||||
| 구분 | 파일 경로 | 형식 | 사용처 |
|
||||
|------|----------|------|--------|
|
||||
| 백엔드 | `/lang/{locale}/*.php` | PHP 배열 | Laravel `__()` 함수 |
|
||||
| 프론트엔드 | `/resources/lang/{locale}.json` | JSON | 레이아웃 JSON `$t:` 문법 |
|
||||
|
||||
### moduleIdentifier 규칙
|
||||
|
||||
```
|
||||
✅ 프론트엔드 다국어 파일에는 moduleIdentifier 없이 순수 키만 작성
|
||||
✅ 템플릿 서빙 시 자동으로 moduleIdentifier를 최상위 키로 병합
|
||||
❌ 내부 JSON 파일에 { "sirsoft-sample": { ... } } 형태로 작성 금지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 백엔드 다국어
|
||||
|
||||
### 파일 위치
|
||||
|
||||
```
|
||||
modules/sirsoft-sample/
|
||||
└── src/
|
||||
└── lang/
|
||||
├── ko/
|
||||
│ └── messages.php
|
||||
└── en/
|
||||
└── messages.php
|
||||
```
|
||||
|
||||
**중요**: 백엔드 다국어 파일은 반드시 `src/lang/` 디렉토리에 위치해야 합니다. TranslationServiceProvider가 이 경로에서 파일을 자동으로 로드합니다.
|
||||
|
||||
### 파일 형식 (PHP 배열)
|
||||
|
||||
```php
|
||||
// src/lang/ko/messages.php
|
||||
return [
|
||||
'product_created' => '상품이 생성되었습니다.',
|
||||
'order_confirmed' => '주문이 확인되었습니다.',
|
||||
'validation' => [
|
||||
'name_required' => '상품명은 필수 항목입니다.',
|
||||
'price_min' => '가격은 0 이상이어야 합니다.',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
```php
|
||||
// lang/en/messages.php
|
||||
return [
|
||||
'product_created' => 'Product has been created.',
|
||||
'order_confirmed' => 'Order has been confirmed.',
|
||||
'validation' => [
|
||||
'name_required' => 'Product name is required.',
|
||||
'price_min' => 'Price must be at least 0.',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### 사용법
|
||||
|
||||
Laravel의 `__()` 함수를 사용합니다. **모듈 다국어는 더블 콜론(::) 문법을 사용합니다**:
|
||||
|
||||
```php
|
||||
// 모듈 네임스페이스 사용 (더블 콜론 ::)
|
||||
__('sirsoft-sample::messages.product_created');
|
||||
// 결과: '상품이 생성되었습니다.' (ko 로케일)
|
||||
|
||||
// 중첩 키
|
||||
__('sirsoft-sample::messages.validation.name_required');
|
||||
// 결과: '상품명은 필수 항목입니다.'
|
||||
|
||||
// 파라미터 치환
|
||||
__('sirsoft-sample::messages.greeting', ['name' => '홍길동']);
|
||||
// messages.php: 'greeting' => ':name님, 환영합니다.'
|
||||
// 결과: '홍길동님, 환영합니다.'
|
||||
|
||||
// ❌ 잘못된 사용 (점 . 사용)
|
||||
__('sirsoft-sample.messages.product_created'); // 작동하지 않음!
|
||||
|
||||
// ✅ 올바른 사용 (더블 콜론 :: 사용)
|
||||
__('sirsoft-sample::messages.product_created'); // 정상 작동
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 다국어
|
||||
|
||||
### 파일 위치
|
||||
|
||||
```
|
||||
modules/sirsoft-sample/
|
||||
└── resources/
|
||||
└── lang/
|
||||
├── ko.json
|
||||
└── en.json
|
||||
```
|
||||
|
||||
### 파일 형식 (JSON)
|
||||
|
||||
```json
|
||||
// resources/lang/ko.json
|
||||
{
|
||||
"admin": {
|
||||
"index": {
|
||||
"title": "샘플 항목 관리",
|
||||
"description": "샘플 모듈의 항목을 관리합니다"
|
||||
},
|
||||
"create": {
|
||||
"title": "새 항목 생성",
|
||||
"submit": "생성하기"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"save_success": "저장되었습니다.",
|
||||
"delete_confirm": "정말 삭제하시겠습니까?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// resources/lang/en.json
|
||||
{
|
||||
"admin": {
|
||||
"index": {
|
||||
"title": "Sample Item Management",
|
||||
"description": "Manage sample module items"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create New Item",
|
||||
"submit": "Create"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"save_success": "Saved successfully.",
|
||||
"delete_confirm": "Are you sure you want to delete?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### moduleIdentifier 자동 병합
|
||||
|
||||
템플릿 서빙 시 시스템이 자동으로 moduleIdentifier를 최상위 키로 병합합니다:
|
||||
|
||||
```json
|
||||
// 원본 파일 (resources/lang/ko.json)
|
||||
{
|
||||
"admin": {
|
||||
"index": {
|
||||
"title": "샘플 항목 관리"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서빙 후 (자동 변환)
|
||||
{
|
||||
"sirsoft-sample": {
|
||||
"admin": {
|
||||
"index": {
|
||||
"title": "샘플 항목 관리"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $partial Fragment 시스템
|
||||
|
||||
### 개요
|
||||
|
||||
프론트엔드 다국어 JSON 파일이 커지면 `$partial` 디렉티브를 사용하여 도메인별로 분리할 수 있습니다.
|
||||
`ResolvesLanguageFragments` trait이 JSON 로드 시 fragment를 자동으로 병합합니다.
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```
|
||||
modules/_bundled/vendor-module/
|
||||
└── resources/lang/
|
||||
├── ko.json ← 메인 JSON (fragment 참조 포함)
|
||||
├── en.json
|
||||
└── partial/ ← fragment 파일 디렉토리
|
||||
├── ko/
|
||||
│ ├── common.json
|
||||
│ └── admin/
|
||||
│ ├── locale.json
|
||||
│ ├── products.json
|
||||
│ └── orders.json
|
||||
└── en/
|
||||
├── common.json
|
||||
└── admin/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### $partial 디렉티브 문법
|
||||
|
||||
메인 JSON 파일에서 `$partial` 키로 fragment 파일을 참조합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"$partial": "partial/ko/common.json"
|
||||
},
|
||||
"admin": {
|
||||
"locale": {
|
||||
"$partial": "partial/ko/admin/locale.json"
|
||||
},
|
||||
"products": {
|
||||
"$partial": "partial/ko/admin/products.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `$partial` 값은 **`resources/lang/` 기준 상대 경로**
|
||||
- `$partial`이 포함된 객체는 fragment 파일의 내용으로 **완전 교체**됨
|
||||
- fragment 파일 내부에서 다시 `$partial`을 사용한 **중첩 참조 가능** (최대 깊이 제한 적용)
|
||||
|
||||
### Fragment 해석 규칙
|
||||
|
||||
| 규칙 | 설명 |
|
||||
| ---- | ---- |
|
||||
| 최대 깊이 | `MAX_FRAGMENT_DEPTH = 10` (초과 시 해석 중단) |
|
||||
| 순환 참조 | `fragmentStack`으로 감지, 순환 시 해당 fragment 무시 |
|
||||
| 파일 미존재 | 해당 `$partial` 객체가 빈 객체로 대체 |
|
||||
| 재귀 해석 | fragment 내 `$partial`도 재귀적으로 해석 |
|
||||
|
||||
### 사용 기준
|
||||
|
||||
| 상황 | 권장 |
|
||||
| ---- | ---- |
|
||||
| JSON 파일 500줄 이하 | 단일 파일 유지 |
|
||||
| JSON 파일 500줄 초과 | 도메인별 `$partial` 분리 |
|
||||
| admin/user 섹션 분리 필요 | `partial/{locale}/admin/`, `partial/{locale}/user/` |
|
||||
|
||||
> **참고**: `$partial`은 프론트엔드 JSON(`resources/lang/`)에서만 사용됩니다.
|
||||
> 백엔드 PHP(`src/lang/`)에서는 사용할 수 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 JSON에서 사용
|
||||
|
||||
### 기본 문법
|
||||
|
||||
레이아웃 JSON에서 `$t:` 접두사를 사용하여 다국어 키를 참조합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "page-title",
|
||||
"type": "basic",
|
||||
"name": "H1",
|
||||
"props": {
|
||||
"text": "$t:sirsoft-sample.admin.index.title"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 키 구조
|
||||
|
||||
```
|
||||
$t:[moduleIdentifier].[섹션].[하위섹션].[키]
|
||||
|
||||
예시:
|
||||
$t:sirsoft-sample.admin.index.title
|
||||
$t:sirsoft-sample.messages.save_success
|
||||
```
|
||||
|
||||
### 다양한 사용 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "page-header",
|
||||
"type": "composite",
|
||||
"name": "PageHeader",
|
||||
"props": {
|
||||
"title": "$t:sirsoft-sample.admin.index.title",
|
||||
"description": "$t:sirsoft-sample.admin.index.description"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "submit-button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"text": "$t:sirsoft-sample.admin.create.submit",
|
||||
"variant": "primary"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [모듈 개발 기초](module-basics.md) - AbstractModule, 디렉토리 구조
|
||||
- [모듈 라우트 규칙](module-routing.md) - 라우트 네이밍, 자동 Prefix
|
||||
- [모듈 레이아웃 시스템](module-layouts.md) - 레이아웃 등록, 오버라이드
|
||||
- [데이터 바인딩](../frontend/data-binding.md) - $t: 문법 상세
|
||||
@@ -0,0 +1,667 @@
|
||||
# 모듈 레이아웃 시스템
|
||||
|
||||
> 이 문서는 그누보드7 모듈의 레이아웃 시스템에 대해 설명합니다. 레이아웃 등록, 오버라이드, 상속 처리를 다룹니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 위치: modules/_bundled/vendor-module/resources/layouts/admin/*.json 또는 user/*.json
|
||||
2. 네이밍: vendor-module.layout-name 형식 (DOT 구분자)
|
||||
3. admin/ 하위 → Admin 템플릿, user/ 하위 → User 템플릿에 등록
|
||||
4. 활성화 시 자동 등록, 비활성화 시 자동 삭제
|
||||
5. 오버라이드: 템플릿 layouts/overrides/vendor-module/ + version_constraint 지원
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [핵심 원칙](#핵심-원칙)
|
||||
3. [레이아웃 파일 위치](#레이아웃-파일-위치)
|
||||
4. [레이아웃 네이밍 규칙](#레이아웃-네이밍-규칙)
|
||||
5. [레이아웃 등록 메커니즘](#레이아웃-등록-메커니즘)
|
||||
6. [모듈 활성화/비활성화 흐름](#모듈-활성화비활성화-흐름)
|
||||
7. [레이아웃 오버라이드 시스템](#레이아웃-오버라이드-시스템)
|
||||
8. [레이아웃 상속 처리](#레이아웃-상속-처리)
|
||||
9. [DB 저장 규칙](#db-저장-규칙)
|
||||
10. [캐시 전략](#캐시-전략)
|
||||
11. [개발 가이드](#모듈-레이아웃-개발-가이드)
|
||||
12. [문제 해결](#문제-해결)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
모듈은 자체 레이아웃 파일을 제공하여 관리자/사용자 페이지 UI를 구성할 수 있습니다. 모듈 레이아웃은 디렉토리 위치에 따라 활성화된 Admin 또는 User 템플릿에 자동으로 등록되며, 템플릿 오버라이드 시스템을 통해 커스터마이징이 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```text
|
||||
필수: ModuleManager와 TemplateManager의 일관성 유지
|
||||
필수: admin/ 하위 레이아웃 → Admin 템플릿에만 등록
|
||||
필수: user/ 하위 레이아웃 → User 템플릿에만 등록
|
||||
경고: layouts/ 루트에 직접 배치된 파일은 스킵됨 (admin/ 또는 user/ 하위 필수)
|
||||
✅ 레이아웃 content는 layoutData 전체를 저장
|
||||
✅ 모듈 비활성화 시 레이아웃 soft delete
|
||||
✅ 모듈 재활성화 시 레이아웃 복원
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 파일 위치
|
||||
|
||||
모듈 레이아웃은 `resources/layouts/` 디렉토리에 위치합니다. 디렉토리 위치에 따라 등록 대상 템플릿이 결정됩니다.
|
||||
|
||||
```
|
||||
modules/
|
||||
└── _bundled/
|
||||
└── vendor-module/
|
||||
└── resources/
|
||||
└── layouts/
|
||||
├── admin/ ← Admin 템플릿에 등록
|
||||
│ ├── index.json
|
||||
│ └── edit.json
|
||||
└── user/ ← User 템플릿에 등록
|
||||
├── main.json
|
||||
└── detail.json
|
||||
```
|
||||
|
||||
> **주의**: `layouts/` 루트에 직접 배치된 파일(예: `layouts/settings.json`)은 등록되지 않고 경고 로그가 출력됩니다. 반드시 `admin/` 또는 `user/` 하위에 배치하세요.
|
||||
|
||||
### 등록 대상 템플릿 결정
|
||||
|
||||
| 파일 위치 | 등록 대상 | 예시 |
|
||||
| ----------------------- | ------------------- | ------------------- |
|
||||
| `layouts/admin/*.json` | 활성 Admin 템플릿 | sirsoft-admin_basic |
|
||||
| `layouts/user/*.json` | 활성 User 템플릿 | sirsoft-user_theme1 |
|
||||
| `layouts/*.json` (루트) | 등록 안됨 (스킵) | 경고 로그 출력 |
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 네이밍 규칙
|
||||
|
||||
### 자동 생성 패턴
|
||||
|
||||
```
|
||||
{module-identifier}.{layout_path}
|
||||
```
|
||||
|
||||
모듈 레이아웃은 DB에 등록될 때 `moduleIdentifier`가 점(`.`) 구분자로 접두사에 추가됩니다.
|
||||
|
||||
### 예시
|
||||
|
||||
```
|
||||
파일: modules/_bundled/sirsoft-sample/resources/layouts/admin/index.json
|
||||
파일 내 layout_name: admin_sample_index
|
||||
DB 등록명: sirsoft-sample.admin_sample_index
|
||||
|
||||
파일: modules/_bundled/sirsoft-sample/resources/layouts/admin/edit.json
|
||||
파일 내 layout_name: admin_sample_edit
|
||||
DB 등록명: sirsoft-sample.admin_sample_edit
|
||||
|
||||
파일: modules/_bundled/sirsoft-sample/resources/layouts/user/main.json
|
||||
파일 내 layout_name: user_sample_main
|
||||
DB 등록명: sirsoft-sample.user_sample_main
|
||||
```
|
||||
|
||||
### 중요 사항
|
||||
|
||||
- 레이아웃 파일 내에서는 `layout_name`을 **접두사 없이** 작성합니다
|
||||
- 시스템이 자동으로 `{moduleIdentifier}.{layout_name}` 형식으로 변환합니다
|
||||
- 점(`.`) 구분자는 moduleIdentifier와 layoutName의 경계를 명확히 구분합니다
|
||||
|
||||
### 파일 내 layout_name 지정 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"layout_name": "admin_sample_index",
|
||||
"version": "1.0.0",
|
||||
"extends": "_admin_base",
|
||||
"meta": {
|
||||
"title": "$t:sirsoft-sample.admin.index.title"
|
||||
},
|
||||
"slots": {
|
||||
"content": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
이 레이아웃은 DB에 `sirsoft-sample.admin_sample_index`로 등록됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 등록 메커니즘
|
||||
|
||||
### ModuleManager.registerLayoutToTemplate()
|
||||
|
||||
```php
|
||||
protected function registerLayoutToTemplate(
|
||||
Template $template,
|
||||
string $layoutName,
|
||||
array $layoutData,
|
||||
string $moduleIdentifier
|
||||
): void
|
||||
{
|
||||
// TemplateManager와 동일하게 layoutData 전체 저장
|
||||
$content = $layoutData;
|
||||
|
||||
TemplateLayout::updateOrCreate(
|
||||
[
|
||||
'template_id' => $template->id,
|
||||
'name' => $layoutName,
|
||||
],
|
||||
[
|
||||
'content' => $content, // extends, layout_name 포함
|
||||
'extends' => $layoutData['extends'] ?? null,
|
||||
'source_type' => LayoutSourceType::Module,
|
||||
'source_identifier' => $moduleIdentifier,
|
||||
'created_by' => Auth::id(),
|
||||
'updated_by' => Auth::id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**중요**: `content`에는 `layoutData` 전체가 저장되므로 `extends`, `layout_name`, `version` 등 모든 필드가 포함됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 모듈 활성화/비활성화 흐름
|
||||
|
||||
### 1. 모듈 활성화 (activateModule)
|
||||
|
||||
```php
|
||||
public function activateModule(string $moduleName): array
|
||||
{
|
||||
// 1. 모듈 activate() 실행
|
||||
$result = $module->activate();
|
||||
|
||||
if ($result) {
|
||||
// 2. 모듈 상태를 Active로 변경
|
||||
Module::where('identifier', $module->getIdentifier())
|
||||
->update(['status' => ExtensionStatus::Active->value]);
|
||||
|
||||
// 3. soft deleted된 레이아웃 복원 (재활성화 시)
|
||||
$this->restoreModuleLayouts($module->getIdentifier());
|
||||
|
||||
// 4. 레이아웃 등록 (새로운 레이아웃 또는 업데이트)
|
||||
$layoutsRegistered = $this->registerModuleLayouts($moduleName);
|
||||
|
||||
// 5. 캐시 무효화
|
||||
$this->invalidateLayoutCache($module->getIdentifier());
|
||||
}
|
||||
|
||||
return ['success' => $result, 'layouts_registered' => $layoutsRegistered];
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 모듈 비활성화 (deactivateModule)
|
||||
|
||||
```php
|
||||
public function deactivateModule(string $moduleName): array
|
||||
{
|
||||
// 1. 모듈 deactivate() 실행
|
||||
$result = $module->deactivate();
|
||||
|
||||
if ($result) {
|
||||
// 2. 모듈 상태를 Inactive로 변경
|
||||
Module::where('identifier', $module->getIdentifier())
|
||||
->update(['status' => ExtensionStatus::Inactive->value]);
|
||||
|
||||
// 3. 모듈 레이아웃 soft delete
|
||||
$layoutsDeleted = $this->softDeleteModuleLayouts($module->getIdentifier());
|
||||
|
||||
// 4. 캐시 무효화
|
||||
$this->invalidateLayoutCache($module->getIdentifier());
|
||||
}
|
||||
|
||||
return ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 템플릿 활성화 시 레이아웃 등록 (activateTemplate)
|
||||
|
||||
새 템플릿이 활성화되면 `TemplateManager::activateTemplate()`이 기존 활성 모듈/플러그인의 레이아웃을 새 템플릿에 자동으로 등록합니다.
|
||||
|
||||
```php
|
||||
// TemplateManager::activateTemplate() 내부 (트랜잭션)
|
||||
DB::transaction(function () use ($templateName, $template, $templateRecord) {
|
||||
// 1. 같은 타입의 기존 활성 템플릿 비활성화
|
||||
$this->deactivateTemplatesByType($template['type']);
|
||||
|
||||
// 2. 상태 업데이트
|
||||
$this->templateRepository->updateByIdentifier($templateName, [...]);
|
||||
|
||||
// 3. 레이아웃 확장(layout_extensions) 등록
|
||||
$this->layoutExtensionService->registerAllActiveExtensionsToTemplate($templateRecord->id);
|
||||
|
||||
// 4. 활성 모듈/플러그인의 레이아웃을 새 템플릿에 등록
|
||||
app(ModuleManager::class)->registerLayoutsForAllActiveModules();
|
||||
app(PluginManager::class)->registerLayoutsForAllActivePlugins();
|
||||
|
||||
// 5. 캐시 워밍
|
||||
$this->warmTemplateCache($templateName);
|
||||
});
|
||||
```
|
||||
|
||||
**중요**: `registerModuleLayouts()`는 `updateOrCreate` 패턴을 사용하므로 중복 호출에 안전합니다.
|
||||
|
||||
### 4. Soft Delete/Restore 이유
|
||||
|
||||
| 작업 | 설명 |
|
||||
|------|------|
|
||||
| **Soft Delete** | 모듈 재활성화 시 레이아웃 복원을 위해 |
|
||||
| **Hard Delete** | 모듈 제거(uninstall) 시에만 사용 |
|
||||
|
||||
**장점**: 모듈 설정 및 오버라이드 데이터 보존
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 오버라이드 시스템
|
||||
|
||||
### 우선순위 (LayoutResolverService)
|
||||
|
||||
```
|
||||
1. 템플릿 오버라이드 (source_type = 'template')
|
||||
2. 모듈 기본 레이아웃 (source_type = 'module')
|
||||
```
|
||||
|
||||
### 오버라이드 생성 예시
|
||||
|
||||
```php
|
||||
// 템플릿에서 모듈 레이아웃 오버라이드
|
||||
TemplateLayout::create([
|
||||
'template_id' => $templateId,
|
||||
'name' => 'sirsoft-sample.admin_sample_index', // 모듈 레이아웃명과 동일
|
||||
'content' => $customizedLayoutData, // 커스터마이징된 데이터
|
||||
'extends' => '_admin_base',
|
||||
'source_type' => LayoutSourceType::Template, // 오버라이드 표시
|
||||
'source_identifier' => 'sirsoft-admin_basic',
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
```
|
||||
|
||||
### 오버라이드 해석 로직
|
||||
|
||||
```php
|
||||
// LayoutService.resolveLayout()
|
||||
if ($this->isModuleLayoutName($layoutName)) {
|
||||
// LayoutResolverService를 통해 우선순위 적용
|
||||
$resolved = $this->layoutResolverService->resolve($layoutName, $templateId);
|
||||
|
||||
// 오버라이드가 있으면 오버라이드 반환, 없으면 모듈 기본 반환
|
||||
return $resolved;
|
||||
}
|
||||
```
|
||||
|
||||
### 오버라이드 버전 호환성 (version_constraint)
|
||||
|
||||
템플릿에서 모듈/플러그인의 레이아웃을 오버라이드할 때, 대상 모듈/플러그인의 버전이 변경되면 오버라이드가 호환되지 않을 수 있습니다. `version_constraint` 필드를 사용하여 오버라이드가 적용될 버전 범위를 지정할 수 있습니다.
|
||||
|
||||
#### 사용 목적
|
||||
|
||||
- 모듈/플러그인 메이저 버전 변경 시 오버라이드 호환성 문제 방지
|
||||
- 비호환 오버라이드 자동 스킵 및 관리자 경고 표시
|
||||
- 템플릿 개발자가 지원하는 버전 범위 명시
|
||||
|
||||
#### JSON 스키마
|
||||
|
||||
```json
|
||||
{
|
||||
"layout_name": "admin_sample_index",
|
||||
"version_constraint": "^1.0",
|
||||
"injections": [...]
|
||||
}
|
||||
```
|
||||
|
||||
#### 지원 문법 (Composer Semver)
|
||||
|
||||
| 문법 | 의미 | 예시 |
|
||||
| ---- | ---- | ---- |
|
||||
| `^1.0` | 1.0.0 이상, 2.0.0 미만 | 1.0.0, 1.5.0, 1.9.9 ✅ / 2.0.0 ❌ |
|
||||
| `~1.5` | 1.5.0 이상, 2.0.0 미만 | 1.5.0, 1.9.0 ✅ / 2.0.0 ❌ |
|
||||
| `~1.5.0` | 1.5.0 이상, 1.6.0 미만 | 1.5.0, 1.5.9 ✅ / 1.6.0 ❌ |
|
||||
| `>=1.0 <2.0` | 명시적 범위 | 1.0.0, 1.9.9 ✅ / 0.9.0, 2.0.0 ❌ |
|
||||
| `1.0.0 - 1.9.9` | 하이픈 범위 | 1.0.0 ~ 1.9.9 ✅ |
|
||||
| `1.0.0 \|\| 2.0.0` | OR 조건 | 1.0.0, 2.0.0 ✅ / 1.5.0 ❌ |
|
||||
|
||||
#### 동작 방식
|
||||
|
||||
1. **version_constraint 없음**: 항상 오버라이드 적용 (하위 호환성 유지)
|
||||
2. **버전 제약 만족**: 오버라이드 정상 적용
|
||||
3. **버전 제약 불만족**: 오버라이드 스킵 + 관리자 경고 표시
|
||||
4. **대상 버전 정보 없음**: 오버라이드 적용 (경고 로그)
|
||||
|
||||
#### 관리자 경고 표시
|
||||
|
||||
비호환 오버라이드가 감지되면 레이아웃 상단에 Alert 컴포넌트가 자동 주입됩니다:
|
||||
|
||||
```
|
||||
호환성 경고
|
||||
오버라이드 "sirsoft-admin_basic"가 모듈 버전과 호환되지 않습니다 (요구: ^1.0, 현재: 2.0.0)
|
||||
```
|
||||
|
||||
#### 사용 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"target_layout": "sirsoft-board.admin_board_settings",
|
||||
"version_constraint": "^1.0",
|
||||
"injections": [
|
||||
{
|
||||
"target_id": "notification_section",
|
||||
"position": "after",
|
||||
"components": [
|
||||
{
|
||||
"id": "custom_notification",
|
||||
"name": "Card",
|
||||
"type": "composite",
|
||||
"props": {
|
||||
"title": "$t:template.custom_notification.title"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
위 예시에서 `sirsoft-board` 모듈이 2.0.0으로 업데이트되면:
|
||||
|
||||
- 오버라이드가 자동으로 스킵됨
|
||||
- 관리자에게 호환성 경고 표시
|
||||
- 템플릿 개발자가 오버라이드를 업데이트할 때까지 기본 레이아웃 사용
|
||||
|
||||
#### 구현 위치
|
||||
|
||||
- **LayoutResolverService**: `checkOverrideVersionCompatibility()` 메서드 (레이아웃 오버라이드)
|
||||
- **LayoutExtensionService**: `checkVersionCompatibility()` 메서드 (확장 오버라이드)
|
||||
- **ModuleManager**: `getModuleVersion()` 헬퍼
|
||||
- **PluginManager**: `getPluginVersion()` 헬퍼
|
||||
|
||||
#### 레이아웃 오버라이드에서의 version_constraint
|
||||
|
||||
템플릿이 모듈 레이아웃을 오버라이드할 때도 `version_constraint`를 사용할 수 있습니다.
|
||||
|
||||
오버라이드 JSON 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"layout_name": "admin_sample_index",
|
||||
"version_constraint": "^1.0",
|
||||
"extends": "_admin_base",
|
||||
"slots": {
|
||||
"content": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
동작:
|
||||
|
||||
1. `version_constraint` 없음 → 항상 오버라이드 적용
|
||||
2. 버전 호환 → 오버라이드 적용
|
||||
3. 버전 비호환 → 오버라이드 스킵, 모듈 기본 레이아웃으로 폴백 + `Log::warning`
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 상속 처리
|
||||
|
||||
### extends 필드 사용
|
||||
|
||||
```json
|
||||
{
|
||||
"layout_name": "admin_sample_index",
|
||||
"extends": "_admin_base",
|
||||
"slots": {
|
||||
"content": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 병합 로직 (LayoutService.mergeLayouts)
|
||||
|
||||
```php
|
||||
// content에 extends가 있어야 병합 수행
|
||||
if (isset($layoutData['extends'])) {
|
||||
$parentLayoutName = $layoutData['extends'];
|
||||
$parentLayout = $this->loadAndMergeLayout($templateId, $parentLayoutName);
|
||||
$mergedLayout = $this->mergeLayouts($parentLayout, $layoutData);
|
||||
} else {
|
||||
$mergedLayout = $layoutData;
|
||||
}
|
||||
```
|
||||
|
||||
### 병합 결과
|
||||
|
||||
```php
|
||||
$result = [
|
||||
'version' => $childLayout['version'] ?? $parentLayout['version'] ?? '1.0.0',
|
||||
'layout_name' => $childLayout['layout_name'] ?? $parentLayout['layout_name'] ?? '',
|
||||
'meta' => $mergedMeta,
|
||||
'data_sources' => $mergedDataSources,
|
||||
'components' => $mergedComponents,
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DB 저장 규칙
|
||||
|
||||
### 템플릿 레이아웃과 일관성 유지
|
||||
|
||||
| 구분 | TemplateManager | ModuleManager |
|
||||
|------|----------------|---------------|
|
||||
| content 저장 | `$layoutData` 전체 | `$layoutData` 전체 |
|
||||
| extends 포함 | ✅ Yes | ✅ Yes |
|
||||
| layout_name 포함 | ✅ Yes | ✅ Yes |
|
||||
| version 포함 | ✅ Yes | ✅ Yes |
|
||||
|
||||
### 잘못된 구현 (❌ DON'T)
|
||||
|
||||
```php
|
||||
// 개별 필드만 추출 - 병합 실패 원인
|
||||
$content = [];
|
||||
if (isset($layoutData['slots'])) {
|
||||
$content['slots'] = $layoutData['slots'];
|
||||
}
|
||||
if (isset($layoutData['meta'])) {
|
||||
$content['meta'] = $layoutData['meta'];
|
||||
}
|
||||
// extends, layout_name 누락!
|
||||
```
|
||||
|
||||
### 올바른 구현 (✅ DO)
|
||||
|
||||
```php
|
||||
// layoutData 전체 저장 - TemplateManager와 일관성
|
||||
$content = $layoutData;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 캐시 전략
|
||||
|
||||
### 무효화 시점
|
||||
|
||||
1. 모듈 활성화 후
|
||||
2. 모듈 비활성화 후
|
||||
3. 레이아웃 파일 변경 시
|
||||
|
||||
### 무효화 메서드
|
||||
|
||||
```php
|
||||
protected function invalidateLayoutCache(string $moduleIdentifier): void
|
||||
{
|
||||
// 1. 태그 기반 캐시 삭제
|
||||
if (method_exists(Cache::getStore(), 'tags')) {
|
||||
Cache::tags(['layouts', $moduleIdentifier])->flush();
|
||||
}
|
||||
|
||||
// 2. 개별 레이아웃 캐시 삭제
|
||||
$moduleLayouts = TemplateLayout::where('source_type', LayoutSourceType::Module)
|
||||
->where('source_identifier', $moduleIdentifier)
|
||||
->get();
|
||||
|
||||
foreach ($moduleLayouts as $layout) {
|
||||
$cacheKey = "layout.{$layout->template_id}.{$layout->name}";
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 캐시 무효화 원칙
|
||||
|
||||
```text
|
||||
중요: 모듈/플러그인 활성화/비활성화 시 관련 캐시 자동 무효화 필수
|
||||
✅ 필수: Manager 레벨에서 캐시 무효화 구현 (Service 아님)
|
||||
✅ 권장: 공통 캐시 무효화 로직은 트레이트로 분리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모듈 레이아웃 개발 가이드
|
||||
|
||||
### 1. 레이아웃 파일 생성
|
||||
|
||||
```bash
|
||||
# 디렉토리 생성
|
||||
mkdir -p modules/_bundled/sirsoft-sample/resources/layouts/admin
|
||||
|
||||
# 레이아웃 파일 생성
|
||||
touch modules/_bundled/sirsoft-sample/resources/layouts/admin/index.json
|
||||
```
|
||||
|
||||
### 2. 레이아웃 JSON 작성
|
||||
|
||||
```json
|
||||
{
|
||||
"layout_name": "admin_sample_index",
|
||||
"version": "1.0.0",
|
||||
"extends": "_admin_base",
|
||||
"meta": {
|
||||
"title": "$t:sirsoft-sample.admin.index.title",
|
||||
"description": "$t:sirsoft-sample.admin.index.description",
|
||||
"auth_required": true
|
||||
},
|
||||
"slots": {
|
||||
"content": [
|
||||
{
|
||||
"id": "page_header",
|
||||
"name": "PageHeader",
|
||||
"type": "composite",
|
||||
"props": {
|
||||
"title": "$t:sirsoft-sample.admin.index.title"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "items",
|
||||
"type": "api",
|
||||
"method": "GET",
|
||||
"endpoint": "/api/modules/sirsoft-sample/admin/items",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 라우트 등록 (routes.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"path": "/admin/sirsoft-sample",
|
||||
"layout_name": "sirsoft-sample.admin_sample_index",
|
||||
"permissions": ["sirsoft-sample.admin.view"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 모듈 활성화
|
||||
|
||||
```bash
|
||||
php artisan module:activate sirsoft-sample
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 레이아웃 병합 안 됨
|
||||
|
||||
**원인**: `content`에 `extends` 필드 누락
|
||||
|
||||
**해결**:
|
||||
1. `ModuleManager.registerLayoutToTemplate()`에서 `$layoutData` 전체 저장 확인
|
||||
2. 모듈 재활성화로 DB 업데이트:
|
||||
```bash
|
||||
php artisan module:deactivate sirsoft-sample
|
||||
php artisan module:activate sirsoft-sample
|
||||
```
|
||||
|
||||
### layout_name 누락 오류
|
||||
|
||||
**원인**: `content`에 `layout_name` 필드 누락
|
||||
|
||||
**해결**: 위와 동일 (ModuleManager 수정 후 재활성화)
|
||||
|
||||
### 오버라이드가 적용 안 됨
|
||||
|
||||
**원인**:
|
||||
1. 레이아웃 이름 불일치
|
||||
2. `source_type`이 'template'이 아님
|
||||
3. 캐시 문제
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# 캐시 초기화
|
||||
php artisan cache:clear
|
||||
|
||||
# 레이아웃 이름 확인 (DB 조회)
|
||||
SELECT name, source_type FROM template_layouts
|
||||
WHERE name = 'sirsoft-sample.admin_sample_index';
|
||||
```
|
||||
|
||||
### user 레이아웃이 등록되지 않음
|
||||
|
||||
**원인**: 레이아웃 파일이 `layouts/user/` 하위에 위치하지 않음
|
||||
|
||||
**해결**: `resources/layouts/user/` 디렉토리로 파일 이동
|
||||
|
||||
### layouts/ 루트에 배치한 레이아웃이 무시됨
|
||||
|
||||
**원인**: `admin/` 또는 `user/` 하위 배치 필수
|
||||
|
||||
**해결**: 적절한 하위 디렉토리로 파일 이동. 경고 로그 확인:
|
||||
|
||||
```text
|
||||
"레이아웃 파일이 admin/ 또는 user/ 하위에 위치하지 않아 스킵됩니다: {file}"
|
||||
```
|
||||
|
||||
### 모듈 재활성화 후에도 레이아웃 안 보임
|
||||
|
||||
**확인 사항**:
|
||||
1. 레이아웃 파일 경로가 올바른지 확인
|
||||
2. JSON 파일 문법 오류 확인
|
||||
3. `layout_name` 필드가 JSON에 있는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [module-basics.md](module-basics.md) - 모듈 개발 기초
|
||||
- [module-routing.md](module-routing.md) - 모듈 라우트 규칙
|
||||
- [module-commands.md](module-commands.md) - 모듈 Artisan 커맨드
|
||||
- [module-i18n.md](module-i18n.md) - 모듈 다국어
|
||||
- [template-caching.md](template-caching.md) - 템플릿 캐싱 전략
|
||||
- [index.md](index.md) - 확장 시스템 인덱스
|
||||
@@ -0,0 +1,343 @@
|
||||
# 모듈 라우트 규칙
|
||||
|
||||
> 이 문서는 모듈의 백엔드 라우트(API/Web)와 프론트엔드 라우트(routes.json) 작성 규칙을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. URL prefix 자동: /api/admin/[vendor-module]/...
|
||||
2. name prefix 자동: api.[vendor-module]....
|
||||
3. 활성화된 모듈만 라우트 등록됨
|
||||
4. 프론트엔드: routes/admin.json, routes/user.json으로 admin/user 분기
|
||||
5. 레거시 routes.json은 admin으로 폴백 (경고 로그 출력)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [핵심 원칙](#1-핵심-원칙)
|
||||
2. [라우트 등록 조건](#2-라우트-등록-조건)
|
||||
3. [자동 적용되는 Prefix](#3-자동-적용되는-prefix)
|
||||
4. [라우트 파일 작성](#4-라우트-파일-작성)
|
||||
5. [라우트 네임 컨벤션](#5-라우트-네임-컨벤션)
|
||||
6. [프론트엔드 라우트 (routes.json)](#6-프론트엔드-라우트-routesjson)
|
||||
7. [캐시 관리](#7-캐시-관리)
|
||||
8. [관련 문서](#8-관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 원칙
|
||||
|
||||
```
|
||||
중요: ModuleRouteServiceProvider가 URL prefix와 name prefix를 자동 적용
|
||||
✅ 필수: 모듈 개발자는 모듈 내부 구조만 정의
|
||||
✅ 조건: 활성화된 모듈만 라우트가 등록됨
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 라우트 등록 조건
|
||||
|
||||
### 활성화된 모듈만 라우트 등록
|
||||
|
||||
- `ModuleRouteServiceProvider`가 DB에서 `status = 'active'`인 모듈만 조회
|
||||
- 설치되지 않거나 비활성화된 모듈의 라우트는 등록되지 않음
|
||||
- 모듈 활성화/비활성화 시 라우트가 자동으로 등록/해제됨
|
||||
|
||||
### 네임스페이스 변환 규칙
|
||||
|
||||
```
|
||||
디렉토리명 → 네임스페이스
|
||||
sirsoft-sample → Modules\Sirsoft\Sample
|
||||
sirsoft-ecommerce → Modules\Sirsoft\Ecommerce
|
||||
vendor-my-module → Modules\Vendor\My\Module
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 자동 적용되는 Prefix
|
||||
|
||||
### API 라우트
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| URL prefix | `api/modules/{module-name}` |
|
||||
| Name prefix | `api.modules.{module-name}.` |
|
||||
|
||||
### 웹 라우트
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| URL prefix | `modules/{module-name}` |
|
||||
| Name prefix | `web.modules.{module-name}.` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 라우트 파일 작성
|
||||
|
||||
### 라우트 파일 위치
|
||||
|
||||
```
|
||||
modules/_bundled/{identifier}/src/routes/
|
||||
├── api.php # API 라우트
|
||||
└── web.php # 웹 라우트
|
||||
```
|
||||
|
||||
### ✅ DO: 모듈 내부 구조만 정의
|
||||
|
||||
```php
|
||||
// modules/_bundled/sirsoft-ecommerce/src/routes/api.php
|
||||
|
||||
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
Route::get('products', [ProductController::class, 'index'])
|
||||
->name('admin.products.index');
|
||||
// 최종 Name: api.modules.sirsoft-ecommerce.admin.products.index
|
||||
// 최종 URL: /api/modules/sirsoft-ecommerce/admin/products
|
||||
});
|
||||
```
|
||||
|
||||
### ❌ DON'T: prefix 중복 입력
|
||||
|
||||
```php
|
||||
// 잘못된 예시 - prefix가 중복됨
|
||||
Route::get('products', [ProductController::class, 'index'])
|
||||
->name('api.modules.sirsoft-ecommerce.admin.products.index');
|
||||
```
|
||||
|
||||
### 라우트 파일 주석 템플릿
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| [Module Name] API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 주의: ModuleRouteServiceProvider가 자동으로 prefix를 적용합니다.
|
||||
| - URL prefix: 'api/modules/{module-name}'
|
||||
| - Name prefix: 'api.modules.{module-name}.'
|
||||
|
|
||||
*/
|
||||
|
||||
// 라우트 정의...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 라우트 네임 컨벤션
|
||||
|
||||
### 구조
|
||||
|
||||
```
|
||||
{type}.modules.{module-name}.{area}.{resource}.{action}
|
||||
```
|
||||
|
||||
### 예시
|
||||
|
||||
```
|
||||
api.modules.sirsoft-ecommerce.admin.products.index
|
||||
api.modules.sirsoft-ecommerce.admin.products.store
|
||||
api.modules.sirsoft-ecommerce.admin.orders.show
|
||||
web.modules.sirsoft-ecommerce.checkout.cart
|
||||
```
|
||||
|
||||
### 구성 요소
|
||||
|
||||
| 구성 요소 | 설명 | 적용 방식 |
|
||||
|-----------|------|-----------|
|
||||
| `type` | api 또는 web | 자동 적용 |
|
||||
| `modules` | 모듈임을 나타냄 | 자동 적용 |
|
||||
| `module-name` | 모듈 식별자 | 자동 적용 |
|
||||
| `area` | admin, public 등 영역 | 개발자 정의 |
|
||||
| `resource` | 리소스명 | 개발자 정의 |
|
||||
| `action` | index, store, show, update, destroy 등 | 개발자 정의 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트엔드 라우트 (routes.json)
|
||||
|
||||
```
|
||||
중요: 백엔드 라우트(routes/api.php)와 별개로 프론트엔드 라우트도 지원
|
||||
✅ 위치: modules/{identifier}/resources/routes/admin.json, routes/user.json
|
||||
✅ 병합: 템플릿 타입(admin/user)에 맞는 모듈 routes만 필터링 병합
|
||||
✅ 레거시: routes.json은 admin으로 폴백 (경고 로그 출력)
|
||||
✅ 캐시: 모듈 활성화/비활성화 시 자동 무효화
|
||||
```
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```
|
||||
modules/{identifier}/resources/
|
||||
├── routes/
|
||||
│ ├── admin.json ← admin 템플릿 전용 라우트
|
||||
│ └── user.json ← user 템플릿 전용 라우트 (선택)
|
||||
└── routes.json ← [레거시] 있으면 admin으로 취급 + 경고 로그
|
||||
```
|
||||
|
||||
### admin/user 분기 규칙
|
||||
|
||||
| 라우트 파일 | 서빙 대상 | 설명 |
|
||||
|------------|----------|------|
|
||||
| `routes/admin.json` | admin 타입 템플릿 | 관리자 화면 라우트 |
|
||||
| `routes/user.json` | user 타입 템플릿 | 일반 사용자 화면 라우트 |
|
||||
| `routes.json` (레거시) | admin 타입 템플릿만 | 하위 호환성, 경고 로그 출력 |
|
||||
|
||||
**탐색 우선순위**:
|
||||
1. `routes/{templateType}.json` 우선 탐색
|
||||
2. (admin 타입만) `routes.json` 레거시 폴백
|
||||
3. user 타입은 레거시 `routes.json`을 로드하지 않음
|
||||
|
||||
### 기본 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"routes": [
|
||||
{
|
||||
"path": "*/admin/sample",
|
||||
"layout": "admin_sample_index",
|
||||
"auth_required": true,
|
||||
"meta": {
|
||||
"title": "$t:sirsoft-sample.admin.index.title"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 병합 방식
|
||||
|
||||
**병합 우선순위**:
|
||||
|
||||
1. 템플릿 routes 배열이 기본
|
||||
2. 활성화된 모듈 routes를 **템플릿 타입에 맞게** 필터링하여 병합 (`array_merge`)
|
||||
3. 플러그인 routes는 **admin 템플릿에만** 포함 (settings 페이지 등 admin 전용)
|
||||
4. version은 템플릿의 version 사용
|
||||
|
||||
**레이아웃 필드 자동 변환**:
|
||||
|
||||
- 모듈 routes의 `layout` 필드에 모듈 식별자 접두사 자동 추가
|
||||
- 예: `"admin_sample_index"` → `"sirsoft-sample.admin_sample_index"`
|
||||
|
||||
**최종 API 응답**:
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"routes": [
|
||||
// 템플릿 routes...
|
||||
{
|
||||
"path": "*/admin/sample",
|
||||
"layout": "sirsoft-sample.admin_sample_index",
|
||||
"auth_required": true,
|
||||
"meta": {
|
||||
"title": "$t:sirsoft-sample.admin.index.title"
|
||||
}
|
||||
}
|
||||
// 다른 모듈 routes (템플릿 타입에 맞는 것만)...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 모듈 레이아웃과의 연계
|
||||
|
||||
**모듈 레이아웃 정의**:
|
||||
```
|
||||
modules/{identifier}/resources/layouts/admin/{layout_name}.json
|
||||
modules/{identifier}/resources/layouts/user/{layout_name}.json
|
||||
```
|
||||
|
||||
**routes에서 레이아웃 참조**:
|
||||
```json
|
||||
{
|
||||
"path": "*/admin/sample",
|
||||
"layout": "admin_sample_index"
|
||||
}
|
||||
```
|
||||
|
||||
**레이아웃 조회 우선순위**:
|
||||
1. 템플릿 오버라이드: `templates/{template}/layouts/overrides/{module}/{layout}.json`
|
||||
2. 모듈 원본: `modules/{module}/resources/layouts/admin/{layout}.json`
|
||||
|
||||
### 다국어 키 사용
|
||||
|
||||
**routes.json에서 다국어 참조**:
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"title": "$t:sirsoft-sample.admin.index.title"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**모듈 다국어 파일**:
|
||||
```json
|
||||
// modules/sirsoft-sample/resources/lang/ko.json
|
||||
{
|
||||
"admin": {
|
||||
"index": {
|
||||
"title": "샘플 모듈 관리"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**프론트엔드에서 최종 키**:
|
||||
- 템플릿 다국어 API가 모듈 다국어 자동 병합
|
||||
- 최종 키: `sirsoft-sample.admin.index.title`
|
||||
|
||||
### 구현 세부사항
|
||||
|
||||
**TemplateService**:
|
||||
|
||||
```php
|
||||
// 템플릿 + 모듈 routes 병합 (템플릿 타입에 따라 필터링)
|
||||
public function getRoutesDataWithModules(string $identifier): array
|
||||
|
||||
// 템플릿 타입에 맞는 모듈 routes 로드 (routes/{type}.json 우선, 레거시 폴백)
|
||||
private function loadActiveModulesRoutesData(string $templateType = 'admin'): array
|
||||
```
|
||||
|
||||
**PublicTemplateController API**:
|
||||
```
|
||||
GET /api/public/templates/{identifier}/routes
|
||||
```
|
||||
|
||||
**ClearsTemplateCaches Trait**:
|
||||
```php
|
||||
protected function clearAllTemplateRoutesCaches(): void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 캐시 관리
|
||||
|
||||
### 캐시 키 패턴
|
||||
|
||||
```
|
||||
template.routes.{identifier}
|
||||
```
|
||||
|
||||
### 자동 캐시 무효화
|
||||
|
||||
| Manager | 메서드 | 동작 |
|
||||
|---------|--------|------|
|
||||
| ModuleManager | `activateModule()` | routes 캐시 무효화 |
|
||||
| ModuleManager | `deactivateModule()` | routes 캐시 무효화 |
|
||||
| PluginManager | `activatePlugin()` | routes 캐시 무효화 |
|
||||
| PluginManager | `deactivatePlugin()` | routes 캐시 무효화 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 관련 문서
|
||||
|
||||
- [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule
|
||||
- [모듈 레이아웃](module-layouts.md) - 레이아웃 등록, 오버라이드
|
||||
- [모듈 다국어](module-i18n.md) - 백엔드/프론트엔드 다국어
|
||||
- [템플릿 라우트](template-routing.md) - 템플릿 라우트 규칙
|
||||
- [확장 시스템 개요](index.md) - 전체 인덱스
|
||||
@@ -0,0 +1,549 @@
|
||||
# 모듈 환경설정 시스템 개발 가이드
|
||||
|
||||
> 이 문서는 모듈에서 환경설정 시스템을 구현하는 방법을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#1-개요)
|
||||
2. [디렉토리 구조](#2-디렉토리-구조)
|
||||
3. [defaults.json 작성](#3-defaultsjson-작성)
|
||||
4. [SettingsService 구현](#4-settingsservice-구현)
|
||||
5. [SettingsController 구현](#5-settingscontroller-구현)
|
||||
6. [API 라우트 설정](#6-api-라우트-설정)
|
||||
7. [레이아웃 연동](#7-레이아웃-연동)
|
||||
8. [백엔드에서 설정 조회](#8-백엔드에서-설정-조회)
|
||||
9. [관련 문서](#9-관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 핵심 원칙
|
||||
|
||||
```
|
||||
중요: 모듈 환경설정은 모듈의 책임
|
||||
✅ 코어는 ModuleSettingsService + 헬퍼 함수(module_setting/module_settings)를 제공
|
||||
✅ 모듈이 ModuleSettingsInterface를 구현하면 코어가 자동 검색하여 위임
|
||||
✅ ModuleSettingsInterface 미구현 모듈은 코어 기본 구현(단일 setting.json) 사용
|
||||
✅ 설정 파일은 모듈별 격리된 경로에 저장
|
||||
```
|
||||
|
||||
### 1.2 설정 조회 흐름
|
||||
|
||||
```
|
||||
module_setting('vendor-module', 'key')
|
||||
→ ModuleSettingsService::get()
|
||||
→ resolveModuleService() — 모듈별 설정 서비스 자동 검색
|
||||
→ 1순위: Modules\{Vendor}\{Module}\Contracts\{Module}SettingsServiceInterface (바인딩)
|
||||
→ 2순위: Modules\{Vendor}\{Module}\Services\{Module}SettingsService (클래스)
|
||||
→ 모듈 서비스 발견 시: 위임 (getSetting/getAllSettings)
|
||||
→ 미발견 시: 코어 기본 구현 (defaults.json + setting.json)
|
||||
```
|
||||
|
||||
### 1.3 설계 원칙
|
||||
|
||||
- **격리성**: 모듈별로 독립적인 설정 저장 경로
|
||||
- **일관성**: 코어 환경설정과 동일한 패턴
|
||||
- **유연성**: 카테고리별 설정 분리 가능
|
||||
- **보안성**: frontend_schema를 통한 민감정보 제어
|
||||
|
||||
---
|
||||
|
||||
## 2. 디렉토리 구조
|
||||
|
||||
### 2.1 모듈 설정 파일 위치
|
||||
|
||||
```
|
||||
modules/{vendor-module}/
|
||||
├── config/
|
||||
│ └── settings/
|
||||
│ └── defaults.json ← 기본 설정값 정의
|
||||
├── src/
|
||||
│ ├── Services/
|
||||
│ │ └── {Module}SettingsService.php ← 설정 서비스
|
||||
│ ├── Http/
|
||||
│ │ └── Controllers/
|
||||
│ │ └── Admin/
|
||||
│ │ └── {Module}SettingsController.php ← 설정 API
|
||||
│ └── routes/
|
||||
│ └── api.php ← API 라우트
|
||||
└── resources/
|
||||
└── layouts/
|
||||
└── admin/
|
||||
└── admin_{module}_settings.json ← 설정 UI
|
||||
```
|
||||
|
||||
### 2.2 설정 저장 경로
|
||||
|
||||
```
|
||||
storage/app/modules/{vendor-module}/settings/
|
||||
├── basic_info.json
|
||||
├── language_currency.json
|
||||
└── seo.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. defaults.json 작성
|
||||
|
||||
### 3.1 기본 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"version": "1.0.0",
|
||||
"description": "모듈 설명",
|
||||
"categories": ["basic_info", "language_currency", "seo"]
|
||||
},
|
||||
"defaults": {
|
||||
"basic_info": {
|
||||
"field_name": "default_value"
|
||||
}
|
||||
},
|
||||
"frontend_schema": {
|
||||
"basic_info": {
|
||||
"expose": true,
|
||||
"fields": {
|
||||
"field_name": { "expose": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 _meta 섹션
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| version | string | 설정 스키마 버전 |
|
||||
| description | string | 모듈 설명 |
|
||||
| categories | array | 설정 카테고리 목록 |
|
||||
|
||||
### 3.3 defaults 섹션
|
||||
|
||||
카테고리별 기본값을 정의합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"basic_info": {
|
||||
"shop_name": "",
|
||||
"route_path": "shop",
|
||||
"no_route": false
|
||||
},
|
||||
"seo": {
|
||||
"meta_main_title": "{site_name} - {commerce_name}",
|
||||
"seo_site_main": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 frontend_schema 섹션
|
||||
|
||||
프론트엔드에 노출할 필드를 제어합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"frontend_schema": {
|
||||
"basic_info": {
|
||||
"expose": true,
|
||||
"fields": {
|
||||
"shop_name": { "expose": true },
|
||||
"api_key": { "expose": false, "sensitive": true }
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"expose": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 속성 | 설명 |
|
||||
|------|------|
|
||||
| expose | 프론트엔드 노출 여부 |
|
||||
| sensitive | 민감 정보 여부 |
|
||||
|
||||
---
|
||||
|
||||
## 4. SettingsService 구현
|
||||
|
||||
### 4.1 ModuleSettingsInterface 구현
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Vendor\Module\Services;
|
||||
|
||||
use App\Contracts\Extension\ModuleSettingsInterface;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ModuleSettingsService implements ModuleSettingsInterface
|
||||
{
|
||||
private const MODULE_IDENTIFIER = 'vendor-module';
|
||||
private ?array $defaults = null;
|
||||
private ?array $settings = null;
|
||||
|
||||
public function getSettingsDefaultsPath(): ?string
|
||||
{
|
||||
$path = $this->getModulePath().'/config/settings/defaults.json';
|
||||
return file_exists($path) ? $path : null;
|
||||
}
|
||||
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$settings = $this->getAllSettings();
|
||||
return Arr::get($settings, $key, $default);
|
||||
}
|
||||
|
||||
public function setSetting(string $key, mixed $value): bool
|
||||
{
|
||||
$settings = $this->getAllSettings();
|
||||
Arr::set($settings, $key, $value);
|
||||
$parts = explode('.', $key);
|
||||
$category = $parts[0];
|
||||
return $this->saveCategorySettings($category, $settings[$category] ?? []);
|
||||
}
|
||||
|
||||
public function getAllSettings(): array
|
||||
{
|
||||
if ($this->settings !== null) {
|
||||
return $this->settings;
|
||||
}
|
||||
|
||||
$defaults = $this->getDefaults();
|
||||
$categories = $defaults['_meta']['categories'] ?? [];
|
||||
$defaultValues = $defaults['defaults'] ?? [];
|
||||
|
||||
$settings = [];
|
||||
foreach ($categories as $category) {
|
||||
$categoryDefaults = $defaultValues[$category] ?? [];
|
||||
$savedSettings = $this->loadCategorySettings($category);
|
||||
$settings[$category] = array_merge($categoryDefaults, $savedSettings);
|
||||
}
|
||||
|
||||
$this->settings = $settings;
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public function getSettings(string $category): array
|
||||
{
|
||||
$allSettings = $this->getAllSettings();
|
||||
return $allSettings[$category] ?? [];
|
||||
}
|
||||
|
||||
public function saveSettings(array $settings): bool
|
||||
{
|
||||
$success = true;
|
||||
foreach ($settings as $category => $categorySettings) {
|
||||
if (str_starts_with($category, '_')) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->saveCategorySettings($category, $categorySettings)) {
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
$this->settings = null;
|
||||
return $success;
|
||||
}
|
||||
|
||||
public function getFrontendSettings(): array
|
||||
{
|
||||
$defaults = $this->getDefaults();
|
||||
$frontendSchema = $defaults['frontend_schema'] ?? [];
|
||||
$allSettings = $this->getAllSettings();
|
||||
|
||||
$frontendSettings = [];
|
||||
foreach ($frontendSchema as $category => $schema) {
|
||||
if (!($schema['expose'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$categorySettings = $allSettings[$category] ?? [];
|
||||
$fields = $schema['fields'] ?? [];
|
||||
|
||||
if (empty($fields)) {
|
||||
$frontendSettings[$category] = $categorySettings;
|
||||
continue;
|
||||
}
|
||||
|
||||
$exposedFields = [];
|
||||
foreach ($fields as $fieldName => $fieldSchema) {
|
||||
if ($fieldSchema['expose'] ?? false) {
|
||||
$exposedFields[$fieldName] = $categorySettings[$fieldName] ?? null;
|
||||
}
|
||||
}
|
||||
if (!empty($exposedFields)) {
|
||||
$frontendSettings[$category] = $exposedFields;
|
||||
}
|
||||
}
|
||||
return $frontendSettings;
|
||||
}
|
||||
|
||||
private function getModulePath(): string
|
||||
{
|
||||
return base_path('modules/'.self::MODULE_IDENTIFIER);
|
||||
}
|
||||
|
||||
private function getStoragePath(): string
|
||||
{
|
||||
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
||||
}
|
||||
|
||||
// 나머지 private 메서드 구현...
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 분리 입력 필드 처리
|
||||
|
||||
전화번호, 사업자번호 등 분리 입력 필드 처리:
|
||||
|
||||
```php
|
||||
private function processSplitFields(string $category, array $settings): array
|
||||
{
|
||||
if ($category !== 'basic_info') {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
// 사업자등록번호 병합
|
||||
if (isset($settings['business_number_1'])) {
|
||||
$parts = [
|
||||
$settings['business_number_1'] ?? '',
|
||||
$settings['business_number_2'] ?? '',
|
||||
$settings['business_number_3'] ?? '',
|
||||
];
|
||||
$settings['business_number'] = implode('-', array_filter($parts));
|
||||
unset(
|
||||
$settings['business_number_1'],
|
||||
$settings['business_number_2'],
|
||||
$settings['business_number_3']
|
||||
);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. SettingsController 구현
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Modules\Vendor\Module\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Vendor\Module\Services\ModuleSettingsService;
|
||||
|
||||
class ModuleSettingsController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private ModuleSettingsService $settingsService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$settings = $this->settingsService->getFrontendSettings();
|
||||
return $this->success('settings.fetch_success', $settings);
|
||||
}
|
||||
|
||||
public function show(string $category): JsonResponse
|
||||
{
|
||||
$settings = $this->settingsService->getSettings($category);
|
||||
return $this->success('settings.fetch_success', [
|
||||
'category' => $category,
|
||||
'settings' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->settingsService->saveSettings($request->all());
|
||||
|
||||
if ($result) {
|
||||
$updatedSettings = $this->settingsService->getFrontendSettings();
|
||||
return $this->success('settings.save_success', $updatedSettings);
|
||||
}
|
||||
|
||||
return $this->error('settings.save_failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API 라우트 설정
|
||||
|
||||
### 6.1 라우트 파일 위치
|
||||
|
||||
```
|
||||
modules/{vendor-module}/src/routes/api.php
|
||||
```
|
||||
|
||||
### 6.2 라우트 정의
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Vendor\Module\Http\Controllers\Admin\ModuleSettingsController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Module API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| URL prefix: 'api/modules/{module-name}'
|
||||
| Name prefix: 'api.modules.{module-name}.'
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
Route::get('settings', [ModuleSettingsController::class, 'index'])
|
||||
->name('admin.settings.index');
|
||||
|
||||
Route::put('settings', [ModuleSettingsController::class, 'store'])
|
||||
->name('admin.settings.store');
|
||||
|
||||
Route::get('settings/{category}', [ModuleSettingsController::class, 'show'])
|
||||
->name('admin.settings.show');
|
||||
});
|
||||
```
|
||||
|
||||
### 6.3 최종 API 경로
|
||||
|
||||
| Method | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/modules/{id}/admin/settings` | 전체 설정 조회 |
|
||||
| PUT | `/api/modules/{id}/admin/settings` | 설정 저장 |
|
||||
| GET | `/api/modules/{id}/admin/settings/{category}` | 카테고리별 조회 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 레이아웃 연동
|
||||
|
||||
### 7.1 data_sources 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/{module-id}/admin/settings",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"initLocal": "form",
|
||||
"refetchOnMount": true
|
||||
}
|
||||
],
|
||||
"state": {
|
||||
"isSaving": false,
|
||||
"hasChanges": false,
|
||||
"form": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 저장 버튼 액션
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/modules/{module-id}/admin/settings",
|
||||
"params": {
|
||||
"method": "PUT",
|
||||
"body": "{{_local.form}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 백엔드에서 설정 조회
|
||||
|
||||
### 8.1 헬퍼 함수 사용 (권장)
|
||||
|
||||
`module_setting()` / `module_settings()`는 내부적으로 `ModuleSettingsService`를 사용합니다.
|
||||
모듈별 설정 서비스(`{Module}SettingsService`)가 존재하면 자동으로 위임합니다.
|
||||
|
||||
```php
|
||||
// 단일 설정값 조회 (도트 노테이션 지원)
|
||||
$shopName = module_setting('vendor-module', 'basic_info.shop_name', '기본값');
|
||||
|
||||
// 전체 설정 조회
|
||||
$allSettings = module_setting('vendor-module');
|
||||
|
||||
// 카테고리 전체 조회
|
||||
$seoSettings = module_settings('vendor-module', 'seo');
|
||||
|
||||
// 전체 설정 조회
|
||||
$allSettings = module_settings('vendor-module');
|
||||
```
|
||||
|
||||
### 8.2 ModuleSettingsService 직접 사용
|
||||
|
||||
```php
|
||||
use App\Services\ModuleSettingsService;
|
||||
|
||||
$service = app(ModuleSettingsService::class);
|
||||
|
||||
// 단일 설정값 조회
|
||||
$value = $service->get('vendor-module', 'basic_info.shop_name', '기본값');
|
||||
|
||||
// 전체 설정 조회
|
||||
$allSettings = $service->get('vendor-module');
|
||||
```
|
||||
|
||||
### 8.3 모듈 내부에서 자체 서비스 사용
|
||||
|
||||
모듈 내부 Service/Controller에서는 자체 설정 서비스를 직접 주입하여 사용할 수 있습니다.
|
||||
모듈 전용 메서드(예: `getStockDeductionTiming()`)가 필요한 경우 이 방식을 사용합니다.
|
||||
|
||||
```php
|
||||
// 모듈 내부 — 모듈 전용 메서드 접근이 필요한 경우
|
||||
use Modules\Vendor\Module\Services\ModuleSettingsService;
|
||||
|
||||
public function __construct(
|
||||
private ModuleSettingsService $settingsService
|
||||
) {}
|
||||
|
||||
// ModuleSettingsInterface 메서드 + 모듈 전용 메서드 모두 사용 가능
|
||||
$value = $this->settingsService->getSetting('basic_info.shop_name');
|
||||
$timing = $this->settingsService->getStockDeductionTiming($method);
|
||||
```
|
||||
|
||||
> **판단 기준**: `getSetting()` 단일 호출만 필요하면 `module_setting()` 헬퍼 사용, 모듈 전용 메서드가 필요하면 서비스 직접 주입
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 문서
|
||||
|
||||
- [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule
|
||||
- [모듈 라우트](module-routing.md) - API 라우트 규칙
|
||||
- [모듈 레이아웃](module-layouts.md) - 레이아웃 등록
|
||||
- [모듈 다국어](module-i18n.md) - 다국어 지원
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
모듈 환경설정 구현 시 확인 사항:
|
||||
|
||||
- [ ] `config/settings/defaults.json` 생성
|
||||
- [ ] `ModuleSettingsInterface` 구현 서비스 생성
|
||||
- [ ] 설정 컨트롤러 생성
|
||||
- [ ] API 라우트 등록
|
||||
- [ ] 레이아웃에 `data_sources` 추가
|
||||
- [ ] 저장 버튼 액션 연결
|
||||
- [ ] `frontend_schema`로 민감정보 필터링 설정
|
||||
@@ -0,0 +1,816 @@
|
||||
# 권한 시스템
|
||||
|
||||
> G7의 권한(Permission)과 역할(Role) 시스템
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 구조: User → Role → Permission (기능 레벨)
|
||||
2. 리소스: User → Role → role_menus 피벗 → Menu
|
||||
3. 네이밍: [module].[entity].[action] (예: users.read)
|
||||
4. type 필드: admin(관리자용) 또는 user(사용자용), 기본값: admin
|
||||
5. scope_type: 역할별 접근 스코프 (null=전체, self=본인, role=소유역할)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [권한 아키텍처](#권한-아키텍처)
|
||||
2. [권한 타입 (Permission Type)](#권한-타입-permission-type)
|
||||
3. [모듈 Role/Permission 자동 관리](#모듈-rolepermission-자동-관리)
|
||||
4. [Role 정의 규칙](#role-정의-규칙)
|
||||
5. [Permission에 Role 할당](#permission에-role-할당)
|
||||
6. [Role 삭제 조건](#role-삭제-조건)
|
||||
7. [콘솔 커맨드 출력](#콘솔-커맨드-출력)
|
||||
8. [권한 네이밍](#권한-네이밍)
|
||||
9. [리소스 레벨 권한](#리소스-레벨-권한)
|
||||
10. [레이아웃 컴포넌트 권한 필터링](#레이아웃-컴포넌트-권한-필터링)
|
||||
|
||||
---
|
||||
|
||||
## 권한 아키텍처
|
||||
|
||||
G7의 권한 시스템은 두 가지 레벨로 구성됩니다.
|
||||
|
||||
### 기능 레벨 권한 (Feature-level)
|
||||
|
||||
```text
|
||||
User → Role → Permission
|
||||
```
|
||||
|
||||
- **User**: 시스템 사용자
|
||||
- **Role**: 역할 그룹 (여러 Permission을 묶음)
|
||||
- **Permission**: 개별 권한 (특정 작업 수행 허용)
|
||||
|
||||
### 리소스 레벨 권한 (Resource-level)
|
||||
|
||||
```text
|
||||
User → Role → role_menus 피벗 테이블 → Menu
|
||||
```
|
||||
|
||||
- **role_menus**: 메뉴 접근 권한
|
||||
|
||||
> **참고**: 첨부파일(Attachment)은 기능 레벨 권한(`core.attachments.*` + `scope_type`)으로 제어합니다. `role_attachments` 독립 시스템은 폐기되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 권한 타입 (Permission Type)
|
||||
|
||||
권한은 `type` 필드로 사용 컨텍스트를 구분합니다.
|
||||
|
||||
### PermissionType Enum
|
||||
|
||||
```php
|
||||
// app/Enums/PermissionType.php
|
||||
|
||||
enum PermissionType: string
|
||||
{
|
||||
case Admin = 'admin'; // 관리자 화면용 권한
|
||||
case User = 'user'; // 사용자(프론트엔드) 화면용 권한
|
||||
}
|
||||
```
|
||||
|
||||
### 타입별 용도
|
||||
|
||||
| 타입 | 용도 | 예시 |
|
||||
|------|------|------|
|
||||
| `admin` | 관리자 화면에서 사용되는 권한 | 사용자 관리, 설정, 모듈 관리 |
|
||||
| `user` | 사용자(프론트엔드) 화면에서 사용되는 권한 | 내 주문 조회, 프로필 수정 |
|
||||
|
||||
### 관리자 판단 기준
|
||||
|
||||
```php
|
||||
// User::isAdmin() 메서드
|
||||
// 사용자가 보유한 권한 중 type='admin'인 것이 하나라도 있으면 관리자
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->roles()
|
||||
->whereHas('permissions', function ($query) {
|
||||
$query->where('type', PermissionType::Admin);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
```
|
||||
|
||||
### 모듈/플러그인에서 권한 타입 지정
|
||||
|
||||
```php
|
||||
// getPermissions() 반환 구조
|
||||
'permissions' => [
|
||||
[
|
||||
'action' => 'read',
|
||||
'name' => ['ko' => '조회', 'en' => 'Read'],
|
||||
'description' => ['ko' => '...', 'en' => '...'],
|
||||
'type' => 'admin', // admin 또는 user (기본값: admin)
|
||||
'roles' => ['admin', 'manager'],
|
||||
],
|
||||
[
|
||||
'action' => 'view_own',
|
||||
'name' => ['ko' => '내 정보 조회', 'en' => 'View Own'],
|
||||
'type' => 'user', // 사용자 화면용 권한
|
||||
'roles' => ['user'],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
### Permission 모델 헬퍼
|
||||
|
||||
```php
|
||||
// 타입 확인 메서드
|
||||
$permission->isAdminPermission(); // type === 'admin'
|
||||
$permission->isUserPermission(); // type === 'user'
|
||||
|
||||
// 스코프
|
||||
Permission::adminPermissions()->get(); // admin 타입만
|
||||
Permission::userPermissions()->get(); // user 타입만
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 모듈 Role/Permission 자동 관리
|
||||
|
||||
```text
|
||||
중요: 모듈 설치 시 Role과 Permission이 자동으로 생성/연결됨
|
||||
✅ 필수: getRoles()와 getPermissions()의 roles 필드 사용
|
||||
```
|
||||
|
||||
> **플러그인 권한**: 플러그인도 모듈과 동일한 3레벨 계층 구조(`categories`)를 사용합니다. 상세는 [plugin-development.md](plugin-development.md)의 "플러그인 권한 시스템" 섹션을 참조하세요.
|
||||
|
||||
### 동작 방식
|
||||
|
||||
#### 모듈 설치 시 (module:install)
|
||||
|
||||
1. 마이그레이션 실행
|
||||
2. `getRoles()`에서 정의한 Role 생성
|
||||
3. `getPermissions()`에서 정의한 Permission 생성
|
||||
4. Permission의 `roles` 필드에 지정된 Role에 권한 할당
|
||||
5. 메뉴 생성
|
||||
6. 시더 실행
|
||||
|
||||
#### 모듈 제거 시 (module:uninstall)
|
||||
|
||||
1. Role에서 Permission 분리 (detach)
|
||||
2. Permission 삭제
|
||||
3. Role 삭제 (조건: `extension_type`이 NULL(사용자 생성) AND 남은 권한 0개)
|
||||
4. 메뉴 삭제
|
||||
5. 마이그레이션 롤백
|
||||
|
||||
---
|
||||
|
||||
## Role 정의 규칙
|
||||
|
||||
### identifier 네이밍
|
||||
|
||||
```text
|
||||
[vendor-module].[rolename]
|
||||
```
|
||||
|
||||
### 예시
|
||||
|
||||
```text
|
||||
sirsoft-ecommerce.manager
|
||||
sirsoft-ecommerce.viewer
|
||||
sirsoft-sample.manager
|
||||
```
|
||||
|
||||
### getRoles() 반환 구조
|
||||
|
||||
```php
|
||||
public function getRoles(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'identifier' => 'sirsoft-ecommerce.manager',
|
||||
'name' => [
|
||||
'ko' => '이커머스 관리자',
|
||||
'en' => 'Ecommerce Manager',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '이커머스 모듈의 관리 권한을 가진 역할',
|
||||
'en' => 'Role with management permissions for ecommerce module',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**필수 필드**:
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
| ------------- | ------ | -------------------------------------- |
|
||||
| `identifier` | string | 고유 식별자 (vendor-module.rolename) |
|
||||
| `name` | array | 다국어 역할명 |
|
||||
| `description` | array | 다국어 설명 |
|
||||
|
||||
---
|
||||
|
||||
## Permission에 Role 할당
|
||||
|
||||
### getPermissions() roles 필드
|
||||
|
||||
```php
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'identifier' => 'sirsoft-ecommerce.products.view',
|
||||
'name' => ['ko' => '상품 조회', 'en' => 'View Products'],
|
||||
'description' => ['ko' => '...', 'en' => '...'],
|
||||
'roles' => ['admin', 'sirsoft-ecommerce.manager'], // Role 지정
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 규칙
|
||||
|
||||
- `roles` 필드에 지정된 Role에 자동으로 권한 할당
|
||||
- 기존 Role의 권한은 `syncWithoutDetaching`으로 보존
|
||||
- 여러 Role에 동일 권한 할당 가능
|
||||
|
||||
### syncWithoutDetaching 동작
|
||||
|
||||
```php
|
||||
// 기존 권한은 유지하면서 새 권한만 추가
|
||||
$role->permissions()->syncWithoutDetaching($permissionIds);
|
||||
```
|
||||
|
||||
- 모듈 A가 `admin` Role에 권한 할당
|
||||
- 모듈 B도 `admin` Role에 권한 할당
|
||||
- 모듈 A 제거 시 모듈 B의 권한은 유지됨
|
||||
|
||||
---
|
||||
|
||||
## Role 삭제 조건
|
||||
|
||||
### 안전한 삭제를 위한 조건
|
||||
|
||||
1. `extension_type`이 NULL (사용자 생성 Role만 삭제 가능)
|
||||
2. `extension_type = 'core'` → 삭제 불가 (코어 역할 보호)
|
||||
3. `extension_type = 'module'` 또는 `'plugin'` → 삭제 불가 (확장 소유 역할 보호, `ExtensionOwnedRoleDeleteException` 발생)
|
||||
4. 남은 권한 개수 = 0 (다른 모듈의 권한이 없어야 함)
|
||||
|
||||
### 조건을 만족하지 않는 경우
|
||||
|
||||
- `extension_type`이 설정된 Role은 `ExtensionOwnedRoleDeleteException` 또는 403 응답
|
||||
- 남은 권한이 있는 경우 삭제되지 않고 로그에 경고 기록
|
||||
|
||||
### Extension Ownership 예시
|
||||
|
||||
```php
|
||||
// 코어 Role (extension_type = 'core', extension_identifier = 'core')
|
||||
'admin' // 최고 관리자 — 삭제 불가
|
||||
|
||||
// 모듈 Role (extension_type = 'module', extension_identifier = 'sirsoft-ecommerce')
|
||||
'sirsoft-ecommerce.manager' // 모듈 소유 — 삭제 불가
|
||||
|
||||
// 사용자 생성 Role (extension_type = NULL)
|
||||
'custom-role' // 사용자 생성 — 삭제 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 콘솔 커맨드 출력
|
||||
|
||||
### 설치 시
|
||||
|
||||
```text
|
||||
✅ 모듈 "sirsoft-sample" 설치 완료
|
||||
- 벤더: sirsoft
|
||||
- 버전: 1.0.0
|
||||
- 1개 역할 생성됨
|
||||
- 4개 권한 생성됨
|
||||
- 3개 메뉴 생성됨
|
||||
```
|
||||
|
||||
### 제거 시
|
||||
|
||||
```text
|
||||
모듈 "sirsoft-sample"을(를) 정말 삭제하시겠습니까?
|
||||
- 1개 역할이 삭제됩니다.
|
||||
- 4개 권한이 삭제됩니다.
|
||||
- 3개 메뉴가 삭제됩니다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 권한 네이밍
|
||||
|
||||
### 코어 권한
|
||||
|
||||
```text
|
||||
core.[entity].[action]
|
||||
```
|
||||
|
||||
**예시**:
|
||||
|
||||
```text
|
||||
core.templates.layouts.view
|
||||
core.templates.layouts.create
|
||||
core.templates.layouts.edit
|
||||
core.templates.layouts.delete
|
||||
core.users.view
|
||||
core.users.manage
|
||||
```
|
||||
|
||||
### 모듈 권한
|
||||
|
||||
```text
|
||||
[vendor-module].[entity].[action]
|
||||
```
|
||||
|
||||
**예시**:
|
||||
|
||||
```text
|
||||
sirsoft-ecommerce.products.view
|
||||
sirsoft-ecommerce.products.create
|
||||
sirsoft-ecommerce.products.edit
|
||||
sirsoft-ecommerce.products.delete
|
||||
sirsoft-ecommerce.orders.view
|
||||
sirsoft-ecommerce.orders.manage
|
||||
```
|
||||
|
||||
### 권한 네이밍 규칙 요약
|
||||
|
||||
| 구분 | 패턴 | 예시 |
|
||||
| ------ | ------------------------------- | ------------------------------------ |
|
||||
| 코어 | `core.[entity].[action]` | `core.users.view` |
|
||||
| 모듈 | `[vendor-module].[entity].[action]` | `sirsoft-ecommerce.products.create` |
|
||||
|
||||
### 일반적인 action 목록
|
||||
|
||||
| Action | 설명 |
|
||||
| -------- | --------------------------------------- |
|
||||
| `view` | 조회 권한 |
|
||||
| `create` | 생성 권한 |
|
||||
| `edit` | 수정 권한 |
|
||||
| `delete` | 삭제 권한 |
|
||||
| `manage` | 관리 권한 (view + create + edit + delete) |
|
||||
|
||||
---
|
||||
|
||||
## 리소스 레벨 권한
|
||||
|
||||
G7은 기능 레벨 권한 외에 **리소스 레벨 권한**을 지원합니다. 특정 리소스(메뉴 등)에 대한 세밀한 접근 제어가 가능합니다.
|
||||
|
||||
### 적용된 리소스
|
||||
|
||||
| 리소스 | 권한 방식 | 설명 |
|
||||
| ------------ | --------- | ---- |
|
||||
| Menu | `role_menus` 피벗 테이블 | 역할별 메뉴 접근 제어 |
|
||||
| Attachment | `core.attachments.*` + `scope_type` | 기능 레벨 권한으로 제어 |
|
||||
|
||||
> **참고**: 첨부파일은 `role_attachments` 독립 시스템이 아닌, 기존 `permissions` + `scope_type` 시스템으로 통합되었습니다.
|
||||
> - `core.attachments.create`: 업로드 권한
|
||||
> - `core.attachments.update`: 수정/순서변경 권한 (`resource_route_key: 'attachment'`, `owner_key: 'created_by'`)
|
||||
> - `core.attachments.delete`: 삭제 권한 (`resource_route_key: 'attachment'`, `owner_key: 'created_by'`)
|
||||
> - 공개 다운로드는 `HookManager::checkHookPermission('core.attachment.download')` 으로 제어
|
||||
|
||||
---
|
||||
|
||||
## API 응답 권한 메타 (is_owner + abilities)
|
||||
|
||||
모든 API 리소스 응답에 `is_owner`와 `abilities` (can_*) 필드가 표준화되어 포함됩니다.
|
||||
|
||||
### 응답 형식
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "상품명",
|
||||
"is_owner": true,
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `is_owner`: 현재 요청 사용자가 리소스 소유자인지 여부
|
||||
- `abilities`: `can_*` 키로 통합된 권한 플래그
|
||||
|
||||
### 권한 확인 순서 (checkAbility)
|
||||
|
||||
```text
|
||||
1. Admin 역할 → 무조건 true
|
||||
2. 인증된 사용자 → $user->hasPermission($identifier)
|
||||
3. 게스트 → PermissionHelper::check($identifier)
|
||||
```
|
||||
|
||||
### 프론트엔드에서 사용
|
||||
|
||||
레이아웃 JSON에서 `abilities.can_*` 키로 접근합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"if": "{{item.abilities.can_update}}",
|
||||
"type": "Button",
|
||||
"props": { "text": "수정" }
|
||||
}
|
||||
```
|
||||
|
||||
> **상세 구현 규칙**: [api-resources.md](../backend/api-resources.md#권한-메타-표준화-is_owner--abilities) 참조
|
||||
|
||||
---
|
||||
|
||||
## PermissionHelper — 서비스/컨트롤러에서 권한 체크
|
||||
|
||||
`PermissionHelper` 헬퍼 클래스를 사용하여 서비스/컨트롤러 내부에서 권한을 체크합니다.
|
||||
scope_type 기반 스코프 체크 기능이 내장되어 있습니다.
|
||||
|
||||
```php
|
||||
use App\Helpers\PermissionHelper;
|
||||
|
||||
// 기본 권한 체크
|
||||
PermissionHelper::check('core.users.update');
|
||||
|
||||
// 구조화 권한 (OR/AND)
|
||||
PermissionHelper::checkWithLogic(['or' => ['perm.a', 'perm.b']]);
|
||||
|
||||
// scope_type 기반 상세 접근 체크
|
||||
PermissionHelper::checkScopeAccess($model, 'core.users.update', $user);
|
||||
|
||||
// scope_type 기반 목록 필터링 (Repository에서 사용)
|
||||
PermissionHelper::applyPermissionScope($query, 'core.users.read');
|
||||
|
||||
// effective scope 조회
|
||||
PermissionHelper::getEffectiveScope('core.users.read', $user); // null|'self'|'role'
|
||||
|
||||
// 소유자 정보 조회 (캐싱)
|
||||
PermissionHelper::getOwnerKey('core.users.read'); // 'id'
|
||||
PermissionHelper::getResourceRouteKey('core.users.read'); // 'user'
|
||||
```
|
||||
|
||||
**메서드 시그니처**:
|
||||
|
||||
```php
|
||||
public static function check(string $ability, ?User $user = null): bool
|
||||
public static function checkWithLogic(array|object $permissions, ?User $user = null): bool
|
||||
public static function checkScopeAccess(Model $model, string $permission, ?User $user = null): bool
|
||||
public static function applyPermissionScope(Builder $query, string $permission, ?User $user = null): void
|
||||
public static function getEffectiveScope(string $permission, ?User $user = null): ?string
|
||||
public static function getOwnerKey(string $permission): ?string
|
||||
public static function getResourceRouteKey(string $permission): ?string
|
||||
```
|
||||
|
||||
### 권한 체크 방식 선택 가이드
|
||||
|
||||
| 상황 | 사용 방법 |
|
||||
|------|----------|
|
||||
| 라우트 접근 제어 | `permission:` 미들웨어 (scope_type 자동 체크) |
|
||||
| 서비스 내부 조건부 권한 체크 | `PermissionHelper::check()` 직접 호출 |
|
||||
| 상세 엔드포인트 스코프 체크 | `PermissionHelper::checkScopeAccess()` |
|
||||
| 목록 엔드포인트 필터링 | `PermissionHelper::applyPermissionScope()` (Repository에서 호출) |
|
||||
| API 응답 abilities 플래그 | `HasAbilityCheck` 트레이트 (`abilityMap()`) |
|
||||
| 리소스 레벨 접근 제어 (메뉴 등) | `role_*` 피벗 테이블 기반 |
|
||||
| 레이아웃 컴포넌트 필터링 | `permissions` 속성 (서버 사이드 제거) |
|
||||
|
||||
### 주의 사항
|
||||
|
||||
```text
|
||||
❌ AuthServiceProvider::checkPermission() 직접 호출 → PermissionHelper::check() 사용
|
||||
❌ 서비스 내부에서 미들웨어에 의존한 권한 체크 생략 → PermissionHelper로 명시적 체크
|
||||
❌ except/only/menu 미들웨어 옵션 사용 (제거됨) → scope_type 데이터 기반 시스템 사용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## scope_type 스코프 시스템
|
||||
|
||||
역할별 접근 범위를 `role_permissions` 피벗의 `scope_type` 컬럼으로 제어합니다.
|
||||
모든 라우트 미들웨어 옵션 (`except:`, `only:`, `menu:`)은 **제거**되었습니다.
|
||||
|
||||
### DB 구조
|
||||
|
||||
| 테이블 | 컬럼 | 타입 | 용도 |
|
||||
|--------|------|------|------|
|
||||
| `permissions` | `resource_route_key` | VARCHAR(50) NULL | 라우트 파라미터명 (예: 'user', 'menu') |
|
||||
| `permissions` | `owner_key` | VARCHAR(50) NULL | 모델의 소유자 식별 컬럼 (예: 'id', 'created_by') |
|
||||
| `role_permissions` | `scope_type` | ENUM('self', 'role') NULL | 역할별 접근 스코프 |
|
||||
|
||||
### ScopeType Enum
|
||||
|
||||
```php
|
||||
// app/Enums/ScopeType.php
|
||||
enum ScopeType: string
|
||||
{
|
||||
case Self = 'self'; // 본인 리소스만
|
||||
case Role = 'role'; // 소유 역할 공유 사용자의 리소스
|
||||
}
|
||||
// null = 전체 접근 (제한 없음)
|
||||
```
|
||||
|
||||
### scope_type 값 정의
|
||||
|
||||
| 값 | 의미 | 목록 필터링 | 상세 접근 체크 |
|
||||
|---|---|---|---|
|
||||
| `null` | 전체 접근 | 필터 미적용 | 항상 통과 |
|
||||
| `'self'` | 본인 리소스만 | `WHERE {owner_key} = {user_id}` | `$model->{owner_key} === $user->id` |
|
||||
| `'role'` | 내 역할 범위 | `WHERE {owner_key} IN (내 역할 공유 사용자)` | 소유자가 내 역할 공유하는지 체크 |
|
||||
|
||||
### union 정책 (복수 역할 보유 시)
|
||||
|
||||
- **우선순위**: `null`(전체) > `'role'`(소유역할) > `'self'`(본인)
|
||||
- 여러 역할 중 하나라도 scope_type=null → 전체 접근
|
||||
- 전부 non-null이면 가장 넓은 범위 적용 (role > self)
|
||||
- 예: 역할A(scope=self) + 역할B(scope=role) → role 적용
|
||||
|
||||
### 미들웨어 처리 흐름
|
||||
|
||||
```text
|
||||
permission:admin,core.users.read ← 옵션 없음
|
||||
|
||||
1. 권한 타입 검증
|
||||
2. 동적 파라미터 치환
|
||||
3. 권한 체크 (인증/게스트)
|
||||
4. scope_type 스코프 체크:
|
||||
a. Permission 조회 (캐싱) → resource_route_key, owner_key
|
||||
b. resource_route_key null → 통과 (시스템 리소스)
|
||||
c. $request->route(resource_route_key) → 모델 resolve
|
||||
d. 모델 없음 → 통과 (목록 엔드포인트)
|
||||
e. 사용자의 effective scope 확인 (union 정책)
|
||||
f. scope=null → 통과
|
||||
g. scope='self' → $model->{owner_key} === $user->id
|
||||
h. scope='role' → 소유자가 내 역할 공유하는지 체크
|
||||
```
|
||||
|
||||
### 목록 엔드포인트 필터링 (Repository 패턴)
|
||||
|
||||
```php
|
||||
// 모든 Repository에서 한 줄로 적용
|
||||
$query = User::query();
|
||||
PermissionHelper::applyPermissionScope($query, 'core.users.read');
|
||||
// ... 기존 필터/정렬/페이지네이션
|
||||
```
|
||||
|
||||
### 소유자 식별 전체 매핑
|
||||
|
||||
| Permission prefix | resource_route_key | owner_key | 비고 |
|
||||
|---|---|---|---|
|
||||
| core.users.* | user | id | self: $user->id === $model->id |
|
||||
| core.menus.* | menu | created_by | |
|
||||
| core.attachments.update/delete | attachment | created_by | create는 scope 불필요 |
|
||||
| core.schedules.* | schedule | created_by | |
|
||||
| core.activity_logs.* | null | null | 소유자 없음 |
|
||||
| sirsoft-board.boards.* | board | created_by | |
|
||||
| sirsoft-board.{slug}.*.posts.* | post | user_id | |
|
||||
| sirsoft-ecommerce.products.* | product | created_by | |
|
||||
| sirsoft-ecommerce.orders.* | order | user_id | |
|
||||
| sirsoft-page.pages.* | page | created_by | |
|
||||
|
||||
> resource_route_key/owner_key가 null → scope_type UI 미표시, 설정해도 무시
|
||||
|
||||
### getPermissions() 포맷 (모듈)
|
||||
|
||||
```php
|
||||
'categories' => [
|
||||
[
|
||||
'identifier' => 'pages',
|
||||
'resource_route_key' => 'page', // 리소스 라우트 키
|
||||
'owner_key' => 'created_by', // 소유자 컬럼
|
||||
'permissions' => [
|
||||
[
|
||||
'action' => 'read',
|
||||
'roles' => ['admin', 'manager'], // 문자열 → scope_type=null
|
||||
],
|
||||
[
|
||||
'action' => 'delete',
|
||||
'roles' => [
|
||||
['role' => 'admin', 'scope_type' => null],
|
||||
['role' => 'manager', 'scope_type' => 'self'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
### 역할 관리 UI
|
||||
|
||||
- PermissionTree 컴포넌트에 scope_type 드롭다운 추가
|
||||
- resource_route_key가 있는 권한 + 체크된 상태에서만 드롭다운 표시
|
||||
- 선택지: 전체(null), 소유역할('role'), 본인만('self')
|
||||
- 폼 데이터: `permissions: [{id: 1, scope_type: null}, {id: 2, scope_type: 'self'}]`
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 컴포넌트 권한 필터링
|
||||
|
||||
레이아웃 JSON의 컴포넌트에 `permissions` 속성을 정의하면, **레이아웃 서빙 API에서 해당 컴포넌트를 제거하여 서빙**합니다. 프론트엔드 `if` 조건부 렌더링과 달리 JSON 구조 자체가 클라이언트에 노출되지 않아 보안이 강화됩니다.
|
||||
|
||||
### 동작 원리
|
||||
|
||||
```text
|
||||
파이프라인 위치:
|
||||
[캐시 조회] → [extends/partial 병합] → [applyExtensions] → [★ filterComponentsByPermissions] → [훅] → [응답]
|
||||
```
|
||||
|
||||
- **Post-cache filtering**: 캐시 이후 사용자별 동적 필터링 (캐시 구조 영향 없음, ~1-2ms 추가)
|
||||
- **AND 조건**: `permissions` 배열의 모든 권한을 충족해야 컴포넌트 포함
|
||||
- **Admin 바이패스**: `admin` 역할은 `Gate::before()`에 의해 모든 권한 통과
|
||||
|
||||
### 필터링 대상 영역
|
||||
|
||||
| 영역 | 설명 |
|
||||
|------|------|
|
||||
| `components` | 메인 컴포넌트 트리 (재귀 children 포함) |
|
||||
| `modals[]` | 모달 자체에 permissions → 모달 항목 전체 제거 |
|
||||
| `modals[].components` | 모달 내부 컴포넌트 트리 (재귀) |
|
||||
| `defines` | 재사용 컴포넌트 정의 (재귀) |
|
||||
| partial 내부 | extends/partial 병합 후 필터링이므로 자동 처리 |
|
||||
|
||||
### 필터링 규칙
|
||||
|
||||
1. 현재 노드의 `permissions` 배열 확인 (없거나 빈 배열이면 통과)
|
||||
2. `AuthServiceProvider::checkPermissions($permissions, $user)` 호출 (AND 조건)
|
||||
3. 권한 없으면 해당 노드 및 하위 children **전체 제거** (하위 평가 생략)
|
||||
4. 권한 있으면 통과 → children을 재귀적으로 독립 평가
|
||||
5. 필터링 완료 후 모든 노드에서 `permissions` 속성 제거 (클라이언트 노출 방지)
|
||||
|
||||
### 상위-하위 중복 선언
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Div",
|
||||
"permissions": ["core.users.read"],
|
||||
"children": [
|
||||
{ "type": "Button", "permissions": ["core.users.delete"], "text": "삭제" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 시나리오 | 상위 결과 | 하위 결과 | 최종 |
|
||||
|---------|---------|---------|------|
|
||||
| `read` 없음 | 상위 제거 → 전체 제거 | 평가 안 됨 | 둘 다 없음 |
|
||||
| `read` 있음 + `delete` 없음 | 통과 | 하위만 제거 | Div만 남음 |
|
||||
| `read` 있음 + `delete` 있음 | 통과 | 통과 | 둘 다 남음 |
|
||||
|
||||
→ 각 노드는 자기 permissions만 독립 평가. 별도 병합 정책 없음.
|
||||
|
||||
### 확장(Extension)과의 관계
|
||||
|
||||
- Extension Point로 주입된 컴포넌트에 `permissions` → 정상 필터링
|
||||
- Overlay로 교체된 컴포넌트에 `permissions` → 교체된 컴포넌트의 permissions 적용
|
||||
- 파이프라인 순서: `applyExtensions` → `filterComponentsByPermissions` (확장 적용 후 필터링)
|
||||
|
||||
### 레이아웃 최상위 permissions와의 비교
|
||||
|
||||
| 항목 | 최상위 `permissions` | 컴포넌트 `permissions` |
|
||||
|------|---------------------|----------------------|
|
||||
| 적용 범위 | 레이아웃 전체 | 개별 컴포넌트 단위 |
|
||||
| 미충족 시 | 403 응답 | 해당 컴포넌트만 제거 |
|
||||
| 조건 방식 | AND | AND |
|
||||
| 처리 시점 | 캐시 조회 전 | 캐시 조회 후 (post-cache) |
|
||||
|
||||
### 관련 코드
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `app/Services/LayoutService.php` | `filterComponentsByPermissions()` 메서드 |
|
||||
| `app/Http/Controllers/Api/Public/PublicLayoutController.php` | 서빙 시 필터링 호출 |
|
||||
| `app/Rules/ValidLayoutStructure.php` | 컴포넌트 `permissions` 검증 |
|
||||
|
||||
> **레이아웃 JSON 문법 상세**: [layout-json.md](../frontend/layout-json.md#컴포넌트별-권한-component-permissions) 참조
|
||||
|
||||
---
|
||||
|
||||
## Model 권한/역할 체크 메서드
|
||||
|
||||
User 모델은 권한/역할 체크를 위한 헬퍼 메서드를 제공합니다.
|
||||
|
||||
### 메서드 시그니처
|
||||
|
||||
```php
|
||||
// 단일 권한 체크
|
||||
$user->hasPermission('core.users.read');
|
||||
$user->hasPermission('core.users.read', PermissionType::Admin);
|
||||
|
||||
// 복수 권한 체크
|
||||
$user->hasPermissions(['perm.a', 'perm.b'], requireAll: true); // AND
|
||||
$user->hasPermissions(['perm.a', 'perm.b'], requireAll: false); // OR
|
||||
|
||||
// 역할 체크
|
||||
$user->hasRole('admin');
|
||||
$user->hasRoles(['admin', 'manager'], requireAll: false); // OR
|
||||
|
||||
// 관리자 판단 (admin 타입 권한 보유 여부)
|
||||
$user->isAdmin();
|
||||
```
|
||||
|
||||
### 인스턴스 레벨 캐싱
|
||||
|
||||
`getEffectiveScopeForPermission()` 메서드는 동일 요청 내 반복 호출 시 DB 쿼리를 방지하기 위해 인스턴스 레벨 캐싱을 사용합니다:
|
||||
|
||||
```php
|
||||
// User 모델 내부
|
||||
protected array $effectiveScopeCache = [];
|
||||
|
||||
public function getEffectiveScopeForPermission(string $identifier): ?string
|
||||
{
|
||||
if (array_key_exists($identifier, $this->effectiveScopeCache)) {
|
||||
return $this->effectiveScopeCache[$identifier];
|
||||
}
|
||||
// ... DB 쿼리 및 union 정책 적용 ...
|
||||
return $this->effectiveScopeCache[$identifier] = $result;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Permission 동기화 및 추적
|
||||
|
||||
### Role-Permission 동기화 패턴
|
||||
|
||||
역할에 권한을 동기화할 때 이전/이후 상태를 추적합니다:
|
||||
|
||||
```php
|
||||
public function syncPermissions(Role $role, array $permissions): void
|
||||
{
|
||||
// 동기화 전 권한 식별자 캡처 (Listener diff 계산용)
|
||||
$previousPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();
|
||||
|
||||
// 피벗 데이터에 granted_at, granted_by 자동 설정
|
||||
$pivotData = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$pivotData[$permission['id']] = [
|
||||
'scope_type' => $permission['scope_type'] ?? null,
|
||||
'granted_at' => now(),
|
||||
'granted_by' => Auth::id(),
|
||||
];
|
||||
}
|
||||
|
||||
$role->permissions()->sync($pivotData);
|
||||
|
||||
// 동기화 후 권한 식별자 (Listener에서 diff 계산 가능)
|
||||
$currentPermIdentifiers = $role->permissions()->pluck('identifier')->toArray();
|
||||
|
||||
HookManager::doAction('core.role.after_sync_permissions',
|
||||
$role, $previousPermIdentifiers, $currentPermIdentifiers);
|
||||
}
|
||||
```
|
||||
|
||||
### 자기잠금 방지
|
||||
|
||||
마지막 admin 역할 보유자가 자신의 admin 역할을 제거하는 것을 방지합니다:
|
||||
|
||||
```text
|
||||
- 관리자가 자기 자신의 역할을 변경하려 하면 → 역할 변경 무시
|
||||
- 마지막 admin이 admin 역할을 삭제하려 하면 → 거부
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 비회원(Guest) 권한
|
||||
|
||||
### Guest 권한 체크 흐름
|
||||
|
||||
```text
|
||||
비회원 요청 → PermissionMiddleware
|
||||
→ Guest 역할 조회 (Role::where('identifier', 'guest'))
|
||||
→ 정적 캐시: self::$guestRoleCache (요청 내 재사용)
|
||||
→ 권한 매핑 없음 → 기본 허용
|
||||
→ 권한 매핑 있음 → guest 역할이 해당 권한 보유하는지 확인
|
||||
```
|
||||
|
||||
### 핵심 규칙
|
||||
|
||||
| 상황 | 동작 |
|
||||
| ------ | ------ |
|
||||
| 권한 미매핑 (permission_hooks 없음) | 모든 사용자(guest 포함) 허용 |
|
||||
| 권한 매핑 + guest 역할 보유 | 허용 |
|
||||
| 권한 매핑 + guest 역할 미보유 | 거부 (401/403) |
|
||||
|
||||
---
|
||||
|
||||
## Role identifier 자동 생성
|
||||
|
||||
관리자가 역할 생성 시 `identifier`를 직접 입력하지 않습니다. `RoleService`가 이름에서 자동 생성합니다:
|
||||
|
||||
```text
|
||||
generateIdentifier(name)
|
||||
↓ 배열인 경우 → 영어 이름 우선 (en → ko → 첫 번째 값)
|
||||
↓ Str::slug($baseName, '_') → 슬러그 변환 (예: "Content Manager" → "content_manager")
|
||||
↓ 중복 체크 → 중복 시 _1, _2, ... suffix 자동 추가
|
||||
↓ 반환: 고유한 identifier
|
||||
```
|
||||
|
||||
**예시**:
|
||||
|
||||
| 입력 이름 | 생성 identifier |
|
||||
| ---------- | ---------- |
|
||||
| `Content Manager` | `content_manager` |
|
||||
| `Content Manager` (중복) | `content_manager_1` |
|
||||
| `['ko' => '편집자', 'en' => 'Editor']` | `editor` |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [menus.md](./menus.md) - 메뉴 권한 시스템
|
||||
- [hooks.md](./hooks.md) - 훅 권한 시스템
|
||||
- [module-basics.md](./module-basics.md) - 모듈 기본 구조
|
||||
- [index.md](./index.md) - 확장 시스템 인덱스
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,609 @@
|
||||
# 템플릿 시스템 기초
|
||||
|
||||
> **위치**: `docs/extension/template-basics.md`
|
||||
> **관련 문서**: [index.md](./index.md) | [template-routing.md](./template-routing.md) | [template-security.md](./template-security.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 타입: Admin (관리자용), User (일반사용자용)
|
||||
2. 디렉토리: templates/vendor-template (예: sirsoft-admin_basic)
|
||||
3. 필수: template.json, routes.json, components.json
|
||||
4. 코어/템플릿 분리: 코어는 엔진, 템플릿은 컴포넌트
|
||||
5. 버전 히스토리: Admin만 지원, User는 미지원
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [템플릿 타입](#템플릿-타입)
|
||||
- [Admin 템플릿](#admin-템플릿)
|
||||
- [User 템플릿](#user-템플릿)
|
||||
3. [템플릿 타입별 버전 히스토리 규칙](#템플릿-타입별-버전-히스토리-규칙)
|
||||
4. [템플릿 네이밍 규칙](#템플릿-네이밍-규칙)
|
||||
5. [템플릿 메타데이터 (template.json)](#템플릿-메타데이터-templatejson)
|
||||
6. [에러 페이지 설정 (error_config)](#에러-페이지-설정-error_config)
|
||||
7. [레이아웃 등록 스캔 범위](#레이아웃-등록-스캔-범위)
|
||||
8. [역호환성 (다국어 필드)](#역호환성-다국어-필드)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
G7의 템플릿 시스템은 JSON 기반 레이아웃 정의를 통해 동적으로 UI를 생성하는 프론트엔드 아키텍처입니다. 코어는 렌더링 엔진만 제공하고, 실제 컴포넌트는 템플릿별로 독립적으로 구현됩니다.
|
||||
|
||||
**핵심 원칙**:
|
||||
- 코어와 템플릿의 완전한 분리
|
||||
- JSON 기반 레이아웃 정의
|
||||
- 컴포넌트 재사용성 극대화
|
||||
- 다국어 및 권한 기반 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 타입
|
||||
|
||||
### Admin 템플릿
|
||||
|
||||
**용도**: 관리자 페이지 UI 제공
|
||||
|
||||
**특징**:
|
||||
- 레이아웃은 **읽기 전용** (DB 저장)
|
||||
- 관리자만 접근 가능
|
||||
- `/admin/*` 경로 전용
|
||||
- 예: `sirsoft-admin_basic`
|
||||
|
||||
### User 템플릿
|
||||
|
||||
**용도**: 일반 사용자 페이지 UI 제공
|
||||
|
||||
**특징**:
|
||||
- 레이아웃 **편집 가능** (사용자 커스터마이징)
|
||||
- 공개 접근 가능
|
||||
- `/` 루트 경로 전용
|
||||
- 예: `sirsoft-user_theme1` (추후 구현 예정)
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 타입별 버전 히스토리 규칙
|
||||
|
||||
```
|
||||
중요: 템플릿 타입에 따라 버전 히스토리 동작이 다름
|
||||
✅ 필수: Admin과 User 템플릿의 버전 관리 차이 이해
|
||||
```
|
||||
|
||||
### Admin 템플릿 (`type: "admin"`)
|
||||
|
||||
**버전 히스토리**: ❌ **사용하지 않음**
|
||||
|
||||
- 레이아웃은 읽기 전용 (수정 불가)
|
||||
- `template_layout_versions` 테이블 미사용
|
||||
- 레이아웃 변경 시 직접 `template_layouts.content` 덮어쓰기
|
||||
- 이유: 디자인 변경만 가능, 레이아웃 구조는 템플릿 교체로만 변경
|
||||
|
||||
**구현 예시**:
|
||||
```php
|
||||
// Admin 템플릿 레이아웃 업데이트
|
||||
public function updateAdminLayout(int $id, array $data): TemplateLayout
|
||||
{
|
||||
$layout = TemplateLayout::findOrFail($id);
|
||||
|
||||
// 버전 생성 없이 직접 수정
|
||||
$layout->update([
|
||||
'content' => $data['content'],
|
||||
]);
|
||||
|
||||
return $layout;
|
||||
}
|
||||
```
|
||||
|
||||
### User 템플릿 (`type: "user"`)
|
||||
|
||||
**버전 히스토리**: ✅ **자동 관리**
|
||||
|
||||
- 레이아웃 편집 가능 (사용자 커스터마이징)
|
||||
- `template_layout_versions` 테이블 사용
|
||||
- 레이아웃 수정 시 자동으로 새 버전 생성
|
||||
- 버전별 저장/복원 기능 제공
|
||||
|
||||
**구현 예시**:
|
||||
```php
|
||||
// User 템플릿 레이아웃 업데이트 (버전 자동 생성)
|
||||
public function updateUserLayout(int $id, array $data): TemplateLayout
|
||||
{
|
||||
$layout = TemplateLayout::findOrFail($id);
|
||||
|
||||
// 현재 버전 번호 조회
|
||||
$latestVersion = $layout->versions()->max('version') ?? 0;
|
||||
$newVersion = $latestVersion + 1;
|
||||
|
||||
// 새 버전 생성
|
||||
TemplateLayoutVersion::create([
|
||||
'layout_id' => $layout->id,
|
||||
'version' => $newVersion,
|
||||
'content' => $data['content'],
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 레이아웃 content도 업데이트
|
||||
$layout->update([
|
||||
'content' => $data['content'],
|
||||
]);
|
||||
|
||||
return $layout;
|
||||
}
|
||||
```
|
||||
|
||||
**버전 복원**:
|
||||
```php
|
||||
// 특정 버전으로 복원
|
||||
public function restoreVersion(int $layoutId, int $version): TemplateLayout
|
||||
{
|
||||
$layout = TemplateLayout::findOrFail($layoutId);
|
||||
$versionRecord = $layout->getVersion($version);
|
||||
|
||||
if (!$versionRecord) {
|
||||
throw new Exception('버전을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 복원 시 새 버전으로 저장
|
||||
$latestVersion = $layout->versions()->max('version') ?? 0;
|
||||
|
||||
TemplateLayoutVersion::create([
|
||||
'layout_id' => $layout->id,
|
||||
'version' => $latestVersion + 1,
|
||||
'content' => $versionRecord->content,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$layout->update([
|
||||
'content' => $versionRecord->content,
|
||||
]);
|
||||
|
||||
return $layout;
|
||||
}
|
||||
```
|
||||
|
||||
### 비교표
|
||||
|
||||
| 항목 | Admin 템플릿 | User 템플릿 |
|
||||
|------|------------|-----------|
|
||||
| 레이아웃 편집 | ❌ 불가 | ✅ 가능 |
|
||||
| 버전 히스토리 | ❌ 미사용 | ✅ 자동 생성 |
|
||||
| `template_layout_versions` | ❌ 미사용 | ✅ 사용 |
|
||||
| 레이아웃 복원 | ❌ 불가 | ✅ 가능 |
|
||||
| 변경 방법 | 템플릿 교체 | 직접 편집 |
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 네이밍 규칙
|
||||
|
||||
**형식**: `[vendor-template]` (GitHub 스타일)
|
||||
|
||||
- 소문자 사용
|
||||
- 하이픈(-) 구분
|
||||
- vendor: 개발자/조직 식별자
|
||||
- template: 템플릿명
|
||||
|
||||
**디렉토리 예시**:
|
||||
- `/templates/_bundled/sirsoft-admin_basic/` - sirsoft가 개발한 기본 관리자 템플릿
|
||||
- `/templates/_bundled/johndoe-admin_dark/` - johndoe가 개발한 다크 모드 관리자 템플릿
|
||||
|
||||
### 식별자 검증 규칙
|
||||
|
||||
모듈/플러그인/템플릿 공통 식별자 검증 규칙은 [extension-manager.md](./extension-manager.md#식별자-검증-규칙-validextensionidentifier)를 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 메타데이터 (template.json)
|
||||
|
||||
**위치**: `/templates/_bundled/[vendor-template]/template.json`
|
||||
|
||||
### 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"identifier": "sirsoft-admin_basic",
|
||||
"vendor": "sirsoft",
|
||||
"name": "Admin Basic",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"description": "Basic admin template for Gnuboard7 platform",
|
||||
"type": "admin",
|
||||
"locales": ["ko", "en"],
|
||||
"dependencies": [],
|
||||
"features": {
|
||||
"responsive": true,
|
||||
"darkMode": true,
|
||||
"rtl": false
|
||||
},
|
||||
"assets": {
|
||||
"css": ["dist/bundle.css"],
|
||||
"js": ["dist/bundle.js"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 필수 필드
|
||||
|
||||
| 필드 | 설명 |
|
||||
|------|------|
|
||||
| `identifier` | 템플릿 고유 식별자 (vendor-template 형식) |
|
||||
| `vendor` | 벤더명 |
|
||||
| `name` | 템플릿 표시명 |
|
||||
| `version` | 버전 (Semantic Versioning) |
|
||||
| `license` | 라이선스 유형 (예: `"MIT"`) — API 리소스의 `license` 필드로 노출 |
|
||||
| `type` | "admin" 또는 "user" |
|
||||
| `locales` | 지원 언어 배열 (`config('app.supported_locales')`와 매칭) |
|
||||
|
||||
---
|
||||
|
||||
## 에러 페이지 설정 (error_config)
|
||||
|
||||
```
|
||||
필수: 모든 템플릿은 에러 페이지 설정을 포함해야 함
|
||||
✅ 규칙: DB에는 layout_name만 저장되므로 경로 없이 이름만 사용
|
||||
```
|
||||
|
||||
### template.json 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"error_config": {
|
||||
"layouts": {
|
||||
"404": "404",
|
||||
"403": "403",
|
||||
"500": "500"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 에러 레이아웃 파일 위치
|
||||
|
||||
에러 레이아웃은 반드시 `layouts/errors/` 디렉토리에 위치해야 합니다:
|
||||
|
||||
```
|
||||
templates/_bundled/[vendor-template]/
|
||||
├── template.json # 메타데이터 (이름, 버전, 라이선스 등 SSoT)
|
||||
├── seo-config.json # SEO 컴포넌트→HTML 매핑 설정 (선택)
|
||||
├── LICENSE # 라이선스 전문 (MIT) — API 엔드포인트 `GET /api/admin/templates/{identifier}/license`로 제공
|
||||
├── layouts/
|
||||
│ ├── dashboard.json # 일반 레이아웃
|
||||
│ ├── _admin_base.json # 베이스 레이아웃
|
||||
│ └── errors/ # 에러 레이아웃 전용
|
||||
│ ├── 404.json
|
||||
│ ├── 403.json
|
||||
│ └── 500.json
|
||||
```
|
||||
|
||||
### 에러 레이아웃 구조
|
||||
|
||||
에러 레이아웃도 일반 레이아웃과 동일하게 `extends` + `slots` 패턴을 사용합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "_admin_base",
|
||||
"slots": {
|
||||
"content": [
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "EmptyState",
|
||||
"props": {
|
||||
"icon": "alert-triangle",
|
||||
"title": "$t:errors.404.title",
|
||||
"description": "$t:errors.404.description"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 설치 시 검증
|
||||
|
||||
`TemplateManager::installTemplate()` 호출 시 다음 사항이 자동 검증됩니다:
|
||||
|
||||
1. `error_config` 섹션 존재 여부
|
||||
2. 필수 에러 코드(404, 403, 500) 레이아웃 정의 여부
|
||||
3. 레이아웃 파일 실제 존재 여부 (`layouts/errors/` 디렉토리 내)
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 등록 스캔 범위
|
||||
|
||||
```
|
||||
중요: registerLayouts()는 특정 디렉토리만 스캔
|
||||
✅ 포함: layouts/*.json (루트) + layouts/errors/*.json
|
||||
❌ 제외: layouts/partials/ 등 기타 하위 디렉토리
|
||||
```
|
||||
|
||||
### 스캔 대상
|
||||
|
||||
| 디렉토리 | 스캔 여부 | 용도 |
|
||||
|----------|----------|------|
|
||||
| `layouts/*.json` | ✅ 스캔 | 일반 레이아웃 |
|
||||
| `layouts/errors/*.json` | ✅ 스캔 | 에러 페이지 레이아웃 |
|
||||
| `layouts/partials/*.json` | ❌ 제외 | extends로 참조되는 부분 레이아웃 |
|
||||
|
||||
### 이유
|
||||
|
||||
- `partials/` 디렉토리의 파일은 메인 레이아웃에서 `extends`로 참조되어 병합됨
|
||||
- 직접 라우팅되지 않으므로 DB에 별도 등록 불필요
|
||||
- 에러 레이아웃은 `ErrorPageHandler`에서 직접 조회하므로 DB 등록 필요
|
||||
|
||||
### TemplateManager.php 구현
|
||||
|
||||
```php
|
||||
// 루트 layouts 디렉토리의 JSON 파일만 스캔
|
||||
$layoutFiles = File::glob("{$layoutsPath}/*.json");
|
||||
|
||||
// errors/ 디렉토리만 추가 스캔 (에러 페이지 레이아웃용)
|
||||
$errorsPath = "{$layoutsPath}/errors";
|
||||
if (File::exists($errorsPath)) {
|
||||
$errorLayoutFiles = File::glob("{$errorsPath}/*.json");
|
||||
$layoutFiles = array_merge($layoutFiles, $errorLayoutFiles);
|
||||
}
|
||||
```
|
||||
|
||||
### 모듈 레이아웃 자동 등록
|
||||
|
||||
모듈 활성화 시 레이아웃은 디렉토리 위치에 따라 해당 타입의 템플릿에 등록됩니다:
|
||||
|
||||
- `modules/{module}/resources/layouts/admin/*.json` → Admin 타입 템플릿에 등록
|
||||
- `modules/{module}/resources/layouts/user/*.json` → User 타입 템플릿에 등록
|
||||
|
||||
이 동작은 `ModuleManager::registerModuleLayouts()`에서 처리됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 역호환성 (다국어 필드)
|
||||
|
||||
```
|
||||
중요: 다국어 필드의 역호환성 지원
|
||||
✅ 필수: 문자열 → 다국어 배열 자동 변환
|
||||
```
|
||||
|
||||
### 지원 필드
|
||||
|
||||
- `name`: 템플릿명
|
||||
- `description`: 템플릿 설명
|
||||
|
||||
### ModuleInterface/PluginInterface와 동일한 패턴
|
||||
|
||||
```php
|
||||
// ✅ 권장: 다국어 배열 반환
|
||||
public function getName(): array
|
||||
{
|
||||
return [
|
||||
'ko' => '기본 관리자 템플릿',
|
||||
'en' => 'Basic Admin Template',
|
||||
];
|
||||
}
|
||||
|
||||
// 역호환: 문자열 반환 (자동 변환됨)
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Basic Admin Template'; // 자동으로 ['ko' => '...', 'en' => '...']로 변환
|
||||
}
|
||||
```
|
||||
|
||||
### TemplateManager 자동 변환
|
||||
|
||||
```php
|
||||
// app/Extension/TemplateManager.php
|
||||
|
||||
/**
|
||||
* 문자열을 다국어 배열로 자동 변환
|
||||
*/
|
||||
protected function convertToMultilingual($value): array
|
||||
{
|
||||
// 이미 배열이면 그대로 반환
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// 문자열이면 모든 로케일에 동일한 값 설정
|
||||
if (is_string($value)) {
|
||||
$locales = config('app.translatable_locales', ['ko', 'en']);
|
||||
$result = [];
|
||||
foreach ($locales as $locale) {
|
||||
$result[$locale] = $value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 그 외 타입은 빈 배열 반환
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
### template.json 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"identifier": "sirsoft-admin_basic",
|
||||
"vendor": "sirsoft",
|
||||
|
||||
// ✅ 권장: 다국어 객체
|
||||
"name": {
|
||||
"ko": "기본 관리자 템플릿",
|
||||
"en": "Basic Admin Template"
|
||||
},
|
||||
"description": {
|
||||
"ko": "그누보드7용 기본 관리자 템플릿",
|
||||
"en": "Basic admin template for Gnuboard7 platform"
|
||||
}
|
||||
|
||||
// 역호환: 문자열 (자동 변환됨)
|
||||
// "name": "Basic Admin Template",
|
||||
// "description": "Basic admin template for Gnuboard7 platform"
|
||||
}
|
||||
```
|
||||
|
||||
### DB 저장 형식
|
||||
|
||||
```php
|
||||
// templates 테이블
|
||||
[
|
||||
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
||||
'description' => ['ko' => 'G7용...', 'en' => 'Basic admin template...'],
|
||||
]
|
||||
```
|
||||
|
||||
### 역호환성 보장 사항
|
||||
|
||||
- ✅ 기존 문자열 기반 template.json 정상 작동
|
||||
- ✅ 문자열 → 다국어 배열 자동 변환
|
||||
- ✅ 변환된 값은 모든 로케일에 동일하게 적용
|
||||
- ✅ Model Accessor (`getLocalizedName()`)에서 올바른 폴백 처리
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 업데이트 레이아웃 충돌 전략
|
||||
|
||||
템플릿 버전 업데이트 시, 관리자가 수정한 레이아웃과 새 버전의 레이아웃이 충돌할 수 있습니다.
|
||||
|
||||
### 사전 확인
|
||||
|
||||
업데이트 전 수정된 레이아웃을 확인할 수 있습니다:
|
||||
|
||||
```text
|
||||
GET /api/admin/templates/{templateName}/check-modified-layouts
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_modified": true,
|
||||
"modified_layouts": [
|
||||
{ "id": 1, "name": "dashboard", "updated_at": "2026-02-24T10:00:00Z" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 충돌 해결 전략
|
||||
|
||||
업데이트 실행 시 `layout_strategy` 파라미터로 전략을 선택합니다:
|
||||
|
||||
| 전략 | 동작 |
|
||||
|------|------|
|
||||
| `apply_new` | 새 버전의 레이아웃으로 덮어쓰기 (관리자 수정 사항 유실) |
|
||||
| `keep_current` | 기존 레이아웃 유지 (새 버전 레이아웃 미적용) |
|
||||
|
||||
```text
|
||||
POST /api/admin/templates/{templateName}/update
|
||||
Body: { "layout_strategy": "apply_new" }
|
||||
```
|
||||
|
||||
> 상세: [extension-update-system.md](./extension-update-system.md)
|
||||
|
||||
---
|
||||
|
||||
## 번들 디렉토리 작업 규칙
|
||||
|
||||
```text
|
||||
필수: 템플릿 수정/개발은 _bundled 디렉토리에서만 작업 (활성 디렉토리 직접 수정 금지)
|
||||
필수: _bundled 작업 완료 후 반영/검증은 업데이트 프로세스 사용
|
||||
```
|
||||
|
||||
### 개발 워크플로우
|
||||
|
||||
```text
|
||||
1. templates/_bundled/{identifier}/ 에서 코드 수정
|
||||
2. template.json 버전 올리기
|
||||
3. php artisan template:update {identifier} 로 활성 디렉토리에 반영
|
||||
4. 테스트 실행으로 검증
|
||||
```
|
||||
|
||||
### 왜 활성 디렉토리 직접 수정이 금지되는가?
|
||||
|
||||
- 활성 디렉토리는 `.gitignore` 대상 → Git에 변경 기록 불가
|
||||
- 다음 업데이트 시 `_bundled` 소스로 덮어쓰기 → 직접 수정 사항 유실
|
||||
- 업데이트 프로세스 미수행 시 레이아웃 갱신/캐시 무효화 누락
|
||||
|
||||
### 예외: 초기 개발 (아직 _bundled에 미등록)
|
||||
|
||||
```text
|
||||
✅ 허용: 신규 템플릿 초기 개발 시 활성 디렉토리에서 직접 작업
|
||||
전환점: _bundled에 최초 반영한 이후부터는 반드시 _bundled에서만 작업
|
||||
```
|
||||
|
||||
> 상세: [extension-update-system.md](./extension-update-system.md) "번들 디렉토리 개발 워크플로우" 참조
|
||||
|
||||
---
|
||||
|
||||
## 코드 변경 시 버전 변경 필수
|
||||
|
||||
```text
|
||||
필수: 템플릿 코드를 변경한 경우 버전을 올려야 합니다.
|
||||
버전 변경 없이 _bundled에 반영하면, 이미 설치된 환경에서 업데이트가 감지되지 않습니다.
|
||||
참고: 템플릿은 모듈/플러그인과 달리 업그레이드 스텝(upgrades/)을 사용하지 않습니다.
|
||||
```
|
||||
|
||||
### 필수 작업
|
||||
|
||||
1. **`template.json` 버전 올리기**: `version` 필드를 Semantic Versioning에 따라 증가
|
||||
2. **`_bundled` 동기화**: `templates/_bundled/{identifier}/` 디렉토리에 변경 사항 반영
|
||||
|
||||
### 템플릿 vs 모듈/플러그인 차이
|
||||
|
||||
| 항목 | 템플릿 | 모듈/플러그인 |
|
||||
|------|--------|-------------|
|
||||
| manifest 버전 올리기 | ✅ 필수 | ✅ 필수 |
|
||||
| _bundled 동기화 | ✅ 필수 | ✅ 필수 |
|
||||
| 업그레이드 스텝 (upgrades/) | ❌ 해당 없음 | 조건부 필요 |
|
||||
| DB 마이그레이션 | ❌ 해당 없음 | 조건부 필요 |
|
||||
| 레이아웃 충돌 전략 | ✅ `layout_strategy` 파라미터 | ❌ 해당 없음 |
|
||||
|
||||
> 상세: [extension-update-system.md](./extension-update-system.md) "개발자 버전 업데이트 가이드" 참조
|
||||
|
||||
---
|
||||
|
||||
## SEO 설정 (seo-config.json)
|
||||
|
||||
**위치**: `templates/_bundled/[vendor-template]/seo-config.json` (선택 파일)
|
||||
|
||||
SEO 페이지 생성기가 컴포넌트를 HTML로 변환할 때 사용하는 매핑 설정입니다. 이 파일이 없으면 모든 컴포넌트가 `<div>` fallback으로 렌더링됩니다.
|
||||
|
||||
### 주요 섹션
|
||||
|
||||
| 섹션 | 설명 |
|
||||
|------|------|
|
||||
| `text_props` | 텍스트 추출 우선순위 (예: `["text", "label", "value", "title"]`) |
|
||||
| `attr_map` | props→HTML 속성 매핑 (예: `{"className": "class", "htmlFor": "for"}`) |
|
||||
| `allowed_attrs` | 허용 HTML 속성 목록 (목록에 없는 속성은 출력 안됨) |
|
||||
| `component_map` | 컴포넌트명 → HTML 태그 매핑 (기본 30개 + 커스텀) |
|
||||
| `render_modes` | 렌더 모드 정의 (iterate/format/raw 타입) |
|
||||
| `self_closing` | 셀프 클로징 태그 목록 (`["img", "input", "hr", "br"]`) |
|
||||
| `stylesheets` | SEO 페이지에 포함할 외부 CSS URL |
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
- `text_props`/`attr_map`/`allowed_attrs` 미선언 → 엔진 내장 기본값 사용 (범용 HTML/React 매핑)
|
||||
- `text_props` 빈 배열로 명시 → props에서 텍스트 추출 불가 (`component.text`는 여전히 동작)
|
||||
- `attr_map` 빈 객체로 명시 → 속성명 변환 없음 (className→class 미적용)
|
||||
- `allowed_attrs` 빈 배열로 명시 → 모든 속성 출력 차단
|
||||
- seo-config.json 미존재 → 모든 컴포넌트 `<div>` fallback, 기본 속성 매핑으로 최소한의 HTML 생성
|
||||
|
||||
### 검증
|
||||
|
||||
`TemplateManager.validateSeoConfig()`가 설치/업데이트 시 자동 검증합니다:
|
||||
- JSON 파싱, `component_map.*.tag` 필수, `render` → `render_modes` 교차 참조
|
||||
- 검증 실패 시 설치/업데이트 차단
|
||||
- 파일 미존재 시 경고만 (설치 허용)
|
||||
|
||||
> 상세 스키마: [seo-system.md](../backend/seo-system.md) "템플릿 seo-config.json" 섹션 참조
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](./index.md) - 확장 시스템 인덱스
|
||||
- [template-routing.md](./template-routing.md) - 템플릿 라우트/언어 파일 규칙
|
||||
- [template-security.md](./template-security.md) - 템플릿 보안 정책
|
||||
- [template-caching.md](./template-caching.md) - 템플릿 캐싱 전략
|
||||
- [template-commands.md](./template-commands.md) - 템플릿 Artisan 커맨드
|
||||
- [extension-update-system.md](./extension-update-system.md) - 확장 업데이트 시스템
|
||||
@@ -0,0 +1,523 @@
|
||||
# 템플릿 캐싱 전략
|
||||
|
||||
> **관련 문서**: [template-basics.md](./template-basics.md) | [template-security.md](./template-security.md) | [index.md](./index.md)
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [핵심 원칙](#핵심-원칙)
|
||||
2. [캐싱 계층](#캐싱-계층)
|
||||
3. [확장 기능 캐시 버전 (Cache Busting)](#확장-기능-캐시-버전-cache-busting)
|
||||
4. [캐시 무효화 구현](#캐시-무효화-구현)
|
||||
5. [서비스에서 캐시 무효화 호출](#서비스에서-캐시-무효화-호출)
|
||||
6. [캐싱 모범 사례](#캐싱-모범-사례)
|
||||
7. [성능 벤치마크](#성능-벤치마크)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```
|
||||
중요: 다층 캐싱으로 성능 최적화
|
||||
✅ 필수: 캐시 무효화 규칙 준수
|
||||
```
|
||||
|
||||
템플릿 시스템은 여러 계층의 캐싱을 통해 성능을 최적화합니다. 각 캐시는 적절한 TTL(Time To Live)을 가지며, 데이터 변경 시 정확한 무효화가 필수입니다.
|
||||
|
||||
---
|
||||
|
||||
## 캐싱 계층
|
||||
|
||||
### 1. 활성 템플릿 캐시
|
||||
|
||||
```php
|
||||
// 키: templates.active.{type}
|
||||
// TTL: 3600초 (1시간)
|
||||
// 값: Template 모델 객체
|
||||
|
||||
Cache::remember("templates.active.admin", 3600, function () {
|
||||
return Template::where('type', 'admin')
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
});
|
||||
```
|
||||
|
||||
**무효화 시점**:
|
||||
- 템플릿 활성화/비활성화 시
|
||||
- 템플릿 삭제 시
|
||||
|
||||
---
|
||||
|
||||
### 2. 레이아웃 JSON 캐시
|
||||
|
||||
```php
|
||||
// 키: template.layout.{layout_name}.{locale}
|
||||
// TTL: 1800초 (30분)
|
||||
// 값: 병합된 레이아웃 JSON 배열
|
||||
|
||||
Cache::remember("template.layout.dashboard.ko", 1800, function () use ($layoutName, $locale) {
|
||||
return $this->layoutService->getLayout($layoutName, $locale);
|
||||
});
|
||||
```
|
||||
|
||||
**무효화 시점**:
|
||||
- 레이아웃 생성/수정/삭제 시
|
||||
- 상위 레이아웃(extends) 변경 시
|
||||
- 로케일 변경 시
|
||||
|
||||
---
|
||||
|
||||
### 3. 병합된 레이아웃 캐시
|
||||
|
||||
상속 체인 처리 결과를 캐싱합니다.
|
||||
|
||||
```php
|
||||
// 키: template.layout.merged.{layout_name}.{locale}
|
||||
// TTL: 3600초 (1시간)
|
||||
// 값: extends/slots 병합 완료된 최종 레이아웃
|
||||
|
||||
Cache::remember("template.layout.merged.dashboard.ko", 3600, function () use ($layoutName, $locale) {
|
||||
return $this->layoutService->mergeLayouts($layoutName, [], 0, $locale);
|
||||
});
|
||||
```
|
||||
|
||||
**무효화 시점**:
|
||||
- 레이아웃 JSON 캐시 무효화 시 함께 무효화
|
||||
- 상속 체인의 어느 레이아웃이든 변경 시
|
||||
|
||||
---
|
||||
|
||||
### 4. 컴포넌트 레지스트리 캐시
|
||||
|
||||
```php
|
||||
// 키: template.components.{identifier}
|
||||
// TTL: 무제한 (수동 무효화)
|
||||
// 값: components.json 파싱 결과
|
||||
|
||||
Cache::rememberForever("template.components.sirsoft-admin_basic", function () use ($identifier) {
|
||||
$componentsPath = base_path("templates/{$identifier}/components.json");
|
||||
return json_decode(file_get_contents($componentsPath), true);
|
||||
});
|
||||
```
|
||||
|
||||
**무효화 시점**:
|
||||
- 템플릿 재설치 시
|
||||
- components.json 수정 시 (개발 중)
|
||||
|
||||
---
|
||||
|
||||
## 확장 기능 캐시 버전 (Cache Busting)
|
||||
|
||||
### 개요
|
||||
|
||||
모듈/플러그인/템플릿 활성화/비활성화 시 프론트엔드가 최신 데이터를 즉시 반영하도록 버전 기반 캐시 버스팅을 구현합니다.
|
||||
|
||||
```
|
||||
문제 상황: 브라우저 HTTP 캐시로 인해 모듈 활성화 후 새로고침(Ctrl+F5) 전까지
|
||||
다국어/라우트 데이터가 반영되지 않음
|
||||
✅ 해결책: 캐시 버전(타임스탬프) 기반 쿼리 파라미터로 캐시 무효화
|
||||
```
|
||||
|
||||
### 백엔드 구현
|
||||
|
||||
#### ClearsTemplateCaches Trait
|
||||
|
||||
모듈/플러그인/템플릿 매니저에서 사용하는 공통 trait입니다.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
trait ClearsTemplateCaches
|
||||
{
|
||||
/**
|
||||
* 확장 기능 캐시 버전을 증가시킵니다.
|
||||
*
|
||||
* 모듈/플러그인 활성화/비활성화 시 호출되어
|
||||
* 프론트엔드가 새로운 캐시 버전으로 API를 요청하도록 합니다.
|
||||
*/
|
||||
protected function incrementExtensionCacheVersion(): void
|
||||
{
|
||||
Cache::put('extension_cache_version', time());
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 확장 기능 캐시 버전을 반환합니다.
|
||||
*
|
||||
* @return int 캐시 버전 (타임스탬프) 또는 0 (미설정 시)
|
||||
*/
|
||||
public static function getExtensionCacheVersion(): int
|
||||
{
|
||||
return (int) Cache::get('extension_cache_version', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 활성 템플릿의 언어 캐시를 무효화합니다.
|
||||
*/
|
||||
protected function clearAllTemplateLanguageCaches(): void
|
||||
{
|
||||
$locales = config('app.supported_locales', ['ko', 'en']);
|
||||
$templates = Template::where('status', ExtensionStatus::Active->value)->get();
|
||||
|
||||
foreach ($templates as $template) {
|
||||
foreach ($locales as $locale) {
|
||||
Cache::forget("template.language.{$template->identifier}.{$locale}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 활성 템플릿의 routes 캐시를 무효화합니다.
|
||||
*/
|
||||
protected function clearAllTemplateRoutesCaches(): void
|
||||
{
|
||||
$templates = Template::where('status', ExtensionStatus::Active->value)->get();
|
||||
|
||||
foreach ($templates as $template) {
|
||||
Cache::forget("template.routes.{$template->identifier}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 캐시 버전 증가 트리거 시점
|
||||
|
||||
| 매니저 | 메서드 | 트리거 상황 |
|
||||
|--------|--------|------------|
|
||||
| ModuleManager | `activate()` | 모듈 활성화 |
|
||||
| ModuleManager | `deactivate()` | 모듈 비활성화 |
|
||||
| ModuleManager | `refreshModuleLayouts()` | 레이아웃 갱신 시 변경 발생 |
|
||||
| PluginManager | `activate()` | 플러그인 활성화 |
|
||||
| PluginManager | `deactivate()` | 플러그인 비활성화 |
|
||||
| TemplateManager | `activate()` | 템플릿 활성화 |
|
||||
| TemplateManager | `refreshTemplateLayouts()` | 레이아웃 갱신 시 변경 발생 |
|
||||
|
||||
### 프론트엔드 구현
|
||||
|
||||
#### 캐시 버전 저장 및 관리
|
||||
|
||||
```typescript
|
||||
// LayoutLoader.ts
|
||||
private cacheVersion: number = 0;
|
||||
|
||||
constructor() {
|
||||
// localStorage에서 캐시 버전 복원
|
||||
const stored = localStorage.getItem('g7_extension_cache_version');
|
||||
this.cacheVersion = stored ? parseInt(stored, 10) : 0;
|
||||
}
|
||||
|
||||
setCacheVersion(version: number): void {
|
||||
const oldVersion = this.cacheVersion;
|
||||
this.cacheVersion = version;
|
||||
localStorage.setItem('g7_extension_cache_version', String(version));
|
||||
|
||||
// 버전 변경 시 캐시 무효화
|
||||
if (oldVersion !== 0 && oldVersion !== version) {
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### API 요청 시 버전 파라미터 추가
|
||||
|
||||
```typescript
|
||||
// LayoutLoader.ts
|
||||
async loadLayout(layoutName: string): Promise<LayoutConfig> {
|
||||
let url = `/api/layout/${layoutName}`;
|
||||
if (this.cacheVersion > 0) {
|
||||
url += `?v=${this.cacheVersion}`;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
// TranslationEngine.ts
|
||||
async loadTranslations(identifier: string, locale: string): Promise<void> {
|
||||
let url = `/api/template/${identifier}/language/${locale}`;
|
||||
const params: string[] = [];
|
||||
|
||||
if (this.cacheVersion > 0) {
|
||||
params.push(`v=${this.cacheVersion}`);
|
||||
}
|
||||
|
||||
if (params.length > 0) {
|
||||
url += `?${params.join('&')}`;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 앱 초기화 시 캐시 버전 설정
|
||||
|
||||
```typescript
|
||||
// TemplateApp.ts
|
||||
async init(): Promise<void> {
|
||||
// 서버에서 캐시 버전 조회
|
||||
const response = await fetch('/api/extension/cache-version');
|
||||
const { version } = await response.json();
|
||||
|
||||
// 각 엔진에 버전 설정
|
||||
this.layoutLoader.setCacheVersion(version);
|
||||
this.translationEngine.setCacheVersion(version);
|
||||
}
|
||||
```
|
||||
|
||||
### 캐시 버전 API 엔드포인트
|
||||
|
||||
```php
|
||||
// routes/api.php
|
||||
Route::get('/extension/cache-version', function () {
|
||||
return response()->json([
|
||||
'version' => ClearsTemplateCaches::getExtensionCacheVersion(),
|
||||
]);
|
||||
});
|
||||
```
|
||||
|
||||
### 동작 흐름
|
||||
|
||||
```
|
||||
1. 사용자가 모듈 활성화 버튼 클릭
|
||||
2. 백엔드: ModuleManager->activate() 실행
|
||||
3. 백엔드: incrementExtensionCacheVersion() 호출 → 캐시 버전 = 현재 타임스탬프
|
||||
4. 백엔드: clearAllTemplateLanguageCaches() 호출 → 서버 캐시 무효화
|
||||
5. 프론트엔드: 페이지 새로고침 또는 SPA 라우트 이동
|
||||
6. 프론트엔드: /api/extension/cache-version 조회
|
||||
7. 프론트엔드: 버전 변경 감지 → localStorage 업데이트 + 내부 캐시 클리어
|
||||
8. 프론트엔드: API 요청 시 ?v={version} 파라미터 추가
|
||||
9. 브라우저: 새로운 URL로 인식하여 캐시 우회 → 최신 데이터 수신
|
||||
```
|
||||
|
||||
### 캐시 버전 키 요약
|
||||
|
||||
| 저장소 | 키 | 값 | 용도 |
|
||||
|--------|-----|-----|------|
|
||||
| Laravel Cache | `extension_cache_version` | 타임스탬프 | 서버 측 버전 관리 |
|
||||
| localStorage | `g7_extension_cache_version` | 타임스탬프 | 클라이언트 측 버전 유지 |
|
||||
|
||||
---
|
||||
|
||||
## 캐시 무효화 구현
|
||||
|
||||
### TemplateCacheService
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Template;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class TemplateCacheService
|
||||
{
|
||||
/**
|
||||
* 템플릿 활성화 캐시 무효화
|
||||
*/
|
||||
public function invalidateActiveTemplate(string $type): void
|
||||
{
|
||||
Cache::forget("templates.active.{$type}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 캐시 무효화
|
||||
*/
|
||||
public function invalidateLayout(string $layoutName): void
|
||||
{
|
||||
$locales = config('app.supported_locales', ['ko', 'en']);
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
Cache::forget("template.layout.{$layoutName}.{$locale}");
|
||||
Cache::forget("template.layout.merged.{$layoutName}.{$locale}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 상속 체인 캐시 무효화
|
||||
*/
|
||||
public function invalidateLayoutChain(string $layoutName): void
|
||||
{
|
||||
// 현재 레이아웃 무효화
|
||||
$this->invalidateLayout($layoutName);
|
||||
|
||||
// 이 레이아웃을 extends하는 모든 자식 레이아웃 무효화
|
||||
$children = TemplateLayout::where('extends', $layoutName)->get();
|
||||
|
||||
foreach ($children as $child) {
|
||||
$this->invalidateLayoutChain($child->layout_name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 레지스트리 캐시 무효화
|
||||
*/
|
||||
public function invalidateComponentRegistry(string $identifier): void
|
||||
{
|
||||
Cache::forget("template.components.{$identifier}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 전체 캐시 무효화
|
||||
*/
|
||||
public function invalidateAllTemplateCache(string $identifier): void
|
||||
{
|
||||
// 활성 템플릿 캐시 무효화
|
||||
$template = Template::where('identifier', $identifier)->first();
|
||||
if ($template) {
|
||||
$this->invalidateActiveTemplate($template->type);
|
||||
}
|
||||
|
||||
// 모든 레이아웃 캐시 무효화
|
||||
$layouts = TemplateLayout::where('template_id', $template->id)->get();
|
||||
foreach ($layouts as $layout) {
|
||||
$this->invalidateLayoutChain($layout->layout_name);
|
||||
}
|
||||
|
||||
// 컴포넌트 레지스트리 캐시 무효화
|
||||
$this->invalidateComponentRegistry($identifier);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서비스에서 캐시 무효화 호출
|
||||
|
||||
### LayoutService 예시
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Template;
|
||||
|
||||
class LayoutService
|
||||
{
|
||||
public function __construct(
|
||||
private TemplateCacheService $cacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 레이아웃 수정
|
||||
*/
|
||||
public function updateLayout(int $id, array $data): TemplateLayout
|
||||
{
|
||||
$layout = TemplateLayout::findOrFail($id);
|
||||
$oldExtends = $layout->extends;
|
||||
|
||||
$layout->update($data);
|
||||
|
||||
// 캐시 무효화
|
||||
$this->cacheService->invalidateLayoutChain($layout->layout_name);
|
||||
|
||||
// extends 변경 시 이전 부모의 자식들도 무효화
|
||||
if ($oldExtends && $oldExtends !== $layout->extends) {
|
||||
$this->cacheService->invalidateLayoutChain($oldExtends);
|
||||
}
|
||||
|
||||
return $layout->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 활성화
|
||||
*/
|
||||
public function activateTemplate(string $identifier): Template
|
||||
{
|
||||
$template = Template::where('identifier', $identifier)->firstOrFail();
|
||||
|
||||
// 동일 타입의 기존 활성 템플릿 비활성화
|
||||
Template::where('type', $template->type)
|
||||
->where('is_active', true)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
$template->update(['is_active' => true]);
|
||||
|
||||
// 캐시 무효화
|
||||
$this->cacheService->invalidateActiveTemplate($template->type);
|
||||
|
||||
return $template->fresh();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 레이아웃 편집(관리자 커스터마이징) 시 캐시 무효화
|
||||
|
||||
관리자 UI에서 레이아웃을 직접 편집/복원할 때도 캐시 무효화가 필수입니다.
|
||||
|
||||
`LayoutService`의 저장(`updateLayoutContent`) 및 버전 복원(`restoreVersion`) 메서드에서:
|
||||
|
||||
1. **`clearDependentLayoutsCache()`** 호출 — 해당 레이아웃 + 이를 extends하는 자식 레이아웃의 병합 캐시 + `PublicLayoutController` 서빙 캐시(`layout.{identifier}.{name}.v{version}`)를 재귀적으로 무효화
|
||||
2. **`extension_cache_version` 증가** (`Cache::put('extension_cache_version', time())`) — CDN/브라우저 HTTP 캐시 busting. `PublicLayoutController`가 `?v={version}` 기반 캐싱을 사용하므로, 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다.
|
||||
|
||||
```php
|
||||
// LayoutService::updateLayoutContent() 내부
|
||||
$layout = $this->layoutRepository->update($layout->id, $updateData);
|
||||
|
||||
// 캐시 무효화 (현재 레이아웃 + 자식 레이아웃 + PublicLayoutController 서빙 캐시)
|
||||
$this->clearDependentLayoutsCache($templateId, $name);
|
||||
|
||||
// 프론트엔드 브라우저 캐시 무효화
|
||||
Cache::put('extension_cache_version', time());
|
||||
```
|
||||
|
||||
```php
|
||||
// LayoutService::restoreVersion() 내부
|
||||
$newVersion = $this->versionRepository->restoreVersion($layout->id, $versionId);
|
||||
|
||||
// 동일하게 캐시 무효화
|
||||
$this->clearDependentLayoutsCache($templateId, $name);
|
||||
Cache::put('extension_cache_version', time());
|
||||
```
|
||||
|
||||
이 과정이 누락되면 편집/복원 내용이 실제 사이트에 반영되지 않습니다.
|
||||
|
||||
> 참고: `clearDependentLayoutsCache()`는 내부적으로 `clearLayoutCache()`를 호출하며, 이 메서드는 병합 캐시 삭제 + `clearPublicServingCache()`(기본 v0 + 현재 extension_cache_version 캐시 삭제) + 모듈 레이아웃인 경우 `LayoutResolverService` 캐시도 무효화합니다.
|
||||
|
||||
---
|
||||
|
||||
## 캐싱 모범 사례
|
||||
|
||||
### DO (권장)
|
||||
|
||||
- ✅ 읽기 빈도가 높은 데이터는 캐싱 필수 (활성 템플릿, 레이아웃 JSON)
|
||||
- ✅ TTL은 데이터 변경 빈도에 따라 조정
|
||||
- ✅ 상속 체인 무효화는 재귀적으로 처리
|
||||
- ✅ 로케일별 캐싱으로 다국어 지원
|
||||
- ✅ 캐시 키에 의미 있는 이름 사용
|
||||
|
||||
### DON'T (금지)
|
||||
|
||||
- ❌ 캐시 무효화 누락 금지 (데이터 불일치 발생)
|
||||
- ❌ 과도한 캐싱으로 메모리 낭비 금지
|
||||
- ❌ TTL 없이 무제한 캐싱 남용 금지 (컴포넌트 레지스트리 제외)
|
||||
|
||||
---
|
||||
|
||||
## 성능 벤치마크
|
||||
|
||||
참고용 성능 비교 데이터입니다.
|
||||
|
||||
| 상태 | 응답 시간 | 개선율 |
|
||||
|------|----------|--------|
|
||||
| 캐싱 없음 | 120ms | - |
|
||||
| 캐싱 적용 | 5ms | 95% ↓ |
|
||||
|
||||
---
|
||||
|
||||
## 캐시 키 요약
|
||||
|
||||
| 캐시 타입 | 키 패턴 | TTL |
|
||||
|----------|---------|-----|
|
||||
| 활성 템플릿 | `templates.active.{type}` | 3600초 (1시간) |
|
||||
| 레이아웃 JSON | `template.layout.{name}.{locale}` | 1800초 (30분) |
|
||||
| 병합된 레이아웃 | `template.layout.merged.{name}.{locale}` | 3600초 (1시간) |
|
||||
| 컴포넌트 레지스트리 | `template.components.{identifier}` | 무제한 |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [템플릿 기초](./template-basics.md) - 템플릿 타입, 메타데이터
|
||||
- [템플릿 보안](./template-security.md) - API 서빙, 화이트리스트
|
||||
- [템플릿 커맨드](./template-commands.md) - Artisan 커맨드
|
||||
- [확장 시스템 인덱스](./index.md) - 전체 문서 목록
|
||||
@@ -0,0 +1,381 @@
|
||||
# 템플릿 Artisan 커맨드
|
||||
|
||||
> 템플릿 관련 모든 작업은 Artisan 커맨드를 통해 수행합니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 목록: php artisan template:list
|
||||
2. 설치: php artisan template:install [identifier]
|
||||
3. 활성화: php artisan template:activate [identifier]
|
||||
4. 비활성화: php artisan template:deactivate [identifier]
|
||||
5. 빌드: php artisan template:build [identifier]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [핵심 원칙](#핵심-원칙)
|
||||
2. [사용 가능한 커맨드](#사용-가능한-커맨드)
|
||||
3. [커맨드 구현 규칙](#커맨드-구현-규칙)
|
||||
4. [커맨드 테스트](#커맨드-테스트)
|
||||
5. [문제 해결](#문제-해결)
|
||||
6. [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```
|
||||
필수: 템플릿 관련 모든 작업은 Artisan 커맨드로 수행
|
||||
✅ 규칙: 직접 DB 조작 또는 파일 시스템 변경 금지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 사용 가능한 커맨드
|
||||
|
||||
### template:list
|
||||
|
||||
템플릿 목록을 조회합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:list
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- 설치된 모든 템플릿 표시
|
||||
- 활성화 상태, 타입, 버전 정보 포함
|
||||
|
||||
---
|
||||
|
||||
### template:install
|
||||
|
||||
템플릿을 설치합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:install [vendor-template]
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- `templates` 테이블에 템플릿 등록
|
||||
- Vite 빌드 실행 (`npm run build`)
|
||||
- 컴포넌트 레지스트리 캐시 생성
|
||||
- 다국어 파일 검증
|
||||
|
||||
---
|
||||
|
||||
### template:activate
|
||||
|
||||
템플릿을 활성화합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:activate [vendor-template]
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- `templates.is_active` 플래그 설정
|
||||
- 동일 타입의 기존 활성 템플릿 비활성화
|
||||
- 캐시 무효화 (`templates.active.{type}`)
|
||||
|
||||
---
|
||||
|
||||
### template:deactivate
|
||||
|
||||
템플릿을 비활성화합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:deactivate [vendor-template]
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- `templates.is_active` 플래그 해제
|
||||
- 관련 캐시 무효화
|
||||
|
||||
---
|
||||
|
||||
### template:uninstall
|
||||
|
||||
템플릿을 삭제합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:uninstall [vendor-template]
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- 템플릿 데이터 삭제 (`templates`, `template_layouts`)
|
||||
- 의존성 있는 템플릿 확인 후 경고
|
||||
- 캐시 전체 무효화
|
||||
|
||||
---
|
||||
|
||||
### template:check-updates
|
||||
|
||||
템플릿 업데이트를 확인합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:check-updates [identifier?]
|
||||
```
|
||||
|
||||
**인수**:
|
||||
- `identifier` (선택): 특정 템플릿만 확인
|
||||
|
||||
**기능**:
|
||||
|
||||
- **전체 확인** (인수 없음): 설치된 모든 템플릿의 업데이트 가용 여부를 테이블로 출력
|
||||
- 감지 우선순위: GitHub > `_bundled` (2단계, `_pending` 미참여)
|
||||
- DB에 `update_available`, `latest_version`, `update_source` 갱신
|
||||
- **단일 확인** (인수 있음): 특정 템플릿의 업데이트 가용 여부 확인
|
||||
- `checkTemplateUpdate()`는 DB 갱신 안 함 → 커맨드에서 `updateByIdentifier()` 직접 호출
|
||||
|
||||
**예시**:
|
||||
```bash
|
||||
# 모든 템플릿 업데이트 확인
|
||||
php artisan template:check-updates
|
||||
|
||||
# 특정 템플릿만 확인
|
||||
php artisan template:check-updates sirsoft-admin_basic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### template:update
|
||||
|
||||
템플릿을 업데이트합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force]
|
||||
```
|
||||
|
||||
**인수**:
|
||||
- `identifier` (필수): 업데이트할 템플릿 식별자
|
||||
|
||||
**옵션**:
|
||||
- `--layout-strategy=overwrite`: 레이아웃 전략 (`overwrite` | `keep`)
|
||||
- `--force`: 버전 비교 없이 강제 업데이트 (파일 손상, 수동 수정 복원 시 사용)
|
||||
|
||||
**수행 작업**:
|
||||
- 레이아웃 전략 검증 (`overwrite` | `keep` 외 FAILURE)
|
||||
- 업데이트 가용 여부 확인 (없으면 info + SUCCESS, `--force` 시 무시하고 진행)
|
||||
- `overwrite` 전략 시 수정된 레이아웃 경고 (`hasModifiedLayouts()`)
|
||||
- 확인 프롬프트 → 업데이트 실행
|
||||
- 실패 시 백업 복원 안내
|
||||
|
||||
**예시**:
|
||||
```bash
|
||||
# 기본 전략(overwrite)으로 업데이트
|
||||
php artisan template:update sirsoft-admin_basic
|
||||
|
||||
# 레이아웃 보존 전략
|
||||
php artisan template:update sirsoft-admin_basic --layout-strategy=keep
|
||||
|
||||
# 강제 재설치
|
||||
php artisan template:update sirsoft-admin_basic --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### template:cache-clear
|
||||
|
||||
레이아웃 캐시를 초기화합니다.
|
||||
|
||||
```bash
|
||||
php artisan template:cache-clear
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- 모든 템플릿 관련 캐시 삭제
|
||||
- 활성 템플릿 캐시
|
||||
- 레이아웃 JSON 캐시
|
||||
- 컴포넌트 레지스트리 캐시
|
||||
|
||||
---
|
||||
|
||||
### template:build
|
||||
|
||||
템플릿의 프론트엔드 에셋을 빌드합니다 (기본: _bundled 디렉토리).
|
||||
|
||||
```bash
|
||||
php artisan template:build [identifier]
|
||||
```
|
||||
|
||||
> **기본 빌드 경로**: `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
|
||||
> 활성 디렉토리 반영은 `template:update` 커맨드로만 수행.
|
||||
|
||||
**옵션**:
|
||||
|
||||
- `--all`: 모든 템플릿의 에셋 빌드 (_bundled 기준, --active 시 활성 기준)
|
||||
- `--watch`: 파일 변경 감시 모드 (자동으로 활성 디렉토리 사용)
|
||||
- `--production`: 프로덕션 최적화 빌드
|
||||
- `--active`: 활성 디렉토리에서 빌드 (_bundled 대신)
|
||||
|
||||
**예시**:
|
||||
|
||||
```bash
|
||||
# _bundled에서 빌드 (기본값)
|
||||
php artisan template:build sirsoft-admin_basic
|
||||
|
||||
# 모든 _bundled 템플릿 빌드
|
||||
php artisan template:build --all
|
||||
|
||||
# 프로덕션 빌드 (_bundled)
|
||||
php artisan template:build sirsoft-admin_basic --production
|
||||
|
||||
# 파일 감시 모드 (활성 디렉토리에서 자동 실행)
|
||||
php artisan template:build sirsoft-admin_basic --watch
|
||||
|
||||
# 활성 디렉토리에서 빌드
|
||||
php artisan template:build sirsoft-admin_basic --active
|
||||
|
||||
# 빌드 후 활성 디렉토리 반영
|
||||
php artisan template:update sirsoft-admin_basic
|
||||
```
|
||||
|
||||
**수행 작업**:
|
||||
|
||||
- 빌드 경로 결정: _bundled 우선 → 활성 폴백 (--active 시 활성만)
|
||||
- npm 의존성 설치 (node_modules 없는 경우)
|
||||
- Vite를 통한 컴포넌트 번들 빌드
|
||||
- `dist/components.iife.js` 생성
|
||||
- 빌드 결과 파일 크기 출력
|
||||
- **extension_cache_version 증가 (프론트엔드 캐시 무효화)**
|
||||
- 주의: `--watch` 모드에서는 캐시 버전이 증가하지 않음
|
||||
|
||||
---
|
||||
|
||||
## 커맨드 구현 규칙
|
||||
|
||||
### 1. 다국어 메시지 필수
|
||||
|
||||
```php
|
||||
// ✅ DO: 다국어 메시지 사용
|
||||
$this->info(__('templates.commands.install.success', ['template' => $identifier]));
|
||||
|
||||
// ❌ DON'T: 하드코딩 메시지
|
||||
$this->info('템플릿 설치 성공');
|
||||
```
|
||||
|
||||
### 2. 로그 기록 필수
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
// 성공 로그
|
||||
Log::info(__('templates.commands.install.success', ['template' => $identifier]));
|
||||
|
||||
// 에러 로그
|
||||
Log::error('템플릿 설치 실패', [
|
||||
'template' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
```
|
||||
|
||||
### 3. 예외 처리 패턴
|
||||
|
||||
```php
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
// 커맨드 로직
|
||||
|
||||
$this->info('✅ ' . __('templates.commands.install.success', ['template' => $identifier]));
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error('❌ ' . $e->getMessage());
|
||||
Log::error('템플릿 설치 실패', [
|
||||
'template' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 상세 정보 출력
|
||||
|
||||
```php
|
||||
// 템플릿 정보 출력
|
||||
$this->info(' - ' . __('templates.commands.install.type', ['type' => __('templates.types.' . $template->type)]));
|
||||
$this->info(' - ' . __('templates.commands.install.version', ['version' => $template->version]));
|
||||
$this->info(' - ' . __('templates.commands.install.layouts_created', ['count' => $template->layouts()->count()]));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 커맨드 테스트
|
||||
|
||||
Feature 테스트 작성이 필수입니다.
|
||||
|
||||
```php
|
||||
public function test_can_install_template_via_command(): void
|
||||
{
|
||||
// Arrange
|
||||
$identifier = 'test-template';
|
||||
|
||||
// Act
|
||||
$this->artisan('template:install', ['identifier' => $identifier])
|
||||
->assertSuccessful();
|
||||
|
||||
// Assert
|
||||
$this->assertDatabaseHas('templates', [
|
||||
'identifier' => $identifier,
|
||||
'is_active' => false,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 컴포넌트가 렌더링되지 않음
|
||||
|
||||
**원인**: ComponentRegistry에 미등록
|
||||
|
||||
**해결**:
|
||||
1. `components.json`에 컴포넌트 추가
|
||||
2. `npm run build` 실행
|
||||
3. 캐시 초기화: `php artisan template:cache-clear`
|
||||
|
||||
---
|
||||
|
||||
### 레이아웃 JSON 저장 실패
|
||||
|
||||
**원인**: 검증 규칙 위반
|
||||
|
||||
**해결**:
|
||||
1. 에러 메시지 확인 (FormRequest 검증)
|
||||
2. 엔드포인트 화이트리스트 확인
|
||||
3. 컴포넌트명 오타 확인
|
||||
4. JSON 구조 검증 (최대 깊이 10)
|
||||
|
||||
---
|
||||
|
||||
### Vite 빌드 에러
|
||||
|
||||
**원인**: 의존성 누락 또는 TypeScript 오류
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# 의존성 재설치
|
||||
npm install
|
||||
|
||||
# TypeScript 체크
|
||||
npm run type-check
|
||||
|
||||
# 빌드 로그 확인
|
||||
npm run build -- --debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [템플릿 기초](template-basics.md) - 템플릿 타입, 메타데이터
|
||||
- [템플릿 라우트](template-routing.md) - routes.json, 언어 파일
|
||||
- [템플릿 보안](template-security.md) - API 서빙, 화이트리스트
|
||||
- [템플릿 캐싱](template-caching.md) - 캐시 계층, 무효화
|
||||
- [인덱스로 돌아가기](index.md)
|
||||
@@ -0,0 +1,366 @@
|
||||
# 템플릿 라우트/언어 파일 규칙
|
||||
|
||||
> **관련 문서**: [index.md](index.md) | [template-basics.md](template-basics.md) | [template-security.md](template-security.md)
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [라우트 정의 (routes.json)](#라우트-정의-routesjson)
|
||||
2. [템플릿 언어 파일 경로 규칙](#템플릿-언어-파일-경로-규칙)
|
||||
3. [TemplateService 구현](#templateservice-구현)
|
||||
4. [API Controller 구현](#api-controller-구현)
|
||||
5. [라우트 정의](#라우트-정의)
|
||||
6. [프론트엔드에서 사용](#프론트엔드에서-사용)
|
||||
7. [보안 규칙](#보안-규칙)
|
||||
8. [에러 처리](#에러-처리)
|
||||
9. [모범 사례](#모범-사례)
|
||||
10. [template.json과의 관계](#templatejson과의-관계)
|
||||
|
||||
---
|
||||
|
||||
## 라우트 정의 (routes.json)
|
||||
|
||||
**위치**: `/templates/[vendor-template]/routes.json`
|
||||
|
||||
### 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"path": "/admin/dashboard",
|
||||
"layout_name": "admin_dashboard",
|
||||
"permissions": ["core.dashboard.view"],
|
||||
"meta": {
|
||||
"title": "대시보드",
|
||||
"icon": "dashboard"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/admin/users",
|
||||
"layout_name": "admin_users_list",
|
||||
"permissions": ["core.users.view"],
|
||||
"meta": {
|
||||
"title": "사용자 관리",
|
||||
"icon": "users"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 필드 설명
|
||||
|
||||
| 필드 | 설명 |
|
||||
|------|------|
|
||||
| `path` | 라우트 경로 (Laravel 라우트와 일치) |
|
||||
| `layout_name` | `template_layouts` 테이블의 레이아웃명 |
|
||||
| `permissions` | 필요한 권한 배열 (빈 배열 시 인증만 필요) |
|
||||
| `meta` | 메타 정보 (제목, 아이콘 등) |
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 언어 파일 경로 규칙
|
||||
|
||||
```
|
||||
중요: 템플릿 언어 파일은 API를 통해 제공
|
||||
✅ 필수: 경로 트래버설 방지 및 로케일 검증
|
||||
```
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
| 항목 | 규칙 |
|
||||
|------|------|
|
||||
| **파일 위치** | `/templates/{vendor-template}/lang/{locale}.json` |
|
||||
| **로케일 형식** | ISO 639-1 (2자리 소문자, 예: `ko`, `en`, `ja`) |
|
||||
| **제공 방식** | API 엔드포인트 (`/api/templates/{identifier}/lang/{locale}`) |
|
||||
| **검증** | supported_locales 확인, 경로 탈출 방지 |
|
||||
|
||||
### 언어 파일 구조
|
||||
|
||||
```json
|
||||
// /templates/sirsoft-admin_basic/lang/ko.json
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "대시보드",
|
||||
"subtitle": "시스템 개요",
|
||||
"stats": {
|
||||
"users": "총 사용자 수",
|
||||
"orders": "주문 수"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"list": "템플릿 목록",
|
||||
"install": "템플릿 설치",
|
||||
"activate": "활성화"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// /templates/sirsoft-admin_basic/lang/en.json
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "System Overview",
|
||||
"stats": {
|
||||
"users": "Total Users",
|
||||
"orders": "Orders"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"list": "Template List",
|
||||
"install": "Install Template",
|
||||
"activate": "Activate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TemplateService 구현
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Template;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class TemplateService
|
||||
{
|
||||
/**
|
||||
* 템플릿 언어 파일 경로 반환
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @param string $locale 로케일 (ko, en 등)
|
||||
* @return array{success: bool, filePath?: string, error?: string}
|
||||
*/
|
||||
public function getLanguageFilePath(string $identifier, string $locale): array
|
||||
{
|
||||
// 1. 템플릿 활성화 여부 확인
|
||||
if (!$this->isTemplateActive($identifier)) {
|
||||
return ['success' => false, 'error' => 'Template not active'];
|
||||
}
|
||||
|
||||
// 2. 로케일 형식 검증 (ISO 639-1: 2자리 소문자)
|
||||
if (!preg_match('/^[a-z]{2}$/', $locale)) {
|
||||
return ['success' => false, 'error' => 'Invalid locale format'];
|
||||
}
|
||||
|
||||
// 3. supported_locales 확인
|
||||
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
|
||||
if (!in_array($locale, $supportedLocales)) {
|
||||
return ['success' => false, 'error' => 'Locale not supported'];
|
||||
}
|
||||
|
||||
// 4. 파일 경로 생성
|
||||
$basePath = base_path("templates/{$identifier}/lang");
|
||||
$filePath = "{$basePath}/{$locale}.json";
|
||||
|
||||
// 5. 경로 트래버설 방지
|
||||
$realPath = realpath($filePath);
|
||||
$realBasePath = realpath($basePath);
|
||||
|
||||
if (!$realPath || !str_starts_with($realPath, $realBasePath)) {
|
||||
return ['success' => false, 'error' => 'Invalid file path'];
|
||||
}
|
||||
|
||||
// 6. 파일 존재 확인
|
||||
if (!File::exists($realPath) || !is_file($realPath)) {
|
||||
return ['success' => false, 'error' => 'Language file not found'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'filePath' => $realPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 언어 데이터 로드
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @param string $locale 로케일
|
||||
* @return array
|
||||
*/
|
||||
public function getLanguageData(string $identifier, string $locale): array
|
||||
{
|
||||
$result = $this->getLanguageFilePath($identifier, $locale);
|
||||
|
||||
if (!$result['success']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = File::get($result['filePath']);
|
||||
return json_decode($content, true) ?? [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Controller 구현
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Public;
|
||||
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use App\Services\Template\TemplateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TemplateLanguageController extends PublicBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private TemplateService $templateService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 템플릿 언어 파일 제공
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @param string $locale 로케일 (ko, en 등)
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function getLanguageFile(string $identifier, string $locale): JsonResponse
|
||||
{
|
||||
$data = $this->templateService->getLanguageData($identifier, $locale);
|
||||
|
||||
if (empty($data)) {
|
||||
return $this->notFound('messages.template.language_file_not_found');
|
||||
}
|
||||
|
||||
return response()->json($data, 200, [
|
||||
'Content-Type' => 'application/json',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 라우트 정의
|
||||
|
||||
```php
|
||||
// routes/api.php
|
||||
Route::get('/templates/{identifier}/lang/{locale}', [TemplateLanguageController::class, 'getLanguageFile'])
|
||||
->where('locale', '[a-z]{2}')
|
||||
->name('api.templates.language');
|
||||
```
|
||||
|
||||
**주요 포인트**:
|
||||
- `where('locale', '[a-z]{2}')`: ISO 639-1 형식만 허용
|
||||
- 라우트 레벨에서 잘못된 형식 차단
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드에서 사용
|
||||
|
||||
### React 컴포넌트
|
||||
|
||||
```javascript
|
||||
// React 컴포넌트에서 언어 파일 로드
|
||||
const loadLanguageFile = async (identifier, locale) => {
|
||||
const response = await fetch(`/api/templates/${identifier}/lang/${locale}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to load language file');
|
||||
return {};
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
// 사용 예시
|
||||
const translations = await loadLanguageFile('sirsoft-admin_basic', 'ko');
|
||||
console.log(translations.dashboard.title); // "대시보드"
|
||||
```
|
||||
|
||||
### Blade 템플릿
|
||||
|
||||
```php
|
||||
<!-- resources/views/app.blade.php -->
|
||||
<script>
|
||||
window.TEMPLATE_LANG_URL = "{{ route('api.templates.language', [
|
||||
'identifier' => $activeTemplate->identifier,
|
||||
'locale' => app()->getLocale()
|
||||
]) }}";
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 보안 규칙
|
||||
|
||||
### 허용 (✅)
|
||||
|
||||
- 활성화된 템플릿만 접근 가능
|
||||
- ISO 639-1 형식 검증 (정규표현식: `/^[a-z]{2}$/`)
|
||||
- supported_locales 화이트리스트 확인
|
||||
- 경로 트래버설 방지 (`realpath()`, `str_starts_with()`)
|
||||
- 파일 확장자 검증 (`.json`만 허용)
|
||||
- 적절한 캐싱 헤더 (`Cache-Control: max-age=3600`)
|
||||
|
||||
### 금지 (❌)
|
||||
|
||||
- 비활성 템플릿 언어 파일 접근 금지
|
||||
- 임의의 파일 경로 접근 금지
|
||||
- supported_locales 외 로케일 접근 금지
|
||||
|
||||
---
|
||||
|
||||
## 에러 처리
|
||||
|
||||
| 요청 | 응답 | 사유 |
|
||||
|------|------|------|
|
||||
| `GET /api/templates/sirsoft-admin_basic/lang/ko-KR` | 404 Not Found | 라우트 패턴 불일치 |
|
||||
| `GET /api/templates/sirsoft-admin_basic/lang/ja` | 404 Not Found | supported_locales에 없음 |
|
||||
| `GET /api/templates/inactive-template/lang/ko` | 404 Not Found | 템플릿 미활성화 |
|
||||
| `GET /api/templates/sirsoft-admin_basic/lang/fr` | 404 Not Found | 파일 존재하지 않음 |
|
||||
|
||||
---
|
||||
|
||||
## 모범 사례
|
||||
|
||||
### 권장 사항 (✅)
|
||||
|
||||
- 모든 템플릿은 최소 `ko.json`, `en.json` 필수 제공
|
||||
- JSON 파일은 UTF-8 인코딩 사용
|
||||
- 키 구조는 계층적으로 구성 (`dashboard.stats.users`)
|
||||
- API를 통한 동적 로딩으로 초기 로딩 시간 최적화
|
||||
- 브라우저 캐싱 활용 (max-age=3600)
|
||||
|
||||
### 금지 사항 (❌)
|
||||
|
||||
- 언어 파일에 HTML 포함 금지 (XSS 방지)
|
||||
- 하드코딩된 텍스트 사용 금지 (모든 텍스트는 언어 파일에서)
|
||||
|
||||
---
|
||||
|
||||
## template.json과의 관계
|
||||
|
||||
```json
|
||||
// template.json
|
||||
{
|
||||
"identifier": "sirsoft-admin_basic",
|
||||
"locales": ["ko", "en"], // 이 배열에 포함된 언어만 제공
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 언어 결정 규칙
|
||||
|
||||
- `template.json`의 `locales` 배열이 제공 가능한 언어 명세
|
||||
- `config('app.supported_locales')`와 교집합이 실제 제공 언어
|
||||
- 예: `template.json`에 `["ko", "en", "ja"]`, `supported_locales`에 `["ko", "en"]`이면 `ko`, `en`만 제공
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [index.md](index.md) - 확장 시스템 가이드 인덱스
|
||||
- [template-basics.md](template-basics.md) - 템플릿 기초
|
||||
- [template-security.md](template-security.md) - 템플릿 보안 정책
|
||||
- [template-caching.md](template-caching.md) - 템플릿 캐싱 전략
|
||||
@@ -0,0 +1,390 @@
|
||||
# 템플릿 보안 정책
|
||||
|
||||
> **목적**: 템플릿 시스템의 보안 정책 및 API 서빙 규칙 가이드
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [레이아웃 JSON 검증](#1-레이아웃-json-검증)
|
||||
2. [의존성 검증](#2-의존성-검증)
|
||||
3. [XSS 방지](#3-xss-방지)
|
||||
4. [템플릿 API 서빙 규칙](#4-템플릿-api-서빙-규칙)
|
||||
5. [파일 확장자 화이트리스트](#5-파일-확장자-화이트리스트)
|
||||
|
||||
---
|
||||
|
||||
## 1. 레이아웃 JSON 검증
|
||||
|
||||
### 검증 계층
|
||||
|
||||
```
|
||||
FormRequest (StoreLayoutRequest)
|
||||
↓
|
||||
Custom Rule: ValidLayoutStructure
|
||||
↓
|
||||
Custom Rule: WhitelistedEndpoint
|
||||
↓
|
||||
Custom Rule: NoExternalUrls
|
||||
↓
|
||||
Custom Rule: ComponentExists
|
||||
```
|
||||
|
||||
### 검증 항목
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| JSON 구조 유효성 | 올바른 JSON 형식인지 검증 |
|
||||
| 최대 중첩 깊이 | 10단계 제한 |
|
||||
| 엔드포인트 화이트리스트 | `/api/(admin\|auth\|public)/` 패턴만 허용 |
|
||||
| 외부 URL 금지 | 외부 도메인 URL 차단 |
|
||||
| 컴포넌트 존재 여부 | components.json 기준으로 검증 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 의존성 검증
|
||||
|
||||
### 템플릿 설치 시 체크
|
||||
|
||||
- 필수 의존성 템플릿 설치 여부
|
||||
- 순환 의존성 방지
|
||||
- 버전 호환성 (Semantic Versioning)
|
||||
|
||||
### 예시
|
||||
|
||||
```json
|
||||
// template.json
|
||||
{
|
||||
"dependencies": {
|
||||
"sirsoft-component-library": "^1.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. XSS 방지
|
||||
|
||||
### 데이터 바인딩
|
||||
|
||||
- 모든 `{{data.field}}` 값은 자동 이스케이프
|
||||
- `dangerouslySetInnerHTML` 사용 금지
|
||||
- HTML 삽입 시 서버 검증 필수
|
||||
|
||||
### Translation
|
||||
|
||||
- `$t:key` 값은 다국어 파일에서만 로드
|
||||
- 사용자 입력 키 금지
|
||||
|
||||
---
|
||||
|
||||
## 4. 템플릿 API 서빙 규칙
|
||||
|
||||
```
|
||||
중요: 템플릿 에셋은 API를 통해 제공
|
||||
✅ 필수: 파일 복사 대신 API 엔드포인트 사용
|
||||
```
|
||||
|
||||
### 원칙
|
||||
|
||||
- 템플릿 설치 시 빌드된 파일(`/templates/[vendor-template]/dist/`)은 그대로 유지
|
||||
- `/public/build/templates/` 디렉토리에 파일 복사하지 않음
|
||||
- API 엔드포인트를 통해 동적으로 템플릿 에셋 제공
|
||||
- 보안: 활성화된 템플릿의 에셋만 접근 가능
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
```php
|
||||
// routes/api.php
|
||||
Route::get('/templates/{identifier}/assets/{file}', [TemplateAssetController::class, 'serve'])
|
||||
->where('file', '.*')
|
||||
->name('api.templates.assets');
|
||||
```
|
||||
|
||||
### Controller 구현 (TemplateAssetController)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Public;
|
||||
|
||||
use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use App\Services\Template\TemplateService;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class TemplateAssetController extends PublicBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private TemplateService $templateService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 템플릿 에셋 파일 제공
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @param string $file 파일 경로 (예: dist/components.js)
|
||||
* @return BinaryFileResponse|Response
|
||||
*/
|
||||
public function serve(string $identifier, string $file): BinaryFileResponse|Response
|
||||
{
|
||||
// 1. 템플릿 활성화 여부 확인
|
||||
if (!$this->templateService->isTemplateActive($identifier)) {
|
||||
return $this->notFound('messages.template.not_active');
|
||||
}
|
||||
|
||||
// 2. 파일 경로 검증 (보안)
|
||||
$allowedExtensions = ['js', 'js.map', 'css', 'css.map', 'woff', 'woff2', 'ttf', 'eot', 'svg', 'png', 'jpg', 'jpeg', 'gif'];
|
||||
$extension = pathinfo($file, PATHINFO_EXTENSION);
|
||||
|
||||
if (!in_array($extension, $allowedExtensions)) {
|
||||
return $this->forbidden('messages.template.invalid_file_type');
|
||||
}
|
||||
|
||||
// 3. 실제 파일 경로 생성
|
||||
$basePath = base_path("templates/{$identifier}");
|
||||
$filePath = realpath("{$basePath}/{$file}");
|
||||
|
||||
// 4. 디렉토리 탈출 방지
|
||||
if (!$filePath || !str_starts_with($filePath, $basePath)) {
|
||||
return $this->forbidden('messages.template.invalid_path');
|
||||
}
|
||||
|
||||
// 5. 파일 존재 확인
|
||||
if (!file_exists($filePath) || !is_file($filePath)) {
|
||||
return $this->notFound('messages.template.file_not_found');
|
||||
}
|
||||
|
||||
// 6. MIME 타입 설정
|
||||
$mimeTypes = [
|
||||
'js' => 'application/javascript',
|
||||
'css' => 'text/css',
|
||||
'map' => 'application/json',
|
||||
'woff' => 'font/woff',
|
||||
'woff2' => 'font/woff2',
|
||||
'ttf' => 'font/ttf',
|
||||
'eot' => 'application/vnd.ms-fontobject',
|
||||
'svg' => 'image/svg+xml',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
];
|
||||
|
||||
$mimeType = $mimeTypes[$extension] ?? 'application/octet-stream';
|
||||
|
||||
// 7. 캐싱 헤더 추가
|
||||
return response()->file($filePath, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Cache-Control' => 'public, max-age=31536000, immutable',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service 메서드
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Template;
|
||||
|
||||
class TemplateService
|
||||
{
|
||||
/**
|
||||
* 템플릿 활성화 여부 확인
|
||||
*/
|
||||
public function isTemplateActive(string $identifier): bool
|
||||
{
|
||||
return Cache::remember(
|
||||
"template.active.{$identifier}",
|
||||
3600,
|
||||
fn() => Template::where('identifier', $identifier)
|
||||
->where('is_active', true)
|
||||
->exists()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Blade 사용 예시
|
||||
|
||||
```php
|
||||
<!-- resources/views/app.blade.php -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- 활성 템플릿 컴포넌트 로드 -->
|
||||
@if($activeTemplate)
|
||||
<script src="{{ route('api.templates.assets', [
|
||||
'identifier' => $activeTemplate->identifier,
|
||||
'file' => 'dist/components.js'
|
||||
]) }}" defer></script>
|
||||
@endif
|
||||
</head>
|
||||
<body>
|
||||
<!-- 앱 컨텐츠 -->
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 보안 규칙
|
||||
|
||||
| 규칙 | 설명 |
|
||||
|------|------|
|
||||
| ✅ 활성화된 템플릿만 접근 가능 | 비활성 템플릿 에셋 접근 차단 |
|
||||
| ✅ 화이트리스트 기반 확장자 검증 | 허용된 확장자만 제공 |
|
||||
| ✅ 디렉토리 탈출 공격 방지 | `realpath()`, `str_starts_with()` 사용 |
|
||||
| ✅ MIME 타입 명시적 설정 | Content-Type 헤더 지정 |
|
||||
| ✅ 적절한 캐싱 헤더 | `Cache-Control`, `immutable` |
|
||||
| ❌ 비활성 템플릿 에셋 접근 금지 | 활성화 여부 검증 필수 |
|
||||
| ❌ 임의의 파일 경로 접근 금지 | 경로 검증 필수 |
|
||||
|
||||
### 장점
|
||||
|
||||
1. **보안 강화**: 활성화된 템플릿만 접근 가능
|
||||
2. **디스크 공간 절약**: 파일 복사 불필요
|
||||
3. **동적 제어**: 템플릿 비활성화 시 즉시 접근 차단
|
||||
4. **캐싱 최적화**: HTTP 캐싱으로 성능 유지
|
||||
5. **유지보수 용이**: 단일 소스(dist 디렉토리)만 관리
|
||||
|
||||
---
|
||||
|
||||
## 5. 파일 확장자 화이트리스트
|
||||
|
||||
```
|
||||
중요: 템플릿 에셋 파일 확장자 제한
|
||||
✅ 필수: 화이트리스트 기반 확장자 검증
|
||||
```
|
||||
|
||||
### 허용 확장자 목록
|
||||
|
||||
#### 스크립트 파일
|
||||
|
||||
| 확장자 | 설명 |
|
||||
|--------|------|
|
||||
| `js` | JavaScript |
|
||||
| `mjs` | ES Module JavaScript |
|
||||
| `js.map` | JavaScript 소스맵 |
|
||||
|
||||
#### 스타일 파일
|
||||
|
||||
| 확장자 | 설명 |
|
||||
|--------|------|
|
||||
| `css` | Cascading Style Sheets |
|
||||
| `css.map` | CSS 소스맵 |
|
||||
|
||||
#### 폰트 파일
|
||||
|
||||
| 확장자 | 설명 |
|
||||
|--------|------|
|
||||
| `woff` | Web Open Font Format |
|
||||
| `woff2` | Web Open Font Format 2 |
|
||||
| `ttf` | TrueType Font |
|
||||
| `otf` | OpenType Font |
|
||||
| `eot` | Embedded OpenType |
|
||||
|
||||
#### 이미지 파일
|
||||
|
||||
| 확장자 | 설명 |
|
||||
|--------|------|
|
||||
| `png` | Portable Network Graphics |
|
||||
| `jpg` | JPEG Image |
|
||||
| `jpeg` | JPEG Image |
|
||||
| `svg` | Scalable Vector Graphics |
|
||||
| `webp` | WebP Image |
|
||||
| `gif` | Graphics Interchange Format |
|
||||
|
||||
#### 데이터 파일
|
||||
|
||||
| 확장자 | 설명 |
|
||||
|--------|------|
|
||||
| `json` | JSON 데이터 (components.json, template.json 등) |
|
||||
|
||||
### TemplateService 구현
|
||||
|
||||
```php
|
||||
// app/Services/TemplateService.php
|
||||
|
||||
/**
|
||||
* 허용된 파일 확장자 화이트리스트
|
||||
*/
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
'js', 'mjs', 'css', 'json',
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif',
|
||||
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||
];
|
||||
|
||||
/**
|
||||
* 파일 확장자 검증
|
||||
*/
|
||||
private function validateFileExtension(string $filePath): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
|
||||
// .js.map, .css.map 처리
|
||||
if (str_ends_with($filePath, '.map')) {
|
||||
$extension = 'map';
|
||||
}
|
||||
|
||||
return in_array($extension, self::ALLOWED_EXTENSIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* MIME 타입 매핑
|
||||
*/
|
||||
private function getMimeType(string $extension): string
|
||||
{
|
||||
$mimeTypes = [
|
||||
'js' => 'application/javascript',
|
||||
'mjs' => 'application/javascript',
|
||||
'css' => 'text/css',
|
||||
'json' => 'application/json',
|
||||
'map' => 'application/json',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'svg' => 'image/svg+xml',
|
||||
'webp' => 'image/webp',
|
||||
'gif' => 'image/gif',
|
||||
'woff' => 'font/woff',
|
||||
'woff2' => 'font/woff2',
|
||||
'ttf' => 'font/ttf',
|
||||
'otf' => 'font/otf',
|
||||
'eot' => 'application/vnd.ms-fontobject',
|
||||
];
|
||||
|
||||
return $mimeTypes[$extension] ?? 'application/octet-stream';
|
||||
}
|
||||
```
|
||||
|
||||
### 보안 규칙
|
||||
|
||||
| 규칙 | 설명 |
|
||||
|------|------|
|
||||
| ✅ 화이트리스트에 명시된 확장자만 허용 | 목록 외 차단 |
|
||||
| ✅ 대소문자 구분 없이 검증 | `strtolower()` 사용 |
|
||||
| ✅ 소스맵 파일 특별 처리 | `.js.map`, `.css.map` |
|
||||
| 실행 파일 확장자 금지 | `.php`, `.sh`, `.exe` 등 |
|
||||
| ❌ 서버 설정 파일 금지 | `.htaccess`, `.env` 등 |
|
||||
| ❌ 화이트리스트 외 모든 확장자 차단 | 기본 거부 정책 |
|
||||
|
||||
### 금지 확장자 예시 (절대 추가 금지)
|
||||
|
||||
| 카테고리 | 확장자 |
|
||||
|----------|--------|
|
||||
| PHP 실행 파일 | `.php`, `.phar` |
|
||||
| 실행 스크립트 | `.sh`, `.bat`, `.exe` |
|
||||
| 설정 파일 | `.env`, `.htaccess`, `.conf` |
|
||||
| 데이터베이스 파일 | `.sql`, `.db` |
|
||||
| 압축 파일 (보안 위험) | `.zip`, `.tar`, `.gz` |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [template-basics.md](template-basics.md) - 템플릿 기초
|
||||
- [template-routing.md](template-routing.md) - 템플릿 라우트/언어 파일 규칙
|
||||
- [template-caching.md](template-caching.md) - 템플릿 캐싱 전략
|
||||
- [template-commands.md](template-commands.md) - 템플릿 Artisan 커맨드
|
||||
@@ -0,0 +1,685 @@
|
||||
# 템플릿 개발 워크플로우
|
||||
|
||||
> **위치**: `docs/extension/template-workflow.md`
|
||||
> **관련 문서**: [template-basics.md](./template-basics.md) | [template-commands.md](./template-commands.md) | [template-development.md](../frontend/template-development.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 필수 파일: template.json, routes.json, _base.json, errors/{404,403,500}.json
|
||||
2. 빌드 설정: package.json, vite.config.ts (IIFE 빌드, terser 대신 esbuild)
|
||||
3. 검증 순서: JSON 파싱 → layout_name 필수 → error_config → 에러 레이아웃 파일 존재
|
||||
4. 컴포넌트: 템플릿에 실제 존재하는 것만 import (없는 컴포넌트 import 시 빌드 실패)
|
||||
5. 설치: php artisan template:build → php artisan template:install → php artisan template:activate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [템플릿 생성 체크리스트](#템플릿-생성-체크리스트)
|
||||
3. [Phase 1: 필수 메타데이터 파일](#phase-1-필수-메타데이터-파일)
|
||||
4. [Phase 2: 레이아웃 파일](#phase-2-레이아웃-파일)
|
||||
5. [Phase 3: 빌드 설정](#phase-3-빌드-설정)
|
||||
6. [Phase 4: 컴포넌트 설정](#phase-4-컴포넌트-설정)
|
||||
7. [Phase 5: 다국어 파일](#phase-5-다국어-파일)
|
||||
8. [Phase 6: 빌드 및 설치](#phase-6-빌드-및-설치)
|
||||
9. [검증 로직 이해](#검증-로직-이해)
|
||||
10. [자주 발생하는 오류와 해결](#자주-발생하는-오류와-해결)
|
||||
11. [템플릿 개발 Artisan 명령어](#템플릿-개발-artisan-명령어)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 그누보드7 템플릿을 처음부터 만들 때 필요한 **실전 워크플로우**를 정리합니다.
|
||||
실제 `sirsoft-user_sample` 템플릿 개발 과정에서 겪은 시행착오를 바탕으로 작성되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 생성 체크리스트
|
||||
|
||||
새 템플릿 생성 시 아래 체크리스트를 순서대로 완료합니다:
|
||||
|
||||
```
|
||||
Phase 1: 필수 메타데이터
|
||||
□ template.json 생성 (error_config 포함)
|
||||
□ routes.json 생성 (layout_name 필수)
|
||||
|
||||
Phase 2: 레이아웃 파일
|
||||
□ layouts/_user_base.json 또는 layouts/_admin_base.json 생성
|
||||
□ layouts/home.json (또는 dashboard.json) 생성
|
||||
□ layouts/errors/404.json 생성
|
||||
□ layouts/errors/403.json 생성
|
||||
□ layouts/errors/500.json 생성
|
||||
|
||||
Phase 3: 빌드 설정
|
||||
□ package.json 생성 (build 스크립트 포함)
|
||||
□ vite.config.ts 생성 (IIFE 빌드, esbuild minify)
|
||||
□ tsconfig.json 생성
|
||||
□ tsconfig.node.json 생성
|
||||
□ tailwind.config.js 생성
|
||||
□ postcss.config.js 생성
|
||||
|
||||
Phase 4: 컴포넌트 설정
|
||||
□ src/index.ts 생성 (실제 존재하는 컴포넌트만 import)
|
||||
□ src/handlers/index.ts 생성
|
||||
□ src/styles/main.css 생성
|
||||
|
||||
Phase 5: 다국어 파일
|
||||
□ lang/ko.json 생성 (errors 섹션 포함)
|
||||
□ lang/en.json 생성 (errors 섹션 포함)
|
||||
|
||||
Phase 6: 빌드 및 설치
|
||||
□ php artisan template:build [identifier]
|
||||
□ php artisan template:install [identifier]
|
||||
□ php artisan template:activate [identifier]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 필수 메타데이터 파일
|
||||
|
||||
### 1.1 template.json
|
||||
|
||||
```json
|
||||
{
|
||||
"identifier": "vendor-template_name",
|
||||
"vendor": "vendor",
|
||||
"name": {
|
||||
"ko": "템플릿 한국어 이름",
|
||||
"en": "Template English Name"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"description": {
|
||||
"ko": "템플릿 설명 (한국어)",
|
||||
"en": "Template description (English)"
|
||||
},
|
||||
"type": "user",
|
||||
"locales": ["ko", "en"],
|
||||
"author": {
|
||||
"name": "vendor",
|
||||
"email": "contact@vendor.com"
|
||||
},
|
||||
"g7_version": ">=1.0.0",
|
||||
"dependencies": {
|
||||
"modules": {},
|
||||
"plugins": {}
|
||||
},
|
||||
"assets": {
|
||||
"css": ["assets/css/main.css"],
|
||||
"js": []
|
||||
},
|
||||
"components": {
|
||||
"basic": ["button", "div", "span", "h1", "h2", "p", "icon"],
|
||||
"composite": ["header", "footer", "card"],
|
||||
"layout": ["container", "flex", "grid"]
|
||||
},
|
||||
"error_config": {
|
||||
"layouts": {
|
||||
"404": "404",
|
||||
"403": "403",
|
||||
"500": "500"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**필수 항목**:
|
||||
- `identifier`: 고유 식별자 (vendor-name 형식)
|
||||
- `type`: "admin" 또는 "user"
|
||||
- `locales`: 지원 언어 배열
|
||||
- `error_config.layouts`: 404, 403, 500 매핑
|
||||
|
||||
### 1.2 routes.json
|
||||
|
||||
```
|
||||
주의: layout_name 필드가 없으면 설치 실패!
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"layout_name": "routes",
|
||||
"meta": {
|
||||
"title": "Routes",
|
||||
"description": "Route definitions for template"
|
||||
},
|
||||
"data_sources": [],
|
||||
"components": [],
|
||||
"routes": [
|
||||
{
|
||||
"path": "/",
|
||||
"layout": "home",
|
||||
"auth": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**필수 필드**:
|
||||
- `layout_name`: DB 저장용 레이아웃 이름 (필수!)
|
||||
- `routes`: 라우트 정의 배열
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 레이아웃 파일
|
||||
|
||||
### 2.1 디렉토리 구조
|
||||
|
||||
```
|
||||
templates/_bundled/vendor-template/
|
||||
└── layouts/
|
||||
├── _user_base.json # 베이스 레이아웃 (user 템플릿)
|
||||
├── _admin_base.json # 베이스 레이아웃 (admin 템플릿)
|
||||
├── home.json # 홈 레이아웃
|
||||
├── routes.json # 라우트 정의
|
||||
├── errors/ # 필수 디렉토리
|
||||
│ ├── 404.json # 필수
|
||||
│ ├── 403.json # 필수
|
||||
│ └── 500.json # 필수
|
||||
└── partials/ # partial 파일 (DB 저장 안됨)
|
||||
```
|
||||
|
||||
### 2.2 베이스 레이아웃 (_user_base.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"layout_name": "_user_base",
|
||||
"meta": {
|
||||
"is_base": true
|
||||
},
|
||||
"data_sources": [],
|
||||
"slots": {
|
||||
"header": [],
|
||||
"content": [],
|
||||
"footer": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 에러 레이아웃
|
||||
|
||||
**404.json 예시**:
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"layout_name": "404",
|
||||
"extends": "_user_base",
|
||||
"meta": {
|
||||
"title": "$t:errors.404.title",
|
||||
"is_error_layout": true,
|
||||
"error_code": 404
|
||||
},
|
||||
"data_sources": [],
|
||||
"slots": {
|
||||
"content": [
|
||||
{
|
||||
"id": "error_container",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex flex-col items-center justify-center min-h-[60vh] text-center p-8"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "error_code",
|
||||
"type": "basic",
|
||||
"name": "H1",
|
||||
"props": {
|
||||
"className": "text-6xl font-bold text-gray-900 dark:text-white mb-4"
|
||||
},
|
||||
"text": "404"
|
||||
},
|
||||
{
|
||||
"id": "error_title",
|
||||
"type": "basic",
|
||||
"name": "H2",
|
||||
"props": {
|
||||
"className": "text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-2"
|
||||
},
|
||||
"text": "$t:errors.404.title"
|
||||
},
|
||||
{
|
||||
"id": "error_message",
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-gray-500 dark:text-gray-400 mb-8"
|
||||
},
|
||||
"text": "$t:errors.404.message"
|
||||
},
|
||||
{
|
||||
"id": "back_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
|
||||
},
|
||||
"text": "$t:errors.back_home",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**403.json, 500.json**도 동일한 구조로 생성 (error_code와 아이콘만 다름)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 빌드 설정
|
||||
|
||||
### 3.1 package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "vendor-template",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.24",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 vite.config.ts
|
||||
|
||||
```
|
||||
주의: minify는 'esbuild' 사용 (terser는 별도 설치 필요)
|
||||
필수: process.env.NODE_ENV 정의 (외부 라이브러리 호환성)
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/index.ts'),
|
||||
name: 'VendorTemplate',
|
||||
formats: ['iife'],
|
||||
fileName: () => 'components.iife.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyDirBeforeWrite: true,
|
||||
minify: 'esbuild', // terser 대신 esbuild 사용!
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3.3 tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 tailwind.config.js
|
||||
|
||||
```javascript
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx}', './layouts/**/*.json'],
|
||||
darkMode: 'class',
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
```
|
||||
|
||||
### 3.5 postcss.config.js
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 컴포넌트 설정
|
||||
|
||||
### 4.1 src/index.ts
|
||||
|
||||
```
|
||||
필수: 실제 존재하는 컴포넌트만 import!
|
||||
존재하지 않는 컴포넌트를 import하면 빌드 실패
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 실제 존재하는 컴포넌트만 import
|
||||
import * as basicComponents from './components/basic';
|
||||
import * as compositeComponents from './components/composite';
|
||||
import * as layoutComponents from './components/layout';
|
||||
import { handlerMap } from './handlers';
|
||||
|
||||
// 전역 등록
|
||||
declare global {
|
||||
interface Window {
|
||||
G7Core: {
|
||||
registerComponents: (components: Record<string, unknown>) => void;
|
||||
registerHandlers: (handlers: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 등록
|
||||
const allComponents = {
|
||||
...basicComponents,
|
||||
...compositeComponents,
|
||||
...layoutComponents,
|
||||
};
|
||||
|
||||
if (window.G7Core) {
|
||||
window.G7Core.registerComponents(allComponents);
|
||||
window.G7Core.registerHandlers(handlerMap);
|
||||
}
|
||||
|
||||
export { allComponents, handlerMap };
|
||||
```
|
||||
|
||||
### 4.2 src/handlers/index.ts
|
||||
|
||||
```typescript
|
||||
export const handlerMap = {
|
||||
// 커스텀 핸들러 등록
|
||||
} as const;
|
||||
|
||||
export type HandlerName = keyof typeof handlerMap;
|
||||
```
|
||||
|
||||
### 4.3 src/styles/main.css
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 다국어 파일
|
||||
|
||||
### 5.1 lang/ko.json
|
||||
|
||||
```
|
||||
필수: errors 섹션 필수! (에러 레이아웃에서 참조)
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"site": {
|
||||
"title": "사이트 제목"
|
||||
},
|
||||
"errors": {
|
||||
"404": {
|
||||
"title": "페이지를 찾을 수 없습니다",
|
||||
"message": "요청하신 페이지가 존재하지 않거나 이동되었습니다."
|
||||
},
|
||||
"403": {
|
||||
"title": "접근이 거부되었습니다",
|
||||
"message": "이 페이지에 접근할 권한이 없습니다."
|
||||
},
|
||||
"500": {
|
||||
"title": "서버 오류",
|
||||
"message": "서버에 문제가 발생했습니다. 잠시 후 다시 시도해 주세요."
|
||||
},
|
||||
"back_home": "홈으로 돌아가기"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 lang/en.json
|
||||
|
||||
```json
|
||||
{
|
||||
"site": {
|
||||
"title": "Site Title"
|
||||
},
|
||||
"errors": {
|
||||
"404": {
|
||||
"title": "Page Not Found",
|
||||
"message": "The page you requested does not exist or has been moved."
|
||||
},
|
||||
"403": {
|
||||
"title": "Access Denied",
|
||||
"message": "You do not have permission to access this page."
|
||||
},
|
||||
"500": {
|
||||
"title": "Server Error",
|
||||
"message": "Something went wrong on our end. Please try again later."
|
||||
},
|
||||
"back_home": "Back to Home"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: 빌드 및 설치
|
||||
|
||||
### 6.1 빌드
|
||||
|
||||
```bash
|
||||
php artisan template:build vendor-template
|
||||
```
|
||||
|
||||
### 6.2 설치
|
||||
|
||||
```bash
|
||||
php artisan template:install vendor-template
|
||||
```
|
||||
|
||||
### 6.3 활성화
|
||||
|
||||
```bash
|
||||
php artisan template:activate vendor-template
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 검증 로직 이해
|
||||
|
||||
### 설치 시 자동 검증 순서
|
||||
|
||||
1. **JSON 파싱 검증**: 모든 레이아웃 파일의 JSON 유효성
|
||||
2. **layout_name 필수**: 템플릿 레이아웃은 반드시 `layout_name` 필드 필요
|
||||
3. **error_config 검증**: `template.json`에 `error_config.layouts` 섹션 필수
|
||||
4. **에러 레이아웃 검증**: 404, 403, 500 레이아웃 파일 존재 확인
|
||||
5. **partial 병합 검증**: `partial` 참조 파일 존재 및 순환 참조 확인
|
||||
|
||||
### ValidatesLayoutFiles 트레이트
|
||||
|
||||
```php
|
||||
// 스캔 대상 디렉토리
|
||||
layouts/*.json // ✅ 스캔 (DB 저장)
|
||||
layouts/errors/*.json // ✅ 스캔 (DB 저장)
|
||||
layouts/partials/*.json // ❌ 스캔 안 함 (extends로만 참조)
|
||||
```
|
||||
|
||||
### TemplateManager::validateErrorLayouts()
|
||||
|
||||
```php
|
||||
// 필수 에러 코드
|
||||
$requiredErrorCodes = [404, 403, 500];
|
||||
|
||||
// 검증 항목
|
||||
1. error_config.layouts 섹션 존재
|
||||
2. 404, 403, 500 키 존재
|
||||
3. layouts/errors/{code}.json 파일 존재
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 자주 발생하는 오류와 해결
|
||||
|
||||
### 오류 1: layout_name 필드 누락
|
||||
|
||||
```
|
||||
❌ 오류: layout_name 필드가 누락되었습니다
|
||||
```
|
||||
|
||||
**원인**: routes.json 또는 레이아웃 파일에 `layout_name` 필드 없음
|
||||
|
||||
**해결**:
|
||||
```json
|
||||
{
|
||||
"layout_name": "routes", // 추가
|
||||
// ... 나머지 내용
|
||||
}
|
||||
```
|
||||
|
||||
### 오류 2: 에러 레이아웃 파일 없음
|
||||
|
||||
```
|
||||
❌ 오류: 404 에러 레이아웃 파일을 찾을 수 없습니다
|
||||
```
|
||||
|
||||
**원인**: `layouts/errors/404.json` 파일 없음
|
||||
|
||||
**해결**:
|
||||
1. `layouts/errors/` 디렉토리 생성
|
||||
2. `404.json`, `403.json`, `500.json` 파일 생성
|
||||
|
||||
### 오류 3: terser not found
|
||||
|
||||
```
|
||||
❌ 오류: Cannot find module 'terser'
|
||||
```
|
||||
|
||||
**원인**: vite.config.ts에서 `minify: 'terser'` 설정했지만 terser 미설치
|
||||
|
||||
**해결**:
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
build: {
|
||||
minify: 'esbuild', // terser 대신 esbuild 사용
|
||||
}
|
||||
```
|
||||
|
||||
### 오류 4: 컴포넌트 import 실패
|
||||
|
||||
```
|
||||
❌ 오류: Module not found: './components/basic/H5'
|
||||
```
|
||||
|
||||
**원인**: 존재하지 않는 컴포넌트를 import
|
||||
|
||||
**해결**: 실제 존재하는 컴포넌트만 import
|
||||
```typescript
|
||||
// ❌ 잘못된 예
|
||||
import { H5, H6, Box } from './components/basic'; // H5, H6, Box가 없으면 에러
|
||||
|
||||
// ✅ 올바른 예
|
||||
import { H1, H2, H3, Div, Span } from './components/basic'; // 실제 존재하는 것만
|
||||
```
|
||||
|
||||
### 오류 5: error_config 누락
|
||||
|
||||
```
|
||||
❌ 오류: 에러 페이지 설정(error_config)이 누락되었습니다
|
||||
```
|
||||
|
||||
**원인**: `template.json`에 `error_config` 섹션 없음
|
||||
|
||||
**해결**:
|
||||
```json
|
||||
{
|
||||
"error_config": {
|
||||
"layouts": {
|
||||
"404": "404",
|
||||
"403": "403",
|
||||
"500": "500"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 개발 Artisan 명령어
|
||||
|
||||
| 명령어 | 설명 |
|
||||
|--------|------|
|
||||
| `php artisan template:list` | 템플릿 목록 조회 |
|
||||
| `php artisan template:build [id]` | 템플릿 빌드 |
|
||||
| `php artisan template:build --all` | 모든 템플릿 빌드 |
|
||||
| `php artisan template:install [id]` | 템플릿 설치 |
|
||||
| `php artisan template:activate [id]` | 템플릿 활성화 |
|
||||
| `php artisan template:deactivate [id]` | 템플릿 비활성화 |
|
||||
| `php artisan template:uninstall [id]` | 템플릿 삭제 |
|
||||
| `php artisan template:refresh-layout [id]` | 레이아웃 갱신 (빌드 없이) |
|
||||
| `php artisan template:cache-clear` | 템플릿 캐시 초기화 |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [템플릿 기초](./template-basics.md) - 템플릿 타입, 메타데이터
|
||||
- [템플릿 커맨드](./template-commands.md) - Artisan 커맨드 상세
|
||||
- [템플릿 개발 가이드](../frontend/template-development.md) - 컴포넌트 개발
|
||||
- [레이아웃 JSON 스키마](../frontend/layout-json.md) - 레이아웃 작성법
|
||||
- [컴포넌트 개발 규칙](../frontend/components.md) - 컴포넌트 작성법
|
||||
@@ -0,0 +1,168 @@
|
||||
# 그누보드7 프론트엔드 개발 가이드
|
||||
|
||||
> 이 문서는 그누보드7의 프론트엔드 및 템플릿 시스템 개발 규칙의 인덱스입니다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
```
|
||||
필수: 기본 컴포넌트 사용 (HTML 태그 직접 사용 금지)
|
||||
필수: 기본 컴포넌트만 사용 (Div, Button, H2 등)
|
||||
✅ 필수: 집합 컴포넌트 재사용 우선
|
||||
✅ 필수: 다크 모드 light/dark variant 함께 지정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 시스템 개요
|
||||
|
||||
그누보드7 템플릿 엔진은 JSON 기반 레이아웃 정의를 통해 화면을 동적으로 생성하는 시스템입니다.
|
||||
|
||||
**핵심 특징**:
|
||||
- JSON 기반 레이아웃 정의 (코드 수정 없이 UI 변경)
|
||||
- 컴포넌트 기반 아키텍처 (재사용성)
|
||||
- 동적 데이터 바인딩 (API 자동 연결)
|
||||
- 자동 발견 메커니즘 (`/templates` 디렉토리 스캔)
|
||||
- 코어 렌더링 엔진과 템플릿 컴포넌트 분리
|
||||
|
||||
**템플릿 타입**:
|
||||
|
||||
| 타입 | 레이아웃 편집 | 버전 히스토리 | 용도 |
|
||||
|------|-------------|--------------|------|
|
||||
| admin | ❌ 불가 | ❌ 미사용 | 관리자 인터페이스 |
|
||||
| user | ✅ 가능 | ✅ 자동 생성 | 사용자 웹사이트 |
|
||||
|
||||
---
|
||||
|
||||
<!-- AUTO-GENERATED-START: frontend-readme-docs -->
|
||||
### 템플릿별 레퍼런스
|
||||
|
||||
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|
||||
|--------------|---------|--------|--------|
|
||||
| `sirsoft-admin_basic` | [components.md](templates/sirsoft-admin_basic/components.md) | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | [layouts.md](templates/sirsoft-admin_basic/layouts.md) |
|
||||
| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) |
|
||||
|
||||
### 컴포넌트 개발
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [component-props-composite.md](component-props-composite.md) | 컴포넌트 Props 레퍼런스 - Composite |
|
||||
| [component-props.md](component-props.md) | 컴포넌트 Props 레퍼런스 |
|
||||
| [components-advanced.md](components-advanced.md) | 컴포넌트 고급 기능 |
|
||||
| [components-patterns.md](components-patterns.md) | 컴포넌트 패턴 및 다국어 |
|
||||
| [components-types.md](components-types.md) | 컴포넌트 타입별 개발 규칙 |
|
||||
| [components.md](components.md) | 컴포넌트 개발 규칙 |
|
||||
| [layout-json-components-loading.md](layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 |
|
||||
| [layout-json-components-rendering.md](layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 |
|
||||
| [layout-json-components-slots.md](layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 |
|
||||
| [layout-json-components.md](layout-json-components.md) | 레이아웃 JSON - 컴포넌트 (반복 렌더링, Blur, 생명주기, 슬롯) |
|
||||
|
||||
### 레이아웃 및 데이터
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [actions-g7core-api.md](actions-g7core-api.md) | 액션 시스템 - G7Core API (React 컴포넌트용) |
|
||||
| [actions-handlers-navigation.md](actions-handlers-navigation.md) | 액션 핸들러 - 네비게이션 |
|
||||
| [actions-handlers-state.md](actions-handlers-state.md) | 액션 핸들러 - 상태 관리 |
|
||||
| [actions-handlers-ui.md](actions-handlers-ui.md) | 액션 핸들러 - UI 인터랙션 |
|
||||
| [actions-handlers.md](actions-handlers.md) | 액션 핸들러 - 핸들러별 상세 사용법 |
|
||||
| [actions.md](actions.md) | 액션 핸들러 가이드 |
|
||||
| [data-binding-i18n.md](data-binding-i18n.md) | 데이터 바인딩 - 다국어 처리 |
|
||||
| [data-binding.md](data-binding.md) | 데이터 바인딩 및 표현식 |
|
||||
| [data-sources-advanced.md](data-sources-advanced.md) | 데이터 소스 - 고급 기능 |
|
||||
| [data-sources.md](data-sources.md) | 데이터 소스 (Data Sources) |
|
||||
| [g7core-api-advanced.md](g7core-api-advanced.md) | G7Core 전역 API 레퍼런스 - 고급 |
|
||||
| [g7core-api.md](g7core-api.md) | G7Core 전역 API 레퍼런스 |
|
||||
| [g7core-helpers.md](g7core-helpers.md) | G7Core 헬퍼 API |
|
||||
| [layout-json-components-loading.md](layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 |
|
||||
| [layout-json-components-rendering.md](layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 |
|
||||
| [layout-json-components-slots.md](layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 |
|
||||
| [layout-json-components.md](layout-json-components.md) | 레이아웃 JSON - 컴포넌트 (반복 렌더링, Blur, 생명주기, 슬롯) |
|
||||
| [layout-json-features-actions.md](layout-json-features-actions.md) | 레이아웃 JSON - 초기화, 모달, 액션, 스크립트 |
|
||||
| [layout-json-features-error.md](layout-json-features-error.md) | 레이아웃 JSON - 에러 핸들링 |
|
||||
| [layout-json-features-styling.md](layout-json-features-styling.md) | 레이아웃 JSON - 스타일 및 계산된 값 |
|
||||
| [layout-json-features.md](layout-json-features.md) | 레이아웃 JSON - 기능 (에러 핸들링, 초기화, 모달, 액션) |
|
||||
| [layout-json-inheritance.md](layout-json-inheritance.md) | 레이아웃 JSON - 상속 (Extends, Partial, 병합) |
|
||||
| [layout-json.md](layout-json.md) | 레이아웃 JSON 스키마 |
|
||||
| [state-management-advanced.md](state-management-advanced.md) | 상태 관리 - 고급 기능 |
|
||||
| [state-management-forms.md](state-management-forms.md) | 상태 관리 - 폼 자동 바인딩 및 setState |
|
||||
| [state-management.md](state-management.md) | 전역 상태 관리 |
|
||||
|
||||
### 스타일링 및 UI
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [dark-mode.md](dark-mode.md) | 다크 모드 지원 (engine-v1.1.0+) |
|
||||
| [responsive-layout.md](responsive-layout.md) | 반응형 레이아웃 개발 (engine-v1.1.0+) |
|
||||
| [tailwind-safelist.md](tailwind-safelist.md) | Tailwind Safelist 가이드 |
|
||||
|
||||
### 템플릿 개발
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [editors.md](editors.md) | 에디터 컴포넌트 가이드 |
|
||||
| [template-development.md](template-development.md) | 템플릿 개발 가이드라인 |
|
||||
| [template-handlers.md](template-handlers.md) | 템플릿 전용 핸들러 |
|
||||
|
||||
### 테스트
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [layout-testing.md](layout-testing.md) | 그누보드7 레이아웃 파일 렌더링 테스트 가이드 |
|
||||
|
||||
### 인증 및 보안
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [auth-system.md](auth-system.md) | 인증 시스템 (AuthManager) |
|
||||
| [modal-usage.md](modal-usage.md) | Modal 컴포넌트 사용 가이드 |
|
||||
| [security.md](security.md) | 보안 및 검증 |
|
||||
|
||||
|
||||
<!-- AUTO-GENERATED-END: frontend-readme-docs -->
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 타입 요약
|
||||
|
||||
| 타입 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| basic | HTML 태그 래핑 | Button, Input, Div, Icon |
|
||||
| composite | 기본 컴포넌트 조합 | Card, Modal, PageHeader, DataGrid |
|
||||
| layout | 자식 요소 배치 | Container, Grid, Flex |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 바인딩 문법 (빠른 참조)
|
||||
|
||||
```json
|
||||
{
|
||||
"props": {
|
||||
"title": "{{user.name}}", // API 데이터
|
||||
"userId": "{{route.id}}", // URL 파라미터
|
||||
"label": "$t:dashboard.title", // 다국어
|
||||
"total": "{{users?.data?.total ?? 0}}" // Optional Chaining + Nullish Coalescing
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다크 모드 색상 매핑 (빠른 참조)
|
||||
|
||||
```
|
||||
배경: bg-white dark:bg-gray-800
|
||||
테두리: border-gray-200 dark:border-gray-700
|
||||
텍스트: text-gray-900 dark:text-white
|
||||
강조: bg-green-50 dark:bg-green-900/20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [AGENTS.md](../../../AGENTS.md) - 그누보드7 개발 가이드 (메인)
|
||||
- [backend/](../backend/) - 백엔드 개발 규칙
|
||||
- [database-guide.md](../database-guide.md) - 데이터베이스 규칙
|
||||
- [testing-guide.md](../testing-guide.md) - 테스트 규칙
|
||||
@@ -0,0 +1,337 @@
|
||||
# 액션 시스템 - G7Core API (React 컴포넌트용)
|
||||
|
||||
> **메인 문서**: [actions.md](actions.md)
|
||||
> **관련 문서**: [actions-handlers.md](actions-handlers.md) | [state-management.md](state-management.md) | [g7core-api.md](g7core-api.md)
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [G7Core.dispatch](#g7coredispatch)
|
||||
2. [G7Core 편의 API](#g7core-편의-api)
|
||||
3. [이벤트 헬퍼](#이벤트-헬퍼)
|
||||
4. [API 전체 목록](#g7core-api-전체-목록)
|
||||
|
||||
---
|
||||
|
||||
## G7Core.dispatch
|
||||
|
||||
템플릿 컴포넌트(React)에서 레이아웃 JSON과 동일한 방식으로 액션을 실행할 수 있습니다.
|
||||
|
||||
```text
|
||||
중요: 템플릿은 코어와 별도 빌드되므로 import 불가
|
||||
✅ 필수: window.G7Core.dispatch 전역 함수 사용
|
||||
✅ 형식: 레이아웃 JSON의 액션 정의와 동일
|
||||
```
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
// 네비게이션
|
||||
(window as any).G7Core?.dispatch({
|
||||
handler: 'navigate',
|
||||
params: { path: '/admin/users/1/edit' },
|
||||
});
|
||||
|
||||
// API 호출
|
||||
(window as any).G7Core?.dispatch({
|
||||
handler: 'apiCall',
|
||||
target: '/api/admin/users/1',
|
||||
params: { method: 'GET' },
|
||||
auth_required: true,
|
||||
});
|
||||
|
||||
// 상태 변경
|
||||
(window as any).G7Core?.dispatch({
|
||||
handler: 'setState',
|
||||
params: { target: 'global', selectedIds: [] },
|
||||
});
|
||||
|
||||
// 토스트 알림
|
||||
(window as any).G7Core?.dispatch({
|
||||
handler: 'toast',
|
||||
params: { type: 'success', message: '저장되었습니다' },
|
||||
});
|
||||
```
|
||||
|
||||
### 후속 액션 (onSuccess/onError)
|
||||
|
||||
```tsx
|
||||
(window as any).G7Core?.dispatch({
|
||||
handler: 'apiCall',
|
||||
target: '/api/admin/users/1',
|
||||
params: { method: 'DELETE' },
|
||||
auth_required: true,
|
||||
onSuccess: [
|
||||
{ handler: 'toast', params: { type: 'success', message: '삭제 완료' } },
|
||||
{ handler: 'navigate', params: { path: '/admin/users' } },
|
||||
],
|
||||
onError: [
|
||||
{ handler: 'toast', params: { type: 'error', message: '삭제 실패' } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 확인 다이얼로그
|
||||
|
||||
```tsx
|
||||
(window as any).G7Core?.dispatch({
|
||||
handler: 'apiCall',
|
||||
target: '/api/admin/users/1',
|
||||
params: { method: 'DELETE' },
|
||||
confirm: '정말 삭제하시겠습니까?', // 확인 후 실행
|
||||
});
|
||||
```
|
||||
|
||||
### 반환값
|
||||
|
||||
```tsx
|
||||
const result = await (window as any).G7Core?.dispatch({
|
||||
handler: 'apiCall',
|
||||
target: '/api/admin/users',
|
||||
params: { method: 'GET' },
|
||||
});
|
||||
|
||||
if (result?.success) {
|
||||
console.log('성공:', result.data);
|
||||
} else {
|
||||
console.error('실패:', result?.error);
|
||||
}
|
||||
```
|
||||
|
||||
### 실제 사용 예시 (UserProfile 컴포넌트)
|
||||
|
||||
```tsx
|
||||
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
|
||||
const handleProfileClick = () => {
|
||||
// SPA 네비게이션으로 사용자 수정 페이지 이동
|
||||
(window as any).G7Core?.dispatch({
|
||||
handler: 'navigate',
|
||||
params: { path: `/admin/users/${user.id}/edit` },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleProfileClick}>
|
||||
프로필 설정
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 사용 시 주의사항
|
||||
|
||||
1. **Optional Chaining 필수**: `G7Core?.dispatch` - 엔진 초기화 전 호출 방지
|
||||
2. **타입 캐스팅**: `(window as any)` - TypeScript 타입 오류 방지
|
||||
3. **비동기 처리**: `dispatch`는 Promise 반환 - 필요시 `await` 사용
|
||||
4. **레이아웃 JSON과 동일**: 모든 핸들러와 파라미터 형식이 레이아웃 JSON과 같음
|
||||
|
||||
---
|
||||
|
||||
## G7Core 편의 API
|
||||
|
||||
`dispatch` 외에도 자주 사용하는 기능들을 편의 API로 제공합니다.
|
||||
|
||||
### G7Core.state - 전역 상태 관리
|
||||
|
||||
```tsx
|
||||
// 현재 전역 상태 조회
|
||||
const state = (window as any).G7Core?.state.get();
|
||||
console.log(state._global?.selectedIds);
|
||||
|
||||
// 전역 상태 업데이트
|
||||
(window as any).G7Core?.state.set({
|
||||
_global: { selectedIds: [1, 2, 3] }
|
||||
});
|
||||
|
||||
// 상태 변경 구독
|
||||
const unsubscribe = (window as any).G7Core?.state.subscribe((newState) => {
|
||||
console.log('상태 변경:', newState);
|
||||
});
|
||||
|
||||
// 구독 해제
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
### G7Core.locale - 로케일 관리
|
||||
|
||||
```tsx
|
||||
// 현재 로케일 조회
|
||||
const currentLocale = (window as any).G7Core?.locale.current();
|
||||
// 'ko'
|
||||
|
||||
// 지원하는 로케일 목록
|
||||
const locales = (window as any).G7Core?.locale.supported();
|
||||
// ['ko', 'en']
|
||||
|
||||
// 로케일 변경 (비동기)
|
||||
await (window as any).G7Core?.locale.change('en');
|
||||
```
|
||||
|
||||
### G7Core.toast - 토스트 알림
|
||||
|
||||
```tsx
|
||||
// 기본 사용
|
||||
(window as any).G7Core?.toast.show('메시지', { type: 'info', duration: 3000 });
|
||||
|
||||
// 편의 메서드
|
||||
(window as any).G7Core?.toast.success('저장되었습니다');
|
||||
(window as any).G7Core?.toast.error('오류가 발생했습니다');
|
||||
(window as any).G7Core?.toast.warning('주의가 필요합니다');
|
||||
(window as any).G7Core?.toast.info('정보 메시지');
|
||||
|
||||
// duration 지정 (ms)
|
||||
(window as any).G7Core?.toast.success('저장 완료', 5000);
|
||||
```
|
||||
|
||||
### G7Core.modal - 모달 관리
|
||||
|
||||
```tsx
|
||||
// 모달 열기
|
||||
(window as any).G7Core?.modal.open('confirm_modal');
|
||||
|
||||
// 모달 닫기 (특정 모달)
|
||||
(window as any).G7Core?.modal.close('confirm_modal');
|
||||
|
||||
// 최상위 모달 닫기
|
||||
(window as any).G7Core?.modal.close();
|
||||
|
||||
// 모든 모달 닫기
|
||||
(window as any).G7Core?.modal.closeAll();
|
||||
|
||||
// 모달 열림 상태 확인
|
||||
const isOpen = (window as any).G7Core?.modal.isOpen('confirm_modal');
|
||||
|
||||
// 현재 열린 모달 스택 조회
|
||||
const stack = (window as any).G7Core?.modal.getStack();
|
||||
// ['parent_modal', 'child_modal']
|
||||
```
|
||||
|
||||
### G7Core.navigation - 페이지 전환 상태
|
||||
|
||||
페이지 전환(네비게이션) 상태를 감지하고 전환 완료 시점에 콜백을 실행할 수 있습니다.
|
||||
|
||||
```tsx
|
||||
// 현재 페이지 전환 진행 중인지 확인
|
||||
const isPending = (window as any).G7Core?.navigation.isPending();
|
||||
|
||||
// 페이지 전환 완료 후 콜백 실행 (1회성)
|
||||
const unsubscribe = (window as any).G7Core?.navigation.onComplete(() => {
|
||||
console.log('페이지 로드 완료!');
|
||||
// 사이드바 닫기, 스크롤 위치 초기화 등
|
||||
});
|
||||
|
||||
// 필요시 구독 해제
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
**onComplete 동작 방식:**
|
||||
|
||||
- 전환이 **시작된 후 완료**될 때 콜백 실행 (true → false 상태 변화 감지)
|
||||
- 콜백은 1회만 실행되고 자동으로 구독 해제됨
|
||||
- `TransitionManager`를 직접 사용하지 않고도 전환 완료를 감지할 수 있음
|
||||
|
||||
**실전 예시 (모바일 사이드바):**
|
||||
|
||||
```tsx
|
||||
const handleNavigate = (event: React.MouseEvent, url: string) => {
|
||||
event.preventDefault();
|
||||
|
||||
// 페이지 전환 완료 후 사이드바 닫기
|
||||
G7Core().navigation?.onComplete(() => {
|
||||
G7Core().dispatch({
|
||||
handler: 'setState',
|
||||
params: { target: 'global', sidebarOpen: false },
|
||||
});
|
||||
});
|
||||
|
||||
G7Core().dispatch({
|
||||
handler: 'navigate',
|
||||
params: { path: url },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 이벤트 헬퍼
|
||||
|
||||
컴포넌트에서 가짜 이벤트를 생성할 때 ActionDispatcher가 인식할 수 있는 표준 형식의 이벤트 객체를 생성합니다.
|
||||
|
||||
```text
|
||||
중요: 컴포넌트에서 직접 가짜 이벤트를 생성하면 ActionDispatcher의 isStandardEvent 체크 실패
|
||||
✅ 필수: G7Core.createChangeEvent 등 헬퍼 함수 사용
|
||||
```
|
||||
|
||||
### 사용 가능한 이벤트 헬퍼
|
||||
|
||||
| 함수 | 설명 | 주요 옵션 |
|
||||
|------|------|----------|
|
||||
| `createChangeEvent(options)` | change 이벤트 생성 | `checked`, `value`, `name` |
|
||||
| `createClickEvent(options)` | click 이벤트 생성 | `button`, `clientX`, `clientY` |
|
||||
| `createSubmitEvent()` | submit 이벤트 생성 | - |
|
||||
| `createKeyboardEvent(key, type)` | 키보드 이벤트 생성 | `key`, `eventType` |
|
||||
|
||||
### 사용 예시 (Toggle 컴포넌트)
|
||||
|
||||
```tsx
|
||||
// G7Core 전역 객체 참조
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
// Toggle 컴포넌트 내부
|
||||
const handleClick = () => {
|
||||
const newChecked = !checked;
|
||||
setChecked(newChecked);
|
||||
if (onChange) {
|
||||
// ActionDispatcher 호환 이벤트 생성
|
||||
(onChange as any)(G7Core.createChangeEvent({ checked: newChecked, name }));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### createChangeEvent 옵션
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `checked` | boolean | 체크박스/토글의 체크 상태 |
|
||||
| `value` | string | input의 값 |
|
||||
| `name` | string | input의 name 속성 |
|
||||
| `type` | string | input의 type 속성 (기본값: "checkbox") |
|
||||
|
||||
### 왜 이벤트 헬퍼가 필요한가?
|
||||
|
||||
ActionDispatcher의 `bindActionsToProps`는 이벤트 객체에 `preventDefault` 메서드가 있는지 확인합니다:
|
||||
|
||||
```typescript
|
||||
// ActionDispatcher.ts
|
||||
const isStandardEvent = firstArg && typeof firstArg === 'object' && 'preventDefault' in firstArg;
|
||||
```
|
||||
|
||||
단순 객체 `{ target: { checked: true } }`는 이 체크를 통과하지 못하므로, 헬퍼 함수가 `preventDefault`, `stopPropagation` 등 필수 메서드를 포함한 완전한 이벤트 객체를 생성합니다.
|
||||
|
||||
---
|
||||
|
||||
## G7Core API 전체 목록
|
||||
|
||||
| API | 설명 | 주요 메서드 |
|
||||
|-----|------|-----------|
|
||||
| `G7Core.dispatch` | 액션 실행 | `dispatch(action)` |
|
||||
| `G7Core.state` | 전역 상태 관리 | `get()`, `set(updates)`, `subscribe(listener)` |
|
||||
| `G7Core.locale` | 로케일 관리 | `current()`, `supported()`, `change(locale)` |
|
||||
| `G7Core.toast` | 토스트 알림 | `show()`, `success()`, `error()`, `warning()`, `info()` |
|
||||
| `G7Core.modal` | 모달 관리 | `open()`, `close()`, `closeAll()`, `isOpen()`, `getStack()` |
|
||||
| `G7Core.navigation` | 페이지 전환 상태 | `isPending()`, `onComplete(callback)` |
|
||||
| `G7Core.createChangeEvent` | change 이벤트 생성 | `createChangeEvent(options)` |
|
||||
| `G7Core.createClickEvent` | click 이벤트 생성 | `createClickEvent(options)` |
|
||||
| `G7Core.createSubmitEvent` | submit 이벤트 생성 | `createSubmitEvent()` |
|
||||
| `G7Core.createKeyboardEvent` | 키보드 이벤트 생성 | `createKeyboardEvent(key, type)` |
|
||||
| `G7Core.AuthManager` | 인증 관리 | `getInstance()`, `isAuthenticated()`, `getUser()` |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [액션 시스템 개요](actions.md) - 액션 정의, 핸들러 목록
|
||||
- [핸들러 상세](actions-handlers.md) - 각 핸들러별 상세 사용법
|
||||
- [상태 관리](state-management.md) - `_global` 전역 상태
|
||||
- [G7Core API 전체](g7core-api.md) - 렌더링, 인증, 반응형 등
|
||||
@@ -0,0 +1,434 @@
|
||||
# 액션 핸들러 - 네비게이션
|
||||
|
||||
> **메인 문서**: [actions-handlers.md](actions-handlers.md)
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [navigate](#navigate)
|
||||
2. [navigateBack](#navigateback)
|
||||
3. [openWindow](#openwindow)
|
||||
4. [reloadRoutes](#reloadroutes)
|
||||
5. [refresh](#refresh)
|
||||
|
||||
---
|
||||
|
||||
## navigate
|
||||
|
||||
페이지 이동을 처리합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/users/{{row.id}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### navigate params 구조
|
||||
|
||||
| 필드 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `path` | string | - | 이동할 경로 |
|
||||
| `query` | object | - | 쿼리 파라미터 객체 |
|
||||
| `mergeQuery` | boolean | false | 기존 쿼리 파라미터와 병합 |
|
||||
| `replace` | boolean | false | URL만 변경 (페이지 리로드 없음) |
|
||||
|
||||
### mergeQuery 옵션
|
||||
|
||||
기존 쿼리 파라미터를 유지하면서 새 파라미터를 병합합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/users",
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"page": "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 배열 쿼리 파라미터
|
||||
|
||||
> **버전**: engine-v1.10.0+
|
||||
|
||||
배열 값은 자동으로 `key[]=value1&key[]=value2` 형태로 변환됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/products",
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"sales_status[]": "{{_local.salesStatus}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**입력 예시**:
|
||||
|
||||
- `_local.salesStatus = ["on_sale", "sold_out"]`
|
||||
|
||||
**생성되는 쿼리스트링**:
|
||||
|
||||
- `sales_status[]=on_sale&sales_status[]=sold_out`
|
||||
|
||||
```text
|
||||
중요: 배열 값이 빈 배열([])이거나 null이면 해당 파라미터는 쿼리스트링에서 제거됩니다.
|
||||
```
|
||||
|
||||
**백엔드 Enum 값과 일치 필수**:
|
||||
|
||||
프론트엔드 필터에서 사용하는 값은 반드시 백엔드 Enum의 `value`와 동일해야 합니다.
|
||||
|
||||
```php
|
||||
// 백엔드 Enum (ProductSalesStatus.php)
|
||||
enum ProductSalesStatus: string
|
||||
{
|
||||
case OnSale = 'on_sale';
|
||||
case Suspended = 'suspended';
|
||||
case SoldOut = 'sold_out';
|
||||
case ComingSoon = 'coming_soon';
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// ✅ 올바른 사용 (Enum 값과 일치)
|
||||
"salesStatus": "{{(_local.salesStatus || []).includes('sold_out') ? ... }}"
|
||||
|
||||
// ❌ 잘못된 사용 (Enum에 없는 값)
|
||||
"salesStatus": "{{(_local.salesStatus || []).includes('out_of_stock') ? ... }}"
|
||||
```
|
||||
|
||||
번역 키도 Enum과 일관되게 사용:
|
||||
|
||||
```json
|
||||
// ✅ 권장: Enum 기반 번역 키
|
||||
"text": "$t:sirsoft-ecommerce.enums.sales_status.sold_out"
|
||||
|
||||
// 비권장: 별도의 필터용 번역 키
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.filter.sales_status_options.out_of_stock"
|
||||
```
|
||||
|
||||
### replace 옵션
|
||||
|
||||
> **버전**: engine-v1.3.0+ (engine-v1.12.0에서 동작 방식 변경)
|
||||
|
||||
`replace: true`를 사용하면 **컴포넌트 리마운트 없이** URL을 변경하고 데이터를 갱신합니다. 같은 페이지 내에서 검색/필터/정렬/페이지네이션 등의 쿼리 파라미터만 변경할 때 사용합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/products",
|
||||
"mergeQuery": true,
|
||||
"replace": true,
|
||||
"query": {
|
||||
"page": "{{_local.page}}",
|
||||
"sort": "{{_local.sortField}}",
|
||||
"order": "{{_local.sortOrder}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**동작 방식** (engine-v1.12.0+):
|
||||
|
||||
1. `window.history.replaceState()`로 URL 업데이트 (히스토리 교체)
|
||||
2. 내부 쿼리 컨텍스트 (`query`) 업데이트
|
||||
3. `auto_fetch: true`인 모든 데이터 소스 자동 refetch
|
||||
4. UI 리렌더링 (컴포넌트 리마운트 없음)
|
||||
|
||||
**사용 사례**:
|
||||
|
||||
- 검색 버튼 클릭 시 필터 적용 (깜빡임 없이 데이터만 갱신)
|
||||
- 정렬 드롭다운 변경 시 목록 갱신
|
||||
- 페이지당 표시 개수 변경
|
||||
- 페이지네이션 (같은 페이지 내 이동)
|
||||
|
||||
**replace vs 일반 navigate 비교**:
|
||||
|
||||
| 특성 | `replace: false` (기본값) | `replace: true` |
|
||||
|------|--------------------------|-----------------|
|
||||
| 컴포넌트 리마운트 | 발생 (전체 라우트 전환) | 발생 안 함 |
|
||||
| 데이터 소스 refetch | 라우트 전환 시 자동 | 즉시 자동 refetch |
|
||||
| 히스토리 스택 | 새 항목 추가 | 현재 항목 교체 |
|
||||
| UI 깜빡임 | 발생 가능 | 없음 |
|
||||
| 쿼리 컨텍스트 업데이트 | React Router 통해 자동 | 내부적으로 즉시 반영 |
|
||||
|
||||
**주의사항**:
|
||||
|
||||
```text
|
||||
replace: true는 같은 페이지 내에서만 사용
|
||||
다른 페이지로 이동할 때는 replace: false (기본값) 사용
|
||||
|
||||
✅ 사용 권장: 검색, 필터, 정렬, 페이지네이션
|
||||
❌ 사용 금지: 다른 페이지로 이동, 상세 페이지 진입
|
||||
```
|
||||
|
||||
**검색 필터 예시**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "search_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"text": "$t:common.search",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/products",
|
||||
"replace": true,
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"page": 1,
|
||||
"search_field": "{{_local.filter.searchField}}",
|
||||
"search_keyword": "{{_local.filter.searchKeyword}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**정렬 드롭다운 예시**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"value": "{{query.sort || 'created_at'}}",
|
||||
"options": [
|
||||
{ "value": "created_at", "label": "$t:common.sort.created_at" },
|
||||
{ "value": "name", "label": "$t:common.sort.name" }
|
||||
]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/products",
|
||||
"replace": true,
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"sort": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## openWindow
|
||||
|
||||
> **버전**: engine-v1.19.0+
|
||||
|
||||
새 브라우저 탭/창에서 URL을 엽니다. `window.open(path, '_blank')`을 호출합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "openWindow",
|
||||
"params": {
|
||||
"path": "/admin/users/{{row.created_by}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### openWindow params 구조
|
||||
|
||||
| 필드 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `path` | string | - | 새 창에서 열 경로 (필수) |
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- 회원 정보를 새 창에서 조회
|
||||
- 외부 링크를 새 탭에서 열기
|
||||
- 현재 페이지를 유지하면서 다른 페이지 참조
|
||||
|
||||
### navigate와의 차이
|
||||
|
||||
| 특성 | `navigate` | `openWindow` |
|
||||
|------|-----------|--------------|
|
||||
| 현재 페이지 유지 | X (이동) | O (유지) |
|
||||
| 새 탭/창 | X | O |
|
||||
| 히스토리 | 현재 탭 히스토리 변경 | 변경 없음 |
|
||||
|
||||
### 예시: 등록자 정보 새 창으로 보기
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"variant": "ghost",
|
||||
"size": "sm"
|
||||
},
|
||||
"text": "$t:common.view_member",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "openWindow",
|
||||
"params": {
|
||||
"path": "/admin/users/{{row.created_by}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## navigateBack
|
||||
|
||||
브라우저 히스토리에서 뒤로 이동합니다. `window.history.back()`을 호출합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigateBack"
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- 상세 페이지에서 목록으로 돌아가기
|
||||
- 폼 페이지에서 취소 버튼 클릭 시 이전 페이지로 이동
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "back_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigateBack"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "arrow-left", "className": "w-4 h-4" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:common.back"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## reloadRoutes
|
||||
|
||||
라우트(routes.json)를 다시 로드합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "reloadRoutes"
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- 모듈/플러그인 설치 후 새 라우트 적용
|
||||
- 동적으로 라우트 설정이 변경된 경우
|
||||
|
||||
---
|
||||
|
||||
## refresh
|
||||
|
||||
현재 페이지를 새로고침합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "refresh"
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- 전체 페이지 새로고침이 필요한 경우
|
||||
- 상태 초기화가 필요한 경우
|
||||
|
||||
---
|
||||
|
||||
## replaceUrl
|
||||
|
||||
> **버전**: engine-v1.18.0+
|
||||
|
||||
URL만 변경하고 데이터소스 refetch나 컴포넌트 리마운트를 수행하지 않습니다. 리스트 항목 선택 시 URL에 상태를 반영할 때 사용합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "replaceUrl",
|
||||
"params": {
|
||||
"path": "/admin/menus",
|
||||
"query": { "menu": "{{$args[0].slug}}", "mode": "view" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### replaceUrl params 구조
|
||||
|
||||
| 필드 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `path` | string | 현재 경로 | 변경할 경로 (생략 시 `window.location.pathname`) |
|
||||
| `query` | object | - | 쿼리 파라미터 객체 |
|
||||
| `mergeQuery` | boolean | false | 기존 쿼리 파라미터와 병합 |
|
||||
|
||||
### replaceUrl vs navigate replace 비교
|
||||
|
||||
| 특성 | `navigate` (`replace: true`) | `replaceUrl` |
|
||||
| ------ | ------------------------------ | -------------- |
|
||||
| URL 변경 | O | O |
|
||||
| 데이터소스 refetch | O (auto_fetch 전체) | **X** |
|
||||
| 컴포넌트 리마운트 | X | **X** |
|
||||
| 히스토리 | 현재 항목 교체 | 현재 항목 교체 |
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- 리스트 항목 선택 시 URL에 선택 상태 반영 (깜빡임 없이)
|
||||
- 편집/보기 모드 전환 시 URL 업데이트
|
||||
- URL 복사/공유 시 현재 상태 복원 가능하도록
|
||||
|
||||
### path 생략 예시
|
||||
|
||||
`path`를 생략하면 현재 페이지 경로가 자동으로 사용됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "replaceUrl",
|
||||
"params": {
|
||||
"query": { "id": "{{$args[0].id}}", "mode": "view" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [액션 핸들러 인덱스](actions-handlers.md)
|
||||
- [상태 핸들러](actions-handlers-state.md)
|
||||
- [UI 핸들러](actions-handlers-ui.md)
|
||||
- [상태 관리](state-management.md)
|
||||
@@ -0,0 +1,973 @@
|
||||
# 액션 핸들러 - 상태 관리
|
||||
|
||||
> **메인 문서**: [actions-handlers.md](actions-handlers.md)
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [apiCall](#apicall)
|
||||
2. [setState](#setstate)
|
||||
3. [setError](#seterror)
|
||||
4. [refetchDataSource](#refetchdatasource)
|
||||
5. [updateDataSource](#updatedatasource)
|
||||
6. [appendDataSource](#appenddatasource)
|
||||
7. [remount](#remount)
|
||||
8. [sortable (드래그앤드롭 정렬)](#sortable-드래그앤드롭-정렬)
|
||||
9. [onSuccess/onError 후속 액션](#onsuccessonerror-후속-액션)
|
||||
10. [API 데이터 바인딩 규칙](#api-데이터-바인딩-규칙)
|
||||
11. [에러 핸들링 시스템](#에러-핸들링-시스템-errorhandling)
|
||||
|
||||
---
|
||||
|
||||
## apiCall
|
||||
|
||||
API를 호출합니다. **주의: `api`가 아닌 `apiCall`을 사용해야 합니다.**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/admin/users/bulk-status",
|
||||
"params": {
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"ids": "{{_global.selectedIds}}",
|
||||
"status": "active"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{ "handler": "closeModal" },
|
||||
{ "handler": "toast", "params": { "type": "success", "message": "$t:common.success" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 액션 레벨 속성
|
||||
|
||||
| 필드 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `auth_required` | boolean | false | true이면 Bearer 토큰을 Authorization 헤더에 포함 (401 에러 발생 시 로그인 페이지 리다이렉트) |
|
||||
| `auth_mode` | string | "required" | 인증 모드: `"required"` (토큰 없으면 에러), `"optional"` (토큰 있으면 포함, 없어도 진행) |
|
||||
|
||||
```text
|
||||
중요: auth_required, auth_mode는 params 안이 아닌 액션 정의 최상위에 선언해야 합니다.
|
||||
```
|
||||
|
||||
#### auth_mode 사용 예시
|
||||
|
||||
비회원도 접근 가능하지만, 로그인 시 추가 기능을 제공하는 API:
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/cart",
|
||||
"auth_mode": "optional",
|
||||
"params": { "method": "GET" }
|
||||
}
|
||||
```
|
||||
|
||||
| auth_mode | 토큰 있음 | 토큰 없음 |
|
||||
|-----------|----------|----------|
|
||||
| `"required"` (기본) | Bearer 토큰 전송 | 에러 발생 |
|
||||
| `"optional"` | Bearer 토큰 전송 | 토큰 없이 요청 진행 |
|
||||
|
||||
```json
|
||||
// ✅ 올바른 사용
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/admin/users",
|
||||
"params": { "method": "POST" }
|
||||
}
|
||||
|
||||
// ❌ 잘못된 사용 (Bearer 토큰이 전송되지 않음)
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/admin/users",
|
||||
"params": { "method": "POST", "auth_required": true }
|
||||
}
|
||||
```
|
||||
|
||||
### apiCall params 구조
|
||||
|
||||
| 필드 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `method` | string | "GET" | HTTP 메서드 (GET, POST, PUT, PATCH, DELETE) |
|
||||
| `body` | object | - | 요청 본문 (JSON 또는 FormData) |
|
||||
| `headers` | object | - | 추가 헤더 |
|
||||
| `contentType` | string | "application/json" | 요청 Content-Type (`"multipart/form-data"` 지정 시 FormData 자동 변환) |
|
||||
|
||||
### multipart/form-data 지원 (파일 업로드)
|
||||
|
||||
> **버전**: engine-v1.19.0+
|
||||
|
||||
`contentType: "multipart/form-data"`를 지정하면 `body`가 자동으로 `FormData`로 변환됩니다.
|
||||
`Content-Type` 헤더는 설정하지 않으며 (브라우저가 boundary를 포함하여 자동 설정), File/Blob 객체는 원본 그대로 전송됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/admin/modules/manual-install",
|
||||
"params": {
|
||||
"method": "POST",
|
||||
"contentType": "multipart/form-data",
|
||||
"body": {
|
||||
"file": "{{_global.moduleUploadFile}}",
|
||||
"source": "file_upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### FormData 변환 규칙
|
||||
|
||||
| body 값 타입 | FormData 처리 |
|
||||
|-------------|---------------|
|
||||
| `File` / `Blob` | `formData.append(key, value)` (원본 유지) |
|
||||
| `string` / `number` / `boolean` | `formData.append(key, String(value))` |
|
||||
| `object` / `array` | `formData.append(key, JSON.stringify(value))` |
|
||||
| `null` / `undefined` | 제외 (전송하지 않음) |
|
||||
|
||||
#### 주의사항
|
||||
|
||||
```text
|
||||
주의: contentType: "multipart/form-data" 시 Content-Type 헤더를 수동 설정하지 말 것
|
||||
→ 브라우저가 boundary를 포함한 Content-Type을 자동 생성
|
||||
주의: File/Blob 객체는 setState로 저장 시 원본 참조 유지됨 (engine-v1.19.0+)
|
||||
→ deepMergeWithState가 non-plain 객체(File, Blob, Date 등)를 spread 없이 직접 할당
|
||||
필수: 파일 업로드 API는 백엔드에서 multipart/form-data를 기대해야 함
|
||||
```
|
||||
|
||||
### apiCall body에서 조건부 필드 제외 (undefined 패턴)
|
||||
|
||||
드라이버/모드 선택에 따라 **특정 필드만 전송**해야 하는 경우, `undefined`를 반환하는 삼항 표현식을 사용합니다. `JSON.stringify()`는 값이 `undefined`인 키를 자동으로 제거합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/admin/settings/test-mail",
|
||||
"params": {
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"mailer": "{{_local.form?.mail?.mailer || 'smtp'}}",
|
||||
"from_address": "{{_local.form?.mail?.from_address || ''}}",
|
||||
"host": "{{_local.form?.mail?.mailer === 'smtp' ? (_local.form?.mail?.host ?? '') : undefined}}",
|
||||
"mailgun_domain": "{{_local.form?.mail?.mailer === 'mailgun' ? (_local.form?.mail?.mailgun_domain ?? '') : undefined}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**동작 원리**:
|
||||
|
||||
| 조건 | 표현식 결과 | JSON 직렬화 |
|
||||
|------|------------|-------------|
|
||||
| mailer === 'smtp' | `host: "smtp.example.com"` | `"host":"smtp.example.com"` (포함) |
|
||||
| mailer === 'mailgun' | `host: undefined` | 키 자체가 제거됨 |
|
||||
|
||||
**핵심 규칙**:
|
||||
|
||||
```text
|
||||
주의: 불필요한 필드를 빈 문자열('')로 전송하지 말 것
|
||||
필수: undefined 패턴으로 해당 드라이버 필드만 전송
|
||||
공통 필드(from_address, from_name 등)는 조건 없이 항상 전송
|
||||
주의: 모든 드라이버 필드를 항상 전송하면 서버 측 불필요한 처리 유발
|
||||
```
|
||||
|
||||
**삼항 표현식 패턴**:
|
||||
|
||||
```text
|
||||
현재 드라이버 필드: 조건 ? (값 ?? 기본값) : undefined
|
||||
다른 드라이버 필드: undefined (JSON에서 제거)
|
||||
공통 필드: 조건 없이 항상 포함
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CSRF 토큰
|
||||
|
||||
`apiCall`은 자동으로 CSRF 토큰을 처리합니다:
|
||||
- POST, PUT, PATCH, DELETE 요청 시 `/sanctum/csrf-cookie` 호출
|
||||
- 쿠키에서 `XSRF-TOKEN`을 추출하여 `X-XSRF-TOKEN` 헤더에 포함
|
||||
|
||||
### globalHeaders 자동 적용
|
||||
|
||||
> **버전**: engine-v1.16.0+
|
||||
|
||||
레이아웃에 `globalHeaders`가 정의되어 있으면, `apiCall` 핸들러도 해당 패턴에 매칭되는 API에 헤더를 자동으로 포함합니다.
|
||||
|
||||
```json
|
||||
// 레이아웃 최상위
|
||||
{
|
||||
"globalHeaders": [
|
||||
{ "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
|
||||
]
|
||||
}
|
||||
|
||||
// apiCall 핸들러 - X-Cart-Key 헤더 자동 포함
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/cart/add",
|
||||
"params": {
|
||||
"method": "POST",
|
||||
"body": { "productId": "{{item.id}}" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**헤더 우선순위**: `params.headers` > `globalHeaders`
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/cart",
|
||||
"params": {
|
||||
"method": "GET",
|
||||
"headers": { "X-Cart-Key": "custom-key" } // globalHeaders보다 우선
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **상세 문서**: [layout-json.md](layout-json.md#전역-헤더-globalheaders)
|
||||
|
||||
---
|
||||
|
||||
## setState
|
||||
|
||||
상태를 변경합니다.
|
||||
|
||||
### 병합 모드 (merge 옵션)
|
||||
|
||||
> **버전**: engine-v1.18.0+ (`replace` 모드 추가)
|
||||
|
||||
setState 핸들러의 `params`에 `merge` 속성을 지정하여 상태 병합 방식을 선택할 수 있습니다.
|
||||
|
||||
| `merge` 값 | 동작 | 사용 시점 |
|
||||
|-------------|------|----------|
|
||||
| 생략 또는 `"deep"` | 재귀적 깊은 병합 (중첩 객체 보존) | 개별 필드 업데이트 (기본값) |
|
||||
| `"shallow"` | 최상위 키만 덮어쓰기 (1단계) | 프리셋 적용, 필터 초기화 |
|
||||
| `"replace"` | 기존 상태 완전 무시, 새 값으로 교체 | 폼 초기화, 서버 데이터로 전체 교체 |
|
||||
|
||||
```json
|
||||
// replace 모드: 기존 _local을 완전히 payload로 교체
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"merge": "replace",
|
||||
"formData": { "name": "", "price": 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// shallow 모드: 최상위 키만 덮어쓰기
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"merge": "shallow",
|
||||
"filter": { "searchField": "all", "status": "all" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **상세 비교**: [state-management-forms.md](state-management-forms.md#병합-모드-선택)
|
||||
|
||||
### 전역 상태 변경 (target: "global")
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"selectedIds": "{{$args[0]}}",
|
||||
"searchQuery": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 로컬 상태 변경 (target: "local" 또는 생략)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"isExpanded": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 격리된 상태 변경 (target: "isolated")
|
||||
|
||||
> **버전**: engine-v1.14.0+
|
||||
|
||||
`isolatedState` 속성이 정의된 컴포넌트 내에서만 동작합니다. 해당 영역의 상태 변경 시 전체 레이아웃이 아닌 격리된 영역만 리렌더링됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "isolated",
|
||||
"selectedId": "{{item.id}}",
|
||||
"currentStep": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 사용 요건
|
||||
|
||||
```text
|
||||
주의: target: "isolated"는 isolatedState 속성이 정의된 컴포넌트 내에서만 동작합니다.
|
||||
격리 스코프 외부에서 호출 시 → _local로 폴백되며 경고 로그 출력
|
||||
isolatedState가 있는 컴포넌트 내에서 호출 시 → 격리된 상태만 업데이트
|
||||
```
|
||||
|
||||
#### 레이아웃 정의 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Div",
|
||||
"isolatedState": {
|
||||
"selectedCategories": [null, null, null, null],
|
||||
"currentStep": 1
|
||||
},
|
||||
"isolatedScopeId": "category-selector",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"label": "다음 단계",
|
||||
"onClick": {
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "isolated",
|
||||
"currentStep": "{{_isolated.currentStep + 1}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### target별 비교
|
||||
|
||||
| target | 상태 스코프 | 리렌더링 범위 | 사용 시점 |
|
||||
| ------ | ----------- | -------------- | ---------- |
|
||||
| `local` (기본) | `_local` | 전체 레이아웃 | 일반 폼 데이터, 필터 |
|
||||
| `global` | `_global` | 전체 앱 | 사용자 인증, 사이드바 상태 |
|
||||
| `isolated` | `_isolated` | 격리된 영역만 | 빈번한 인터랙션 (카테고리 선택, 드래그) |
|
||||
|
||||
### 주요 사용 패턴
|
||||
|
||||
```json
|
||||
// 체크박스 선택 ID 저장
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"selectedIds": "{{$args[0]}}"
|
||||
}
|
||||
}
|
||||
|
||||
// 검색어 입력 시 상태 저장
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"searchQuery": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
|
||||
// 토글 상태 변경
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"isExpanded": "{{!_local.isExpanded}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## setError
|
||||
|
||||
에러 상태를 설정합니다. `apiError` 상태 키에 저장됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "setError",
|
||||
"target": "{{error.response.message}}"
|
||||
}
|
||||
```
|
||||
|
||||
### target 값
|
||||
|
||||
| 형식 | 설명 |
|
||||
|------|------|
|
||||
| `{{error.message}}` | 데이터 바인딩 (에러 객체에서 추출) |
|
||||
| `$t:errors.login_failed` | 다국어 키 |
|
||||
| `"로그인에 실패했습니다."` | 직접 문자열 |
|
||||
|
||||
---
|
||||
|
||||
## refetchDataSource
|
||||
|
||||
특정 데이터 소스를 다시 fetch합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "modules"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### refetchDataSource params 구조
|
||||
|
||||
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
||||
|------|------|------|--------|------|
|
||||
| `dataSourceId` | string | ✅ | - | 다시 fetch할 데이터 소스 ID |
|
||||
| `sync` | boolean | ❌ | false | true면 즉시 동기 렌더링 |
|
||||
|
||||
### sync 옵션
|
||||
|
||||
> **버전**: engine-v1.4.0+
|
||||
|
||||
`sync: true`를 사용하면 React의 `startTransition` 없이 즉시 동기적으로 렌더링합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "admin_menu",
|
||||
"sync": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 케이스 | sync 필요 여부 |
|
||||
|--------|---------------|
|
||||
| 드래그 앤 드롭 순서 변경 | ✅ `sync: true` |
|
||||
| 토글/체크박스 상태 변경 | ✅ `sync: true` |
|
||||
| 일반 데이터 갱신 | ❌ 기본값 사용 |
|
||||
|
||||
### 상태 오버라이드 (engine-v1.17.0+)
|
||||
|
||||
refetch 시 상태를 임시로 오버라이드하여 데이터소스 파라미터 치환에 반영할 수 있습니다.
|
||||
|
||||
| 필드 | 타입 | 필수 | 엔진 버전 | 설명 |
|
||||
|------|------|------|----------|------|
|
||||
| `globalStateOverride` | object | ❌ | engine-v1.17.0+ | `_global` 값 임시 오버라이드 |
|
||||
| `localStateOverride` | object | ❌ | engine-v1.19.0+ | `_local` 값 임시 오버라이드 |
|
||||
| `isolatedStateOverride` | object | ❌ | engine-v1.19.0+ | `_isolated` 값 임시 오버라이드 |
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "products",
|
||||
"sync": true,
|
||||
"globalStateOverride": {
|
||||
"currentPage": 1
|
||||
},
|
||||
"localStateOverride": {
|
||||
"filter": { "status": "active" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
오버라이드는 해당 refetch에만 적용 (실제 상태 미변경)
|
||||
✅ 상세 문서: data-sources-advanced.md "상태 오버라이드 파라미터" 섹션
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## updateDataSource
|
||||
|
||||
데이터 소스를 직접 업데이트합니다. API 응답을 사용하여 데이터 소스를 갱신할 때 사용합니다. `refetchDataSource`와 달리 추가 API 요청 없이 즉시 데이터를 업데이트합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/checkout",
|
||||
"params": { "method": "PUT", "body": "{{_local.checkout}}" },
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "updateDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "checkoutData",
|
||||
"data": "{{response}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### updateDataSource params 구조
|
||||
|
||||
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
||||
|------|------|------|--------|------|
|
||||
| `dataSourceId` | string | ✅ | - | 업데이트할 데이터 소스 ID |
|
||||
| `data` | any | ✅ | - | 새로운 데이터 (API 응답 등) |
|
||||
|
||||
### refetchDataSource vs updateDataSource
|
||||
|
||||
| 핸들러 | 동작 | 네트워크 요청 | 사용 시점 |
|
||||
|--------|------|--------------|----------|
|
||||
| `refetchDataSource` | 데이터 소스의 endpoint를 다시 호출 | ✅ 발생 | 서버에서 최신 데이터를 가져와야 할 때 |
|
||||
| `updateDataSource` | 전달받은 data로 직접 교체 | ❌ 없음 | PUT/POST 응답으로 즉시 갱신할 때 |
|
||||
|
||||
### 사용 예시
|
||||
|
||||
**PUT API 응답으로 데이터 소스 갱신** (추가 GET 요청 없이):
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/checkout",
|
||||
"params": { "method": "PUT", "body": "{{_local.checkout}}" },
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "updateDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "checkoutData",
|
||||
"data": "{{response}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": { "type": "success", "message": "$t:common.saved" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
주의: onSuccess 콜백에서 response 변수를 사용할 때 $response가 아닌 response 사용
|
||||
"data": "{{$response}}" → undefined로 평가됨
|
||||
"data": "{{response}}" → 정상 동작
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## appendDataSource
|
||||
|
||||
기존 데이터 소스에 새 데이터를 병합합니다. 무한스크롤 구현에 유용합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "appendDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "templates",
|
||||
"dataPath": "data",
|
||||
"newData": "{{response.data}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### appendDataSource params 구조
|
||||
|
||||
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
||||
|------|------|------|--------|------|
|
||||
| `dataSourceId` | string | ✅ | - | 대상 데이터 소스 ID |
|
||||
| `dataPath` | string | ❌ | null | 병합할 데이터 경로 (예: "data") |
|
||||
| `newData` | array | ✅ | - | 병합할 새 데이터 배열 |
|
||||
|
||||
### 무한스크롤 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "scroll",
|
||||
"debounce": 200,
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "switch",
|
||||
"params": {
|
||||
"value": "{{$event.target.scrollHeight - $event.target.scrollTop <= $event.target.clientHeight + 100 && _global.infiniteScroll.hasMore && !_global.infiniteScroll.isLoadingMore}}"
|
||||
},
|
||||
"cases": {
|
||||
"true": {
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"infiniteScroll.isLoadingMore": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/items",
|
||||
"params": {
|
||||
"method": "GET",
|
||||
"query": {
|
||||
"page": "{{_global.infiniteScroll.currentPage + 1}}",
|
||||
"per_page": 20
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "appendDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "items",
|
||||
"dataPath": "data",
|
||||
"newData": "{{response.data}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"infiniteScroll.currentPage": "{{_global.infiniteScroll.currentPage + 1}}",
|
||||
"infiniteScroll.hasMore": "{{(response.data?.length ?? 0) >= 20}}",
|
||||
"infiniteScroll.isLoadingMore": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### scroll 이벤트 속성
|
||||
|
||||
scroll 이벤트에서 `$event.target`으로 접근 가능한 속성:
|
||||
|
||||
| 속성 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `scrollHeight` | number | 전체 콘텐츠 높이 |
|
||||
| `scrollTop` | number | 현재 스크롤 위치 (상단 기준) |
|
||||
| `clientHeight` | number | 보이는 영역 높이 |
|
||||
| `scrollWidth` | number | 전체 콘텐츠 너비 |
|
||||
| `scrollLeft` | number | 현재 스크롤 위치 (좌측 기준) |
|
||||
| `clientWidth` | number | 보이는 영역 너비 |
|
||||
|
||||
---
|
||||
|
||||
## remount
|
||||
|
||||
컴포넌트를 강제로 리마운트합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "remount",
|
||||
"params": {
|
||||
"componentId": "template_card_grid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 사례
|
||||
|
||||
- Toggle/Checkbox 상태 복원
|
||||
- 폼 초기화
|
||||
- 컴포넌트 상태 리셋
|
||||
|
||||
---
|
||||
|
||||
## sortable (드래그앤드롭 정렬)
|
||||
|
||||
> **버전**: engine-v1.14.0+
|
||||
> **기반**: @dnd-kit/core + @dnd-kit/sortable (React 네이티브 D&D 라이브러리)
|
||||
|
||||
레이아웃 JSON의 `sortable` 속성을 사용하여 드래그앤드롭 정렬을 구현합니다.
|
||||
HTML5 네이티브 D&D가 아닌 @dnd-kit 기반으로 동작하며, DynamicRenderer가 자동으로 SortableContainer/SortableItemWrapper를 렌더링합니다.
|
||||
|
||||
### sortable 속성 구조
|
||||
|
||||
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
||||
| ---- | ---- | ---- | ------ | ---- |
|
||||
| `source` | string | ✅ | - | 배열 바인딩 표현식 (예: `"{{_local.form.options}}"`) |
|
||||
| `itemKey` | string | ❌ | `"id"` | 아이템 고유 키 필드명 |
|
||||
| `strategy` | string | ❌ | `"verticalList"` | 정렬 전략: `verticalList` / `horizontalList` / `rectSorting` |
|
||||
| `handle` | string | ❌ | - | 드래그 핸들 CSS 선택자 (예: `"[data-drag-handle]"`) |
|
||||
| `itemVar` | string | ❌ | `"$item"` | 아이템 컨텍스트 변수명 |
|
||||
| `indexVar` | string | ❌ | `"$index"` | 인덱스 컨텍스트 변수명 |
|
||||
|
||||
### 기본 사용 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"sortable": {
|
||||
"source": "{{_local.form.additional_options}}",
|
||||
"itemKey": "id",
|
||||
"strategy": "verticalList",
|
||||
"handle": "[data-drag-handle]"
|
||||
},
|
||||
"itemTemplate": {
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2 p-2 border rounded"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"data-drag-handle": true,
|
||||
"className": "cursor-grab"
|
||||
},
|
||||
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "fa-grip-vertical" } }]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": { "textContent": "{{$item.name}}" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"event": "onSortEnd",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"form.additional_options": "{{$sortedItems}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### sortable 전용 이벤트
|
||||
|
||||
| 이벤트 | 설명 | 컨텍스트 변수 |
|
||||
| ------ | ---- | ------------- |
|
||||
| `onSortEnd` | 정렬 완료 시 | `$sortedItems` (정렬된 배열), `$oldIndex`, `$newIndex` |
|
||||
| `onSortStart` | 드래그 시작 시 | `$activeId` (드래그 중인 아이템 ID) |
|
||||
|
||||
### 드래그 핸들
|
||||
|
||||
`handle` 속성을 지정하면 해당 CSS 선택자를 가진 요소만 드래그 가능합니다.
|
||||
`data-drag-handle` 속성이 있는 요소에 DynamicRenderer가 자동으로 드래그 리스너를 바인딩합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"sortable": {
|
||||
"source": "{{_local.form.items}}",
|
||||
"handle": "[data-drag-handle]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
핸들을 지정하지 않으면 아이템 전체가 드래그 가능합니다.
|
||||
|
||||
### 주의 사항
|
||||
|
||||
```text
|
||||
sortable은 iteration보다 우선 처리됨 (같은 컴포넌트에 둘 다 있으면 sortable만 동작)
|
||||
sortable 아이템 내부에서는 폼 자동 바인딩(auto-binding)이 비활성화됨
|
||||
→ 인덱스 기반 경로가 정렬 후 stale 값을 참조하는 문제 방지
|
||||
→ 핸들러(setState 등)에서 직접 상태를 관리해야 함
|
||||
onSortEnd에서 setState로 소스 배열을 업데이트해야 정렬 결과가 반영됨
|
||||
아이템에 고유한 id 필드가 필수 (itemKey로 지정)
|
||||
HTML5 네이티브 D&D(dragstart/dragover/drop 이벤트)는 sortable에 사용하지 않음
|
||||
→ @dnd-kit이 PointerSensor/KeyboardSensor로 자체 처리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## onSuccess/onError 후속 액션
|
||||
|
||||
API 호출 등의 액션 후에 후속 액션을 실행할 수 있습니다.
|
||||
|
||||
### 배열 지원
|
||||
|
||||
`onSuccess`와 `onError`는 **단일 액션 또는 배열**을 지원합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/admin/users/bulk-status",
|
||||
"params": {
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"ids": "{{_global.selectedIds}}",
|
||||
"status": "active"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
{ "handler": "closeModal" },
|
||||
{ "handler": "setState", "params": { "target": "global", "selectedIds": [] } },
|
||||
{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, "query": {} } },
|
||||
{ "handler": "toast", "params": { "type": "success", "message": "$t:admin.users.modals.bulk_activate_success" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### onError에서 접근 가능한 데이터
|
||||
|
||||
| 바인딩 | 설명 |
|
||||
|--------|------|
|
||||
| `{{error.message}}` | 에러 메시지 (번역됨) |
|
||||
| `{{error.status}}` | HTTP 상태 코드 |
|
||||
| `{{error.data}}` | API 응답 전체 (success, message, errors 포함) |
|
||||
| `{{error.data.errors}}` | errors 객체 (추가 에러 정보) |
|
||||
|
||||
---
|
||||
|
||||
## API 데이터 바인딩 규칙
|
||||
|
||||
```text
|
||||
API 응답 데이터 구조를 절대 추측하지 않음
|
||||
|
||||
1. 새로운 API 연동 시 → 실제 API 응답 구조를 컨트롤러 코드에서 확인
|
||||
2. onSuccess/onError 콜백 → 정확한 컨텍스트 변수 경로 확인 필수
|
||||
3. 불확실한 경우 → 기존 레이아웃의 유사 패턴 참조
|
||||
```
|
||||
|
||||
### 그누보드7 API 응답 표준 구조
|
||||
|
||||
ResponseHelper를 사용하는 모든 API는 다음 구조를 따릅니다:
|
||||
|
||||
**성공 응답 (success)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "번역된 메시지",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답 (error)**:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "번역된 에러 메시지",
|
||||
"errors": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 콜백별 컨텍스트 변수 경로
|
||||
|
||||
```text
|
||||
주의: onSuccess/onError 콜백에서 `$response` 사용 금지 — `response` 사용
|
||||
올바른 변수명: `response` ($ 접두사 없음)
|
||||
`$response`는 ActionDispatcher 컨텍스트에 존재하지 않는 변수 → undefined 반환
|
||||
→ preprocessOptionalChaining이 `$response?.data?.xxx`로 변환 → undefined (에러 없이 조용히 실패)
|
||||
→ fallback 값이 있으면 항상 fallback만 표시되어 버그가 은폐됨
|
||||
```
|
||||
|
||||
| 콜백 | 변수 | 실제 경로 | 설명 |
|
||||
|------|------|----------|------|
|
||||
| onSuccess | `response` | - | 전체 API 응답 (권장) |
|
||||
| onSuccess | `response.data` | - | 성공 시 data 필드 |
|
||||
| onSuccess | `result` | - | `response`와 동일 (하위 호환성) |
|
||||
| onError | `error.message` | - | 번역된 에러 메시지 |
|
||||
| onError | `error.status` | - | HTTP 상태 코드 |
|
||||
| onError | `error.data` | - | 전체 API 응답 |
|
||||
| onError | `error.data.errors` | - | errors 객체 |
|
||||
| onError | `error.data.errors.필드명` | - | 특정 에러 필드 |
|
||||
|
||||
### API 데이터 바인딩 체크리스트
|
||||
|
||||
```
|
||||
□ API 응답 구조를 실제로 확인했는가? (추측 금지)
|
||||
□ ResponseHelper의 success/error 반환 구조를 이해했는가?
|
||||
□ 중첩된 객체 경로가 정확한가? (예: error.data.errors.field)
|
||||
□ 배열 vs 객체 구분이 명확한가?
|
||||
□ Optional Chaining(?.)을 적절히 사용했는가?
|
||||
```
|
||||
|
||||
### 자주 하는 실수
|
||||
|
||||
```json
|
||||
// ❌ 잘못된 예: 응답 구조 추측
|
||||
"deactivateWarningData": "{{error.response}}"
|
||||
"deactivateWarningData": "{{error.dependent_templates}}"
|
||||
|
||||
// ✅ 올바른 예: 실제 구조 확인 후 사용
|
||||
"deactivateWarningData": "{{error.data}}"
|
||||
// 모달에서: {{_global.deactivateWarningData.errors.dependent_templates}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 에러 핸들링 시스템 (errorHandling)
|
||||
|
||||
> **버전**: engine-v1.6.0+
|
||||
|
||||
HTTP 에러 코드별로 다른 처리 로직을 정의할 수 있습니다.
|
||||
|
||||
### errorHandling vs onError 역할 분리
|
||||
|
||||
| 속성 | 역할 | 실행 조건 |
|
||||
|------|------|----------|
|
||||
| `errorHandling` | 특정 에러 코드별 처리 | 해당 코드 또는 default가 정의된 경우 |
|
||||
| `onError` | 범용 에러 처리 (폴백) | errorHandling에 해당 코드가 없을 때 |
|
||||
|
||||
### 처리 흐름
|
||||
|
||||
```text
|
||||
API 에러 발생 (예: 403)
|
||||
↓
|
||||
errorHandling[403] 있음?
|
||||
├── ✅ → errorHandling[403] 실행 (종료)
|
||||
↓ ❌
|
||||
errorHandling[default] 있음?
|
||||
├── ✅ → errorHandling[default] 실행 (종료)
|
||||
↓ ❌
|
||||
onError 있음?
|
||||
├── ✅ → onError 실행 (종료)
|
||||
↓ ❌
|
||||
상위 레벨 errorHandling 확인
|
||||
```
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/admin/users/{{id}}",
|
||||
"params": { "method": "DELETE" },
|
||||
"errorHandling": {
|
||||
"403": {
|
||||
"handler": "openModal",
|
||||
"target": "permission_denied_modal"
|
||||
},
|
||||
"404": {
|
||||
"handler": "toast",
|
||||
"params": { "type": "warning", "message": "$t:errors.user_not_found" }
|
||||
}
|
||||
},
|
||||
"onError": {
|
||||
"handler": "toast",
|
||||
"params": { "type": "error", "message": "{{error.message}}" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [액션 핸들러 인덱스](actions-handlers.md)
|
||||
- [네비게이션 핸들러](actions-handlers-navigation.md)
|
||||
- [UI 핸들러](actions-handlers-ui.md)
|
||||
- [상태 관리](state-management.md)
|
||||
- [데이터 소스](data-sources.md)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,245 @@
|
||||
# 액션 핸들러 - 핸들러별 상세 사용법
|
||||
|
||||
> **메인 문서**: [actions.md](actions.md)
|
||||
> **관련 문서**: [actions-g7core-api.md](actions-g7core-api.md) | [layout-json.md](layout-json.md) | [state-management.md](state-management.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. navigate: 페이지 이동 (path, query, mergeQuery 옵션)
|
||||
2. apiCall: API 호출 (method, endpoint, body, onSuccess/onError)
|
||||
3. setState: 상태 변경 (_global/_local/_isolated 경로, target 옵션)
|
||||
4. openModal/closeModal: 모달 열기/닫기
|
||||
5. sequence/parallel: 여러 액션 순차/병렬 실행
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 하위 문서 안내
|
||||
|
||||
이 문서는 규모가 커서 카테고리별로 분할되었습니다. 아래 링크를 통해 각 핸들러에 대한 상세 내용을 확인하세요.
|
||||
|
||||
| 하위 문서 | 주요 핸들러 | 설명 |
|
||||
|----------|------------|------|
|
||||
| [actions-handlers-navigation.md](actions-handlers-navigation.md) | navigate, navigateBack, openWindow, replaceUrl, reloadRoutes, refresh | 페이지 이동 및 라우트 관리 |
|
||||
| [actions-handlers-state.md](actions-handlers-state.md) | apiCall, setState, setError, refetchDataSource, remount | API 호출 및 상태 관리 |
|
||||
| [actions-handlers-ui.md](actions-handlers-ui.md) | login/logout, openModal/closeModal, toast, switch, sequence/parallel, loadScript, callExternal | UI 인터랙션 및 외부 스크립트 |
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
### 네비게이션 핸들러 → [상세 문서](actions-handlers-navigation.md)
|
||||
|
||||
1. [navigate](actions-handlers-navigation.md#navigate) - 페이지 이동
|
||||
2. [navigateBack](actions-handlers-navigation.md#navigateback) - 뒤로 가기
|
||||
3. [openWindow](actions-handlers-navigation.md#openwindow) - 새 창/탭에서 열기
|
||||
4. [replaceUrl](actions-handlers-navigation.md#replaceurl) - URL만 변경 (refetch 없음)
|
||||
5. [reloadRoutes](actions-handlers-navigation.md#reloadroutes) - 라우트 재로드
|
||||
6. [refresh](actions-handlers-navigation.md#refresh) - 페이지 새로고침
|
||||
|
||||
### 상태 관리 핸들러 → [상세 문서](actions-handlers-state.md)
|
||||
|
||||
5. [apiCall](actions-handlers-state.md#apicall) - API 호출
|
||||
6. [setState](actions-handlers-state.md#setstate) - 상태 변경
|
||||
7. [setError](actions-handlers-state.md#seterror) - 에러 상태 설정
|
||||
8. [refetchDataSource](actions-handlers-state.md#refetchdatasource) - 데이터 소스 재조회
|
||||
9. [appendDataSource](actions-handlers-state.md#appenddatasource) - 데이터 소스 병합 (무한스크롤)
|
||||
10. [remount](actions-handlers-state.md#remount) - 컴포넌트 리마운트
|
||||
11. [onSuccess/onError 후속 액션](actions-handlers-state.md#onsuccessonerror-후속-액션)
|
||||
12. [API 데이터 바인딩 규칙](actions-handlers-state.md#api-데이터-바인딩-규칙)
|
||||
13. [에러 핸들링 시스템](actions-handlers-state.md#에러-핸들링-시스템-errorhandling)
|
||||
|
||||
### UI 인터랙션 핸들러 → [상세 문서](actions-handlers-ui.md)
|
||||
|
||||
14. [login / logout](actions-handlers-ui.md#login--logout) - 인증
|
||||
15. [openModal / closeModal](actions-handlers-ui.md#openmodal--closemodal) - 모달
|
||||
16. [showAlert / toast](actions-handlers-ui.md#showalert--toast) - 알림
|
||||
17. [confirm (액션 속성)](actions-handlers-ui.md#confirm-액션-속성) - 실행 전 확인 대화상자
|
||||
18. [switch](actions-handlers-ui.md#switch) - 조건부 액션
|
||||
19. [sequence / parallel](actions-handlers-ui.md#sequence--parallel) - 액션 조합
|
||||
20. [reloadTranslations](actions-handlers-ui.md#reloadtranslations) - 다국어 재로드
|
||||
21. [showErrorPage](actions-handlers-ui.md#showerrorpage) - 에러 페이지
|
||||
22. [loadScript](actions-handlers-ui.md#loadscript) ⭐ NEW - 외부 스크립트 로드
|
||||
23. [callExternal](actions-handlers-ui.md#callexternal) ⭐ NEW - 외부 라이브러리 호출
|
||||
24. [실전 예시](actions-handlers-ui.md#실전-예시)
|
||||
|
||||
---
|
||||
|
||||
## 핸들러 빠른 참조
|
||||
|
||||
### 자주 사용하는 핸들러
|
||||
|
||||
| 핸들러 | 용도 | 상세 문서 |
|
||||
|--------|------|----------|
|
||||
| `navigate` | 페이지 이동 | [navigation](actions-handlers-navigation.md#navigate) |
|
||||
| `openWindow` | 새 창/탭에서 열기 | [navigation](actions-handlers-navigation.md#openwindow) |
|
||||
| `replaceUrl` | URL만 변경 (refetch 없음) | [navigation](actions-handlers-navigation.md#replaceurl) |
|
||||
| `apiCall` | API 호출 | [state](actions-handlers-state.md#apicall) |
|
||||
| `setState` | 상태 변경 | [state](actions-handlers-state.md#setstate) |
|
||||
| `openModal` | 모달 열기 | [ui](actions-handlers-ui.md#openmodal--closemodal) |
|
||||
| `closeModal` | 모달 닫기 | [ui](actions-handlers-ui.md#openmodal--closemodal) |
|
||||
| `toast` | 토스트 알림 | [ui](actions-handlers-ui.md#showalert--toast) |
|
||||
| `sequence` | 순차 실행 | [ui](actions-handlers-ui.md#sequence--parallel) |
|
||||
| `suppress` | 에러 전파 방지 (no-op) | [error](layout-json-features-error.md#에러-전파-방지-suppress-핸들러) |
|
||||
|
||||
### 핸들러별 필수 속성
|
||||
|
||||
| 핸들러 | 필수 속성 | 선택 속성 |
|
||||
|--------|----------|----------|
|
||||
| `navigate` | `params.path` | `params.query`, `params.mergeQuery`, `params.replace` |
|
||||
| `openWindow` | `params.path` | - |
|
||||
| `replaceUrl` | - | `params.path`, `params.query`, `params.mergeQuery` |
|
||||
| `apiCall` | `target` | `params.method`, `params.body`, `params.contentType`, `auth_required`, `onSuccess`, `onError` |
|
||||
| `setState` | `params.*` | `params.target` (global/local/isolated) |
|
||||
| `openModal` | `target` (모달 ID) | - |
|
||||
| `toast` | `params.message` | `params.type`, `params.duration` |
|
||||
| `switch` | `cases` | `params.value` |
|
||||
| `sequence` | `actions` | - |
|
||||
| `parallel` | `actions` | - |
|
||||
| `suppress` | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 모듈 커스텀 핸들러 다국어 처리
|
||||
|
||||
모듈에서 커스텀 핸들러를 개발할 때, 사용자에게 표시되는 모든 문자열은 반드시 다국어 처리해야 합니다.
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
```text
|
||||
필수: 모든 사용자 표시 문자열은 G7Core.t() 사용
|
||||
필수: 다국어 키는 moduleIdentifier로 시작
|
||||
✅ 필수: 영어 폴백 메시지 제공
|
||||
✅ 필수: 파라미터는 {param} 형식 사용
|
||||
❌ 금지: 한글 문자열 하드코딩 (toast, 알림 메시지 등)
|
||||
예외: 로케일별 콘텐츠 생성용 상수맵은 허용 (예: DETAIL_REF_TRANSLATIONS)
|
||||
```
|
||||
|
||||
### 다국어 키 네이밍 규칙
|
||||
|
||||
```text
|
||||
[moduleId].admin.[section].handler.[message_key]
|
||||
```
|
||||
|
||||
**예시**:
|
||||
|
||||
- `sirsoft-ecommerce.admin.product.handler.category_max_5`
|
||||
- `sirsoft-ecommerce.admin.product.handler.options_generated`
|
||||
|
||||
**키 접미사 규칙**:
|
||||
|
||||
| 접미사 | 용도 | 예시 |
|
||||
|--------|------|------|
|
||||
| `_success` | 성공 메시지 | `copy_success` |
|
||||
| `_error`, `_failed` | 오류 메시지 | `copy_error`, `upload_failed` |
|
||||
| `_required` | 필수 입력 안내 | `name_required` |
|
||||
| `_max_N` | 최대 개수 제한 | `category_max_5` |
|
||||
|
||||
### 구현 패턴
|
||||
|
||||
**기본 패턴**:
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T: 하드코딩
|
||||
G7Core.toast?.warning?.('최대 5개까지 선택 가능합니다.');
|
||||
|
||||
// ✅ DO: G7Core.t() + 영어 폴백
|
||||
G7Core.toast?.warning?.(
|
||||
G7Core.t?.('sirsoft-ecommerce.admin.product.handler.category_max_5')
|
||||
?? 'You can select up to 5 categories.'
|
||||
);
|
||||
```
|
||||
|
||||
**파라미터 패턴**:
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T: 템플릿 리터럴만 사용
|
||||
G7Core.toast?.success?.(`${count}개의 옵션이 생성되었습니다.`);
|
||||
|
||||
// ✅ DO: 파라미터 전달 + 폴백
|
||||
G7Core.toast?.success?.(
|
||||
G7Core.t?.('sirsoft-ecommerce.admin.product.handler.options_generated', { count })
|
||||
?? `${count} options have been generated.`
|
||||
);
|
||||
```
|
||||
|
||||
**다국어 파일 등록** (`resources/lang/ko.json`, `en.json`):
|
||||
|
||||
```json
|
||||
// ko.json
|
||||
{
|
||||
"admin": {
|
||||
"product": {
|
||||
"handler": {
|
||||
"category_max_5": "최대 5개까지 선택 가능합니다.",
|
||||
"options_generated": "{count}개의 옵션이 생성되었습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// en.json
|
||||
{
|
||||
"admin": {
|
||||
"product": {
|
||||
"handler": {
|
||||
"category_max_5": "You can select up to 5 categories.",
|
||||
"options_generated": "{count} options have been generated."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 예외: 로케일별 콘텐츠 생성용 상수맵
|
||||
|
||||
로케일별로 다른 콘텐츠를 **생성**해야 하는 경우, 상수맵은 허용됩니다:
|
||||
|
||||
```typescript
|
||||
// ✅ 허용: 로케일별 콘텐츠 생성용 상수맵
|
||||
const DETAIL_REF_TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
ko: { goods_name: '상품명', model_name: '모델명' },
|
||||
en: { goods_name: 'Product Name', model_name: 'Model Name' },
|
||||
};
|
||||
|
||||
// 사용: 특정 로케일의 콘텐츠 생성
|
||||
const text = DETAIL_REF_TRANSLATIONS[locale]?.[key] ?? key;
|
||||
```
|
||||
|
||||
### 핸들러 개발 체크리스트
|
||||
|
||||
```text
|
||||
□ 모든 toast 메시지에 G7Core.t() 적용
|
||||
□ 모든 확인/알림 모달 텍스트에 G7Core.t() 적용
|
||||
□ 영어 폴백 메시지 제공 (G7Core.t?.() ?? 'fallback')
|
||||
□ 다국어 파일(ko.json, en.json)에 키 추가
|
||||
□ 파라미터는 {param} 형식으로 정의
|
||||
□ 로그 메시지는 다국어 처리 불필요 (logger.log, logger.warn 등)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 증상별 문서 찾기
|
||||
|
||||
| 증상 | 관련 문서 | 핵심 키워드 |
|
||||
|------|----------|------------|
|
||||
| 페이지 이동 안 됨 | [navigation](actions-handlers-navigation.md) | navigate, path, replace |
|
||||
| API 호출 실패 | [state](actions-handlers-state.md#apicall) | apiCall, auth_required, onError |
|
||||
| 상태 변경 안 됨 | [state](actions-handlers-state.md#setstate) | setState, target: global/local/isolated |
|
||||
| 모달 안 열림/안 닫힘 | [ui](actions-handlers-ui.md#openmodal--closemodal) | openModal, closeModal, modalStack |
|
||||
| 토스트 안 나옴 | [ui](actions-handlers-ui.md#showalert--toast) | toast, params.type |
|
||||
| 외부 스크립트 로드 | [ui](actions-handlers-ui.md#loadscript) | loadScript, onLoad |
|
||||
| 조건부 액션 분기 | [ui](actions-handlers-ui.md#switch) | switch, cases, default |
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [액션 시스템 기초](actions.md)
|
||||
- [G7Core API](actions-g7core-api.md)
|
||||
- [상태 관리](state-management.md)
|
||||
- [데이터 소스](data-sources.md)
|
||||
- [레이아웃 JSON](layout-json.md)
|
||||
@@ -0,0 +1,596 @@
|
||||
# 액션 핸들러 가이드
|
||||
|
||||
> **관련 문서**: [레이아웃 JSON](layout-json.md) | [데이터 바인딩](data-binding.md) | [상태 관리](state-management.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션)
|
||||
2. type: 표준 DOM 이벤트 (click, change) → $event.target.value
|
||||
3. event: 커스텀 콜백 (CodeEditor, DataGrid) → $args[0]
|
||||
4. 핸들러명 정확히: apiCall (❌ api), navigate (❌ goto)
|
||||
5. 조합: sequence(순차), parallel(병렬), onSuccess/onError
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 분리된 문서
|
||||
|
||||
이 문서는 가독성을 위해 다음과 같이 분리되었습니다:
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| **actions.md** (현재) | 개요, 액션 정의 구조, 내장 핸들러 목록, 주의사항 |
|
||||
| [actions-handlers.md](actions-handlers.md) | 핸들러별 상세 사용법, onSuccess/onError, errorHandling, 실전 예시 |
|
||||
| [actions-g7core-api.md](actions-g7core-api.md) | G7Core.dispatch, 편의 API, 이벤트 헬퍼 |
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [액션 정의 구조](#액션-정의-구조)
|
||||
3. [내장 핸들러 목록](#내장-핸들러-목록)
|
||||
4. [주의사항](#주의사항)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
**액션 핸들러**는 레이아웃 JSON에서 사용자 인터랙션(클릭, 폼 제출 등)을 처리하는 시스템입니다.
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
```text
|
||||
중요: 핸들러 이름은 ActionDispatcher에 정의된 이름과 정확히 일치해야 함
|
||||
✅ 필수: apiCall (❌ api 아님)
|
||||
✅ 필수: navigate, setState, openModal, closeModal, toast 등
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 액션 정의 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"target": "/path",
|
||||
"params": {
|
||||
"key": "value"
|
||||
},
|
||||
"onSuccess": [],
|
||||
"onError": [],
|
||||
"confirm": "확인 메시지",
|
||||
"key": "Enter"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 필드 설명
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `type` | string | | 표준 DOM 이벤트 타입 (click, change, submit 등). `event` 필드와 상호 배타적 |
|
||||
| `event` | string | | 커스텀 콜백 이벤트명 (onSortChange, onChange 등). `type` 필드와 상호 배타적 |
|
||||
| `handler` | string | ✅ | 액션 핸들러 이름 |
|
||||
| `target` | string | ❌ | 핸들러별 타겟 값 (URL, 모달 ID 등) |
|
||||
| `params` | object | ❌ | 핸들러에 전달할 파라미터 |
|
||||
| `onSuccess` | array | ❌ | 성공 시 실행할 후속 액션 (단일 또는 배열) |
|
||||
| `onError` | array | ❌ | 실패 시 실행할 후속 액션 (단일 또는 배열) |
|
||||
| `confirm` | string | ❌ | 실행 전 확인 대화상자 메시지 |
|
||||
| `key` | string | ❌ | 키보드 이벤트 필터 (Enter, Escape 등) |
|
||||
| `cases` | object | ❌ | switch 핸들러용 케이스별 액션 정의 |
|
||||
| `if` | string | ❌ | 조건부 실행 (`{{expression}}` 형식, truthy일 때만 실행) |
|
||||
| `debounce` | number \| object | ❌ | 연속 호출 제어 (300 또는 { delay, leading, trailing }) |
|
||||
| `resultTo` | object | ❌ | 핸들러 실행 결과를 상태에 저장 (engine-v1.11.0+). `{ target, key, merge }` |
|
||||
| `actionRef` | string | ❌ | `named_actions`에 정의된 액션을 참조 (engine-v1.19.0+). handler/params 대신 사용 |
|
||||
|
||||
> **참고**: `type`과 `event` 중 하나는 반드시 필요. `type`은 표준 DOM 이벤트용, `event`는 커스텀 콜백용
|
||||
|
||||
### actionRef로 named_actions 참조 (engine-v1.19.0+)
|
||||
|
||||
`named_actions`에 정의된 재사용 가능한 액션을 `actionRef`로 참조합니다. 동일한 액션이 여러 컴포넌트에서 반복될 때 중복을 제거합니다.
|
||||
|
||||
```json
|
||||
// 레이아웃 최상위에 named_actions 정의
|
||||
{
|
||||
"named_actions": {
|
||||
"searchProducts": {
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/products",
|
||||
"replace": true,
|
||||
"mergeQuery": true,
|
||||
"query": { "page": 1, "search_keyword": "{{$computed.keyword || null}}" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"name": "Button",
|
||||
"actions": [{ "type": "click", "actionRef": "searchProducts" }]
|
||||
},
|
||||
{
|
||||
"name": "Input",
|
||||
"actions": [{ "type": "keypress", "key": "Enter", "actionRef": "searchProducts" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**규칙**:
|
||||
|
||||
- `actionRef` 사용 시 `handler`/`params`/`target`은 생략 가능 (named_actions 정의에서 가져옴)
|
||||
- `type`, `key`, `event` 등 이벤트 바인딩 속성은 인라인에서 지정
|
||||
- 인라인에 `handler`/`params`를 함께 지정하면 named_actions 값을 **오버라이드**
|
||||
- `named_actions`는 Partial 파일이 아닌 **부모 레이아웃**에 정의
|
||||
- 상속(extends) 시 자식 레이아웃의 `named_actions`가 부모를 오버라이드
|
||||
|
||||
### resultTo로 핸들러 결과 저장 (engine-v1.11.0+)
|
||||
|
||||
핸들러 실행 결과를 상태(`_local`, `_global`, `_isolated`)에 저장합니다. 커스텀 핸들러가 반환한 값을 레이아웃에서 활용할 때 사용합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "sirsoft-ecommerce.buildProductColumns",
|
||||
"params": {
|
||||
"baseColumns": [],
|
||||
"currencies": "{{ecommerceSettings?.data?.language_currency?.currencies}}"
|
||||
},
|
||||
"resultTo": {
|
||||
"target": "_local",
|
||||
"key": "productColumns"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### resultTo 필드
|
||||
|
||||
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
||||
| ---- | ---- | ---- | ------ | ---- |
|
||||
| `target` | string | ✅ | - | 저장 대상: `"_local"`, `"_global"`, `"_isolated"` |
|
||||
| `key` | string | ✅ | - | 상태 키 (dot notation 지원, `{{}}` 동적 경로 지원) |
|
||||
| `merge` | string | ❌ | `"deep"` | 병합 모드: `"replace"`, `"shallow"`, `"deep"` |
|
||||
|
||||
#### 병합 모드
|
||||
|
||||
| 모드 | 설명 |
|
||||
| ---- | ---- |
|
||||
| `replace` | 기존 값을 완전히 교체 |
|
||||
| `shallow` | 1단계만 병합 (`{ ...existing, ...new }`) |
|
||||
| `deep` | 중첩 객체까지 재귀 병합 (기본값) |
|
||||
|
||||
#### 동적 key 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "sirsoft-ecommerce.calculatePrice",
|
||||
"params": { "optionId": "{{option.id}}" },
|
||||
"resultTo": {
|
||||
"target": "_local",
|
||||
"key": "calculatedPrices.{{option.id}}",
|
||||
"merge": "shallow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 주의사항
|
||||
|
||||
```text
|
||||
resultTo는 핸들러가 값을 반환(return)해야 동작 — undefined 반환 시 무시됨
|
||||
init_actions에서 _local 저장 시 globalStateUpdater 경유 (componentContext 없는 환경)
|
||||
✅ onSuccess/onError와 독립적으로 동작 — resultTo 저장 후 onSuccess 실행
|
||||
```
|
||||
|
||||
### 이벤트 타입
|
||||
|
||||
| type | 설명 | 사용 컴포넌트 |
|
||||
|------|------|--------------|
|
||||
| `click` | 클릭 이벤트 | Button, Link, 모든 요소 |
|
||||
| `change` | 값 변경 이벤트 | Input, Select, Checkbox |
|
||||
| `submit` | 폼 제출 이벤트 | Form |
|
||||
| `keydown` | 키보드 누름 이벤트 | Input, 모든 요소 |
|
||||
| `keyup` | 키보드 뗌 이벤트 | Input, 모든 요소 |
|
||||
| `focus` | 포커스 획득 | Input, Select |
|
||||
| `blur` | 포커스 해제 | Input, Select |
|
||||
| `scroll` | 스크롤 이벤트 | 스크롤 가능한 요소 (Div 등) |
|
||||
| `mount` | 컴포넌트 마운트 | 모든 요소 |
|
||||
| `unmount` | 컴포넌트 언마운트 | 모든 요소 |
|
||||
| `onSortEnd` | sortable 정렬 완료 | sortable 컴포넌트 |
|
||||
| `onSortStart` | sortable 드래그 시작 | sortable 컴포넌트 |
|
||||
|
||||
> **참고**: HTML5 네이티브 드래그 이벤트(`dragstart`, `dragover`, `drop` 등)도 지원되지만,
|
||||
> 목록 정렬에는 `sortable` 속성 + `onSortEnd` 이벤트 사용을 권장합니다.
|
||||
> 상세: [actions-handlers-state.md](actions-handlers-state.md#sortable-드래그앤드롭-정렬)
|
||||
|
||||
### 커스텀 이벤트 (event 필드)
|
||||
|
||||
표준 DOM 이벤트가 아닌 **컴포넌트 콜백**을 처리할 때 사용합니다.
|
||||
|
||||
```text
|
||||
중요: 컴포넌트가 DOM Event 객체가 아닌 값(문자열, 배열 등)을 직접 전달하는 경우
|
||||
type 필드 대신 event 필드를 사용하고, $args로 인자에 접근해야 함
|
||||
```
|
||||
|
||||
| 방식 | 필드 | 데이터 접근 | 사용 상황 |
|
||||
|------|------|-------------|----------|
|
||||
| 표준 DOM 이벤트 | `type: "change"` | `{{$event.target.value}}` | Input, Select, Checkbox 등 |
|
||||
| 커스텀 콜백 | `event: "onChange"` | `{{$args[0]}}` | CodeEditor, DataGrid 등 |
|
||||
|
||||
#### 예시: CodeEditor onChange 처리
|
||||
|
||||
```json
|
||||
// ✅ 올바름: CodeEditor는 string을 직접 전달
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "CodeEditor",
|
||||
"props": {
|
||||
"value": "{{_global.editorContent}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"event": "onChange",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"editorContent": "{{$args[0]}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ❌ 잘못됨: type: "change"는 DOM Event 객체를 기대함
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"editorContent": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 예시: DataGrid onRowAction 처리
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "DataGrid",
|
||||
"actions": [
|
||||
{
|
||||
"event": "onRowAction",
|
||||
"handler": "switch",
|
||||
"params": {
|
||||
"value": "{{$args[0].actionId}}"
|
||||
},
|
||||
"cases": {
|
||||
"delete": {
|
||||
"handler": "apiCall",
|
||||
"target": "/api/items/{{$args[0].row.id}}",
|
||||
"params": { "method": "DELETE" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 예시: DataGrid onSelectionChange 처리
|
||||
|
||||
```json
|
||||
// ✅ 올바름: onSelectionChange는 선택된 ID 배열을 직접 전달
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "DataGrid",
|
||||
"props": {
|
||||
"selectable": true,
|
||||
"selectedIds": "{{_local.selectedIds ?? []}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"event": "onSelectionChange",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"selectedIds": "{{$args[0]}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// ❌ 잘못됨: $event는 DOM Event 객체 → DataGrid가 깨짐 (컴포넌트 로드 실패)
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"event": "onSelectionChange",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"selectedIds": "{{$event}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 주요 composite 컴포넌트 콜백 목록
|
||||
|
||||
```text
|
||||
필수: 아래 콜백은 모두 event 필드 + $args[0] 패턴 사용 필수
|
||||
$event 사용 시 DOM Event 객체가 전달되어 컴포넌트 오류 발생
|
||||
```
|
||||
|
||||
| 컴포넌트 | 콜백 이벤트 | $args[0] 값 |
|
||||
|----------|------------|-------------|
|
||||
| DataGrid | `onSelectionChange` | 선택된 ID 배열 `number[]` |
|
||||
| DataGrid | `onRowAction` | `{ actionId, row }` 객체 |
|
||||
| DataGrid | `onSortChange` | `{ key, direction }` 객체 |
|
||||
| DataGrid | `onPageChange` | 페이지 번호 `number` |
|
||||
| CategoryTree | `onNodeSelect` | 선택된 노드 객체 |
|
||||
| CodeEditor | `onChange` | 에디터 내용 `string` |
|
||||
| HtmlEditor | `onChange` | HTML 내용 `string` |
|
||||
| FileUploader | `onUploadComplete` | 업로드 결과 객체 |
|
||||
| TagInput | `onChange` | 태그 배열 `string[]` |
|
||||
|
||||
#### $args 배열 접근
|
||||
|
||||
`event` 필드 사용 시 콜백의 모든 인자가 `$args` 배열에 저장됩니다:
|
||||
|
||||
| 바인딩 | 설명 |
|
||||
|--------|------|
|
||||
| `{{$args[0]}}` | 첫 번째 인자 |
|
||||
| `{{$args[1]}}` | 두 번째 인자 |
|
||||
| `{{$args[0].field}}` | 첫 번째 인자의 field 속성 |
|
||||
|
||||
---
|
||||
|
||||
## 내장 핸들러 목록
|
||||
|
||||
| 핸들러 | 설명 | target 용도 | params 용도 |
|
||||
|--------|------|-------------|-------------|
|
||||
| `navigate` | 페이지 이동 | - | path, mergeQuery, query |
|
||||
| `navigateBack` | 브라우저 뒤로가기 | - | - |
|
||||
| `navigateForward` | 브라우저 앞으로가기 | - | - |
|
||||
| `apiCall` | API 호출 | 엔드포인트 URL | method, body, headers |
|
||||
| `login` | 로그인 (토큰 저장) | 인증 타입 (admin/user) | body (email, password) |
|
||||
| `logout` | 로그아웃 | - | - |
|
||||
| `setState` | 상태 변경 | - | target (global/local), 상태 값들 |
|
||||
| `setError` | 에러 상태 설정 | 에러 메시지 | - |
|
||||
| `openModal` | 모달 열기 | 모달 ID | - |
|
||||
| `closeModal` | 모달 닫기 | - | - |
|
||||
| `showAlert` | 알림 표시 (alert) | 메시지 | - |
|
||||
| `toast` | 토스트 알림 표시 | - | type, message |
|
||||
| `switch` | 조건 분기 처리 | - | params.value, cases (케이스별 액션 정의, default 지원) |
|
||||
| `sequence` | 순차 액션 실행 | - | actions (액션 배열) |
|
||||
| `parallel` | 병렬 액션 실행 | - | actions (액션 배열) |
|
||||
| `refetchDataSource` | 데이터 소스 다시 fetch | - | dataSourceId |
|
||||
| `remount` | 컴포넌트 강제 리마운트 | - | componentId |
|
||||
| `reloadRoutes` | 라우트 다시 로드 | - | - |
|
||||
| `reloadTranslations` | 다국어 파일 다시 로드 | - | - |
|
||||
| `refresh` | 현재 페이지 새로고침 | - | - |
|
||||
| `stopPropagation` | 이벤트 전파 중지 | - | - |
|
||||
| `preventDefault` | 기본 동작 방지 | - | - |
|
||||
| `showErrorPage` | 에러 페이지 렌더링 | - | errorCode, target, containerId, layout |
|
||||
|
||||
> **상세 문서**: 각 핸들러별 상세 사용법은 [actions-handlers.md](actions-handlers.md) 참조
|
||||
|
||||
---
|
||||
|
||||
## Debounce 옵션
|
||||
|
||||
연속 호출되는 액션의 실행을 제어하여 성능을 최적화합니다. 텍스트 입력 필드의 실시간 업데이트 등에 유용합니다.
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "sirsoft-ecommerce.updateOptionField",
|
||||
"params": {
|
||||
"productId": "{{row.id}}",
|
||||
"field": "selling_price",
|
||||
"value": "{{$event.target.value}}"
|
||||
},
|
||||
"debounce": 300
|
||||
}
|
||||
```
|
||||
|
||||
### 상세 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "myHandler",
|
||||
"debounce": {
|
||||
"delay": 500,
|
||||
"leading": false,
|
||||
"trailing": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 옵션 설명
|
||||
|
||||
| 옵션 | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `delay` | number | 300 | 대기 시간 (ms) |
|
||||
| `leading` | boolean | false | 첫 호출 즉시 실행 |
|
||||
| `trailing` | boolean | true | 마지막 호출 후 실행 |
|
||||
|
||||
### 주의사항
|
||||
|
||||
```text
|
||||
debounce 사용 시 이벤트 객체는 비동기 접근을 위해 추출됩니다
|
||||
✅ 정상 접근 가능: $event.target.value, $event.target.checked
|
||||
❌ 사용 불가: $event.preventDefault(), $event.stopPropagation()
|
||||
```
|
||||
|
||||
### 사용 시나리오
|
||||
|
||||
| 시나리오 | debounce 권장 |
|
||||
|----------|---------------|
|
||||
| 가격 입력 필드 | ✅ 300ms |
|
||||
| 검색 입력 필드 | ✅ 300~500ms |
|
||||
| 텍스트 입력 필드 | ✅ 300ms |
|
||||
| Toggle/Checkbox | ❌ 즉시 실행 |
|
||||
| Select 드롭다운 | ❌ 즉시 실행 |
|
||||
| 버튼 클릭 | ❌ 즉시 실행 |
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
### 1. 핸들러 이름 정확히 사용
|
||||
|
||||
```json
|
||||
// ✅ 올바른 핸들러 이름
|
||||
{ "handler": "apiCall" }
|
||||
{ "handler": "navigate" }
|
||||
{ "handler": "setState" }
|
||||
|
||||
// ❌ 잘못된 핸들러 이름
|
||||
{ "handler": "api" }
|
||||
{ "handler": "nav" }
|
||||
{ "handler": "set_state" }
|
||||
```
|
||||
|
||||
### 2. params vs target 구분
|
||||
|
||||
```json
|
||||
// navigate: params.path 사용
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/admin/users" }
|
||||
}
|
||||
|
||||
// apiCall: target으로 URL 지정
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/admin/users"
|
||||
}
|
||||
|
||||
// openModal: target으로 모달 ID 지정
|
||||
{
|
||||
"handler": "openModal",
|
||||
"target": "confirm_modal"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 상태 변경 target 타입
|
||||
|
||||
```json
|
||||
// 전역 상태 변경
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"selectedIds": []
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 상태 변경
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"isEditing": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 후속 액션 체인
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/admin/users/{{row.id}}",
|
||||
"params": { "method": "DELETE" },
|
||||
"onSuccess": [
|
||||
{ "handler": "toast", "params": { "type": "success", "message": "삭제되었습니다" } },
|
||||
{ "handler": "navigate", "params": { "path": "/admin/users" } }
|
||||
],
|
||||
"onError": [
|
||||
{ "handler": "toast", "params": { "type": "error", "message": "삭제 실패" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 확인 다이얼로그
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"target": "/api/admin/users/{{row.id}}",
|
||||
"params": { "method": "DELETE" },
|
||||
"confirm": "정말 삭제하시겠습니까?"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 키보드 이벤트 필터링
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "keydown",
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/admin/search" },
|
||||
"key": "Enter"
|
||||
}
|
||||
```
|
||||
|
||||
### 7. $event 바인딩 시 값 추출
|
||||
|
||||
```text
|
||||
주의: {{$event}}를 직접 사용하면 Event 객체가 전달됩니다
|
||||
Input/Select: "{{$event.target.value}}" 사용
|
||||
Checkbox/Toggle: "{{$event.target.checked}}" 사용
|
||||
❌ 잘못됨: "{{$event}}" → NaN 오류 발생
|
||||
```
|
||||
|
||||
```json
|
||||
// ✅ 올바른 예시 - Input/Select
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"searchQuery": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 올바른 예시 - Checkbox/Toggle
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"isActive": "{{$event.target.checked}}"
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 잘못된 예시 - NaN 오류 발생
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "myHandler",
|
||||
"params": {
|
||||
"value": "{{$event}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**주의**: `{{$event}}`를 숫자 필드에 사용하면 `parseFloat(eventObject)`가 NaN을 반환하여 0으로 처리됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [핸들러 상세 사용법](actions-handlers.md) - 각 핸들러별 파라미터, onSuccess/onError, errorHandling
|
||||
- [G7Core API](actions-g7core-api.md) - React 컴포넌트에서 액션 실행
|
||||
- [레이아웃 JSON 스키마](layout-json.md) - 전체 레이아웃 구조
|
||||
- [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어
|
||||
- [상태 관리](state-management.md) - `_global` 전역 상태
|
||||
@@ -0,0 +1,310 @@
|
||||
# 인증 시스템 (AuthManager)
|
||||
|
||||
> 그누보드7 프론트엔드 인증 시스템 가이드
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. AuthManager: 싱글톤 인증 상태 관리 클래스
|
||||
2. 토큰 자동 갱신: 401 응답 시 자동 처리
|
||||
3. 컨텍스트 분리: admin/user 분리 관리
|
||||
4. 로그인: login 핸들러 또는 AuthManager.login()
|
||||
5. 이벤트: auth:login, auth:logout 이벤트 구독 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
- [개요](#개요)
|
||||
- [인증 흐름](#인증-흐름)
|
||||
- [인증 타입 판단](#인증-타입-판단)
|
||||
- [API 엔드포인트](#api-엔드포인트)
|
||||
- [토큰 자동 갱신](#토큰-자동-갱신)
|
||||
- [로그인 시 로케일 처리](#로그인-시-로케일-처리)
|
||||
- [사용 예시](#사용-예시)
|
||||
- [이벤트 처리](#이벤트-처리)
|
||||
- [주의사항](#주의사항)
|
||||
- [관련 문서](#관련-문서)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
`AuthManager`는 프론트엔드 인증 상태 관리를 담당하는 싱글톤 클래스입니다.
|
||||
|
||||
**위치**: `/resources/js/core/auth/AuthManager.ts`
|
||||
|
||||
**주요 기능**:
|
||||
- 인증 상태 확인 및 관리
|
||||
- 토큰 자동 갱신 (401 응답 시)
|
||||
- 로그인/로그아웃 처리
|
||||
- 관리자/일반사용자 컨텍스트 분리
|
||||
- 로그인 시 사용자 언어 설정 자동 반영
|
||||
|
||||
---
|
||||
|
||||
## 인증 흐름
|
||||
|
||||
```
|
||||
사용자가 /admin/dashboard 접근
|
||||
↓
|
||||
Router.navigateToCurrentPath()
|
||||
↓
|
||||
auth_required: true 확인
|
||||
↓
|
||||
auth_type 결정 (명시 또는 경로 기반)
|
||||
↓
|
||||
AuthManager.checkAuth(authType)
|
||||
↓
|
||||
┌────┴────┐
|
||||
↓ ↓
|
||||
인증됨 미인증
|
||||
↓ ↓
|
||||
렌더링 로그인 페이지로 리다이렉트
|
||||
(?redirect 파라미터 포함)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 인증 타입 판단
|
||||
|
||||
`auth_type`이 라우트에 명시되지 않은 경우 경로 기반으로 자동 판단:
|
||||
|
||||
```typescript
|
||||
// Router.ts
|
||||
private getAuthType(route: Route, pathname: string): AuthType {
|
||||
// 1. 라우트에 명시된 경우 사용
|
||||
if (route.auth_type) {
|
||||
return route.auth_type;
|
||||
}
|
||||
|
||||
// 2. 경로 기반 자동 판단
|
||||
if (pathname.startsWith('/admin')) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
return 'user';
|
||||
}
|
||||
```
|
||||
|
||||
**우선순위**:
|
||||
1. 라우트에 명시된 `auth_type` 값
|
||||
2. 경로 기반 자동 판단 (`/admin` → admin, 그 외 → user)
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
| 구분 | 로그인 | 사용자 정보 | 로그아웃 | 토큰 갱신 |
|
||||
|------|--------|------------|---------|----------|
|
||||
| **관리자** | `/auth/login` | `/admin/auth/user` | `/admin/auth/logout` | `/admin/auth/refresh` |
|
||||
| **일반사용자** | `/auth/login` | `/user/auth/user` | `/user/auth/logout` | `/user/auth/refresh` |
|
||||
|
||||
**공통**:
|
||||
|
||||
- 로그인 엔드포인트는 관리자/일반사용자 동일 (`/auth/login`)
|
||||
- 인증 후 작업은 각각 분리된 엔드포인트 사용
|
||||
- API 인증은 Bearer 토큰 전용 (세션 기반 인증 미사용)
|
||||
- 401 응답 시 서버가 세션 쿠키 만료 헤더를 자동 전송 (잔존 쿠키 정리)
|
||||
|
||||
---
|
||||
|
||||
## 토큰 자동 갱신
|
||||
|
||||
ApiClient의 응답 인터셉터에서 401 응답 시 자동으로 토큰 갱신을 시도합니다:
|
||||
|
||||
```typescript
|
||||
// ApiClient.ts 응답 인터셉터
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const refreshed = await authManager.refreshToken();
|
||||
|
||||
if (refreshed) {
|
||||
// 토큰 갱신 성공, 원래 요청 재시도
|
||||
const token = this.getToken();
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return this.client(originalRequest);
|
||||
}
|
||||
|
||||
// 갱신 실패 시 로그아웃 처리
|
||||
}
|
||||
```
|
||||
|
||||
**동시 갱신 방지**: 여러 요청이 동시에 401을 받아도 하나의 갱신 요청만 수행
|
||||
|
||||
---
|
||||
|
||||
## 로그인 시 로케일 처리
|
||||
|
||||
로그인 성공 시 사용자의 `language` 설정을 자동으로 반영합니다:
|
||||
|
||||
```text
|
||||
로그인 API 응답 수신
|
||||
↓
|
||||
user.language 확인
|
||||
↓
|
||||
현재 g7_locale과 비교
|
||||
↓
|
||||
┌────┴────┐
|
||||
↓ ↓
|
||||
동일 다름
|
||||
↓ ↓
|
||||
skip localStorage 저장
|
||||
↓
|
||||
TemplateApp.changeLocale() 호출
|
||||
↓
|
||||
UI 즉시 업데이트
|
||||
```
|
||||
|
||||
**구현 위치**: `AuthManager.login()`
|
||||
|
||||
```typescript
|
||||
// 로케일 변경 감지 및 처리
|
||||
const userLanguage = response.data.user.language;
|
||||
const currentLocale = localStorage.getItem('g7_locale');
|
||||
const localeChanged = userLanguage && userLanguage !== currentLocale;
|
||||
|
||||
if (userLanguage) {
|
||||
localStorage.setItem('g7_locale', userLanguage);
|
||||
}
|
||||
|
||||
// 로케일이 변경된 경우 TemplateApp 재초기화
|
||||
if (localeChanged && window.__templateApp) {
|
||||
window.__templateApp.changeLocale(userLanguage);
|
||||
}
|
||||
```
|
||||
|
||||
**주요 포인트**:
|
||||
|
||||
- 사용자의 DB `language` 값을 `g7_locale` localStorage에 저장
|
||||
- 로케일이 변경된 경우에만 `changeLocale()` 호출 (불필요한 재초기화 방지)
|
||||
- SPA 환경에서도 새로고침 없이 즉시 반영
|
||||
|
||||
---
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### routes.json 설정
|
||||
|
||||
```json
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"path": "/admin/login",
|
||||
"layout": "admin_login",
|
||||
"auth_required": false
|
||||
},
|
||||
{
|
||||
"path": "/admin/dashboard",
|
||||
"layout": "admin_dashboard",
|
||||
"auth_required": true
|
||||
},
|
||||
{
|
||||
"path": "/profile",
|
||||
"layout": "user_profile",
|
||||
"auth_required": true,
|
||||
"auth_type": "user"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 로그인 후 리다이렉트
|
||||
|
||||
```typescript
|
||||
// 로그인 성공 후 원래 페이지로 이동
|
||||
const authManager = AuthManager.getInstance();
|
||||
const redirectUrl = authManager.getRedirectUrl('admin');
|
||||
window.location.href = redirectUrl; // URL 파라미터의 redirect 값 또는 기본 경로
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 이벤트 처리
|
||||
|
||||
AuthManager는 이벤트 기반으로 인증 상태 변경을 알립니다:
|
||||
|
||||
```typescript
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
// 인증 상태 변경 감지
|
||||
authManager.on('authStateChange', (state) => {
|
||||
console.log('Auth state:', state.isAuthenticated, state.user);
|
||||
});
|
||||
|
||||
// 로그아웃 감지
|
||||
authManager.on('logout', () => {
|
||||
console.log('User logged out');
|
||||
});
|
||||
```
|
||||
|
||||
**사용 가능한 이벤트**:
|
||||
- `authStateChange`: 인증 상태가 변경될 때
|
||||
- `logout`: 사용자가 로그아웃할 때
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
### 로그인 페이지 설정
|
||||
|
||||
```
|
||||
로그인 페이지는 반드시 auth_required: false로 설정
|
||||
→ 그렇지 않으면 무한 루프 발생
|
||||
```
|
||||
|
||||
```json
|
||||
// ✅ DO
|
||||
{
|
||||
"path": "/admin/login",
|
||||
"layout": "admin_login",
|
||||
"auth_required": false
|
||||
}
|
||||
|
||||
// ❌ DON'T - 무한 루프 발생
|
||||
{
|
||||
"path": "/admin/login",
|
||||
"layout": "admin_login",
|
||||
"auth_required": true
|
||||
}
|
||||
```
|
||||
|
||||
### 보안 규칙
|
||||
|
||||
- **리다이렉트 URL**: 같은 도메인 경로만 허용 (외부 URL 차단)
|
||||
- **토큰 저장**: localStorage의 `auth_token` 키에 저장
|
||||
|
||||
### 토큰 관리
|
||||
|
||||
```typescript
|
||||
// 토큰 저장 위치
|
||||
localStorage.getItem('auth_token'); // 토큰 조회
|
||||
localStorage.setItem('auth_token', token); // 토큰 저장
|
||||
localStorage.removeItem('auth_token'); // 토큰 삭제 (로그아웃)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [데이터 소스](./data-sources.md) - API 호출 및 인증 헤더
|
||||
- [보안 및 검증](./security.md) - 프론트엔드 보안 규칙
|
||||
- [컴포넌트](./components.md) - 로그인 폼 컴포넌트
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
인증 기능 구현 시 확인 사항:
|
||||
|
||||
- [ ] 로그인 페이지에 `auth_required: false` 설정
|
||||
- [ ] 인증 필요 페이지에 `auth_required: true` 설정
|
||||
- [ ] 적절한 `auth_type` 설정 (admin/user)
|
||||
- [ ] 리다이렉트 URL 보안 검증
|
||||
- [ ] 토큰 갱신 로직 테스트
|
||||
- [ ] 로그아웃 시 토큰 정리 확인
|
||||
Loaded 100 of 3967 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user