Merge pull request from gnuboard:HeuJung/issue601
HeuJung/issue601
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
|
||||
| 문서 | 설명 | TL;DR 핵심 |
|
||||
|------|------|-----------|
|
||||
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
|
||||
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 확장 132훅 = 총 198훅 (확장별 목록은 그 확장이 소유) |
|
||||
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
|
||||
| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
|
||||
| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... |
|
||||
@@ -47,7 +47,7 @@
|
||||
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
|
||||
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
|
||||
|
||||
### 프론트엔드 [frontend/](docs/frontend/) (50개)
|
||||
### 프론트엔드 [frontend/](docs/frontend/) (44개)
|
||||
|
||||
| 문서 | 설명 | TL;DR 핵심 |
|
||||
|------|------|-----------|
|
||||
@@ -95,20 +95,15 @@
|
||||
| [tailwind-safelist.md](docs/frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | Tailwind는 빌드 시 사용된 클래스만 CSS에 포함 |
|
||||
| [template-development.md](docs/frontend/template-development.md) | 템플릿 개발 가이드라인 | 디렉토리: templates/[vendor-template]/ (예: sirsoft-admin_basic) |
|
||||
| [template-handlers.md](docs/frontend/template-handlers.md) | 템플릿 전용 핸들러 | setLocale: 앱 언어 변경 — 엔진 빌트인 (ActionDispatcher) |
|
||||
| [components.md](docs/frontend/templates/sirsoft-admin_basic/components.md) | sirsoft-admin_basic 컴포넌트 | Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
|
||||
| [handlers.md](docs/frontend/templates/sirsoft-admin_basic/handlers.md) | sirsoft-admin_basic 핸들러 | setLocale: 앱 언어 변경 (locale 파라미터) |
|
||||
| [layouts.md](docs/frontend/templates/sirsoft-admin_basic/layouts.md) | sirsoft-admin_basic 레이아웃 | 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯) |
|
||||
| [components.md](docs/frontend/templates/sirsoft-basic/components.md) | sirsoft-basic 컴포넌트 | Basic 26개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
|
||||
| [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
|
||||
| [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
|
||||
|
||||
### 확장 시스템 [extension/](docs/extension/) (30개)
|
||||
### 확장 시스템 [extension/](docs/extension/) (31개)
|
||||
|
||||
| 문서 | 설명 | TL;DR 핵심 |
|
||||
|------|------|-----------|
|
||||
| [cache-driver.md](docs/extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) | 모든 캐시 저장은 CacheInterface 사용 (Cache:: 직접 호출 금지) |
|
||||
| [changelog-rules.md](docs/extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) | 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가) |
|
||||
| [editor-spec.md](docs/extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) | editor-spec.json = 편집기 팔레트/스타일 컨트롤/중첩 규칙/샘플 데이터/레시피의 선언 (... |
|
||||
| [extension-documentation.md](docs/extension/extension-documentation.md) | 확장 개발자 문서 (Extension Documentation) | 확장마다 AGENTS.md(개발자·에이전트용) + README.md(사람용) + docs/(상세) 를 갖는다 |
|
||||
| [extension-manager.md](docs/extension/extension-manager.md) | ExtensionManager (확장 관리자) | composer.json 수정 없음 - 런타임 오토로드 방식 사용 |
|
||||
| [extension-update-system.md](docs/extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) | 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여) |
|
||||
| [hooks.md](docs/extension/hooks.md) | 훅 시스템 (Hook System) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
|
||||
@@ -179,6 +174,34 @@
|
||||
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
|
||||
|
||||
|
||||
### 확장 개발자 문서 (20개 확장, 자동 스캔)
|
||||
|
||||
> 확장을 수정하기 전에 읽는 문서. 설계 의도 · 디렉토리 지도 · 확장점(발행/구독 훅) · 수정 시 동반 의무 · 금지 패턴을 담는다. `php artisan ext:docgen` 이 실측 부분을 유지하며, 이 표는 `{modules,plugins,templates}/_bundled/*/docs/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
|
||||
|
||||
| 확장 | 유형 | 에이전트 가이드 | 문서 목차 | 실측 집계 |
|
||||
|------|------|----------------|----------|----------|
|
||||
| `gnuboard7-hello_module` | 모듈 | [AGENTS.md](modules/_bundled/gnuboard7-hello_module/AGENTS.md) | [docs/](modules/_bundled/gnuboard7-hello_module/docs/README.md) | 훅 1 · 라우트 7 · 모델 1 · 레이아웃 3 |
|
||||
| `sirsoft-board` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-board/AGENTS.md) | [docs/](modules/_bundled/sirsoft-board/docs/README.md) | 훅 90 · 라우트 80 · 모델 9 · 레이아웃 46 |
|
||||
| `sirsoft-ecommerce` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-ecommerce/AGENTS.md) | [docs/](modules/_bundled/sirsoft-ecommerce/docs/README.md) | 훅 508 · 라우트 239 · 모델 47 · 레이아웃 206 |
|
||||
| `sirsoft-page` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-page/AGENTS.md) | [docs/](modules/_bundled/sirsoft-page/docs/README.md) | 훅 21 · 라우트 17 · 모델 3 · 레이아웃 3 |
|
||||
| `gnuboard7-hello_plugin` | 플러그인 | [AGENTS.md](plugins/_bundled/gnuboard7-hello_plugin/AGENTS.md) | [docs/](plugins/_bundled/gnuboard7-hello_plugin/docs/README.md) | 훅 1 · 라우트 0 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-ckeditor5` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-ckeditor5/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-ckeditor5/docs/README.md) | 훅 4 · 라우트 5 · 모델 1 · 레이아웃 2 |
|
||||
| `sirsoft-daum_postcode` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-daum_postcode/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-daum_postcode/docs/README.md) | 훅 2 · 라우트 0 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-gdpr` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-gdpr/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-gdpr/docs/README.md) | 훅 2 · 라우트 15 · 모델 3 · 레이아웃 4 |
|
||||
| `sirsoft-marketing` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-marketing/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-marketing/docs/README.md) | 훅 4 · 라우트 2 · 모델 2 · 레이아웃 1 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-message_bizppurio/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-message_bizppurio/docs/README.md) | 훅 1 · 라우트 21 · 모델 2 · 레이아웃 1 |
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_kginicis/docs/README.md) | 훅 6 · 라우트 35 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md) | 훅 8 · 라우트 16 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md) | 훅 5 · 라우트 15 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-tosspayments` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-tosspayments/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-tosspayments/docs/README.md) | 훅 4 · 라우트 4 · 모델 0 · 레이아웃 1 |
|
||||
| `sirsoft-verification_kginicis` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_kginicis/docs/README.md) | 훅 3 · 라우트 2 · 모델 2 · 레이아웃 1 |
|
||||
| `sirsoft-verification_nhnkcp` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md) | 훅 0 · 라우트 2 · 모델 2 · 레이아웃 1 |
|
||||
| `gnuboard7-hello_admin_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_admin_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
|
||||
| `gnuboard7-hello_user_template` | 템플릿 | [AGENTS.md](templates/_bundled/gnuboard7-hello_user_template/AGENTS.md) | [docs/](templates/_bundled/gnuboard7-hello_user_template/docs/README.md) | 훅 0 · 라우트 1 · 모델 0 · 레이아웃 8 |
|
||||
| `sirsoft-admin_basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-admin_basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-admin_basic/docs/README.md) | 훅 0 · 라우트 29 · 모델 0 · 레이아웃 145 |
|
||||
| `sirsoft-basic` | 템플릿 | [AGENTS.md](templates/_bundled/sirsoft-basic/AGENTS.md) | [docs/](templates/_bundled/sirsoft-basic/docs/README.md) | 훅 0 · 라우트 40 · 모델 0 · 레이아웃 166 |
|
||||
|
||||
|
||||
<!-- AUTO-GENERATED-END: docs-quick-reference -->
|
||||
|
||||
---
|
||||
@@ -461,6 +484,40 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
> 상세: [module-assets.md](docs/extension/module-assets.md) "사용자 추가 에셋", [static-asset-publishing.md](docs/backend/static-asset-publishing.md)
|
||||
> 정적 검사가 외부 자산 URL 과 번들 확장의 `custom/` 배포를 차단한다. 서술자 형태와 교체 2경로 보존은 테스트가 잠근다.
|
||||
|
||||
### 확장은 자기 개발자 문서를 소유한다
|
||||
|
||||
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답한다. 확장을 고치려는 쪽이 실제로 묻는 것은 그 앞이다 — **왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안 되는가.** 그 답이 코드 안에만 있으면 매번 `src/` 전체를 훑어 구조를 재발견하게 되고, 확장이 발행하는 훅은 확장점인데도 사실상 비공개가 된다.
|
||||
|
||||
확장마다 `AGENTS.md`(고치는 쪽) · `README.md`(도입·운영 쪽) · `docs/**`(상세)를 두고, 코드에서 실측되는 표는 `php artisan ext:docgen` 이 유지한다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 확장 표면(훅·라우트·권한·모델·레이아웃·핸들러)을 바꾸고 그 확장 문서를 그대로 둠 | 같은 작업 단위에 `ext:docgen --scope={type}:{id}` 재실행 + 낡은 서술 정정 |
|
||||
| 자동 생성 블록(`@generated:*`) 안쪽을 손으로 고침 | 생성기가 교체하는 자리다 — 코드를 고치거나 블록 **밖**에 서술한다 |
|
||||
| 생성기에 파괴적 재생성 플래그(`--force`)를 추가 | 기본 동작이 "블록 안쪽 교체" 다. 사람 서술이 소실될 경로를 만들지 않는다 |
|
||||
| 문서에 없는 블록 키를 생성기가 임의 위치에 주입 | 누락으로 보고하고 사람이 마커 자리를 정한다 (문서 구조는 사람 소유) |
|
||||
| 필수 문서·섹션·블록 목록을 검사 스크립트에 복제 | `ExtensionDocScaffolder::DOCUMENTS` 단일 SSoT — 스크립트는 `ext:docgen --check --json` 을 소비한다 |
|
||||
| `TODO:` 마커를 추측으로 채움 | 코드 근거를 읽어 서술한다 — 다섯 자리(의도·흐름·금지패턴·사용방법·트러블슈팅)는 생성기가 채울 수 없는 **왜** 다 |
|
||||
| `5. 수정 시 동반 의무` 에 코어 횡단 규정을 전부 나열 | 그 확장에 **실제로 걸리는 것만** 추린다 — 전부 적으면 정작 걸리는 항목이 묻힌다 |
|
||||
| 신규 확장을 문서 없이 스캐폴딩 | `php artisan ext:docgen --scope={type}:{id} --init` 으로 골격을 함께 만든다 — 없으면 21번째 확장부터 다시 문서 없이 태어난다 |
|
||||
| 확장 문서를 활성 디렉토리에서 작성 | `_bundled` 에서만 작성하고 update 커맨드로 반영 (문서만이면 빌드 불필요) |
|
||||
| 확장이 훅을 추가할 때 코어 문서를 고침 | 훅 집계는 그 확장의 `docs/extension-points.md` 소유 — 코어에는 총계와 링크만 |
|
||||
| 레이아웃에 `data_source` 를 추가하고 `editor-spec.json` 의 `sampleData` 를 그대로 둠 | 같은 ID 로 프리뷰 샘플 추가 — 없으면 **편집기 캔버스에서만** 그 영역이 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다 |
|
||||
| 컴포넌트를 추가하고 팔레트에만 등록 | 템플릿 스펙은 `componentPalette.entries` · `componentPalette.groups` · `nesting` · `componentCapabilities` **넷 다** — 하나만 빠지면 편집기에서 절반만 동작하고, 어느 단계가 빠졌는지는 증상으로만 구분된다 |
|
||||
| 모듈·플러그인 스펙에 `componentPalette` 선언 | 컴포넌트는 템플릿 소유 — 모듈·플러그인 스펙은 도메인 데이터(`sampleData`·`states`)만 담는다. 같은 자리를 두고 다투면 어느 쪽이 이기는지가 병합 순서에 좌우된다 |
|
||||
| 공용 ID(`settings`·`roles`·`me`)를 확장마다 각자 선언 | 템플릿 스펙 한 곳 — 사본이 갈라져도 오류가 나지 않는다 |
|
||||
| 편집기 스펙 `description` 에 작업 단계·심사 판정·작업 방법을 적음 (`Phase 4/5 에서 추가`·`— 정당`·`전수 스캔 기반`) | **무엇을 담았는가**만 적는다 — 확장만 내려받은 제3자에게 내부 맥락은 해석 불가이고, "다음에 추가" 는 그 항목이 실제로 들어온 뒤에도 남아 **거짓이 된다**. 문서의 한 줄 요약은 이 필드를 옮기지 않고 실측에서 생성한다 |
|
||||
| 편집기 스펙을 고치고 update 커맨드 생략 | 서빙은 **활성 디렉토리만** 읽는다(`_bundled` 폴백 없음) — 파일은 고쳤는데 편집기에 직전 내용이 그대로 보인다 |
|
||||
|
||||
이 결함군은 오류를 남기지 않는다. 문서가 코드와 어긋난 채로 계속 읽히는 것이 유일한 증상이며, 훅 이름이 어긋나면 그 확장을 잡으려던 쪽이 **잡히지 않는 훅을 구독**하게 된다(예외도 경고도 없이 리스너가 호출되지 않을 뿐이다).
|
||||
|
||||
mermaid 문법 오류는 GitHub 렌더 시점에만 드러난다. 구조 검사가 잡을 수 있는 것은 선언된 다이어그램 종류·빈 본문·괄호 균형까지이므로, 새 형식은 실제 렌더를 눈으로 확인한다.
|
||||
|
||||
`docs/editor-spec.md` 는 세 유형 공통이다 — 편집기 스펙을 두지 않는 확장에도 문서를 둔다. 미보유가 정상일 수 있고("이 확장은 공용 ID 만 쓴다") 그 정상 여부를 적을 자리가 없으면 다음 사람이 부재를 누락으로 오해하거나 필요한 시점을 놓친다. 그 문서의 "샘플 데이터와 페이지 상태" 절은 그 확장 레이아웃의 `data_source` 중 프리뷰 샘플이 붙지 않는 것을 실측해 나열한다 — 이 결함은 편집기 캔버스에서만 빈 화면으로 나타나므로 그 목록이 유일한 통로다.
|
||||
|
||||
> 상세: [extension-documentation.md](docs/extension/extension-documentation.md)
|
||||
> 정적 검사가 확장 표면 변경 시 문서 미동반과 미채움 마커 잔존을 검출한다. 생성기의 비파괴 계약(블록 밖 손실 0 · 재실행 멱등 · 미존재 키 미주입)과 필수 문서·섹션·블록 목록은 테스트가 잠근다.
|
||||
|
||||
### 의존성 감사 신호는 거짓일 수 있다
|
||||
|
||||
`npm audit --omit=dev` 는 **`dependencies` 에 선언된 것만** 본다. 실행에 쓰이는 라이브러리가 `devDependencies` 에 있으면 그 패키지는 검사 대상에서 통째로 빠지고, 취약점이 있어도 감사는 0건을 돌려준다. "운영 의존성 취약점 없음" 이라는 완료 조건이 취약한 상태로도 충족된 것처럼 보인다.
|
||||
@@ -1388,6 +1445,7 @@ php artisan migrate:rollback
|
||||
|
||||
| 수정 대상 파일 패턴 | 작업 전 필수 참조 |
|
||||
| ------------------- | ------------------ |
|
||||
| `(modules\|plugins\|templates)/_bundled/{id}/**` (그 확장의 소스 전반) | 그 확장의 `AGENTS.md` · `docs/README.md` — 설계 의도·디렉토리 지도·확장점·**수정 시 동반 의무**·금지 패턴. 수정 후 표면이 바뀌었으면 `php artisan ext:docgen --scope={type}:{id}` ([extension-documentation.md](docs/extension/extension-documentation.md)) |
|
||||
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md), [api-documentation.md](docs/backend/api-documentation.md) |
|
||||
| `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) |
|
||||
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
|
||||
|
||||
@@ -22,11 +22,18 @@
|
||||
- 프록시 뒤에서 구동 중인데 신뢰 프록시가 지정되지 않았으면 관리자 대시보드가 그 사실을 알립니다. 환경설정 > 고급 에서 사이트가 인식한 접속 방식과 방문자 IP 를 확인할 수 있고, 서버에서 `php artisan trusted-proxy:status` 로도 확인할 수 있으며, 설치 마법사도 설치 단계에서 함께 안내합니다. HTTPS 를 쓰지 않는 사이트도 대상입니다 — 이 경우 화면은 정상이지만 방문자 IP 기록과 결제 통보 수신이 어긋나 있어도 드러나지 않기 때문입니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 사이트 설정 문제로 화면 구성 파일이 브라우저에 차단된 경우, 네트워크 오류와 구분되는 안내를 표시합니다. 새로고침해도 낫지 않는 상황이므로 [새로고침] 버튼을 두지 않으며, 원인과 조치 방법은 운영자가 확인할 수 있도록 브라우저 콘솔에 남깁니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
- 사이트가 쓰는 외부 라이브러리의 알려진 취약점을 한 번에 점검하는 명령이 추가되었습니다. `php artisan security:audit-dependencies` 로 코어와 설치된 모든 확장을 함께 확인할 수 있고, 개발 대시보드에서도 실행할 수 있습니다. 점검 도구가 원리상 볼 수 없는 동봉 라이브러리는 버전 목록으로 함께 표시해 운영자가 직접 확인할 수 있게 했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 확장(모듈·플러그인·템플릿)이 개발자 문서를 갖추기 위한 체계가 마련되었습니다. 확장마다 `AGENTS.md`(확장을 고치는 사람용 — 설계 의도·확장점·수정 시 동반 의무·금지 패턴)와 `README.md`(도입 검토·운영자용 — 기능·설치·사용 방법·트러블슈팅), `docs/` 상세 문서를 두는 형식을 정의했으며, **동봉된 확장 20개 전부에 문서가 채워졌습니다.** 새 확장을 만들면 스캐폴딩 단계에서 이 문서 골격이 함께 생성됩니다.
|
||||
- 확장 문서에서 코드로 확인되는 부분(발행·구독 훅, 라우트, 권한, 메뉴, 설정 항목, 모델과 테이블, 레이아웃, 액션 핸들러, 테스트 실행 경로, 다른 확장과의 의존 관계)을 `php artisan ext:docgen` 이 자동으로 채우고 유지합니다. 사람이 쓴 서술은 손대지 않고 자동 생성 표만 교체하며, `php artisan ext:docgen --check` 로 문서가 코드와 어긋났는지 확인할 수 있습니다. 개발 대시보드에서도 실행할 수 있습니다.
|
||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목이 추가되었습니다. 그 확장이 레이아웃 편집기에 무엇을 선언했는지(추가 가능한 화면 요소, 스타일 조절 항목, 미리보기용 샘플 데이터, 화면 상태)와 화면 요소·데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담으며, 편집기 스펙을 두지 않은 확장에는 그것이 정상인지 아닌지를 적습니다.
|
||||
- 확장 문서 검사가 「설명 자리를 비워 둔 상태」도 미작성으로 셉니다. 종전에는 채워 넣으라는 표시만 지우고 내용을 쓰지 않으면 검사를 통과해, 빈 문서가 완비된 것으로 집계되었습니다.
|
||||
- 확장의 화면에 데이터를 붙였는데 레이아웃 편집기 미리보기에서 그 자리가 비는 경우를 문서가 실측해 알려 줍니다. 이 어긋남은 실제 화면이 정상 동작해 아무 오류도 남지 않으므로 종전에는 편집기를 열어 보기 전까지 드러나지 않았습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 레이아웃 편집기를 여는 중 네트워크가 잠시 끊겨도 자동으로 다시 시도합니다. 끝내 실패하면 내부 파일 경로 대신 다음에 무엇을 하면 되는지를 안내합니다.
|
||||
- 확장 문서에서 제품을 가리키는 이름이 「그누보드7」로 통일되었습니다. 종전에는 같은 문서 안에서도 약칭과 정식 명칭이 섞여, 확장만 내려받은 사람에게 별개 제품처럼 보였습니다.
|
||||
- 번들 템플릿의 컴포넌트·핸들러·레이아웃 상세 문서와 확장이 사용하는 활동 로그 항목 목록이 각 확장의 문서로 옮겨졌습니다. 확장이 기능을 늘릴 때 코어 문서를 함께 고쳐야 하던 의존이 사라졌으며, 코어 문서에는 총계와 각 확장 문서로의 링크만 남습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+3
-7
@@ -1,13 +1,9 @@
|
||||
<p align="center"><a href="README.md">English</a> | 한국어</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
|
||||
</p>
|
||||
# 그누보드7
|
||||
|
||||
<p align="center">
|
||||
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br>
|
||||
A modern, extensible CMS platform built with Laravel + React
|
||||
</p>
|
||||
**모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS**
|
||||
A modern, extensible CMS platform built with Laravel + React
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<p align="center">English | <a href="README.ko.md">한국어</a></p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Gnuboard7-그누보드7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="Gnuboard7 (그누보드7)">
|
||||
</p>
|
||||
# Gnuboard7
|
||||
|
||||
<p align="center">
|
||||
<strong>A modern, extensible CMS platform built with Laravel + React</strong><br>
|
||||
The next generation of Gnuboard — Korea's most widely used open-source CMS
|
||||
</p>
|
||||
**A modern, extensible CMS platform built with Laravel + React**
|
||||
The next generation of Gnuboard — Korea's most widely used open-source CMS
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Extension;
|
||||
|
||||
use App\Support\ExtensionDoc\ExtensionDocContext;
|
||||
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
|
||||
use App\Support\ExtensionDoc\ExtensionInventory;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* 확장 개발자 문서 생성 커맨드
|
||||
*
|
||||
* 번들 확장의 `AGENTS.md` · `README.md` · `docs/**` 를 스캐폴딩하고, 코드에서 실측 가능한
|
||||
* 부분(훅·라우트·권한·모델·레이아웃·테스트 경로)을 자동 생성 블록 안에 갱신합니다.
|
||||
*
|
||||
* 기본 동작이 **블록 안쪽 교체**이므로 사람이 쓴 서술이 소실될 경로가 없습니다.
|
||||
* 그래서 `--force` 같은 파괴적 플래그를 두지 않고, 신규 파일 생성만 `--init` 으로 분리합니다.
|
||||
*/
|
||||
class ExtDocgenCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'ext:docgen
|
||||
{--scope=all : 범위 (all, module:vendor-id, plugin:vendor-id, template:vendor-id)}
|
||||
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
|
||||
{--check : 생성하지 않고 누락·드리프트만 리포트}
|
||||
{--json : 기계 판독 출력}
|
||||
{--dry-run : 대상과 실측 집계만 출력}';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = '번들 확장의 개발자 문서(AGENTS.md/README.md/docs)를 실측 기반으로 생성·갱신합니다';
|
||||
|
||||
/**
|
||||
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못해 대상에서 빠진 디렉토리
|
||||
*/
|
||||
private array $malformed = [];
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param ExtensionInventory $inventory 번들 확장 인벤토리
|
||||
* @param ExtensionDocContext $context 수집 컨텍스트 조립기
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(
|
||||
ExtensionInventory $inventory,
|
||||
ExtensionDocContext $context,
|
||||
ExtensionDocScaffolder $scaffolder
|
||||
): int {
|
||||
$scope = (string) $this->option('scope');
|
||||
|
||||
// `--check --dry-run` 은 dry-run 분기가 먼저 반환해 이슈 배열이 전부 빈 채로 남는다 —
|
||||
// 결과가 "이상 0건" 과 같은 모양이라 검사한 적 없는 실행이 통과로 보인다. 두 모드는
|
||||
// 함께 쓸 수 없다고 명시적으로 거부한다.
|
||||
// `--init` 도 같은 성질이다 — dry-run·check 와 함께 주면 조용히 무시되어
|
||||
// "골격을 만들라고 시켰는데 아무 일도 없었다" 가 성공으로 보인다.
|
||||
$conflict = match (true) {
|
||||
$this->option('check') && $this->option('dry-run') => '--check 와 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 검사를 수행하지 않습니다).',
|
||||
$this->option('init') && $this->option('dry-run') => '--init 과 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 파일을 만들지 않습니다).',
|
||||
$this->option('init') && $this->option('check') => '--init 과 --check 는 함께 쓸 수 없습니다 (check 는 파일을 만들지 않습니다).',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($conflict !== null) {
|
||||
$message = $conflict;
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(
|
||||
['scope' => $scope, 'extensions' => [], 'error' => $message],
|
||||
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->error($message);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$records = $inventory->collect($scope);
|
||||
$this->malformed = $inventory->malformed();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(
|
||||
['scope' => $scope, 'extensions' => [], 'error' => $e->getMessage()],
|
||||
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($records === []) {
|
||||
$message = "범위 '{$scope}' 에 해당하는 번들 확장이 없습니다.";
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode(['scope' => $scope, 'extensions' => [], 'error' => $message], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->warn($message);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$ctx = $context->build($record);
|
||||
|
||||
$results[] = $this->processExtension($ctx, $scaffolder);
|
||||
}
|
||||
|
||||
return $this->report($results, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 확장을 처리합니다.
|
||||
*
|
||||
* @param array<string, mixed> $ctx 수집 컨텍스트
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @return array<string, mixed> 처리 결과
|
||||
*/
|
||||
private function processExtension(array $ctx, ExtensionDocScaffolder $scaffolder): array
|
||||
{
|
||||
$record = $ctx['record'];
|
||||
$stats = ExtensionDocScaffolder::statsOf($ctx);
|
||||
|
||||
$result = [
|
||||
'type' => $record['type'],
|
||||
'id' => $record['id'],
|
||||
'relPath' => $record['relPath'],
|
||||
'version' => $record['version'],
|
||||
'stats' => $stats,
|
||||
'surfaceAvailable' => $ctx['surface']['available'],
|
||||
'surfaceReason' => $ctx['surface']['reason'],
|
||||
'surfaceErrors' => $ctx['surface']['errors'],
|
||||
'documents' => [],
|
||||
'created' => [],
|
||||
'updated' => [],
|
||||
'missingDocuments' => [],
|
||||
'missingSections' => [],
|
||||
'missingBlocks' => [],
|
||||
'driftedBlocks' => [],
|
||||
'orphanBlocks' => [],
|
||||
'unfilled' => [],
|
||||
];
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$result['documents'] = ExtensionDocScaffolder::documentsForType($record['type']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// --init 은 파일을 만든 **뒤에** 블록을 다시 렌더한다.
|
||||
// `docs-index` · `doc-toc` 블록은 문서 파일의 존재 여부를 읽어 링크와 상태를 채우므로,
|
||||
// 생성 전에 렌더한 본문은 방금 만든 문서를 전부 "미작성" 으로 표기한다.
|
||||
if ($this->option('init') && ! $this->option('check')) {
|
||||
$this->initSkeletons($ctx, $scaffolder, $result);
|
||||
}
|
||||
|
||||
$bodies = $scaffolder->renderBlocks($ctx);
|
||||
|
||||
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
||||
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
|
||||
|
||||
if (! is_file($abs)) {
|
||||
$result['missingDocuments'][] = $doc;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string) File::get($abs);
|
||||
|
||||
// 섹션 골격 검사 — 헤딩 누락은 문서가 형식을 벗어났다는 신호다.
|
||||
// 유형별로 절 이름이 달라지는 자리가 있으므로 sectionsFor() 판정을 쓴다.
|
||||
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
|
||||
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
|
||||
$result['missingSections'][] = $doc.' → '.$section;
|
||||
}
|
||||
}
|
||||
|
||||
// 미채움 마커 잔량
|
||||
foreach (ExtensionDocScaffolder::todoMarkers() as $marker) {
|
||||
$count = substr_count($content, $marker);
|
||||
if ($count > 0) {
|
||||
$result['unfilled'][] = ['doc' => $doc, 'marker' => $marker, 'count' => $count];
|
||||
}
|
||||
}
|
||||
|
||||
// 마커를 지우기만 하고 서술을 안 쓴 자리도 미채움이다. 이 축이 없으면
|
||||
// "TODO 를 삭제한 문서" 가 "채운 문서" 와 같은 모양으로 통과한다.
|
||||
$emptyIntents = ExtensionDocScaffolder::emptyIntentBlocks($content);
|
||||
|
||||
if ($emptyIntents > 0) {
|
||||
$result['unfilled'][] = [
|
||||
'doc' => $doc,
|
||||
'marker' => ExtensionDocScaffolder::EMPTY_INTENT_LABEL,
|
||||
'count' => $emptyIntents,
|
||||
];
|
||||
}
|
||||
|
||||
$docBodies = [];
|
||||
foreach (ExtensionDocScaffolder::blocksFor($doc) as $key) {
|
||||
if (array_key_exists($key, $bodies)) {
|
||||
$docBodies[$key] = $bodies[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$merged = ExtensionDocScaffolder::replaceBlocks($content, $docBodies);
|
||||
|
||||
foreach ($merged['missing'] as $key) {
|
||||
$result['missingBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
|
||||
// 문서에 실재하지만 `DOCUMENTS` 가 모르는 블록 — 생성기가 순회 대상으로 삼지
|
||||
// 않으므로 **영영 갱신되지 않고 누락으로도 보고되지 않는다**. 낡은 실측을 단 채
|
||||
// `--check` 는 이상 0건을 보고한다. 절을 옮기거나 목록을 재편하면 즉시 생긴다.
|
||||
foreach (ExtensionDocScaffolder::presentBlockKeys($content) as $key) {
|
||||
if (! in_array($key, ExtensionDocScaffolder::blocksFor($doc), true)) {
|
||||
$result['orphanBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('check')) {
|
||||
if (! $merged['unchanged']) {
|
||||
foreach ($merged['replaced'] as $key) {
|
||||
if ($this->blockDiffers($content, $key, $docBodies[$key])) {
|
||||
$result['driftedBlocks'][] = $doc.' → '.$key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $merged['unchanged']) {
|
||||
File::put($abs, $merged['content']);
|
||||
|
||||
// 방금 만든 파일은 '생성' 으로만 보고한다 (같은 실행의 2차 렌더는 생성의 일부).
|
||||
if (! in_array($doc, $result['created'], true)) {
|
||||
$result['updated'][] = $doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서가 없는 자리에 골격 파일을 만듭니다 (기존 파일은 건드리지 않습니다).
|
||||
*
|
||||
* @param array<string, mixed> $ctx 수집 컨텍스트
|
||||
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
|
||||
* @param array<string, mixed> $result 처리 결과 (created 누적)
|
||||
*/
|
||||
private function initSkeletons(array $ctx, ExtensionDocScaffolder $scaffolder, array &$result): void
|
||||
{
|
||||
$record = $ctx['record'];
|
||||
|
||||
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
||||
|
||||
if (is_file($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists(dirname($abs));
|
||||
File::put($abs, $scaffolder->skeleton($doc, $ctx));
|
||||
$result['created'][] = $doc;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서에 이미 들어 있는 블록 본문이 새로 렌더한 본문과 다른지 판정합니다.
|
||||
*
|
||||
* @param string $content 문서 내용
|
||||
* @param string $key 블록 키
|
||||
* @param string $body 새 본문
|
||||
* @return bool 다르면 true
|
||||
*/
|
||||
private function blockDiffers(string $content, string $key, string $body): bool
|
||||
{
|
||||
$startPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+START\b.*?-->/s';
|
||||
$endPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+END\s*-->/s';
|
||||
|
||||
if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$from = (int) $sm[0][1] + strlen($sm[0][0]);
|
||||
|
||||
if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $from)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existing = trim(substr($content, $from, ((int) $em[0][1]) - $from));
|
||||
|
||||
return $existing !== trim($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 결과를 출력하고 종료 코드를 결정합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $results 확장별 결과
|
||||
* @param string $scope 범위
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function report(array $results, string $scope): int
|
||||
{
|
||||
$issues = 0;
|
||||
foreach ($results as $result) {
|
||||
$issues += count($result['missingDocuments'])
|
||||
+ count($result['missingSections'])
|
||||
+ count($result['missingBlocks'])
|
||||
+ count($result['driftedBlocks'])
|
||||
+ count($result['orphanBlocks']);
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode([
|
||||
'scope' => $scope,
|
||||
'mode' => $this->mode(),
|
||||
'extensions' => $results,
|
||||
'malformed' => $this->malformed,
|
||||
'issues' => $issues,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
|
||||
return ($this->option('check') && $issues > 0) ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info(count($results).'개 확장 (scope='.$scope.')');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($results as $result) {
|
||||
$parts = [];
|
||||
foreach ($result['stats'] as $label => $value) {
|
||||
// 세지 못한 지표는 `null` 로 온다. 그대로 보간하면 빈 칸이 되어
|
||||
// "0" 과도 "확인 못함" 과도 구분되지 않는다.
|
||||
$parts[] = $value === null
|
||||
? "{$label} ".ExtensionDocScaffolder::STAT_UNMEASURED
|
||||
: "{$label} {$value}";
|
||||
}
|
||||
|
||||
$this->line(sprintf(' [%s] %s v%s', $result['type'], $result['id'], $result['version']));
|
||||
$this->line(' '.implode(' · ', $parts));
|
||||
$this->line(' 문서 '.count($result['documents']).'종: '.implode(', ', $result['documents']));
|
||||
|
||||
if (! $result['surfaceAvailable'] && $result['surfaceReason'] !== null) {
|
||||
$this->line(' 선언형 표면: '.$result['surfaceReason']);
|
||||
}
|
||||
if ($result['surfaceErrors'] !== []) {
|
||||
foreach ($result['surfaceErrors'] as $getter => $message) {
|
||||
$this->line(" ⚠ {$getter}(): {$message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($results as $result) {
|
||||
$label = sprintf('[%s] %s', $result['type'], $result['id']);
|
||||
|
||||
if ($result['created'] !== []) {
|
||||
$this->info("{$label} 생성: ".implode(', ', $result['created']));
|
||||
}
|
||||
if ($result['updated'] !== []) {
|
||||
$this->info("{$label} 갱신: ".implode(', ', $result['updated']));
|
||||
}
|
||||
if ($result['missingDocuments'] !== []) {
|
||||
$this->warn("{$label} 문서 없음: ".implode(', ', $result['missingDocuments']));
|
||||
}
|
||||
if ($result['missingBlocks'] !== []) {
|
||||
$this->warn("{$label} 자동 생성 블록 없음: ".implode(', ', $result['missingBlocks']));
|
||||
}
|
||||
if ($result['missingSections'] !== []) {
|
||||
$this->warn("{$label} 필수 섹션 없음: ".implode(', ', $result['missingSections']));
|
||||
}
|
||||
if ($result['driftedBlocks'] !== []) {
|
||||
$this->warn("{$label} 블록 드리프트(코드 실측과 불일치): ".implode(', ', $result['driftedBlocks']));
|
||||
}
|
||||
if ($result['orphanBlocks'] !== []) {
|
||||
$this->warn("{$label} 갱신 대상이 아닌 자동 생성 블록(고아): ".implode(', ', $result['orphanBlocks']));
|
||||
}
|
||||
// 미채움 잔량은 계산만 하고 `--json` 에만 실려 있었다 — 계획이 이 마커를 둔
|
||||
// 이유가 "잔량 집계" 이므로 사람이 읽는 출력에도 낸다.
|
||||
if ($result['unfilled'] !== []) {
|
||||
$total = array_sum(array_column($result['unfilled'], 'count'));
|
||||
$this->line("{$label} 미채움 마커 {$total}건: ".implode(', ', array_map(
|
||||
static fn (array $u): string => $u['doc'].' → '.$u['marker'].'×'.$u['count'],
|
||||
$result['unfilled'],
|
||||
)));
|
||||
}
|
||||
foreach ($result['surfaceErrors'] as $getter => $message) {
|
||||
$this->warn("{$label} 선언형 표면 수집 실패 {$getter}(): {$message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->malformed as $bad) {
|
||||
$this->warn(sprintf(
|
||||
'[%s] %s — %s 를 읽지 못해 검사 대상에서 빠졌습니다 (%s). "확장이 없음" 이 아니라 "읽지 못함" 입니다.',
|
||||
$bad['type'], $bad['id'], $bad['manifest'], $bad['reason'],
|
||||
));
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf('%d개 확장 처리 (scope=%s, mode=%s) — 이슈 %d건', count($results), $scope, $this->mode(), $issues));
|
||||
|
||||
if ($this->option('check') && $issues > 0) {
|
||||
$this->line('`php artisan ext:docgen --init` 으로 골격을 만들고, `php artisan ext:docgen` 으로 블록을 갱신하세요.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 실행 모드를 반환합니다.
|
||||
*
|
||||
* @return string 모드 문자열
|
||||
*/
|
||||
private function mode(): string
|
||||
{
|
||||
if ($this->option('dry-run')) {
|
||||
return 'dry-run';
|
||||
}
|
||||
if ($this->option('check')) {
|
||||
return 'check';
|
||||
}
|
||||
if ($this->option('init')) {
|
||||
return 'init';
|
||||
}
|
||||
|
||||
return 'update';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 확장 데이터 모델 수집기
|
||||
*
|
||||
* 모델·Enum·마이그레이션·Repository 계약을 `_bundled` 소스에서 수집합니다.
|
||||
* 모델 클래스를 로드하지 않고 소스를 파싱하므로 DB 연결이나 확장 활성화 상태와 무관하게
|
||||
* 동작합니다 (문서 생성은 설치되지 않은 확장에도 수행되어야 합니다).
|
||||
*/
|
||||
class DataModelCollector
|
||||
{
|
||||
/**
|
||||
* Eloquent 관계 정의 메서드.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const RELATION_METHODS = [
|
||||
'hasOne', 'hasMany', 'belongsTo', 'belongsToMany',
|
||||
'hasOneThrough', 'hasManyThrough',
|
||||
'morphOne', 'morphMany', 'morphTo', 'morphToMany', 'morphedByMany',
|
||||
];
|
||||
|
||||
/**
|
||||
* 확장의 데이터 모델 표면을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array{models: array<int, array<string, mixed>>, enums: array<int, array<string, mixed>>, migrations: array<int, array<string, mixed>>, tables: array<int, string>, repositories: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function collect(array $record): array
|
||||
{
|
||||
$models = $this->collectModels($record);
|
||||
$migrations = $this->collectMigrations($record);
|
||||
|
||||
$tables = [];
|
||||
foreach ($migrations as $migration) {
|
||||
foreach ($migration['creates'] as $table) {
|
||||
$tables[$table] = true;
|
||||
}
|
||||
}
|
||||
foreach ($models as $model) {
|
||||
if ($model['table'] !== null) {
|
||||
$tables[$model['table']] = true;
|
||||
}
|
||||
}
|
||||
$tables = array_keys($tables);
|
||||
sort($tables);
|
||||
|
||||
return [
|
||||
'models' => $models,
|
||||
'enums' => $this->collectEnums($record),
|
||||
'migrations' => $migrations,
|
||||
'tables' => $tables,
|
||||
'repositories' => $this->collectRepositories($record),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `src/Models/**` 의 모델을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> 모델 목록
|
||||
*/
|
||||
private function collectModels(array $record): array
|
||||
{
|
||||
$models = [];
|
||||
|
||||
foreach ($this->filesIn($record, 'src/Models') as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
$short = basename($file, '.php');
|
||||
|
||||
$models[] = [
|
||||
'class' => $short,
|
||||
'relFile' => $this->relative($record, $file),
|
||||
'table' => $this->stringProperty($content, 'table'),
|
||||
'fillable' => $this->arrayPropertyCount($content, 'fillable'),
|
||||
'softDeletes' => (bool) preg_match('/\buse\s+[^;]*\bSoftDeletes\b/', $content),
|
||||
'userOverrides' => str_contains($content, 'HasUserOverrides'),
|
||||
'searchable' => str_contains($content, 'FulltextSearchable') || str_contains($content, 'Laravel\Scout\Searchable'),
|
||||
'relations' => $this->collectRelations($content),
|
||||
'summary' => $this->classDocSummary($content),
|
||||
];
|
||||
}
|
||||
|
||||
usort($models, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
|
||||
|
||||
return $models;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 소스에서 관계 정의를 수집합니다.
|
||||
*
|
||||
* @param string $content 모델 소스
|
||||
* @return array<int, array{method: string, type: string, target: string|null}> 관계 목록
|
||||
*/
|
||||
private function collectRelations(string $content): array
|
||||
{
|
||||
$relations = [];
|
||||
$alternation = implode('|', self::RELATION_METHODS);
|
||||
|
||||
$pattern = '/public\s+function\s+(\w+)\s*\([^)]*\)[^{]*\{(?:[^{}]|\{[^{}]*\})*?\$this->('
|
||||
.$alternation
|
||||
.')\s*\(\s*(?:([A-Za-z_\\\\]+)::class)?/s';
|
||||
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $m) {
|
||||
$relations[] = [
|
||||
'method' => $m[1],
|
||||
'type' => $m[2],
|
||||
'target' => ($m[3] ?? '') !== '' ? $this->shortName($m[3]) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $relations;
|
||||
}
|
||||
|
||||
/**
|
||||
* `src/Enums/**` 의 Enum 을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> Enum 목록
|
||||
*/
|
||||
private function collectEnums(array $record): array
|
||||
{
|
||||
$enums = [];
|
||||
|
||||
foreach ($this->filesIn($record, 'src/Enums') as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
|
||||
$backing = null;
|
||||
if (preg_match('/^\s*enum\s+\w+\s*:\s*(\w+)/m', $content, $bm)) {
|
||||
$backing = $bm[1];
|
||||
}
|
||||
|
||||
$cases = [];
|
||||
if (preg_match_all("/^\s*case\s+(\w+)\s*(?:=\s*'([^']*)')?/m", $content, $cm, PREG_SET_ORDER)) {
|
||||
foreach ($cm as $c) {
|
||||
$cases[] = ['name' => $c[1], 'value' => $c[2] ?? null];
|
||||
}
|
||||
}
|
||||
|
||||
$enums[] = [
|
||||
'class' => basename($file, '.php'),
|
||||
'relFile' => $this->relative($record, $file),
|
||||
'backing' => $backing,
|
||||
'cases' => $cases,
|
||||
'summary' => $this->classDocSummary($content),
|
||||
];
|
||||
}
|
||||
|
||||
usort($enums, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
|
||||
|
||||
return $enums;
|
||||
}
|
||||
|
||||
/**
|
||||
* `database/migrations/**` 을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> 마이그레이션 목록 (파일명 정렬)
|
||||
*/
|
||||
private function collectMigrations(array $record): array
|
||||
{
|
||||
$migrations = [];
|
||||
|
||||
foreach ($this->filesIn($record, 'database/migrations') as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
|
||||
$creates = [];
|
||||
if (preg_match_all("/Schema::(?:connection\([^)]*\)->)?create\s*\(\s*'([^']+)'/", $content, $m)) {
|
||||
$creates = array_values(array_unique($m[1]));
|
||||
}
|
||||
|
||||
$alters = [];
|
||||
if (preg_match_all("/Schema::(?:connection\([^)]*\)->)?table\s*\(\s*'([^']+)'/", $content, $m)) {
|
||||
$alters = array_values(array_unique($m[1]));
|
||||
}
|
||||
|
||||
$migrations[] = [
|
||||
'file' => basename($file),
|
||||
'relFile' => $this->relative($record, $file),
|
||||
'creates' => $creates,
|
||||
'alters' => $alters,
|
||||
'hasDown' => (bool) preg_match('/function\s+down\s*\(/', $content),
|
||||
];
|
||||
}
|
||||
|
||||
usort($migrations, static fn (array $a, array $b): int => $a['file'] <=> $b['file']);
|
||||
|
||||
return $migrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository 계약과 구현을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> Repository 목록
|
||||
*/
|
||||
private function collectRepositories(array $record): array
|
||||
{
|
||||
$repositories = [];
|
||||
|
||||
foreach (['src/Repositories', 'src/Contracts/Repositories'] as $sub) {
|
||||
foreach ($this->filesIn($record, $sub) as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
|
||||
$repositories[] = [
|
||||
'class' => basename($file, '.php'),
|
||||
'relFile' => $this->relative($record, $file),
|
||||
'isInterface' => (bool) preg_match('/^\s*interface\s+\w+/m', $content),
|
||||
'summary' => $this->classDocSummary($content),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($repositories, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
|
||||
|
||||
return $repositories;
|
||||
}
|
||||
|
||||
/**
|
||||
* `protected $x = '...'` 형태의 문자열 프로퍼티 값을 읽습니다.
|
||||
*
|
||||
* @param string $content 소스
|
||||
* @param string $name 프로퍼티명
|
||||
* @return string|null 값 (없으면 null)
|
||||
*/
|
||||
private function stringProperty(string $content, string $name): ?string
|
||||
{
|
||||
if (preg_match('/\$'.preg_quote($name, '/')."\s*=\s*'([^']*)'/", $content, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `protected $x = [...]` 형태의 배열 프로퍼티 원소 수를 셉니다.
|
||||
*
|
||||
* 근사치입니다 — 문서의 규모 감을 주기 위한 값이며 계약 판정에 쓰지 않습니다.
|
||||
*
|
||||
* @param string $content 소스
|
||||
* @param string $name 프로퍼티명
|
||||
* @return int|null 원소 수 (프로퍼티 없으면 null)
|
||||
*/
|
||||
private function arrayPropertyCount(string $content, string $name): ?int
|
||||
{
|
||||
if (! preg_match('/\$'.preg_quote($name, '/').'\s*=\s*\[(.*?)\];/s', $content, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match_all("/'[^']*'/", $m[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 클래스 docblock 의 첫 문장을 요약으로 뽑습니다.
|
||||
*
|
||||
* @param string $content 소스
|
||||
* @return string|null 요약 (없으면 null)
|
||||
*/
|
||||
private function classDocSummary(string $content): ?string
|
||||
{
|
||||
if (! preg_match('#/\*\*(.*?)\*/\s*(?:final\s+|abstract\s+|readonly\s+)*(?:class|enum|interface|trait)\s+\w+#s', $content, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (explode("\n", $m[1]) as $line) {
|
||||
$line = trim(preg_replace('/^\s*\*\s?/', '', $line) ?? '');
|
||||
if ($line !== '' && ! str_starts_with($line, '@')) {
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FQCN 에서 클래스 짧은 이름을 뽑습니다.
|
||||
*
|
||||
* @param string $fqcn 클래스명
|
||||
* @return string 짧은 이름
|
||||
*/
|
||||
private function shortName(string $fqcn): string
|
||||
{
|
||||
$parts = explode('\\', trim($fqcn, '\\'));
|
||||
|
||||
return end($parts) ?: $fqcn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 하위 디렉토리의 PHP 파일을 열거합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param string $sub 확장 루트 기준 하위 경로
|
||||
* @return array<int, string> PHP 파일 절대 경로
|
||||
*/
|
||||
private function filesIn(array $record, string $sub): array
|
||||
{
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
|
||||
if (! is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
if ($file->getExtension() === 'php') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 루트 기준 상대 경로로 변환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param string $absolute 절대 경로
|
||||
* @return string 상대 경로 (POSIX 구분자)
|
||||
*/
|
||||
private function relative(array $record, string $absolute): string
|
||||
{
|
||||
$base = rtrim((string) $record['path'], '/\\').DIRECTORY_SEPARATOR;
|
||||
$rel = str_starts_with($absolute, $base) ? substr($absolute, strlen($base)) : $absolute;
|
||||
|
||||
return str_replace('\\', '/', $rel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use App\Extension\AbstractModule;
|
||||
use App\Extension\AbstractPlugin;
|
||||
use ReflectionClass;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 확장 선언형 표면 수집기
|
||||
*
|
||||
* `AbstractModule` / `AbstractPlugin` 이 이미 갖고 있는 선언형 getter 를 실제로 호출해
|
||||
* 라우트·권한·메뉴·훅·설정·스케줄 등의 표면을 읽습니다. 정규식으로 소스를 긁는 방식과 달리
|
||||
* 상속 기본값(`getRoutes()` 가 파일 존재 여부로 계산하는 값 등)까지 정확히 반영됩니다.
|
||||
*
|
||||
* 읽기 대상은 항상 `_bundled` 소스입니다. 활성 디렉토리에 같은 FQCN 이 이미 로드되어 있으면
|
||||
* PHP 는 클래스를 재정의할 수 없으므로, `ModuleManager::evalFreshModule()` 과 같은 방식으로
|
||||
* 진입 클래스명만 바꿔 메모리에 다시 로드합니다 (namespace 유지 → use/extends 정상 동작).
|
||||
*
|
||||
* 확장 getter 는 DB·파일시스템·다른 확장에 의존할 수 있으므로 개별 호출을 각각 격리합니다.
|
||||
* 한 getter 의 실패가 나머지 수집을 중단시키지 않으며, 실패 사유는 `errors` 로 올라가
|
||||
* 문서에 "수집 실패" 로 드러납니다 (조용한 누락 금지).
|
||||
*/
|
||||
class DeclarativeSurfaceCollector
|
||||
{
|
||||
/**
|
||||
* 수집 대상 getter 와 문서상 라벨.
|
||||
*
|
||||
* 확장 유형에 없는 getter 는 `method_exists` 로 건너뜁니다 (모듈 전용 · 플러그인 전용 혼재).
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const GETTERS = [
|
||||
// 라우트·마이그레이션·뷰
|
||||
'getRoutes' => '라우트 파일',
|
||||
'getMigrations' => '마이그레이션 경로',
|
||||
'getViews' => '뷰 경로',
|
||||
'getSeeders' => '시더',
|
||||
'getDynamicTables' => '동적 테이블',
|
||||
// 권한·역할·메뉴
|
||||
'getPermissions' => '권한 정의',
|
||||
'getDynamicPermissionIdentifiers' => '동적 권한 식별자',
|
||||
'getRoles' => '역할 정의',
|
||||
'getDynamicRoleIdentifiers' => '동적 역할 식별자',
|
||||
'getAdminMenus' => '관리자 메뉴',
|
||||
'getCustomMenus' => '사용자 메뉴',
|
||||
'getDynamicMenuSlugs' => '동적 메뉴 slug',
|
||||
// 확장점
|
||||
'getHooks' => '발행 훅 선언',
|
||||
'getHookListeners' => '훅 리스너',
|
||||
'getChannels' => '브로드캐스트 채널',
|
||||
'getSchedules' => '스케줄',
|
||||
'getMiddleware' => '미들웨어',
|
||||
'getLayoutExtensions' => '레이아웃 확장',
|
||||
'getNotificationDefinitions' => '알림 정의',
|
||||
'getBenchmarkProfiles' => '성능 계측 프로파일',
|
||||
// 본인인증
|
||||
'getIdentityPolicies' => 'IDV 정책',
|
||||
'getIdentityPurposes' => 'IDV 목적',
|
||||
'getIdentityMessages' => 'IDV 메시지',
|
||||
// 설정
|
||||
'getConfig' => 'config 파일',
|
||||
'getConfigValues' => 'config 값',
|
||||
'getSettingsSchema' => '설정 스키마',
|
||||
'getSettingsDefaultsPath' => '설정 기본값 경로',
|
||||
'getSettingsLayout' => '설정 레이아웃',
|
||||
'getSettingsRoute' => '설정 라우트',
|
||||
'getSeoConfigPath' => 'SEO 설정 경로',
|
||||
// 에셋
|
||||
'getAssets' => '프론트 에셋',
|
||||
'getAssetLoadingConfig' => '에셋 로딩 설정',
|
||||
'getBuiltAssetPaths' => '빌드 산출물 경로',
|
||||
'getTrustedScriptHosts' => '신뢰 스크립트 호스트',
|
||||
'getStorageDisk' => '스토리지 디스크',
|
||||
'getCacheStore' => '캐시 스토어',
|
||||
// 메타
|
||||
'getDependencies' => '의존 확장',
|
||||
'getRequiredCoreVersion' => '코어 최소 버전',
|
||||
'getLicense' => '라이선스',
|
||||
'getGithubUrl' => 'GitHub URL',
|
||||
];
|
||||
|
||||
/**
|
||||
* 확장의 선언형 표면을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array{available: bool, reason: string|null, values: array<string, mixed>, errors: array<string, string>, endpoints: int}
|
||||
* available=false 이면 values 는 비고 reason 에 사유가 담깁니다.
|
||||
*/
|
||||
public function collect(array $record): array
|
||||
{
|
||||
$empty = ['available' => false, 'reason' => null, 'values' => [], 'errors' => [], 'endpoints' => 0];
|
||||
// 확장마다 초기화한다 — 남겨 두면 앞 확장의 실패가 다음 확장의 사유로 새어 나간다.
|
||||
$this->pathInjectionError = null;
|
||||
|
||||
if (($record['entryFile'] ?? null) === null || ($record['entryClass'] ?? null) === null) {
|
||||
$empty['reason'] = '진입 클래스 없음 (템플릿은 선언형 표면을 갖지 않습니다)';
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$restore = $this->registerBundledAutoloader($record);
|
||||
|
||||
try {
|
||||
$instance = $this->instantiate($record);
|
||||
} catch (Throwable $e) {
|
||||
$restore();
|
||||
$empty['reason'] = '진입 클래스 로드 실패: '.$e->getMessage();
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
if ($instance === null) {
|
||||
$restore();
|
||||
$empty['reason'] = '진입 클래스 인스턴스화 실패: '.$record['entryClass'];
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$values = [];
|
||||
$errors = [];
|
||||
|
||||
if ($this->pathInjectionError !== null) {
|
||||
$errors['__path_injection'] = $this->pathInjectionError;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach (array_keys(self::GETTERS) as $getter) {
|
||||
if (! method_exists($instance, $getter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$values[$getter] = $instance->{$getter}();
|
||||
} catch (Throwable $e) {
|
||||
$errors[$getter] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$restore();
|
||||
}
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'reason' => null,
|
||||
'values' => $values,
|
||||
'errors' => $errors,
|
||||
'endpoints' => $this->countEndpoints($values['getRoutes'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언된 라우트 파일에 등록된 엔드포인트 수를 셉니다.
|
||||
*
|
||||
* 집계 배지가 말하는 "라우트 수" 는 **주소(엔드포인트) 개수**입니다 — 라우트 파일 개수가
|
||||
* 아닙니다. 확장 대부분이 파일 1~2개에 수십 개의 주소를 담으므로 파일 수는 규모를 전혀
|
||||
* 알려주지 않습니다. 이 값은 `docs/api/README.md` 목차의 엔드포인트 수와 같은 것을 세므로
|
||||
* 두 표가 서로 다른 숫자를 말하지 않습니다.
|
||||
*
|
||||
* `Route::match(['GET','POST'], ...)` 는 한 번 등록되지만 주소는 메서드 수만큼이므로
|
||||
* 배열 길이로 셉니다 (API 문서 생성기와 같은 기준).
|
||||
*
|
||||
* @param mixed $routes `getRoutes()` 반환값 (종류 => 파일 경로)
|
||||
* @return int 엔드포인트 수 (셀 수 없으면 0)
|
||||
*/
|
||||
private function countEndpoints(mixed $routes): int
|
||||
{
|
||||
if (! is_array($routes)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$verbs = 'get|post|put|patch|delete|options|any|dualSuffix|dualSuffixSegment|dualAsset';
|
||||
$total = 0;
|
||||
|
||||
foreach ($routes as $path) {
|
||||
if (! is_string($path) || ! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = (string) file_get_contents($path);
|
||||
|
||||
$total += preg_match_all('/Route::(?:'.$verbs.')\s*\(/', $source);
|
||||
|
||||
// match 는 메서드 배열의 길이만큼 주소를 만든다.
|
||||
if (preg_match_all('/Route::match\s*\(\s*\[([^\]]*)\]/', $source, $m) > 0) {
|
||||
foreach ($m[1] as $methods) {
|
||||
$total += max(1, preg_match_all('/[\'"]/', $methods) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (int) $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 진입 클래스를 인스턴스화합니다.
|
||||
*
|
||||
* 같은 FQCN 이 이미 로드되어 있으면(활성 디렉토리 확장이 부팅된 경우) 클래스명을 바꿔
|
||||
* eval 로 다시 로드합니다. 그렇지 않으면 `_bundled` 파일을 직접 include 합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return object|null 확장 인스턴스 (실패 시 null)
|
||||
*/
|
||||
/**
|
||||
* 직전 인스턴스화에서 발생한 경로 주입 실패 사유 (없으면 null).
|
||||
*/
|
||||
private ?string $pathInjectionError = null;
|
||||
|
||||
private function instantiate(array $record): ?object
|
||||
{
|
||||
$fqcn = (string) $record['entryClass'];
|
||||
$entryFile = (string) $record['entryFile'];
|
||||
|
||||
if (! class_exists($fqcn, false)) {
|
||||
require_once $entryFile;
|
||||
|
||||
return class_exists($fqcn, false) ? new $fqcn : null;
|
||||
}
|
||||
|
||||
return $this->evalFreshEntry($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 로드된 FQCN 을 피해 `_bundled` 진입 클래스를 새 이름으로 다시 로드합니다.
|
||||
*
|
||||
* PHP 는 동일 프로세스에서 클래스를 재정의할 수 없으므로 클래스명만 치환합니다.
|
||||
* namespace 는 유지하므로 use/extends/implements 가 그대로 동작합니다.
|
||||
* eval 로 만든 클래스는 `ReflectionClass::getFileName()` 이 비정상이라
|
||||
* 경로 프로퍼티를 리플렉션으로 직접 주입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return object|null 확장 인스턴스 (실패 시 null)
|
||||
*/
|
||||
private function evalFreshEntry(array $record): ?object
|
||||
{
|
||||
$content = @file_get_contents((string) $record['entryFile']);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$short = (string) $record['entryClassShort'];
|
||||
$uid = '_extdoc_'.bin2hex(random_bytes(6));
|
||||
|
||||
$renamed = preg_replace('/\bclass\s+'.preg_quote($short, '/').'\b/', 'class '.$short.$uid, $content, 1);
|
||||
if (! is_string($renamed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$renamed = preg_replace('/^<\?php\s*/', '', $renamed);
|
||||
if (! is_string($renamed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
eval($renamed);
|
||||
|
||||
$freshClass = $record['namespace'].'\\'.$short.$uid;
|
||||
if (! class_exists($freshClass, false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$instance = new $freshClass;
|
||||
$this->pathInjectionError = $this->injectExtensionPath($instance, (string) $record['path']);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* eval 로 로드한 인스턴스에 확장 디렉토리 경로를 주입합니다.
|
||||
*
|
||||
* @param object $instance 확장 인스턴스
|
||||
* @param string $path 확장 디렉토리 절대 경로
|
||||
*/
|
||||
private function injectExtensionPath(object $instance, string $path): ?string
|
||||
{
|
||||
$targets = [
|
||||
AbstractModule::class => 'modulePath',
|
||||
AbstractPlugin::class => 'pluginPath',
|
||||
];
|
||||
|
||||
foreach ($targets as $class => $property) {
|
||||
if (! $instance instanceof $class) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$ref = new ReflectionClass($class);
|
||||
if (! $ref->hasProperty($property)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$prop = $ref->getProperty($property);
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($instance, $path);
|
||||
} catch (Throwable $e) {
|
||||
// 경로 주입 실패는 예외를 던지지 않는다. 그런데 경로 기반 getter(`getRoutes`
|
||||
// `getMigrations` 등)는 그 상태에서 **예외 없이 빈 값**을 돌려주므로 getter 별
|
||||
// try/catch 에도 걸리지 않는다 — "없음" 으로 굳는 침묵 경로다. 사유를 올린다.
|
||||
return $property.' 주입 실패: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_bundled` composer.json 의 PSR-4 매핑을 임시 오토로더로 등록합니다.
|
||||
*
|
||||
* 아직 로드되지 않은 확장 내부 클래스(Listener·Model 등)가 활성 디렉토리가 아니라
|
||||
* `_bundled` 소스에서 해석되도록 합니다. 수집이 끝나면 반드시 해제해야 하므로
|
||||
* 해제 클로저를 돌려줍니다 (프로세스 잔류 시 이후 코드가 `_bundled` 를 보게 됨).
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return \Closure 해제 클로저
|
||||
*/
|
||||
private function registerBundledAutoloader(array $record): \Closure
|
||||
{
|
||||
$composerPath = $record['path'].DIRECTORY_SEPARATOR.'composer.json';
|
||||
$psr4 = [];
|
||||
|
||||
if (is_file($composerPath)) {
|
||||
$composer = json_decode((string) file_get_contents($composerPath), true);
|
||||
$declared = $composer['autoload']['psr-4'] ?? null;
|
||||
|
||||
if (is_array($declared)) {
|
||||
foreach ($declared as $prefix => $dir) {
|
||||
$dirs = is_array($dir) ? $dir : [$dir];
|
||||
foreach ($dirs as $one) {
|
||||
$psr4[(string) $prefix][] = rtrim($record['path'].DIRECTORY_SEPARATOR.trim((string) $one, '/\\'), '/\\');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($psr4 === []) {
|
||||
return static function (): void {};
|
||||
}
|
||||
|
||||
$loader = static function (string $class) use ($psr4): void {
|
||||
foreach ($psr4 as $prefix => $dirs) {
|
||||
if (! str_starts_with($class, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($prefix))).'.php';
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
$file = $dir.DIRECTORY_SEPARATOR.$relative;
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
spl_autoload_register($loader, true, true);
|
||||
|
||||
return static function () use ($loader): void {
|
||||
spl_autoload_unregister($loader);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
/**
|
||||
* 확장 의존 관계 수집기
|
||||
*
|
||||
* manifest 의 `dependencies` 선언을 정방향(내가 의존하는 확장)과 역방향(나에게 의존하는
|
||||
* 확장) 양쪽으로 해석합니다.
|
||||
*
|
||||
* 역방향이 이 수집기의 존재 이유입니다 — 운영자가 "이 확장을 끄면 무엇이 같이 죽는가" 를
|
||||
* 알아야 하는데, 그 정보는 어느 한 manifest 에도 없고 번들 전수를 교차 스캔해야만 나옵니다.
|
||||
* 확장명을 하드코딩하지 않고 인벤토리 스캔 결과에서 도출하므로 신규 확장이 자동 편입됩니다.
|
||||
*/
|
||||
class DependencyGraphCollector
|
||||
{
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>|null 전수 인벤토리 캐시
|
||||
*/
|
||||
private ?array $universe = null;
|
||||
|
||||
/**
|
||||
* @param ExtensionInventory $inventory 번들 확장 인벤토리
|
||||
*/
|
||||
public function __construct(private readonly ExtensionInventory $inventory) {}
|
||||
|
||||
/**
|
||||
* 확장의 의존 관계를 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array{requires: array<int, array{type: string, id: string, constraint: string, bundled: bool}>, requiredBy: array<int, array{type: string, id: string, constraint: string}>, coreVersion: string|null}
|
||||
*/
|
||||
public function collect(array $record): array
|
||||
{
|
||||
return [
|
||||
'requires' => $this->requires($record),
|
||||
'requiredBy' => $this->requiredBy($record),
|
||||
'coreVersion' => $this->coreConstraint($record),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 확장이 의존하는 확장 목록을 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array{type: string, id: string, constraint: string, bundled: bool}>
|
||||
*/
|
||||
private function requires(array $record): array
|
||||
{
|
||||
$requires = [];
|
||||
|
||||
foreach ($this->declaredDependencies($record) as $type => $entries) {
|
||||
foreach ($entries as $id => $constraint) {
|
||||
$requires[] = [
|
||||
'type' => $type,
|
||||
'id' => (string) $id,
|
||||
'constraint' => (string) $constraint,
|
||||
'bundled' => $this->isBundled($type, (string) $id),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($requires, static fn (array $a, array $b): int => [$a['type'], $a['id']] <=> [$b['type'], $b['id']]);
|
||||
|
||||
return $requires;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 확장에 의존하는 확장 목록을 반환합니다 (번들 전수 교차 스캔).
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array{type: string, id: string, constraint: string}>
|
||||
*/
|
||||
private function requiredBy(array $record): array
|
||||
{
|
||||
$selfType = $this->pluralize((string) $record['type']);
|
||||
$selfId = (string) $record['id'];
|
||||
$dependents = [];
|
||||
|
||||
foreach ($this->allExtensions() as $other) {
|
||||
if ($other['id'] === $selfId && $other['type'] === $record['type']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$declared = $this->declaredDependencies($other);
|
||||
$constraint = $declared[$selfType][$selfId] ?? null;
|
||||
|
||||
if ($constraint === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dependents[] = [
|
||||
'type' => (string) $other['type'],
|
||||
'id' => (string) $other['id'],
|
||||
'constraint' => (string) $constraint,
|
||||
];
|
||||
}
|
||||
|
||||
usort($dependents, static fn (array $a, array $b): int => [$a['type'], $a['id']] <=> [$b['type'], $b['id']]);
|
||||
|
||||
return $dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 의 코어 버전 제약을 읽습니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return string|null 코어 버전 제약 (없으면 null)
|
||||
*/
|
||||
private function coreConstraint(array $record): ?string
|
||||
{
|
||||
$value = $record['manifest']['g7_version'] ?? ($record['manifest']['requires']['g7_version'] ?? null);
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 의 dependencies 선언을 정규화합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array{modules: array<string, string>, plugins: array<string, string>}
|
||||
*/
|
||||
private function declaredDependencies(array $record): array
|
||||
{
|
||||
$declared = $record['manifest']['dependencies'] ?? [];
|
||||
// templates 도 정규화한다 — pluralize() 가 'templates' 를 만드는데 여기에 그 키가
|
||||
// 없으면 템플릿의 역방향 의존(`requiredBy`)이 구조적으로 항상 빈 배열이 된다.
|
||||
$normalized = ['modules' => [], 'plugins' => [], 'templates' => []];
|
||||
|
||||
if (! is_array($declared)) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
foreach (['modules', 'plugins', 'templates'] as $key) {
|
||||
$entries = $declared[$key] ?? [];
|
||||
if (! is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($entries as $id => $constraint) {
|
||||
if (is_string($constraint)) {
|
||||
$normalized[$key][(string) $id] = $constraint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형 단수형을 manifest dependencies 의 복수 키로 바꿉니다.
|
||||
*
|
||||
* @param string $type 확장 유형
|
||||
* @return string 복수 키 (`modules` | `plugins` | `templates`)
|
||||
*/
|
||||
private function pluralize(string $type): string
|
||||
{
|
||||
return $type.'s';
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상이 번들 확장인지 확인합니다.
|
||||
*
|
||||
* @param string $pluralType 복수 키
|
||||
* @param string $id 확장 식별자
|
||||
* @return bool 번들 여부
|
||||
*/
|
||||
private function isBundled(string $pluralType, string $id): bool
|
||||
{
|
||||
$singular = rtrim($pluralType, 's');
|
||||
|
||||
foreach ($this->allExtensions() as $ext) {
|
||||
if ($ext['type'] === $singular && $ext['id'] === $id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 확장 전수를 반환합니다 (1회 스캔 후 캐시).
|
||||
*
|
||||
* @return array<int, array<string, mixed>> 확장 레코드 목록
|
||||
*/
|
||||
private function allExtensions(): array
|
||||
{
|
||||
if ($this->universe === null) {
|
||||
$this->universe = $this->inventory->collect('all');
|
||||
}
|
||||
|
||||
return $this->universe;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use App\Extension\Helpers\EditorSpecAssembler;
|
||||
|
||||
/**
|
||||
* 확장 편집기 스펙 수집기
|
||||
*
|
||||
* 확장이 `editor-spec.json`(+ 분할 `editor-spec/*.json`)으로 레이아웃 편집기에 선언한
|
||||
* 표면을 실측합니다. 팔레트 항목·스타일 컨트롤·중첩 규칙·샘플 데이터·레시피가 각각 몇
|
||||
* 개이고 어떤 ID 를 갖는지가 산출물입니다.
|
||||
*
|
||||
* 이 축이 문서에 없으면 확장에 새 화면 요소를 추가해도 편집기 팔레트에 나타나지 않는
|
||||
* 상태가 오류도 경고도 없이 남습니다 — 편집기는 선언되지 않은 컴포넌트를 "없는 것" 으로
|
||||
* 다룰 뿐 실패를 보고하지 않기 때문입니다. 스펙을 **갖지 않은** 확장도 그 사실 자체를
|
||||
* 산출물로 돌려줍니다(`present => false`). 미보유는 정상 상태일 수 있고, 문서는 그
|
||||
* 정상 여부를 서술할 자리를 가져야 합니다.
|
||||
*
|
||||
* 합본은 런타임 서빙과 같은 경로(`EditorSpecAssembler`)를 씁니다. 수집기가 별도 병합
|
||||
* 규칙을 갖게 되면 문서가 말하는 스펙과 편집기가 읽는 스펙이 갈라집니다.
|
||||
*/
|
||||
class EditorSpecCollector
|
||||
{
|
||||
/**
|
||||
* 블록별 **항목이 실제로 담긴 자리**.
|
||||
*
|
||||
* 블록 최상위 키를 그대로 세면 안 됩니다 — 블록들은 자기 항목을 `entries` / `groups` /
|
||||
* `byDataSourceId` 같은 하위 자리에 담고, 최상위에는 `comment` 같은 메타 키를 함께
|
||||
* 둡니다. 최상위를 세면 팔레트 79개가 3(comment·groups·entries)으로 집계되는데, 그
|
||||
* 숫자는 오류 없이 문서에 실려 "이 확장은 팔레트 항목이 3개" 라는 사실 주장이 됩니다.
|
||||
*
|
||||
* 값이 빈 배열인 블록은 최상위(메타 키 제외)가 곧 항목입니다.
|
||||
*
|
||||
* 선언 순서가 곧 문서 표의 행 순서입니다.
|
||||
*
|
||||
* @var array<string, array<int, string>> 블록 키 → 항목이 담긴 하위 키 목록
|
||||
*/
|
||||
private const ITEM_PATHS = [
|
||||
'componentPalette' => ['entries', 'groups'],
|
||||
'controls' => [],
|
||||
'componentCapabilities' => [],
|
||||
'nesting' => ['draggable', 'containers'],
|
||||
'sampleData' => ['byDataSourceId', 'byEndpointPattern'],
|
||||
'sampleGlobal' => [],
|
||||
'states' => ['groups'],
|
||||
'stateLabels' => [],
|
||||
'actionRecipes' => [],
|
||||
'conditionRecipes' => ['operators'],
|
||||
'computedRecipes' => [],
|
||||
'errorRecipes' => [],
|
||||
'loadingComponents' => [],
|
||||
'actionChipCandidates' => [],
|
||||
];
|
||||
|
||||
/**
|
||||
* 항목이 아니라 설명인 키 — 개수에서 제외한다.
|
||||
*
|
||||
* **접두 규칙으로 넓히지 않는다.** `_` 로 시작하는 키를 일괄 배제하면 실제 항목까지
|
||||
* 삼킨다 — `sampleGlobal._local` 이 그 예이고, 그렇게 빠진 항목은 오류 없이 문서에
|
||||
* "1개 적은 수" 로 실린다. 설명 키는 실측으로 확인된 것만 이름으로 열거한다.
|
||||
*
|
||||
* @var array<int, string> 설명 키 목록
|
||||
*/
|
||||
private const META_KEYS = ['comment', '$comment', '$schema', '_propControlsComment'];
|
||||
|
||||
/**
|
||||
* @var array<int, string>|null 번들 템플릿이 커버하는 샘플 ID (프로세스 단위 메모)
|
||||
*/
|
||||
private static ?array $fallbackSampleIds = null;
|
||||
|
||||
/**
|
||||
* 확장의 편집기 스펙 표면을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @param array<int, string> $layoutRelFiles 이 확장의 레이아웃 파일(확장 루트 기준 상대 경로)
|
||||
* @return array<string, mixed> 편집기 스펙 인벤토리
|
||||
*/
|
||||
public function collect(array $record, array $layoutRelFiles = []): array
|
||||
{
|
||||
$manifestPath = $record['path'].DIRECTORY_SEPARATOR.'editor-spec.json';
|
||||
|
||||
if (! is_file($manifestPath)) {
|
||||
return $this->absent($record, $layoutRelFiles);
|
||||
}
|
||||
|
||||
$spec = EditorSpecAssembler::assemble($manifestPath);
|
||||
|
||||
if ($spec === null) {
|
||||
// manifest 가 있는데 디코드에 실패한 상태다. "스펙 없음" 과 구분해 보고한다 —
|
||||
// 뭉뚱그리면 깨진 JSON 이 "이 확장은 편집기 스펙을 두지 않는다" 로 읽힌다.
|
||||
return $this->absent($record, $layoutRelFiles, malformed: true);
|
||||
}
|
||||
|
||||
$includes = $this->includeMap($manifestPath);
|
||||
|
||||
return [
|
||||
'present' => true,
|
||||
'malformed' => false,
|
||||
'manifest' => $record['relPath'].'/editor-spec.json',
|
||||
'split' => $includes !== [],
|
||||
'includes' => $includes,
|
||||
'version' => $this->stringOrNull($spec['version'] ?? null),
|
||||
'description' => $this->stringOrNull($spec['description'] ?? null),
|
||||
'styleSystem' => $this->stringOrNull($spec['styleSystem'] ?? null),
|
||||
'darkMode' => $this->stringOrNull(($spec['darkMode']['strategy'] ?? null)),
|
||||
'blocks' => $this->blockSummaries($spec, $includes),
|
||||
'paletteGroups' => $this->paletteGroups($spec['componentPalette'] ?? null),
|
||||
'sampleDataIds' => $this->idsAt($spec['sampleData'] ?? null, 'byDataSourceId'),
|
||||
'sampleEndpointPatterns' => $this->idsAt($spec['sampleData'] ?? null, 'byEndpointPattern'),
|
||||
'stateScopes' => $this->idsAt($spec['states'] ?? null, 'groups'),
|
||||
'uncovered' => $this->uncoveredDataSources($record, $layoutRelFiles, $spec),
|
||||
'declaredPaths' => [
|
||||
'sampleData.byDataSourceId' => $this->hasPath($spec['sampleData'] ?? null, 'byDataSourceId'),
|
||||
'sampleData.byEndpointPattern' => $this->hasPath($spec['sampleData'] ?? null, 'byEndpointPattern'),
|
||||
'states.groups' => $this->hasPath($spec['states'] ?? null, 'groups'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 스펙 미보유(또는 손상) 상태의 산출물을 만듭니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param array<int, string> $layoutRelFiles 레이아웃 파일 목록
|
||||
* @param bool $malformed manifest 는 있으나 디코드에 실패했는지 여부
|
||||
* @return array<string, mixed> 인벤토리
|
||||
*/
|
||||
private function absent(array $record, array $layoutRelFiles, bool $malformed = false): array
|
||||
{
|
||||
return [
|
||||
'present' => false,
|
||||
'malformed' => $malformed,
|
||||
'manifest' => null,
|
||||
'split' => false,
|
||||
'includes' => [],
|
||||
'version' => null,
|
||||
'description' => null,
|
||||
'styleSystem' => null,
|
||||
'darkMode' => null,
|
||||
'blocks' => [],
|
||||
'paletteGroups' => [],
|
||||
'sampleDataIds' => [],
|
||||
'sampleEndpointPatterns' => [],
|
||||
'stateScopes' => [],
|
||||
'uncovered' => $this->uncoveredDataSources($record, $layoutRelFiles, []),
|
||||
'declaredPaths' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 의 `$include` 맵을 원본 그대로 읽습니다.
|
||||
*
|
||||
* 합본 결과에는 `$include` 가 남지 않으므로(assemble 이 벗겨낸다) 분할 여부와 블록
|
||||
* 파일 경로는 manifest 를 다시 읽어야 알 수 있습니다.
|
||||
*
|
||||
* @param string $manifestPath manifest 절대 경로
|
||||
* @return array<string, string> 블록 키 → 상대 경로
|
||||
*/
|
||||
private function includeMap(string $manifestPath): array
|
||||
{
|
||||
$decoded = json_decode((string) file_get_contents($manifestPath), true);
|
||||
|
||||
if (! is_array($decoded) || ! is_array($decoded['$include'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($decoded['$include'] as $key => $relative) {
|
||||
if (is_string($key) && is_string($relative) && $relative !== '') {
|
||||
$map[$key] = $relative;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록별 요약(항목 수·출처)을 만듭니다.
|
||||
*
|
||||
* 항목이 여러 하위 자리에 나뉜 블록(`nesting`, `sampleData`)은 자리마다 한 행을
|
||||
* 냅니다. 합산하면 "끌 수 있는 컴포넌트 84 + 담을 수 있는 컨테이너 19 = 103" 처럼
|
||||
* 뜻이 없는 수가 되고, 그 수는 표에서 사실처럼 읽힙니다.
|
||||
*
|
||||
* @param array<string, mixed> $spec 합본 spec
|
||||
* @param array<string, string> $includes 블록 키 → 분할 파일 상대 경로
|
||||
* @return array<int, array{key: string, count: int|null, source: string}> 블록 요약
|
||||
*/
|
||||
private function blockSummaries(array $spec, array $includes): array
|
||||
{
|
||||
$summaries = [];
|
||||
|
||||
foreach (self::ITEM_PATHS as $key => $paths) {
|
||||
if (! array_key_exists($key, $spec)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = $includes[$key] ?? 'editor-spec.json (인라인)';
|
||||
$block = $spec[$key];
|
||||
|
||||
if ($paths === []) {
|
||||
$summaries[] = [
|
||||
'key' => $key,
|
||||
'count' => $this->countItems($block),
|
||||
'source' => $source,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 선언된 하위 자리 중 **실제로 있는 것만** 행으로 낸다. 없는 자리를 0 으로
|
||||
// 내보내면 "선언했는데 비었다" 와 "그 형태를 쓰지 않는다" 가 같은 모양이 된다.
|
||||
foreach ($paths as $path) {
|
||||
if (! is_array($block) || ! array_key_exists($path, $block)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summaries[] = [
|
||||
'key' => $key.'.'.$path,
|
||||
'count' => $this->countItems($block[$path]),
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값의 항목 수를 셉니다. 맵이면 메타 키를 뺀 나머지, 리스트면 길이입니다.
|
||||
*
|
||||
* @param mixed $value 블록 또는 하위 자리의 값
|
||||
* @return int|null 항목 수 (개수 개념이 없으면 null)
|
||||
*/
|
||||
private function countItems(mixed $value): ?int
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
return count($value);
|
||||
}
|
||||
|
||||
return count($this->withoutMeta($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 맵에서 메타 키를 제거합니다.
|
||||
*
|
||||
* @param array<string, mixed> $map 대상 맵
|
||||
* @return array<string, mixed> 메타 키를 뺀 맵
|
||||
*/
|
||||
private function withoutMeta(array $map): array
|
||||
{
|
||||
return array_filter(
|
||||
$map,
|
||||
static fn ($k): bool => ! in_array((string) $k, self::META_KEYS, true),
|
||||
ARRAY_FILTER_USE_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 팔레트 그룹별 컴포넌트 수를 셉니다.
|
||||
*
|
||||
* 팔레트는 `{ groups: [{ label, kind, components[] }], entries: { … } }` 형태입니다.
|
||||
* `groups` 가 편집기 좌측 목록의 묶음이고, 각 묶음이 담는 컴포넌트 이름이 `components`
|
||||
* 입니다.
|
||||
*
|
||||
* @param mixed $palette componentPalette 블록
|
||||
* @return array<int, array{group: string, kind: string, count: int}> 그룹 요약
|
||||
*/
|
||||
private function paletteGroups(mixed $palette): array
|
||||
{
|
||||
if (! is_array($palette) || ! is_array($palette['groups'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($palette['groups'] as $group) {
|
||||
if (! is_array($group)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'group' => $this->resolveLabel($this->stringOrNull($group['label'] ?? null) ?? '(이름 없음)'),
|
||||
'kind' => $this->stringOrNull($group['kind'] ?? null) ?? '-',
|
||||
'count' => is_array($group['components'] ?? null) ? count($group['components']) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록의 하위 자리에서 항목 ID 목록을 뽑습니다.
|
||||
*
|
||||
* 맵이면 키가 ID 이고, 리스트면 각 항목의 `id`(없으면 `scope.match`)가 ID 입니다.
|
||||
* 페이지 상태는 리스트 + `scope` 형태라 키가 없습니다.
|
||||
*
|
||||
* @param mixed $block 블록 값
|
||||
* @param string $path 항목이 담긴 하위 키
|
||||
* @return array<int, string> ID 목록
|
||||
*/
|
||||
private function idsAt(mixed $block, string $path): array
|
||||
{
|
||||
if (! is_array($block) || ! is_array($block[$path] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = $block[$path];
|
||||
|
||||
if (! array_is_list($items)) {
|
||||
return array_values(array_map(
|
||||
static fn ($k): string => (string) $k,
|
||||
array_keys($this->withoutMeta($items)),
|
||||
));
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $this->stringOrNull($item['id'] ?? null)
|
||||
?? $this->stringOrNull($item['scope']['match'] ?? null)
|
||||
?? $this->stringOrNull($item['label'] ?? null);
|
||||
|
||||
if ($id !== null) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 확장 레이아웃의 `data_source` 중 **프리뷰 샘플이 붙지 않는 것**을 찾습니다.
|
||||
*
|
||||
* 편집기 캔버스는 실제 API 를 부르지 않고 `sampleData` 로 화면을 그립니다. 그래서
|
||||
* 레이아웃에 `data_source` 를 추가하고 샘플을 붙이지 않으면 그 영역만 편집기에서
|
||||
* 빈 화면이 되는데, 실제 화면은 정상 동작하므로 어긋남이 드러나지 않습니다. 오류도
|
||||
* 경고도 남지 않아 문서의 이 목록이 유일한 통로입니다.
|
||||
*
|
||||
* 커버 판정에는 확장 자신의 스펙뿐 아니라 **번들 템플릿의 스펙**도 넣습니다 —
|
||||
* `settings` 처럼 여러 확장이 함께 쓰는 공용 ID 는 템플릿 스펙이 대신 채우도록 설계된
|
||||
* 것이라, 그것까지 미커버로 세면 목록이 잡음으로 가득 차 정작 볼 것이 묻힙니다.
|
||||
*
|
||||
* 번들 템플릿은 출하 기본값입니다. 운영자가 다른 템플릿을 쓰면 커버 집합이 달라질 수
|
||||
* 있으므로, 이 목록은 "반드시 빈다" 가 아니라 "기본 구성에서 빈다" 로 읽습니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param array<int, string> $layoutRelFiles 레이아웃 파일(확장 루트 기준 상대 경로)
|
||||
* @param array<string, mixed> $spec 이 확장의 합본 spec (없으면 빈 배열)
|
||||
* @return array<int, string> 샘플이 없는 data_source ID 목록
|
||||
*/
|
||||
private function uncoveredDataSources(array $record, array $layoutRelFiles, array $spec): array
|
||||
{
|
||||
if ($layoutRelFiles === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$covered = array_flip(array_merge($this->sampleIdsOf($spec), $this->fallbackSampleIds()));
|
||||
$uncovered = [];
|
||||
|
||||
foreach ($layoutRelFiles as $rel) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
||||
$layout = $this->decodeJson($abs);
|
||||
|
||||
if ($layout === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->layoutDataSourceIds($layout) as $id) {
|
||||
if (! isset($covered[$id])) {
|
||||
$uncovered[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ids = array_keys($uncovered);
|
||||
sort($ids);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* spec 의 `sampleData.byDataSourceId` 키 목록을 뽑습니다.
|
||||
*
|
||||
* @param array<string, mixed> $spec 합본 spec
|
||||
* @return array<int, string> ID 목록
|
||||
*/
|
||||
private function sampleIdsOf(array $spec): array
|
||||
{
|
||||
return $this->idsAt($spec['sampleData'] ?? null, 'byDataSourceId');
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 템플릿 스펙이 채우는 샘플 ID 집합을 돌려줍니다.
|
||||
*
|
||||
* 결과를 프로세스 단위로 기억합니다. 확장 20개를 도는 동안 매번 다시 합본하면 템플릿
|
||||
* 스펙(팔레트·컨트롤·역량을 담아 수만 줄에 이른다)을 스무 번 메모리에 올리게 되고,
|
||||
* 메모리 한도가 낮은 실행 환경(테스트 프로세스)에서는 그대로 OOM 이 됩니다. 남기는
|
||||
* 것은 스펙 전체가 아니라 **ID 문자열 목록**이라 유지 비용도 작습니다.
|
||||
*
|
||||
* @return array<int, string> 번들 템플릿이 커버하는 data_source ID 목록
|
||||
*/
|
||||
private function fallbackSampleIds(): array
|
||||
{
|
||||
if (self::$fallbackSampleIds !== null) {
|
||||
return self::$fallbackSampleIds;
|
||||
}
|
||||
|
||||
$glob = glob(base_path('templates'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'*'.DIRECTORY_SEPARATOR.'editor-spec.json'));
|
||||
$ids = [];
|
||||
|
||||
foreach ($glob === false ? [] : $glob as $manifest) {
|
||||
$assembled = EditorSpecAssembler::assemble($manifest);
|
||||
|
||||
if (is_array($assembled)) {
|
||||
$ids = array_merge($ids, $this->sampleIdsOf($assembled));
|
||||
}
|
||||
|
||||
// 합본 결과를 즉시 놓아준다 — 다음 manifest 를 읽기 전에 회수되게 한다.
|
||||
unset($assembled);
|
||||
}
|
||||
|
||||
return self::$fallbackSampleIds = array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 JSON 트리에서 `data_sources[].id` 를 전부 긁습니다.
|
||||
*
|
||||
* `data_sources` 는 최상위뿐 아니라 컴포넌트 노드에도 붙을 수 있어 트리 전체를 봅니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 레이아웃 노드
|
||||
* @return array<int, string> data_source ID 목록
|
||||
*/
|
||||
private function layoutDataSourceIds(array $node): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
if (is_array($node['data_sources'] ?? null)) {
|
||||
foreach ($node['data_sources'] as $ds) {
|
||||
$id = is_array($ds) ? $this->stringOrNull($ds['id'] ?? null) : null;
|
||||
|
||||
if ($id !== null) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($node as $value) {
|
||||
if (is_array($value)) {
|
||||
$ids = array_merge($ids, $this->layoutDataSourceIds($value));
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* `$t:` 접두 다국어 키를 코어 한국어 문자열로 풉니다.
|
||||
*
|
||||
* 편집기 스펙의 라벨은 화면에 그대로 나오지 않고 프론트 i18n 을 거칩니다. 문서에 키
|
||||
* 원문(`$t:layout_editor.palette.group.design`)을 그대로 실으면 읽는 쪽이 그 묶음이
|
||||
* 무엇인지 알 수 없습니다.
|
||||
*
|
||||
* Laravel 의 `__()` 로는 풀리지 않습니다 — 이 키들은 PHP lang 파일이 아니라 프론트
|
||||
* 다국어 JSON(`lang/ko.json` + `$partial` 분할)에 있고, JSON 번역기는 문자열 전체를
|
||||
* 키로 쓰기 때문입니다. 그래서 `$partial` 한 홉을 직접 따라갑니다.
|
||||
*
|
||||
* 풀리지 않으면 **원문을 그대로 둡니다.** 없는 번역을 지어내는 것보다 키가 드러나는
|
||||
* 편이 어디를 고쳐야 하는지 알려 주고, 이 해석이 어긋나도 문서가 틀린 사실을 주장하는
|
||||
* 대신 키만 남습니다.
|
||||
*
|
||||
* @param string $label 라벨 원문
|
||||
* @return string 해석된 라벨 (실패 시 원문)
|
||||
*/
|
||||
private function resolveLabel(string $label): string
|
||||
{
|
||||
if (! str_starts_with($label, '$t:')) {
|
||||
return $label;
|
||||
}
|
||||
|
||||
$root = base_path('lang');
|
||||
$node = $this->langRoot($root);
|
||||
|
||||
if ($node === null) {
|
||||
return $label;
|
||||
}
|
||||
|
||||
foreach (explode('.', substr($label, 3)) as $segment) {
|
||||
if (is_array($node) && isset($node['$partial']) && is_string($node['$partial'])) {
|
||||
$node = $this->decodeJson($root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $node['$partial']));
|
||||
}
|
||||
|
||||
if (! is_array($node) || ! array_key_exists($segment, $node)) {
|
||||
return $label;
|
||||
}
|
||||
|
||||
$node = $node[$segment];
|
||||
}
|
||||
|
||||
return is_string($node) && $node !== '' ? $node : $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 프론트 다국어 루트(`lang/ko.json`)를 읽습니다.
|
||||
*
|
||||
* @param string $root `lang` 디렉토리 절대 경로
|
||||
* @return array<string, mixed>|null 디코드 결과
|
||||
*/
|
||||
private function langRoot(string $root): ?array
|
||||
{
|
||||
return $this->decodeJson($root.DIRECTORY_SEPARATOR.'ko.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 파일을 배열로 읽습니다.
|
||||
*
|
||||
* @param string $path 절대 경로
|
||||
* @return array<string, mixed>|null 디코드 결과 (실패 시 null)
|
||||
*/
|
||||
private function decodeJson(string $path): ?array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록이 그 하위 자리를 **선언했는지** 봅니다.
|
||||
*
|
||||
* 선언하고 비운 것(`0`)과 아예 선언하지 않은 것은 다릅니다 — 전자는 채울 자리가
|
||||
* 있다는 뜻이고 후자는 그 형태를 쓰지 않는다는 뜻입니다. 뭉뚱그려 `0` 으로 적으면
|
||||
* 읽는 쪽이 "채워야 할 자리를 비워 뒀다" 로 오해합니다.
|
||||
*
|
||||
* @param mixed $block 블록 값
|
||||
* @param string $path 하위 키
|
||||
* @return bool 선언 여부
|
||||
*/
|
||||
private function hasPath(mixed $block, string $path): bool
|
||||
{
|
||||
return is_array($block) && array_key_exists($path, $block);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비어 있지 않은 문자열만 통과시킵니다.
|
||||
*
|
||||
* @param mixed $value 후보 값
|
||||
* @return string|null 문자열 또는 null
|
||||
*/
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
/**
|
||||
* 확장 문서 수집 컨텍스트 조립기
|
||||
*
|
||||
* 문서 블록을 렌더하려면 수집기 여덟 축(선언형 표면·훅·데이터 모델·프론트·테스트·의존
|
||||
* 관계·편집기 스펙)의 산출물을 한 배열로 묶어야 합니다. 그 조립을 커맨드와 계약 테스트가
|
||||
* 각자 하고 있으면, 축이 하나 늘 때 한쪽만 갱신되어 **드리프트가 아닌 드리프트**가 보고
|
||||
* 됩니다 — 테스트가 만든 블록에는 그 축이 없어 파일과 달라지기 때문입니다.
|
||||
*
|
||||
* 그 어긋남은 "생성기를 다시 돌려라" 라는 메시지로 나타나는데, 아무리 돌려도 사라지지
|
||||
* 않습니다. 조립을 이 한 곳이 소유해 그 경로 자체를 없앱니다.
|
||||
*/
|
||||
class ExtensionDocContext
|
||||
{
|
||||
/**
|
||||
* 수집기를 주입받습니다.
|
||||
*
|
||||
* @param DeclarativeSurfaceCollector $surface 선언형 표면 수집기
|
||||
* @param HookInventory $hooks 훅 인벤토리
|
||||
* @param DataModelCollector $data 데이터 모델 수집기
|
||||
* @param FrontendInventory $frontend 프론트 인벤토리
|
||||
* @param TestPathCollector $tests 테스트 경로 수집기
|
||||
* @param DependencyGraphCollector $deps 의존 관계 수집기
|
||||
* @param EditorSpecCollector $editorSpec 편집기 스펙 수집기
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly DeclarativeSurfaceCollector $surface,
|
||||
private readonly HookInventory $hooks,
|
||||
private readonly DataModelCollector $data,
|
||||
private readonly FrontendInventory $frontend,
|
||||
private readonly TestPathCollector $tests,
|
||||
private readonly DependencyGraphCollector $deps,
|
||||
private readonly EditorSpecCollector $editorSpec,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 확장 하나의 수집 컨텍스트를 조립합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array<string, mixed> 수집 컨텍스트
|
||||
*/
|
||||
public function build(array $record): array
|
||||
{
|
||||
// 선언형 표면을 먼저 모은다 — 발행 훅의 1차 출처가 그 안의 `getHooks()` 선언이다.
|
||||
$surface = $this->surface->collect($record);
|
||||
$declaredHooks = $surface['values']['getHooks'] ?? [];
|
||||
|
||||
// 편집기 스펙 수집기는 레이아웃 목록을 받아 "프리뷰 샘플이 없는 data_source" 를
|
||||
// 판정한다. 레이아웃 경로 규약(모듈·플러그인 `resources/layouts/` ↔ 템플릿
|
||||
// `layouts/`)은 FrontendInventory 가 단독으로 소유하므로, 그 결과를 넘겨 분기가
|
||||
// 두 곳에 생기지 않게 한다.
|
||||
$frontend = $this->frontend->collect($record);
|
||||
$layoutRelFiles = array_map(
|
||||
static fn (array $layout): string => $layout['relFile'],
|
||||
$frontend['layouts'],
|
||||
);
|
||||
|
||||
return [
|
||||
'record' => $record,
|
||||
'surface' => $surface,
|
||||
'hooks' => $this->hooks->collect($record, is_array($declaredHooks) ? $declaredHooks : []),
|
||||
'data' => $this->data->collect($record),
|
||||
'frontend' => $frontend,
|
||||
'tests' => $this->tests->collect($record),
|
||||
'deps' => $this->deps->collect($record),
|
||||
'editorSpec' => $this->editorSpec->collect($record, $layoutRelFiles),
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use App\Extension\ExtensionManager;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* 번들 확장 인벤토리
|
||||
*
|
||||
* `{modules,plugins,templates}/_bundled/*` 를 패턴 스캔해 확장 목록과 manifest 를
|
||||
* 로드합니다. 확장명을 하드코딩하지 않으므로 신규 확장이 추가되면 자동으로 편입됩니다
|
||||
* (동적 로딩 원칙).
|
||||
*
|
||||
* 문서 생성 대상은 `_bundled` 뿐입니다. 활성 디렉토리는 update 커맨드의 산출물이며
|
||||
* 비번들 제3자 확장이 섞여 있어 집필 대상이 아닙니다.
|
||||
*/
|
||||
class ExtensionInventory
|
||||
{
|
||||
/**
|
||||
* @var string 모듈 유형
|
||||
*/
|
||||
public const TYPE_MODULE = 'module';
|
||||
|
||||
/**
|
||||
* @var string 플러그인 유형
|
||||
*/
|
||||
public const TYPE_PLUGIN = 'plugin';
|
||||
|
||||
/**
|
||||
* @var string 템플릿 유형
|
||||
*/
|
||||
public const TYPE_TEMPLATE = 'template';
|
||||
|
||||
/**
|
||||
* 유형 → 저장소 최상위 디렉토리 / manifest 파일명 / 진입 클래스 파일명 매핑
|
||||
*
|
||||
* @var array<string, array{dir: string, manifest: string, entryFile: string|null, entryClass: string|null, rootNamespace: string|null}>
|
||||
*/
|
||||
private const TYPE_MAP = [
|
||||
self::TYPE_MODULE => [
|
||||
'dir' => 'modules',
|
||||
'manifest' => 'module.json',
|
||||
'entryFile' => 'module.php',
|
||||
'entryClass' => 'Module',
|
||||
'rootNamespace' => 'Modules',
|
||||
],
|
||||
self::TYPE_PLUGIN => [
|
||||
'dir' => 'plugins',
|
||||
'manifest' => 'plugin.json',
|
||||
'entryFile' => 'plugin.php',
|
||||
'entryClass' => 'Plugin',
|
||||
'rootNamespace' => 'Plugins',
|
||||
],
|
||||
self::TYPE_TEMPLATE => [
|
||||
'dir' => 'templates',
|
||||
'manifest' => 'template.json',
|
||||
'entryFile' => null,
|
||||
'entryClass' => null,
|
||||
'rootNamespace' => null,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 지원 유형 목록을 반환합니다.
|
||||
*
|
||||
* @return array<int, string> 유형 목록
|
||||
*/
|
||||
public static function types(): array
|
||||
{
|
||||
return array_keys(self::TYPE_MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형에 대응하는 저장소 디렉토리명을 반환합니다.
|
||||
*
|
||||
* @param string $type 확장 유형
|
||||
* @return string|null 디렉토리명 (미지원 유형이면 null)
|
||||
*/
|
||||
public static function directoryFor(string $type): ?string
|
||||
{
|
||||
return self::TYPE_MAP[$type]['dir'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* scope 문자열을 파싱합니다.
|
||||
*
|
||||
* @param string $scope `all` | `module:{id}` | `plugin:{id}` | `template:{id}`
|
||||
* @return array{type: string|null, id: string|null} 파싱 결과 (all 이면 둘 다 null)
|
||||
*/
|
||||
public static function parseScope(string $scope): array
|
||||
{
|
||||
$scope = trim($scope);
|
||||
|
||||
if ($scope === '' || $scope === 'all') {
|
||||
return ['type' => null, 'id' => null];
|
||||
}
|
||||
|
||||
// 해석 실패를 "전체" 로 되돌리지 않는다. `--scope=modules:board`(복수형 오타)나
|
||||
// `--scope=modul:board` 가 조용히 번들 20개 전 문서를 기록하게 되기 때문이다.
|
||||
// 반면 `--scope=module:없는id` 는 이미 명확한 실패로 처리되므로, 두 오타가 정반대로
|
||||
// 다뤄지는 비대칭을 없앤다.
|
||||
if (! str_contains($scope, ':')) {
|
||||
throw new InvalidArgumentException(
|
||||
"scope 형식이 올바르지 않습니다: '{$scope}'. all | module:{id} | plugin:{id} | template:{id} 중 하나여야 합니다."
|
||||
);
|
||||
}
|
||||
|
||||
[$type, $id] = explode(':', $scope, 2);
|
||||
$type = trim($type);
|
||||
$id = trim($id);
|
||||
|
||||
if (! isset(self::TYPE_MAP[$type])) {
|
||||
throw new InvalidArgumentException(
|
||||
"알 수 없는 확장 유형입니다: '{$type}'. ".implode(' | ', array_keys(self::TYPE_MAP)).' 중 하나여야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($id === '') {
|
||||
throw new InvalidArgumentException("scope 에 확장 식별자가 없습니다: '{$scope}'.");
|
||||
}
|
||||
|
||||
return ['type' => $type, 'id' => $id];
|
||||
}
|
||||
|
||||
/**
|
||||
* scope 에 해당하는 번들 확장 목록을 수집합니다.
|
||||
*
|
||||
* @param string $scope 범위 (`all` | `{type}:{id}`)
|
||||
* @return array<int, array<string, mixed>> 확장 레코드 목록 (유형 → 식별자 정렬)
|
||||
*/
|
||||
public function collect(string $scope = 'all'): array
|
||||
{
|
||||
$parsed = self::parseScope($scope);
|
||||
$records = [];
|
||||
$this->malformed = [];
|
||||
|
||||
foreach (self::TYPE_MAP as $type => $meta) {
|
||||
if ($parsed['type'] !== null && $parsed['type'] !== $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bundledRoot = base_path($meta['dir'].'/_bundled');
|
||||
if (! is_dir($bundledRoot)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (File::directories($bundledRoot) as $dirPath) {
|
||||
$id = basename($dirPath);
|
||||
|
||||
if ($parsed['id'] !== null && $parsed['id'] !== $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$record = $this->buildRecord($type, $id, $dirPath);
|
||||
if ($record !== null) {
|
||||
$records[] = $record;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usort($records, function (array $a, array $b): int {
|
||||
return [$a['type'], $a['id']] <=> [$b['type'], $b['id']];
|
||||
});
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 확장 레코드를 조회합니다.
|
||||
*
|
||||
* @param string $type 확장 유형
|
||||
* @param string $id 확장 식별자
|
||||
* @return array<string, mixed>|null 확장 레코드 (없으면 null)
|
||||
*/
|
||||
public function find(string $type, string $id): ?array
|
||||
{
|
||||
$records = $this->collect("{$type}:{$id}");
|
||||
|
||||
return $records[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못한 디렉토리
|
||||
*/
|
||||
private array $malformed = [];
|
||||
|
||||
/**
|
||||
* 직전 `collect()` 에서 manifest 파싱에 실패한 디렉토리 목록을 반환합니다.
|
||||
*
|
||||
* 호출자가 이 목록을 보고해야 "확장이 없다" 와 "읽지 못했다" 가 구분됩니다.
|
||||
*
|
||||
* @return array<int, array{type: string, id: string, manifest: string, reason: string}> 실패 목록
|
||||
*/
|
||||
public function malformed(): array
|
||||
{
|
||||
return $this->malformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 레코드를 조립합니다.
|
||||
*
|
||||
* manifest 가 없거나 JSON 파싱에 실패한 디렉토리는 확장이 아니므로 제외합니다
|
||||
* (`_backup_*` · 업데이트 중 임시 디렉토리 등).
|
||||
*
|
||||
* @param string $type 확장 유형
|
||||
* @param string $id 확장 식별자
|
||||
* @param string $dirPath 확장 절대 경로
|
||||
* @return array<string, mixed>|null 확장 레코드 (manifest 부재 시 null)
|
||||
*/
|
||||
private function buildRecord(string $type, string $id, string $dirPath): ?array
|
||||
{
|
||||
$meta = self::TYPE_MAP[$type];
|
||||
$manifestPath = $dirPath.DIRECTORY_SEPARATOR.$meta['manifest'];
|
||||
|
||||
if (! is_file($manifestPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$manifest = json_decode((string) file_get_contents($manifestPath), true);
|
||||
if (! is_array($manifest)) {
|
||||
// manifest 가 있는데 못 읽은 것은 "확장이 아님"(`_backup_*` 등) 과 다르다.
|
||||
// 조용히 탈락시키면 그 확장이 문서 체계에서 통째로 사라지고, 검사 대상 수
|
||||
// 자체가 줄어 "20개 중 5개 보유" 분모까지 함께 줄어 회귀로 보이지 않는다.
|
||||
$this->malformed[] = [
|
||||
'type' => $type,
|
||||
'id' => $id,
|
||||
'manifest' => $meta['manifest'],
|
||||
'reason' => json_last_error_msg(),
|
||||
];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$namespace = $meta['rootNamespace'] !== null
|
||||
? $meta['rootNamespace'].'\\'.ExtensionManager::directoryToNamespace($id)
|
||||
: null;
|
||||
|
||||
$entryFile = $meta['entryFile'] !== null
|
||||
? $dirPath.DIRECTORY_SEPARATOR.$meta['entryFile']
|
||||
: null;
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'id' => $id,
|
||||
'label' => self::typeLabel($type),
|
||||
'path' => $dirPath,
|
||||
'relPath' => $meta['dir'].'/_bundled/'.$id,
|
||||
'manifest' => $manifest,
|
||||
'manifestFile' => $meta['manifest'],
|
||||
'manifestPath' => $manifestPath,
|
||||
'namespace' => $namespace,
|
||||
'entryFile' => ($entryFile !== null && is_file($entryFile)) ? $entryFile : null,
|
||||
'entryClass' => $namespace !== null ? $namespace.'\\'.$meta['entryClass'] : null,
|
||||
'entryClassShort' => $meta['entryClass'],
|
||||
'docsPath' => $dirPath.DIRECTORY_SEPARATOR.'docs',
|
||||
'version' => (string) ($manifest['version'] ?? ''),
|
||||
'name' => self::localizedName($manifest, $id),
|
||||
'description' => self::localized($manifest['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형의 한국어 라벨을 반환합니다.
|
||||
*
|
||||
* @param string $type 확장 유형
|
||||
* @return string 한국어 라벨
|
||||
*/
|
||||
public static function typeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
self::TYPE_MODULE => '모듈',
|
||||
self::TYPE_PLUGIN => '플러그인',
|
||||
self::TYPE_TEMPLATE => '템플릿',
|
||||
default => $type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 의 name 을 한국어 우선으로 해석합니다.
|
||||
*
|
||||
* @param array<string, mixed> $manifest manifest 배열
|
||||
* @param string $fallback 이름이 없을 때 사용할 값
|
||||
* @return string 확장명
|
||||
*/
|
||||
public static function localizedName(array $manifest, string $fallback): string
|
||||
{
|
||||
$name = self::localized($manifest['name'] ?? '');
|
||||
|
||||
return $name !== '' ? $name : $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다국어 값(문자열 또는 로케일 배열)을 한국어 우선으로 해석합니다.
|
||||
*
|
||||
* @param mixed $value manifest 값
|
||||
* @return string 해석된 문자열 (해석 불가 시 빈 문자열)
|
||||
*/
|
||||
public static function localized(mixed $value): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
if (isset($value[$locale]) && is_string($value[$locale])) {
|
||||
return $value[$locale];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($value as $item) {
|
||||
if (is_string($item)) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 확장 프론트엔드 진입점 수집기
|
||||
*
|
||||
* 레이아웃 JSON · 액션 핸들러 · 전역 재등록 진입점 · 컴포넌트 · 빌드 산출물을 수집합니다.
|
||||
*
|
||||
* 레이아웃 경로는 유형마다 다릅니다 — 모듈/플러그인은 `resources/layouts/`, 템플릿은
|
||||
* `layouts/` 가 루트입니다. 유형별 분기를 이 수집기 한 곳에 두어, 소비자(스캐폴더·검사
|
||||
* 스크립트)가 경로 규약을 각자 알 필요가 없게 합니다.
|
||||
*/
|
||||
class FrontendInventory
|
||||
{
|
||||
/**
|
||||
* 확장의 프론트엔드 표면을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array<string, mixed> 프론트 인벤토리
|
||||
*/
|
||||
public function collect(array $record): array
|
||||
{
|
||||
$layouts = $this->collectLayouts($record);
|
||||
|
||||
return [
|
||||
'layoutRoot' => $this->layoutRoot($record),
|
||||
'layouts' => $layouts,
|
||||
'layoutGroups' => $this->groupLayouts($layouts),
|
||||
'layoutExtensions' => $this->collectJsonFiles($record, $this->layoutExtensionRoot($record)),
|
||||
'handlers' => $this->collectHandlers($record),
|
||||
'entryPoints' => $this->collectEntryPoints($record),
|
||||
'components' => $this->collectComponents($record),
|
||||
'builtAssets' => $this->collectBuiltAssets($record),
|
||||
'vendoredAssets' => $this->collectVendoredAssets($record),
|
||||
'customDir' => is_dir($record['path'].DIRECTORY_SEPARATOR.'custom'),
|
||||
'editorSpec' => $this->collectEditorSpec($record),
|
||||
'routesJson' => is_file($record['path'].DIRECTORY_SEPARATOR.'routes.json') ? 'routes.json' : null,
|
||||
'routeCount' => $this->countDeclaredRoutes($record),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `routes.json` 에 선언된 주소 수를 셉니다 (셀 수 없으면 null).
|
||||
*
|
||||
* 템플릿은 선언형 표면(`getRoutes()`)을 갖지 않으므로 집계 배지의 "라우트 수" 가
|
||||
* 구조적으로 항상 0 이 됩니다 — 주소를 `routes.json` 에 적기 때문입니다. 0 은 사실이
|
||||
* 아닌데 템플릿에는 "확인하지 못함" 안내도 붙지 않아(선언형 표면 부재가 정상이라)
|
||||
* 단서 없이 사실처럼 읽힙니다. 실측은 `sirsoft-basic` 40 · `sirsoft-admin_basic` 29.
|
||||
*
|
||||
* "라우트 수 = 주소 개수" 규율은 모듈·플러그인과 같습니다.
|
||||
*
|
||||
* 읽지 못한 것과 없는 것은 구분합니다 — 파일이 없으면 `null`, 파일이 깨졌어도 `null`
|
||||
* 입니다. 0 을 돌려주면 "주소가 없다" 는 사실 주장이 됩니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return int|null 주소 수, 셀 수 없으면 null
|
||||
*/
|
||||
private function countDeclaredRoutes(array $record): ?int
|
||||
{
|
||||
$path = $record['path'].DIRECTORY_SEPARATOR.'routes.json';
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode((string) @file_get_contents($path), true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['routes']) || ! is_array($data['routes'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return count(array_filter(
|
||||
$data['routes'],
|
||||
static fn (mixed $row): bool => is_array($row) && isset($row['path']),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형별 레이아웃 루트(확장 루트 기준 상대 경로)를 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return string 레이아웃 루트
|
||||
*/
|
||||
private function layoutRoot(array $record): string
|
||||
{
|
||||
return $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'layouts' : 'resources/layouts';
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형별 레이아웃 확장 조각 루트(확장 루트 기준 상대 경로)를 반환합니다.
|
||||
*
|
||||
* 레이아웃과 같은 규율이다 — 템플릿은 확장 루트 직속, 모듈·플러그인은 `resources/`
|
||||
* 아래. 형제인 `layoutRoot()` 만 유형을 갈랐던 탓에 템플릿의 조각이 항상 빈 목록으로
|
||||
* 수집돼(`sirsoft-basic` 실측 1건) 문서에 "없음" 으로 실렸다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return string 레이아웃 확장 루트
|
||||
*/
|
||||
private function layoutExtensionRoot(array $record): string
|
||||
{
|
||||
return $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'extensions' : 'resources/extensions';
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 JSON 을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> 레이아웃 목록
|
||||
*/
|
||||
private function collectLayouts(array $record): array
|
||||
{
|
||||
$layouts = [];
|
||||
|
||||
foreach ($this->collectJsonFiles($record, $this->layoutRoot($record)) as $rel) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
||||
$root = $this->layoutRoot($record).'/';
|
||||
$inner = str_starts_with($rel, $root) ? substr($rel, strlen($root)) : $rel;
|
||||
$segments = explode('/', $inner);
|
||||
|
||||
$layouts[] = [
|
||||
'relFile' => $rel,
|
||||
'name' => basename($inner, '.json'),
|
||||
'group' => count($segments) > 1 ? $segments[0] : '(root)',
|
||||
'partial' => str_starts_with(basename($inner), '_'),
|
||||
'extends' => $this->readJsonString($abs, 'extends'),
|
||||
];
|
||||
}
|
||||
|
||||
return $layouts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃을 그룹(admin/user 등)별로 집계합니다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $layouts 레이아웃 목록
|
||||
* @return array<string, int> 그룹 => 개수
|
||||
*/
|
||||
private function groupLayouts(array $layouts): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($layouts as $layout) {
|
||||
$groups[$layout['group']] = ($groups[$layout['group']] ?? 0) + 1;
|
||||
}
|
||||
|
||||
ksort($groups);
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 핸들러 이름을 수집합니다.
|
||||
*
|
||||
* `handlerMap` 객체의 최상위 키가 핸들러 이름이며, 엔트리포인트가
|
||||
* `{identifier}.{name}` 으로 네임스페이스를 붙여 등록합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array{namespace: string|null, names: array<int, string>, source: string|null}
|
||||
*/
|
||||
private function collectHandlers(array $record): array
|
||||
{
|
||||
$candidates = [
|
||||
'resources/js/handlers/index.ts',
|
||||
'src/handlers/index.ts',
|
||||
'resources/js/index.ts',
|
||||
'src/index.ts',
|
||||
];
|
||||
|
||||
foreach ($candidates as $rel) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
||||
if (! is_file($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string) file_get_contents($abs);
|
||||
$names = [];
|
||||
|
||||
// 핸들러 맵 이름은 확장마다 다르다 (`handlerMap` / `handlers` + alias 재수출).
|
||||
// 앞선 후보가 alias 대입(`handlerMap = handlers;`)이면 객체 리터럴이 아니므로 비고,
|
||||
// 다음 후보에서 실제 리터럴을 찾는다.
|
||||
foreach (['handlerMap', 'handlers'] as $objectName) {
|
||||
$names = $this->objectKeys($content, $objectName);
|
||||
if ($names !== []) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names === []) {
|
||||
$names = $this->literalHandlerNames($content);
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
return [
|
||||
'namespace' => $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? null : $record['id'],
|
||||
'names' => $names,
|
||||
'source' => $rel,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['namespace' => null, 'names' => [], 'source' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 재등록 진입점(`window.__[Name]`)과 초기화 함수를 수집합니다.
|
||||
*
|
||||
* 로케일 전환 후 액션이 무반응이 되는 결함을 막는 계약이므로, 노출 여부 자체가
|
||||
* 문서에 드러나야 합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array{global: string|null, initFunction: string|null, source: string|null}
|
||||
*/
|
||||
private function collectEntryPoints(array $record): array
|
||||
{
|
||||
$candidates = ['resources/js/index.ts', 'src/index.ts', 'src/index.tsx'];
|
||||
|
||||
foreach ($candidates as $rel) {
|
||||
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
||||
if (! is_file($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = (string) file_get_contents($abs);
|
||||
|
||||
$global = null;
|
||||
if (preg_match('/window[^;\n]*\)\.\s*(__\w+)\s*=/', $content, $m)) {
|
||||
$global = $m[1];
|
||||
}
|
||||
|
||||
$init = null;
|
||||
foreach (['initModule', 'initPlugin', 'initTemplate'] as $fn) {
|
||||
if (preg_match('/function\s+'.$fn.'\s*\(/', $content)) {
|
||||
$init = $fn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($global !== null || $init !== null) {
|
||||
return ['global' => $global, 'initFunction' => $init, 'source' => $rel];
|
||||
}
|
||||
}
|
||||
|
||||
return ['global' => null, 'initFunction' => null, 'source' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿이 제공하는 컴포넌트를 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array{total: int, byCategory: array<string, int>, root: string|null}
|
||||
*/
|
||||
private function collectComponents(array $record): array
|
||||
{
|
||||
$root = 'src/components';
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $root);
|
||||
|
||||
if (! is_dir($dir)) {
|
||||
return ['total' => 0, 'byCategory' => [], 'root' => null];
|
||||
}
|
||||
|
||||
$byCategory = [];
|
||||
$total = 0;
|
||||
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
if ($file->getExtension() !== 'tsx') {
|
||||
continue;
|
||||
}
|
||||
if (str_contains(str_replace('\\', '/', $file->getPathname()), '/__tests__/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', '/', $file->getRelativePath());
|
||||
$category = $relative === '' ? '(root)' : explode('/', $relative)[0];
|
||||
$byCategory[$category] = ($byCategory[$category] ?? 0) + 1;
|
||||
$total++;
|
||||
}
|
||||
|
||||
ksort($byCategory);
|
||||
|
||||
return ['total' => $total, 'byCategory' => $byCategory, 'root' => $root];
|
||||
}
|
||||
|
||||
/**
|
||||
* 커밋된 빌드 산출물을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, string> 산출물 상대 경로
|
||||
*/
|
||||
private function collectBuiltAssets(array $record): array
|
||||
{
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.'dist';
|
||||
if (! is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
if (! in_array($file->getExtension(), ['js', 'css'], true)) {
|
||||
continue;
|
||||
}
|
||||
$rel = $this->relative($record, $file->getPathname());
|
||||
if (str_starts_with($rel, 'dist/vendor/')) {
|
||||
continue;
|
||||
}
|
||||
$files[] = $rel;
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동봉(self-hosted) 제3자 자산을 수집합니다.
|
||||
*
|
||||
* 외부 CDN 대신 확장이 자기 서버에서 제공하는 구동 자산이며, 버전이 디렉토리명에
|
||||
* 드러나므로 문서에 그대로 노출합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, string> `{라이브러리}/{버전}` 목록
|
||||
*/
|
||||
private function collectVendoredAssets(array $record): array
|
||||
{
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.'dist'.DIRECTORY_SEPARATOR.'vendor';
|
||||
if (! is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
foreach (File::directories($dir) as $libPath) {
|
||||
$lib = basename($libPath);
|
||||
$versions = array_map('basename', File::directories($libPath));
|
||||
|
||||
if ($versions === []) {
|
||||
$entries[] = $lib;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($versions as $version) {
|
||||
$entries[] = $lib.'/'.$version;
|
||||
}
|
||||
}
|
||||
|
||||
sort($entries);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기 스펙(단일 파일 / 분할) 보유 형태를 판정합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array{manifest: bool, split: int}
|
||||
*/
|
||||
private function collectEditorSpec(array $record): array
|
||||
{
|
||||
$manifest = is_file($record['path'].DIRECTORY_SEPARATOR.'editor-spec.json');
|
||||
$splitDir = $record['path'].DIRECTORY_SEPARATOR.'editor-spec';
|
||||
$split = 0;
|
||||
|
||||
if (is_dir($splitDir)) {
|
||||
foreach (File::allFiles($splitDir) as $file) {
|
||||
if ($file->getExtension() === 'json') {
|
||||
$split++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['manifest' => $manifest, 'split' => $split];
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 디렉토리의 JSON 파일 상대 경로를 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param string $sub 확장 루트 기준 하위 경로
|
||||
* @return array<int, string> 상대 경로 목록 (정렬)
|
||||
*/
|
||||
private function collectJsonFiles(array $record, string $sub): array
|
||||
{
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
|
||||
if (! is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
if ($file->getExtension() === 'json') {
|
||||
$files[] = $this->relative($record, $file->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 파일에서 최상위 문자열 키 값을 읽습니다.
|
||||
*
|
||||
* @param string $absolute 파일 절대 경로
|
||||
* @param string $key 키
|
||||
* @return string|null 값 (없거나 문자열이 아니면 null)
|
||||
*/
|
||||
private function readJsonString(string $absolute, string $key): ?string
|
||||
{
|
||||
$data = json_decode((string) @file_get_contents($absolute), true);
|
||||
|
||||
return is_array($data) && isset($data[$key]) && is_string($data[$key]) ? $data[$key] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `export const {name} = { ... }` 객체의 최상위 키를 뽑습니다.
|
||||
*
|
||||
* @param string $content TS 소스
|
||||
* @param string $name 객체 변수명
|
||||
* @return array<int, string> 키 목록
|
||||
*/
|
||||
private function objectKeys(string $content, string $name): array
|
||||
{
|
||||
// 타입 주석이 붙은 선언(`handlerMap: Record<string, (...a) => unknown> = {`)까지 잡되,
|
||||
// alias 대입(`handlerMap = handlers;`)은 잡지 않는다. `[^;{]*` 가 문(statement) 경계를
|
||||
// 넘지 못하게 하고, `=\s*\{` 로 객체 리터럴 대입만 받는다.
|
||||
if (! preg_match('/\b'.preg_quote($name, '/').'\b[^;{]*=\s*\{/', $content, $m, PREG_OFFSET_CAPTURE)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$open = strpos($content, '{', (int) $m[0][1]);
|
||||
if ($open === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$depth = 0;
|
||||
$len = strlen($content);
|
||||
$close = null;
|
||||
|
||||
for ($i = $open; $i < $len; $i++) {
|
||||
$ch = $content[$i];
|
||||
if ($ch === '{') {
|
||||
$depth++;
|
||||
} elseif ($ch === '}') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
$close = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($close === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$body = substr($content, $open + 1, $close - $open - 1);
|
||||
$keys = [];
|
||||
|
||||
foreach (explode("\n", $body) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '//') || str_starts_with($line, '*') || str_starts_with($line, '/*')) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^([A-Za-z_$][\w$]*)\s*[,:]/', $line, $km)) {
|
||||
$keys[] = $km[1];
|
||||
|
||||
continue;
|
||||
}
|
||||
// 네임스페이스를 붙인 등록 키(`'vendor-ext.doThing': handler`)는 `.`·`-` 때문에
|
||||
// 반드시 따옴표로 감싸인다. 식별자 키만 보면 그 항목이 통째로 빠지는데, 결과가
|
||||
// "그만큼만 등록했다" 와 같은 모양이라 누락이 드러나지 않는다 (sirsoft-basic 이
|
||||
// 32개 중 10개를 그렇게 잃고 있었다).
|
||||
if (preg_match('/^["\']([^"\']+)["\']\s*:/', $line, $km)) {
|
||||
$keys[] = $km[1];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerHandler(...)` 인자에서 핸들러 이름을 뽑습니다.
|
||||
*
|
||||
* 두 형태를 모두 봅니다 — 리터럴(`'name'`)과 식별자 보간 템플릿 리터럴
|
||||
* (`` `${PLUGIN_IDENTIFIER}.name` ``). 후자를 놓치면 핸들러 맵을 쓰지 않고 개별
|
||||
* 등록하는 확장(gdpr 등)이 "핸들러 0개" 로 잘못 집계됩니다.
|
||||
*
|
||||
* @param string $content TS 소스
|
||||
* @return array<int, string> 핸들러 이름 목록
|
||||
*/
|
||||
private function literalHandlerNames(string $content): array
|
||||
{
|
||||
$names = [];
|
||||
|
||||
if (preg_match_all("/registerHandler\s*\(\s*'([^']+)'/", $content, $m)) {
|
||||
foreach ($m[1] as $name) {
|
||||
$names[] = $this->stripIdentifierPrefix($name);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/registerHandler\s*\(\s*`\$\{[^}]+\}\.([A-Za-z_$][\w$]*)`/', $content, $m)) {
|
||||
$names = array_merge($names, $m[1]);
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
/**
|
||||
* `{identifier}.{handler}` 형태의 이름에서 확장 식별자 접두를 떼어냅니다.
|
||||
*
|
||||
* 등록 코드는 네임스페이스를 붙인 전체 이름을 쓰지만, 표는 핸들러 이름과 호출 이름을
|
||||
* 각각 보여주므로 접두를 벗긴 이름이 필요합니다.
|
||||
*
|
||||
* @param string $name 등록된 이름
|
||||
* @return string 접두를 뗀 핸들러 이름
|
||||
*/
|
||||
private function stripIdentifierPrefix(string $name): string
|
||||
{
|
||||
$pos = strrpos($name, '.');
|
||||
|
||||
return $pos === false ? $name : substr($name, $pos + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 루트 기준 상대 경로로 변환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param string $absolute 절대 경로
|
||||
* @return string 상대 경로 (POSIX 구분자)
|
||||
*/
|
||||
private function relative(array $record, string $absolute): string
|
||||
{
|
||||
$base = rtrim((string) $record['path'], '/\\').DIRECTORY_SEPARATOR;
|
||||
$rel = str_starts_with($absolute, $base) ? substr($absolute, strlen($base)) : $absolute;
|
||||
|
||||
return str_replace('\\', '/', $rel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 확장 훅 인벤토리
|
||||
*
|
||||
* 확장이 **발행하는 훅**과 **구독하는 훅**(리스너의 `getSubscribedHooks()`)을 수집합니다.
|
||||
*
|
||||
* 발행 훅의 1차 출처는 확장이 `getHooks()` 로 **선언한 목록**입니다. 소스 스캔은 그 선언을
|
||||
* 보강할 뿐입니다 — 훅 이름을 `self::PLUGIN_ID.'.consent.granted'` 처럼 조립하는 확장이
|
||||
* 실재하고, 그런 호출은 리터럴 스캔이 원리상 읽을 수 없기 때문입니다. 스캔만 믿으면 훅을
|
||||
* 12곳에서 발행하는 확장이 "훅을 발행하지 않습니다" 로 문서화됩니다(실제 `sirsoft-gdpr`).
|
||||
* 선언은 유형과 ko/en 설명까지 갖고 있어 스캔이 만들 수 없는 정보를 준다는 이점도 있습니다.
|
||||
*
|
||||
* 구독 훅은 `_bundled` 소스 파일을 직접 파싱합니다. 리스너 클래스를 로드해 static 메서드를
|
||||
* 호출하면 활성 디렉토리에 이미 로드된 동일 FQCN 이 우선하므로, `_bundled` 를 고쳐도 그
|
||||
* 변경이 문서에 반영되지 않습니다. 문서 SSoT 는 `_bundled` 소스이므로 파싱으로 읽습니다.
|
||||
*/
|
||||
class HookInventory
|
||||
{
|
||||
/**
|
||||
* 훅 발행 호출 형태 → 훅 유형.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const EMIT_FORMS = [
|
||||
'doAction' => 'action',
|
||||
'applyFilters' => 'filter',
|
||||
'broadcast' => 'broadcast',
|
||||
];
|
||||
|
||||
/**
|
||||
* 훅 이름 리터럴을 갖는 발행 호출을 찾는 패턴.
|
||||
*
|
||||
* 첫 인자가 리터럴이 아닌(변수/보간) 호출은 이름을 정적으로 알 수 없으므로 별도 집계한다.
|
||||
*/
|
||||
private const EMIT_LITERAL = "/HookManager::(doAction|applyFilters|broadcast)\s*\(\s*'([^']+)'/";
|
||||
|
||||
/**
|
||||
* 발행 호출 전수(리터럴 여부 무관)를 세는 패턴.
|
||||
*/
|
||||
private const EMIT_ANY = '/HookManager::(doAction|applyFilters|broadcast)\s*\(/';
|
||||
|
||||
/**
|
||||
* 확장의 훅 표면을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @param array<int, mixed> $declared 확장이 `getHooks()` 로 선언한 발행 훅 목록
|
||||
* @return array{published: array<int, array<string, mixed>>, publishedSites: int, publishedDynamic: int, publishedUndeclared: int, subscribed: array<int, array<string, mixed>>, listeners: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function collect(array $record, array $declared = []): array
|
||||
{
|
||||
$published = $this->collectPublished($record, $declared);
|
||||
$listeners = $this->collectListeners($record);
|
||||
|
||||
$subscribed = [];
|
||||
foreach ($listeners as $listener) {
|
||||
foreach ($listener['hooks'] as $hook) {
|
||||
$subscribed[] = $hook + ['listener' => $listener['class'], 'listenerFile' => $listener['relFile']];
|
||||
}
|
||||
}
|
||||
|
||||
usort($subscribed, static fn (array $a, array $b): int => [$a['name'], $a['listener']] <=> [$b['name'], $b['listener']]);
|
||||
|
||||
return [
|
||||
'published' => $published['hooks'],
|
||||
'publishedSites' => $published['sites'],
|
||||
'publishedDynamic' => $published['dynamic'],
|
||||
'publishedUndeclared' => $published['undeclared'],
|
||||
'subscribed' => $subscribed,
|
||||
'listeners' => $listeners,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장이 발행하는 훅을 수집합니다.
|
||||
*
|
||||
* 선언(`getHooks()`)이 1차 출처이고 소스 스캔이 보강합니다. 선언에만 있는 훅은 호출
|
||||
* 지점 없이 표에 남고(이름·유형·설명은 선언이 준다), 스캔에만 있는 훅은 `declared`
|
||||
* 를 false 로 실어 "선언되지 않음" 을 문서가 드러낼 수 있게 합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param array<int, mixed> $declared `getHooks()` 선언 목록
|
||||
* @return array{hooks: array<int, array<string, mixed>>, sites: int, dynamic: int, undeclared: int}
|
||||
* sites 는 리터럴/동적을 합한 전체 호출 지점 수
|
||||
*/
|
||||
private function collectPublished(array $record, array $declared = []): array
|
||||
{
|
||||
$byName = $this->declaredHooks($declared);
|
||||
$sites = 0;
|
||||
$literalSites = 0;
|
||||
|
||||
foreach ($this->phpFiles($record) as $file) {
|
||||
$content = (string) file_get_contents($file);
|
||||
|
||||
if (! str_contains($content, 'HookManager::')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sites += preg_match_all(self::EMIT_ANY, $content);
|
||||
|
||||
if (! preg_match_all(self::EMIT_LITERAL, $content, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($matches as $m) {
|
||||
$literalSites++;
|
||||
$form = $m[1][0];
|
||||
$name = $m[2][0];
|
||||
$line = substr_count(substr($content, 0, (int) $m[0][1]), "\n") + 1;
|
||||
$rel = $this->relative($record, $file);
|
||||
|
||||
if (! isset($byName[$name])) {
|
||||
$byName[$name] = [
|
||||
'name' => $name,
|
||||
'type' => self::EMIT_FORMS[$form] ?? $form,
|
||||
'description' => null,
|
||||
'parameters' => [],
|
||||
'declared' => false,
|
||||
'sites' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$scannedType = self::EMIT_FORMS[$form] ?? $form;
|
||||
if (($byName[$name]['typeDeclared'] ?? true) === false
|
||||
&& $byName[$name]['type'] !== $scannedType) {
|
||||
// 선언이 유형을 말하지 않았고 소스가 다른 유형으로 발행한다 —
|
||||
// 기본값이 사실을 덮은 자리이므로 실측을 따르고 표에 사유를 남긴다.
|
||||
$byName[$name]['type'] = $scannedType;
|
||||
$byName[$name]['typeInferred'] = true;
|
||||
}
|
||||
|
||||
$byName[$name]['sites'][] = ['file' => $rel, 'line' => $line];
|
||||
}
|
||||
}
|
||||
|
||||
ksort($byName);
|
||||
|
||||
$undeclared = 0;
|
||||
foreach ($byName as $hook) {
|
||||
if ($hook['declared'] === false) {
|
||||
$undeclared++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'hooks' => array_values($byName),
|
||||
'sites' => $sites,
|
||||
'dynamic' => max(0, $sites - $literalSites),
|
||||
'undeclared' => $undeclared,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `getHooks()` 선언을 훅 이름 색인으로 정규화합니다.
|
||||
*
|
||||
* 선언 형식이 어긋난 항목(이름 없음 등)은 조용히 버리지 않고 건너뛰되, 이름만 있으면
|
||||
* 유형·설명이 없어도 표에 남깁니다 — 발행 사실 자체가 확장점 공개의 핵심입니다.
|
||||
*
|
||||
* @param array<int, mixed> $declared 선언 목록
|
||||
* @return array<string, array<string, mixed>> 훅 이름 → 항목
|
||||
*/
|
||||
private function declaredHooks(array $declared): array
|
||||
{
|
||||
$byName = [];
|
||||
|
||||
foreach ($declared as $hook) {
|
||||
if (! is_array($hook)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $hook['name'] ?? null;
|
||||
if (! is_string($name) || $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$description = $hook['description'] ?? null;
|
||||
if (is_array($description)) {
|
||||
// ko 우선 — 확장 문서는 한국어다. ko 가 없으면 첫 값을 쓴다.
|
||||
$description = $description['ko'] ?? (reset($description) ?: null);
|
||||
}
|
||||
|
||||
$byName[$name] = [
|
||||
'name' => $name,
|
||||
// 유형 미기재를 조용히 `action` 으로 굳히면, 실제로 filter 인 훅이
|
||||
// action 으로 문서화되어 구독하는 쪽이 반환값 계약을 잘못 읽는다.
|
||||
// 미기재 사실을 남겨 스캔 결과와 어긋날 때 드러나게 한다.
|
||||
'type' => is_string($hook['type'] ?? null) ? $hook['type'] : 'action',
|
||||
'typeDeclared' => is_string($hook['type'] ?? null),
|
||||
'description' => is_string($description) && $description !== '' ? $description : null,
|
||||
'parameters' => is_array($hook['parameters'] ?? null) ? $hook['parameters'] : [],
|
||||
'declared' => true,
|
||||
'sites' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $byName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장의 훅 리스너와 각 리스너가 구독하는 훅을 수집합니다.
|
||||
*
|
||||
* `src/Listeners/` 를 스캔해 리스너와 그 구독 훅을 모읍니다. 명시 등록(`getHookListeners()`)
|
||||
* 여부는 여기서 판정하지 않습니다 — 스캐폴더가 선언형 표면의 `getHookListeners` 값과
|
||||
* 대조해 표기합니다. 이 배열에는 `registered` 키가 없습니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, array<string, mixed>> 리스너 목록
|
||||
*/
|
||||
private function collectListeners(array $record): array
|
||||
{
|
||||
$listenerDir = $record['path'].DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'Listeners';
|
||||
$listeners = [];
|
||||
|
||||
if (! is_dir($listenerDir)) {
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
foreach (File::allFiles($listenerDir) as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
$content = (string) file_get_contents($path);
|
||||
$class = $this->fqcnOf($content);
|
||||
|
||||
$listeners[] = [
|
||||
'class' => $class ?? $file->getFilenameWithoutExtension(),
|
||||
'shortClass' => $file->getFilenameWithoutExtension(),
|
||||
'relFile' => $this->relative($record, $path),
|
||||
'implementsContract' => str_contains($content, 'HookListenerInterface'),
|
||||
'hooks' => $this->parseSubscribedHooks($content),
|
||||
];
|
||||
}
|
||||
|
||||
usort($listeners, static fn (array $a, array $b): int => $a['shortClass'] <=> $b['shortClass']);
|
||||
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
/**
|
||||
* `getSubscribedHooks()` 메서드 본문에서 구독 훅 선언을 파싱합니다.
|
||||
*
|
||||
* 반환 배열의 최상위 항목만 봅니다 — `'훅이름' => [ ... ]` 형태의 키가 훅 이름이고,
|
||||
* 값 슬라이스에서 method/priority/type 을 읽습니다. `type` 미선언은 action 으로 간주하되
|
||||
* 그 사실을 `typeDeclared` 로 남깁니다 (filter 훅의 type 마커 누락은 별도 룰이 검사).
|
||||
*
|
||||
* @param string $content 리스너 소스
|
||||
* @return array<int, array<string, mixed>> 구독 훅 목록
|
||||
*/
|
||||
private function parseSubscribedHooks(string $content): array
|
||||
{
|
||||
$body = $this->extractReturnArray($content, 'getSubscribedHooks');
|
||||
if ($body === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$hooks = [];
|
||||
|
||||
foreach ($this->splitTopLevel($body) as $item) {
|
||||
if (! preg_match("/^\s*'([^']+)'\s*=>\s*(.*)$/s", $item, $m)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $m[2];
|
||||
$type = null;
|
||||
if (preg_match("/'type'\s*=>\s*'([^']+)'/", $value, $tm)) {
|
||||
$type = $tm[1];
|
||||
}
|
||||
|
||||
$method = null;
|
||||
if (preg_match("/'method'\s*=>\s*'([^']+)'/", $value, $mm)) {
|
||||
$method = $mm[1];
|
||||
} elseif (preg_match("/^\s*'([^']+)'\s*$/", $value, $sm)) {
|
||||
$method = $sm[1];
|
||||
}
|
||||
|
||||
$priority = null;
|
||||
if (preg_match("/'priority'\s*=>\s*(-?\d+)/", $value, $pm)) {
|
||||
$priority = (int) $pm[1];
|
||||
}
|
||||
|
||||
$hooks[] = [
|
||||
'name' => $m[1],
|
||||
'method' => $method,
|
||||
'priority' => $priority,
|
||||
'type' => $type ?? 'action',
|
||||
'typeDeclared' => $type !== null,
|
||||
];
|
||||
}
|
||||
|
||||
return $hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 메서드의 `return [...]` 배열 본문을 대괄호 균형으로 잘라냅니다.
|
||||
*
|
||||
* 정규식 하나로 배열을 잡으려 하면 중첩 배열에서 끊기므로, 여는 대괄호부터
|
||||
* 문자열·주석을 건너뛰며 깊이를 세어 대응하는 닫는 위치를 찾습니다.
|
||||
*
|
||||
* @param string $content 소스
|
||||
* @param string $method 메서드명
|
||||
* @return string|null 배열 본문 (대괄호 제외, 없으면 null)
|
||||
*/
|
||||
private function extractReturnArray(string $content, string $method): ?string
|
||||
{
|
||||
if (! preg_match('/function\s+'.preg_quote($method, '/').'\s*\(/', $content, $m, PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = (int) $m[0][1];
|
||||
$returnPos = strpos($content, 'return [', $from);
|
||||
if ($returnPos === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$open = $returnPos + strlen('return ');
|
||||
$close = $this->matchBracket($content, $open);
|
||||
|
||||
return $close === null ? null : substr($content, $open + 1, $close - $open - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여는 대괄호 위치에 대응하는 닫는 대괄호 위치를 찾습니다.
|
||||
*
|
||||
* @param string $s 소스
|
||||
* @param int $open 여는 대괄호 인덱스
|
||||
* @return int|null 닫는 대괄호 인덱스 (불균형이면 null)
|
||||
*/
|
||||
private function matchBracket(string $s, int $open): ?int
|
||||
{
|
||||
$depth = 0;
|
||||
$len = strlen($s);
|
||||
|
||||
for ($i = $open; $i < $len; $i++) {
|
||||
$ch = $s[$i];
|
||||
|
||||
if ($ch === "'" || $ch === '"') {
|
||||
$i = $this->skipString($s, $i);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === '/' && $i + 1 < $len && ($s[$i + 1] === '/' || $s[$i + 1] === '*')) {
|
||||
$i = $this->skipComment($s, $i);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === '[') {
|
||||
$depth++;
|
||||
} elseif ($ch === ']') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배열 본문을 최상위 쉼표 기준으로 분할합니다.
|
||||
*
|
||||
* @param string $body 배열 본문
|
||||
* @return array<int, string> 항목 목록 (빈 항목 제외)
|
||||
*/
|
||||
private function splitTopLevel(string $body): array
|
||||
{
|
||||
$items = [];
|
||||
$buf = '';
|
||||
$depth = 0;
|
||||
$len = strlen($body);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ch = $body[$i];
|
||||
|
||||
if ($ch === "'" || $ch === '"') {
|
||||
$end = $this->skipString($body, $i);
|
||||
$buf .= substr($body, $i, $end - $i + 1);
|
||||
$i = $end;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === '/' && $i + 1 < $len && ($body[$i + 1] === '/' || $body[$i + 1] === '*')) {
|
||||
$i = $this->skipComment($body, $i);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ch === '[' || $ch === '(') {
|
||||
$depth++;
|
||||
} elseif ($ch === ']' || $ch === ')') {
|
||||
$depth--;
|
||||
}
|
||||
|
||||
if ($ch === ',' && $depth === 0) {
|
||||
if (trim($buf) !== '') {
|
||||
$items[] = $buf;
|
||||
}
|
||||
$buf = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$buf .= $ch;
|
||||
}
|
||||
|
||||
if (trim($buf) !== '') {
|
||||
$items[] = $buf;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 리터럴의 끝 인덱스를 찾습니다 (이스케이프 인지).
|
||||
*
|
||||
* @param string $s 소스
|
||||
* @param int $start 따옴표 인덱스
|
||||
* @return int 닫는 따옴표 인덱스 (미종료 시 문자열 끝)
|
||||
*/
|
||||
private function skipString(string $s, int $start): int
|
||||
{
|
||||
$quote = $s[$start];
|
||||
$len = strlen($s);
|
||||
|
||||
for ($i = $start + 1; $i < $len; $i++) {
|
||||
if ($s[$i] === '\\') {
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($s[$i] === $quote) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
|
||||
return $len - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주석의 끝 인덱스를 찾습니다.
|
||||
*
|
||||
* @param string $s 소스
|
||||
* @param int $start `/` 인덱스
|
||||
* @return int 주석 마지막 문자 인덱스
|
||||
*/
|
||||
private function skipComment(string $s, int $start): int
|
||||
{
|
||||
if ($s[$start + 1] === '/') {
|
||||
$end = strpos($s, "\n", $start);
|
||||
|
||||
return $end === false ? strlen($s) - 1 : $end;
|
||||
}
|
||||
|
||||
$end = strpos($s, '*/', $start);
|
||||
|
||||
return $end === false ? strlen($s) - 1 : $end + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스에서 FQCN 을 추출합니다.
|
||||
*
|
||||
* @param string $content 소스
|
||||
* @return string|null FQCN (없으면 null)
|
||||
*/
|
||||
private function fqcnOf(string $content): ?string
|
||||
{
|
||||
if (! preg_match('/^\s*namespace\s+([^;]+);/m', $content, $nm)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/^\s*(?:final\s+|abstract\s+)?class\s+(\w+)/m', $content, $cm)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($nm[1]).'\\'.$cm[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 발행 스캔 대상 PHP 파일을 열거합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return \Generator<string> PHP 파일 절대 경로
|
||||
*/
|
||||
private function phpFiles(array $record): \Generator
|
||||
{
|
||||
foreach (['src', 'database', 'upgrades', 'resources', 'config'] as $sub) {
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.$sub;
|
||||
if (! is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
if ($file->getExtension() === 'php') {
|
||||
yield $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($record['entryFile'] !== null) {
|
||||
yield $record['entryFile'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 루트 기준 상대 경로로 변환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param string $absolute 절대 경로
|
||||
* @return string 상대 경로 (POSIX 구분자)
|
||||
*/
|
||||
private function relative(array $record, string $absolute): string
|
||||
{
|
||||
$base = rtrim((string) $record['path'], '/\\').DIRECTORY_SEPARATOR;
|
||||
$rel = str_starts_with($absolute, $base) ? substr($absolute, strlen($base)) : $absolute;
|
||||
|
||||
return str_replace('\\', '/', $rel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ExtensionDoc;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 확장 테스트 경로 수집기
|
||||
*
|
||||
* 확장이 보유한 PHPUnit · Vitest · Playwright 테스트와 시나리오 매니페스트를 수집하고,
|
||||
* 실행 명령을 규정에 맞는 형태로 조립합니다.
|
||||
*
|
||||
* 실행 명령은 문서의 핵심 산출물입니다 — 확장 테스트는 무필터 전체 실행이 금지되어 있고
|
||||
* 프론트/백엔드가 서로 다른 셸 규약을 요구하므로, 그 형태를 문서가 직접 제시하지 않으면
|
||||
* 읽는 쪽이 매번 규정을 되짚어야 합니다.
|
||||
*/
|
||||
class TestPathCollector
|
||||
{
|
||||
/**
|
||||
* 확장의 테스트 표면을 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record ExtensionInventory 레코드
|
||||
* @return array<string, mixed> 테스트 인벤토리
|
||||
*/
|
||||
public function collect(array $record): array
|
||||
{
|
||||
$phpunit = $this->countFiles($record, 'tests', 'php', ['Playwright']);
|
||||
$vitestRoots = $this->vitestRoots($record);
|
||||
$playwright = $this->countFiles($record, 'tests/Playwright', 'ts');
|
||||
$scenarios = $this->scenarioManifests($record);
|
||||
|
||||
return [
|
||||
'phpunit' => $phpunit,
|
||||
'vitest' => $vitestRoots,
|
||||
'playwright' => $playwright,
|
||||
'scenarios' => $scenarios,
|
||||
'commands' => $this->buildCommands($record, $phpunit, $vitestRoots, $playwright),
|
||||
'testCaseBase' => $this->testCaseBase($record),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 테스트의 기저 TestCase 클래스를 찾습니다.
|
||||
*
|
||||
* 모듈/플러그인 테스트는 `Tests\TestCase` 직접 상속이 금지되고 확장 전용 기저 클래스를
|
||||
* 상속해야 하므로, 그 클래스명이 문서에 드러나야 합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return string|null 기저 클래스 파일명 (없으면 null)
|
||||
*/
|
||||
private function testCaseBase(array $record): ?string
|
||||
{
|
||||
foreach (['ModuleTestCase.php', 'PluginTestCase.php', 'TemplateTestCase.php'] as $name) {
|
||||
if (is_file($record['path'].DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.$name)) {
|
||||
return 'tests/'.$name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vitest 대상 디렉토리를 찾습니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array{config: string|null, dirs: array<int, string>, files: int}
|
||||
*/
|
||||
private function vitestRoots(array $record): array
|
||||
{
|
||||
$config = null;
|
||||
foreach (['vitest.config.ts', 'vitest.config.js'] as $name) {
|
||||
if (is_file($record['path'].DIRECTORY_SEPARATOR.$name)) {
|
||||
$config = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$dirs = [];
|
||||
$files = 0;
|
||||
|
||||
foreach (['resources/js', 'src', '__tests__'] as $sub) {
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
|
||||
if (! is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found = 0;
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
$rel = str_replace('\\', '/', $file->getPathname());
|
||||
if (! preg_match('/\.(test|spec)\.(ts|tsx)$/', $rel)) {
|
||||
continue;
|
||||
}
|
||||
$found++;
|
||||
}
|
||||
|
||||
if ($found > 0) {
|
||||
$dirs[] = $sub;
|
||||
$files += $found;
|
||||
}
|
||||
}
|
||||
|
||||
return ['config' => $config, 'dirs' => $dirs, 'files' => $files];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시나리오 매니페스트를 수집합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @return array<int, string> 매니페스트 상대 경로
|
||||
*/
|
||||
private function scenarioManifests(array $record): array
|
||||
{
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'scenarios';
|
||||
if (! is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
if (in_array($file->getExtension(), ['yaml', 'yml'], true)) {
|
||||
$files[] = 'tests/scenarios/'.$file->getFilename();
|
||||
}
|
||||
}
|
||||
|
||||
sort($files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 실행 명령을 조립합니다.
|
||||
*
|
||||
* 무필터 전체 실행은 규정상 차단되므로 PHPUnit 명령에는 `--filter` 자리를 남깁니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param array{count: int, dirs: array<int, string>} $phpunit PHPUnit 집계
|
||||
* @param array{config: string|null, dirs: array<int, string>, files: int} $vitest Vitest 집계
|
||||
* @param array{count: int, dirs: array<int, string>} $playwright Playwright 집계
|
||||
* @return array<int, array{label: string, command: string, shell: string}> 실행 명령 목록
|
||||
*/
|
||||
private function buildCommands(array $record, array $phpunit, array $vitest, array $playwright): array
|
||||
{
|
||||
$commands = [];
|
||||
$rel = $record['relPath'];
|
||||
|
||||
if ($phpunit['count'] > 0) {
|
||||
$commands[] = [
|
||||
'label' => 'PHPUnit (변경 범위만)',
|
||||
'command' => "php vendor/bin/phpunit {$rel}/tests --filter='<대상클래스>'",
|
||||
'shell' => 'Bash',
|
||||
];
|
||||
}
|
||||
|
||||
if ($vitest['files'] > 0) {
|
||||
$commands[] = [
|
||||
'label' => 'Vitest (확장 디렉토리에서)',
|
||||
'command' => "cd {$rel} && powershell -Command \"npm run test:run -- <대상>\"",
|
||||
'shell' => 'PowerShell',
|
||||
];
|
||||
}
|
||||
|
||||
if ($playwright['count'] > 0) {
|
||||
$commands[] = [
|
||||
'label' => 'Playwright E2E',
|
||||
'command' => "npx playwright test {$rel}/tests/Playwright/specs/<대상>.spec.ts",
|
||||
'shell' => 'Bash',
|
||||
];
|
||||
}
|
||||
|
||||
return $commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 확장자에서 "테스트 파일" 로 셀 파일명인지 판정합니다.
|
||||
*
|
||||
* 계수 모집단은 문서의 "테스트 N건" 과 AGENTS `## 7. 테스트 실행` 표에 그대로 실리므로,
|
||||
* 설정 파일·픽스처를 함께 세면 공개 문서가 틀린 수치를 싣는다.
|
||||
*
|
||||
* @param string $extension 파일 확장자
|
||||
* @param string $filename 파일명
|
||||
* @return bool 테스트 파일이면 true
|
||||
*/
|
||||
private static function isTestFilename(string $extension, string $filename): bool
|
||||
{
|
||||
return match ($extension) {
|
||||
'php' => (bool) preg_match('/Test\.php$/', $filename),
|
||||
'ts', 'tsx' => (bool) preg_match('/\.(test|spec)\.tsx?$/', $filename),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 디렉토리의 파일 수와 1단계 하위 디렉토리를 집계합니다.
|
||||
*
|
||||
* @param array<string, mixed> $record 확장 레코드
|
||||
* @param string $sub 확장 루트 기준 하위 경로
|
||||
* @param string $extension 대상 확장자
|
||||
* @param array<int, string> $excludeDirs 제외할 1단계 하위 디렉토리명
|
||||
* @return array{count: int, dirs: array<int, string>, root: string|null}
|
||||
*/
|
||||
private function countFiles(array $record, string $sub, string $extension, array $excludeDirs = []): array
|
||||
{
|
||||
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
|
||||
if (! is_dir($dir)) {
|
||||
return ['count' => 0, 'dirs' => [], 'root' => null];
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$dirs = [];
|
||||
|
||||
foreach (File::allFiles($dir) as $file) {
|
||||
if ($file->getExtension() !== $extension) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 테스트 파일이 아닌 것이 같은 디렉토리에 산다 — 세면 "테스트 N건" 이 실제
|
||||
// 테스트 수보다 커진다. PHP 는 기저 TestCase·헬퍼(board 실측: 157 중 2건),
|
||||
// Playwright 는 `playwright.config.ts` 와 픽스처(gdpr 실측: 5 중 3건)다.
|
||||
// 두 축이 같은 규율을 갖도록 확장자별 파일명 규칙을 한자리에서 적용한다.
|
||||
if (! self::isTestFilename($extension, $file->getFilename())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace('\\', '/', $file->getRelativePath());
|
||||
$first = $relative === '' ? '(root)' : explode('/', $relative)[0];
|
||||
|
||||
if (in_array($first, $excludeDirs, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
if (! in_array($first, $dirs, true)) {
|
||||
$dirs[] = $first;
|
||||
}
|
||||
}
|
||||
|
||||
sort($dirs);
|
||||
|
||||
return ['count' => $count, 'dirs' => $dirs, 'root' => $sub];
|
||||
}
|
||||
}
|
||||
+9
-11
@@ -10,8 +10,8 @@
|
||||
| 카테고리 | 문서 수 | 링크 상태 |
|
||||
|----------|---------|----------|
|
||||
| [백엔드](backend/) | 37개 | 정상 |
|
||||
| [프론트엔드](frontend/) | 51개 | 정상 |
|
||||
| [확장 시스템](extension/) | 31개 | 정상 |
|
||||
| [프론트엔드](frontend/) | 46개 | 정상 |
|
||||
| [확장 시스템](extension/) | 32개 | 정상 |
|
||||
| 공통 | 20개 | 정상 |
|
||||
| [AI 도구](ai-tools/) | - | 정상 |
|
||||
|
||||
@@ -43,7 +43,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
|
||||
| 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개) |
|
||||
| 7 | [sirsoft-basic 컴포넌트](../templates/_bundled/sirsoft-basic/docs/components.md) | 확장 소유 문서 (basic / composite / layout) |
|
||||
| 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(옵션) |
|
||||
@@ -52,6 +52,8 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
|
||||
| 13 | [데이터 소스](frontend/data-sources.md) | data_sources 배열에 API 정의: id, endpoint, method |
|
||||
| 14 | [다크 모드 지원](frontend/dark-mode.md) | Tailwind dark: variant 사용 |
|
||||
|
||||
sirsoft-admin_basic 컴포넌트 문서는 확장이 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/components.md](../templates/_bundled/sirsoft-admin_basic/docs/components.md) 를 참고하세요.
|
||||
|
||||
### 컨트롤러 작성
|
||||
|
||||
| 순서 | 문서 | TL;DR 핵심 |
|
||||
@@ -167,7 +169,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
|
||||
| [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) |
|
||||
| [validation.md](backend/validation.md) | 검증 (Validation) |
|
||||
|
||||
### 프론트엔드 (51개)
|
||||
### 프론트엔드 (46개)
|
||||
|
||||
| 문서 | 제목 |
|
||||
|------|------|
|
||||
@@ -216,20 +218,16 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
|
||||
| [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 레이아웃 |
|
||||
| [README.md](frontend/README.md) | 템플릿별 컴포넌트·핸들러·레이아웃 문서 |
|
||||
|
||||
### 확장 시스템 (31개)
|
||||
### 확장 시스템 (32개)
|
||||
|
||||
| 문서 | 제목 |
|
||||
|------|------|
|
||||
| [cache-driver.md](extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) |
|
||||
| [changelog-rules.md](extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) |
|
||||
| [editor-spec.md](extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) |
|
||||
| [extension-documentation.md](extension/extension-documentation.md) | 확장 개발자 문서 (Extension Documentation) |
|
||||
| [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) |
|
||||
|
||||
@@ -173,8 +173,8 @@ global.window = {
|
||||
- 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
|
||||
- templates/_bundled/sirsoft-admin_basic/docs/components.md
|
||||
- templates/_bundled/sirsoft-basic/docs/components.md
|
||||
|
||||
## 테스트 실행
|
||||
\`\`\`powershell
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
| 문서 | 제목 | 핵심 내용 |
|
||||
|------|------|----------|
|
||||
| [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
|
||||
| [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 확장 132훅 = 총 198훅 (확장별 목록은 그 확장이 소유) |
|
||||
| [activity-log.md](activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel... |
|
||||
| [admin-settings-access.md](admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → Setting... |
|
||||
| [api-documentation.md](api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 +... |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅
|
||||
1. 코어 66훅 + 확장 132훅 = 총 198훅 (확장별 목록은 그 확장이 소유)
|
||||
2. Listener에서 Log::channel('activity')->info() 직접 호출 (Monolog → ActivityLogHandler → DB)
|
||||
3. 스냅샷 패턴: before_update(priority 5) → 캡처, after_update → ChangeDetector로 비교
|
||||
4. 사용자 행위: ActivityLogType::User (장바구니/위시리스트/쿠폰 다운로드/주문/결제)
|
||||
@@ -18,11 +18,9 @@
|
||||
|
||||
1. [아키텍처 개요](#1-아키텍처-개요)
|
||||
2. [코어 훅 (CoreActivityLogListener)](#2-코어-훅-coreactivityloglistener)
|
||||
3. [이커머스 모듈 훅](#3-이커머스-모듈-훅)
|
||||
4. [게시판 모듈 훅 (BoardActivityLogListener)](#4-게시판-모듈-훅-boardactivityloglistener)
|
||||
5. [페이지 모듈 훅 (PageActivityLogListener)](#5-페이지-모듈-훅-pageactivityloglistener)
|
||||
6. [스냅샷/변경감지 패턴](#6-스냅샷변경감지-패턴)
|
||||
7. [새 모듈에 ActivityLog 추가하기](#7-새-모듈에-activitylog-추가하기)
|
||||
3. [확장 모듈 훅](#3-확장-모듈-훅)
|
||||
4. [스냅샷/변경감지 패턴](#4-스냅샷변경감지-패턴)
|
||||
5. [새 모듈에 ActivityLog 추가하기](#5-새-모듈에-activitylog-추가하기)
|
||||
|
||||
---
|
||||
|
||||
@@ -192,344 +190,30 @@ Service → doAction('hook.name') → ActivityLogListener → Log::channel('acti
|
||||
|
||||
---
|
||||
|
||||
## 3. 이커머스 모듈 훅
|
||||
## 3. 확장 모듈 훅
|
||||
|
||||
**모듈**: `sirsoft-ecommerce`
|
||||
**총 92훅** (7개 Listener)
|
||||
확장이 구독하는 활동 로그 훅 목록은 **그 확장이 소유**합니다(#601). 확장이 훅을 추가할 때
|
||||
코어 문서를 고쳐야 하는 역방향 의존을 없애기 위해서이며, 코어에는 아래 총계와 링크만 남습니다.
|
||||
|
||||
### 3.1 OrderActivityLogListener (21훅)
|
||||
| 확장 | 훅 수 | 문서 |
|
||||
|------|------|------|
|
||||
| `sirsoft-ecommerce` | 92 | [docs/extension-points.md](../../modules/_bundled/sirsoft-ecommerce/docs/extension-points.md) |
|
||||
| `sirsoft-board` | 30 | [docs/extension-points.md](../../modules/_bundled/sirsoft-board/docs/extension-points.md) |
|
||||
| `sirsoft-page` | 7 | [docs/extension-points.md](../../modules/_bundled/sirsoft-page/docs/extension-points.md) |
|
||||
| `sirsoft-pay_kginicis` | 1 | [docs/extension-points.md](../../plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md) |
|
||||
| `sirsoft-pay_nhnkcp` | 1 | [docs/extension-points.md](../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md) |
|
||||
| `sirsoft-pay_nicepayments` | 1 | [docs/extension-points.md](../../plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md) |
|
||||
| **합계** | **132** | |
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/OrderActivityLogListener.php`
|
||||
코어 66훅 + 확장 132훅 = **총 198훅**입니다.
|
||||
|
||||
#### 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 |
|
||||
확장에 새 활동 로그 항목을 추가할 때도 **다국어 키는 코어가 SSoT** 입니다 — action 라벨과
|
||||
description 본문을 코어 `lang/{ko,en}/activity_log.php` 에 정의해야 하며, 모듈 lang 파일에
|
||||
넣으면 해석되지 않습니다. 번들 일본어 팩도 같은 작업 단위에서 동기화합니다.
|
||||
|
||||
---
|
||||
|
||||
## 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. 스냅샷/변경감지 패턴
|
||||
## 4. 스냅샷/변경감지 패턴
|
||||
|
||||
ActivityLog에서 수정(update) 작업의 변경 이력을 기록하려면 **스냅샷 패턴**을 사용합니다.
|
||||
|
||||
@@ -592,16 +276,31 @@ public function handleAfterUpdate(Model $entity): void
|
||||
- `BackedEnum` 자동 변환 지원
|
||||
- 스냅샷이 `null`이면 `null` 반환 (변경 없음)
|
||||
|
||||
### ProductActivityLogListener의 별도 패턴
|
||||
### 스냅샷은 Service 가 잡아 넘긴다
|
||||
|
||||
`ProductActivityLogListener`는 `ProductLogService`를 주입받아 사용하는 별도 패턴입니다:
|
||||
- `ChangeDetector` 대신 자체 `detectChanges()` 메서드로 변경 감지
|
||||
- 해시 비교로 옵션/추가옵션/이미지 변경 감지
|
||||
- `Log::channel('activity')` 대신 `ProductLogService`를 통해 처리로그 테이블에 기록
|
||||
리스너는 `before_*` 훅을 구독해 스냅샷을 잡지 않습니다. **Service 가 수정 직전에 스냅샷을
|
||||
만들어 `after_*` 훅의 인자로 넘기고**, 리스너는 그것을 `ChangeDetector::detect()` 에 그대로
|
||||
전달합니다:
|
||||
|
||||
```php
|
||||
// Service (예: ProductService::update())
|
||||
HookManager::doAction('sirsoft-ecommerce.product.after_update', $product, $snapshot);
|
||||
|
||||
// Listener
|
||||
public function handleProductAfterUpdate(Product $product, ?array $snapshot = null): void
|
||||
{
|
||||
$changes = ChangeDetector::detect($product, $snapshot);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`before_*` 훅 자체는 발행되므로 다른 확장이 구독할 수 있습니다 — 다만 **활동 로그 리스너의
|
||||
구독 대상은 아닙니다.** 확장별 구독 목록에서 `before_*` 가 보이지 않는 것은 누락이 아니라
|
||||
이 구조 때문입니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 새 모듈에 ActivityLog 추가하기
|
||||
## 5. 새 모듈에 ActivityLog 추가하기
|
||||
|
||||
### Step 1: Listener 클래스 생성
|
||||
|
||||
|
||||
@@ -84,6 +84,13 @@ G7은 **동적 로딩** 기반의 확장 시스템을 제공합니다:
|
||||
| [permissions.md](permissions.md) | Role, Permission, 자동 관리 |
|
||||
| [menus.md](menus.md) | 메뉴 권한, 시더 |
|
||||
|
||||
### 확장 문서화
|
||||
|
||||
| 문서 | 설명 |
|
||||
|------|------|
|
||||
| [extension-documentation.md](extension-documentation.md) | AGENTS.md/README.md/docs 역할 경계, 자동 생성 블록 규약, `ext:docgen` |
|
||||
| [changelog-rules.md](changelog-rules.md) | 버전 상향 시 CHANGELOG 기재 의무 |
|
||||
|
||||
---
|
||||
|
||||
## 확장 타입별 네이밍 규칙
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
3. 분할 형식: manifest(editor-spec.json) + `$include` 맵 → editor-spec/{block}.json. 서버가 합본해 단일 spec 으로 서빙
|
||||
4. 서빙은 활성 디렉토리만 기준 (_bundled 폴백 없음). _bundled 작업분은 {type}:update 로 활성 반영 후 런타임 노출
|
||||
5. 친화 라벨은 $t: 다국어 키, comment 는 작성자 메모(엔진 무시), 알려진 필드 외 자유 필드는 보존만
|
||||
6. 확장별 선언 실측·동반 의무는 그 확장의 docs/editor-spec.md 가 소유 (ext:docgen 이 유지)
|
||||
```
|
||||
|
||||
## 개요
|
||||
@@ -60,6 +61,71 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄
|
||||
|
||||
런타임 서빙은 **활성 디렉토리(`templates/{id}/...`)만** 읽는다. `_bundled` 폴백은 없다. `_bundled` 에서 작업한 분할본은 `{type}:update {id} --force` 로 활성 디렉토리에 반영된 뒤에만 편집기에 나타난다. JSON 만 바뀐 경우 빌드는 불필요하고 update 만 실행한다.
|
||||
|
||||
## 확장 개발자의 작업 순서
|
||||
|
||||
아래 규칙들은 블록 하나하나의 **문법**을 말한다. 실제로 확장을 고칠 때 필요한 것은 그
|
||||
앞의 판단이다 — 무엇을 손대야 하고, 무엇을 빠뜨리면 어떤 증상이 나는가.
|
||||
|
||||
### 어느 확장의 스펙에 넣는가
|
||||
|
||||
컴포넌트를 만드는 것은 템플릿의 일이고, 모듈·플러그인은 템플릿이 제공하는 컴포넌트를
|
||||
쓰기만 한다. 그래서 두 부류가 담는 것이 다르다.
|
||||
|
||||
| 선언할 것 | 자리 |
|
||||
|---|---|
|
||||
| `componentPalette` · `controls` · `componentCapabilities` · `nesting` | **템플릿** 스펙 |
|
||||
| `sampleData` · `sampleGlobal` · `states` · `*Recipes` | 그 데이터를 소유한 **모듈·플러그인** 스펙 |
|
||||
| 여러 확장이 함께 쓰는 공용 ID(`settings` · `roles` · `me` 등) | **템플릿** 스펙 |
|
||||
|
||||
공용 ID 를 확장마다 각자 선언하면 같은 ID 의 샘플이 여러 곳에 생기고, 그것들이 갈라져도
|
||||
오류가 나지 않는다 — 어느 것이 쓰이는지가 병합 순서에 좌우된다.
|
||||
|
||||
모듈·플러그인이 `componentPalette` 를 선언하면 템플릿 선언과 같은 자리를 두고 다투게
|
||||
되므로 두지 않는다. 팔레트에 얹고 싶은 것이 있다면 그것은 활성 템플릿의 스펙으로 간다.
|
||||
|
||||
### 무엇을 빠뜨렸는지는 증상으로 가른다
|
||||
|
||||
편집기는 선언되지 않은 것을 **없는 것**으로 다룰 뿐 실패를 보고하지 않는다. 그래서
|
||||
빠뜨린 자리를 알려 주는 것은 오류 메시지가 아니라 증상이다.
|
||||
|
||||
| 증상 | 빠뜨린 자리 |
|
||||
|---|---|
|
||||
| 컴포넌트가 팔레트 목록에 아예 없다 | `componentPalette.entries` |
|
||||
| 팔레트에 등록했는데 목록에 안 보인다 | `componentPalette.groups` 의 어느 묶음에도 없다 |
|
||||
| 팔레트에서 끌어 놓을 수 없다 | `nesting.draggable` |
|
||||
| 놓을 자리가 없다(컨테이너가 거부한다) | `nesting.containers` |
|
||||
| 놓았는데 속성 패널이 비어 있다 | `componentCapabilities` |
|
||||
| 속성 패널에 특정 항목만 없다 | `controls` |
|
||||
| 캔버스의 한 영역만 빈 화면이다 | `sampleData.byDataSourceId`(또는 `byEndpointPattern`) |
|
||||
| 값이 `undefined` 라 영역 전체가 사라진다 | `sampleGlobal` |
|
||||
| 특정 상태(빈 목록·오류·모달 열림)를 볼 수 없다 | `states` |
|
||||
|
||||
캔버스는 실제 API 를 부르지 않고 `sampleData` 로 그린다. 그래서 레이아웃에
|
||||
`data_source` 를 추가하고 샘플을 붙이지 않으면 **편집기에서만** 그 자리가 비고 실제
|
||||
화면은 정상 동작한다 — 오류도 경고도 서버 로그도 남지 않는다.
|
||||
|
||||
캔버스는 또한 정적 시뮬레이션이라 클릭·응답으로 만들어지는 상태를 스스로 만들지 못한다.
|
||||
모바일 드로어(햄버거 클릭), 쿠키 동의 배너(동의 전 방문자), 본인인증 창(428 응답)처럼
|
||||
**어떤 사건 뒤에만 나타나는 화면**은 `states` 로 그 상태를 주입해 두지 않으면 편집
|
||||
자체가 불가능하다.
|
||||
|
||||
### 반영 절차
|
||||
|
||||
편집기 스펙은 JSON 이므로 빌드가 필요 없다. 다만 서빙은 **활성 디렉토리만** 읽고
|
||||
`_bundled` 폴백이 없으므로, `_bundled` 에서 고친 뒤 update 커맨드를 돌리지 않으면
|
||||
편집기에는 직전 내용이 그대로 보인다. 파일은 고쳤는데 화면이 안 바뀌었다면 거의 이 경우다.
|
||||
|
||||
```bash
|
||||
php artisan {module|plugin|template}:update {id} --force
|
||||
```
|
||||
|
||||
### 확장별 실측은 그 확장이 소유한다
|
||||
|
||||
어느 확장이 무엇을 얼마나 선언했는지, 그 확장에서 프리뷰 샘플이 붙지 않는 `data_source`
|
||||
가 무엇인지는 **그 확장의 `docs/editor-spec.md`** 가 답한다. `php artisan ext:docgen` 이
|
||||
그 실측 부분을 유지하므로 스펙을 고친 뒤 재실행한다. 코어 문서(이 문서)에는 블록 문법과
|
||||
공통 판단 기준만 둔다.
|
||||
|
||||
## 블록별 작성 규칙
|
||||
|
||||
### componentPalette — 요소 추가 팔레트
|
||||
@@ -226,6 +292,21 @@ export function registerSirsoftAdminBasicEditorWidgets(): void {
|
||||
6. **어포던스는 대상 바깥 전용 레일 + 코어 위 z-index** — 거터/핸들/이동 버튼을 대상(셀/노드/항목) 위에 겹치지 않고 대상 바깥 전용 레인에 두고, 코어 오버레이 위 전용 z-index 밴드에 둔다(클릭 가로채기 0 — `elementFromPoint` topmost=self 로 실측). 빈 셀 찌부러짐은 편집기 전용 CSS(td height) 1회 주입(content 무오염).
|
||||
7. **탭/인플레이스 본체는 노드 파생 무상태** — 속성 모달은 패치마다 content 를 재마운트하므로, 탭 본체(ConditionBuilder/배열 에디터 등)는 자체 `useState` 로 값을 들고 있지 않고 매 렌더 노드 prop 에서 재구성한다(또는 노드 path 로 keying + 자유값 state 는 `useEffect` 재동기화). stale state 로 인한 오저장/409 회귀를 막는다.
|
||||
|
||||
## manifest `description` 작성 규칙
|
||||
|
||||
`description` 은 **이 스펙이 무엇을 담고 있는가**만 적는다. 독자는 이 확장을 쓰는 사람이다.
|
||||
|
||||
| 적지 않는다 | 이유 |
|
||||
|---|---|
|
||||
| 내부 작업 단계 (`Phase 3`, `1차 작업`, `추후 추가`) | 확장만 내려받은 제3자에게는 해석할 근거가 없다. 그리고 다음 단계가 실제로 들어온 뒤에도 문구는 그대로 남아 **거짓이 된다** |
|
||||
| 심사·검토 판정 (`— 정당`, `타당함`, `확인 완료`) | 작업자가 리뷰어에게 하는 해명이다. 읽는 쪽은 누가 무엇을 심사했는지 모른다 |
|
||||
| 작업 방법 (`전수 스캔 기반`, `역추론해 작성`) | 스펙이 무엇인지가 아니라 어떻게 만들었는지다. 그 정보가 필요하면 `$comment` 에 둔다 |
|
||||
| 항목 ID 나열 | 스펙이 커지면 곧 낡는다. 개수와 목록은 문서 생성기가 실측해 싣는다 |
|
||||
|
||||
비어 있을 것과 없을 것을 구분해 적는 것은 권장한다 — "`sampleGlobal` 은 이 모듈이 `_global` 키를 두지 않아 선언하지 않는다" 처럼, **왜 없는지**는 읽는 쪽이 부재를 누락으로 오해하지 않게 한다.
|
||||
|
||||
확장 문서(`docs/editor-spec.md`)의 한 줄 요약은 이 필드를 옮겨 싣지 않고 **실측에서 생성**한다. 사람이 쓴 메모는 코드와 대조되지 않아 낡아도 드러나지 않는데, 문서에 옮겨지면 바로 아래 실측 표와 서로를 반박하게 된다.
|
||||
|
||||
## 작성자 메모와 자유 필드
|
||||
|
||||
- `comment` 키는 어느 블록에서나 작성자 메모이며 엔진이 무시한다(레시피/컨트롤로 해석되지 않음).
|
||||
@@ -240,4 +321,6 @@ export function registerSirsoftAdminBasicEditorWidgets(): void {
|
||||
- 타입 SSoT: `resources/js/core/template-engine/layout-editor/spec/specTypes.ts`
|
||||
- 로더(fetch + 병합): `resources/js/core/template-engine/layout-editor/spec/editorSpecLoader.ts`
|
||||
- 서버 합본 헬퍼: `app/Extension/Helpers/EditorSpecAssembler.php`
|
||||
- 확장별 실측 문서 생성: `php artisan ext:docgen` — 각 확장 `docs/editor-spec.md`
|
||||
- 확장 문서 규정: [extension-documentation.md](extension-documentation.md)
|
||||
- 서빙: `app/Http/Controllers/Api/Public/PublicTemplateController.php`, `app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php`, `app/Services/ModuleService.php`, `app/Services/PluginService.php`
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# 확장 개발자 문서 (Extension Documentation)
|
||||
|
||||
> 번들 확장이 갖추는 `AGENTS.md` · `README.md` · `docs/**` 의 역할 경계, 골격, 자동 생성 규약, 갱신 의무.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 확장마다 AGENTS.md(개발자·에이전트용) + README.md(사람용) + docs/(상세) 를 갖는다
|
||||
2. 같은 사실은 한쪽만 SSoT — 소개·기능·요구사항은 README, 설계 의도·확장점·동반 의무는 AGENTS
|
||||
3. 코드에서 실측되는 표는 `php artisan ext:docgen` 이 @generated 블록 안쪽만 교체한다
|
||||
4. 블록 밖 전부가 사람 영역 — 생성기는 그 자리를 절대 건드리지 않으며 파괴적 재생성 플래그가 없다
|
||||
5. 사람만 쓸 수 있는 다섯 자리는 TODO 마커로 남는다 (의도 · 흐름 · 금지패턴 · 사용방법 · 트러블슈팅)
|
||||
```
|
||||
|
||||
## 목차
|
||||
|
||||
- [1. 왜 두 문서인가](#1-왜-두-문서인가)
|
||||
- [2. 파일 배치](#2-파일-배치)
|
||||
- [3. 역할 경계와 SSoT](#3-역할-경계와-ssot)
|
||||
- [4. 자동 생성 블록 규약](#4-자동-생성-블록-규약)
|
||||
- [5. 미채움 마커](#5-미채움-마커)
|
||||
- [6. `ext:docgen` 사용법](#6-extdocgen-사용법)
|
||||
- [7. 갱신 의무](#7-갱신-의무)
|
||||
- [8. 체크리스트](#8-체크리스트)
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 두 문서인가
|
||||
|
||||
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답한다. 확장을 수정하려는 사람이 실제로 필요로 하는 것은 그 앞의 질문이다 — **왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안 되는가.**
|
||||
|
||||
그 답이 코드 안에만 있으면 세 가지가 생긴다.
|
||||
|
||||
- 확장을 수정할 때마다 `src/` 전체를 훑어 구조를 재발견한다.
|
||||
- 확장이 발행하는 훅이 코드 안에만 있어 확장점이 사실상 비공개가 된다.
|
||||
- 확장 저장소만 받은 제3자에게는 참고할 문서가 `CHANGELOG.md` 뿐이다.
|
||||
|
||||
독자가 둘이므로 문서도 둘이다. 도입을 검토하고 운영하는 사람은 "무엇을 해결해 주는가" 를 묻고, 확장을 고치는 사람은 "어디를 어떻게 건드리는가" 를 묻는다. 한 문서에 섞으면 양쪽 모두에게 길어진다.
|
||||
|
||||
## 2. 파일 배치
|
||||
|
||||
```text
|
||||
{ext}/
|
||||
├─ AGENTS.md 에이전트·확장개발자 진입점
|
||||
├─ README.md 사람(도입검토자·운영자) 진입점 — 한국어
|
||||
├─ CHANGELOG.md 변경 이력
|
||||
└─ docs/
|
||||
├─ README.md 문서 통합 목차 + 실측 집계
|
||||
├─ architecture.md 설계 의도 · 계층 지도 · 디렉토리 맵
|
||||
├─ extension-points.md 발행/구독 훅 · 미들웨어 · 채널 · 스케줄 [모듈·플러그인]
|
||||
├─ data-model.md 모델 · 소유 테이블 · 마이그레이션 · Enum [모듈·플러그인]
|
||||
├─ settings.md 설정 스키마 · 권한 · 메뉴 · 라우트 · 의존 [모듈·플러그인]
|
||||
├─ frontend.md 레이아웃 · 핸들러 · 전역 진입점 · 에셋 [모듈·플러그인]
|
||||
├─ components.md 제공 컴포넌트 [템플릿]
|
||||
├─ layouts.md 레이아웃 목록 · 라우트 매핑 [템플릿]
|
||||
├─ handlers.md 템플릿 전용 핸들러 · 부트스트랩 [템플릿]
|
||||
├─ editor-spec.md 레이아웃 편집기 선언 · 프리뷰 샘플 커버리지
|
||||
└─ api/ API 레퍼런스 (별도 체계)
|
||||
```
|
||||
|
||||
템플릿은 API·모델·훅 축이 사실상 비므로 골격이 다르다. `ext:docgen` 이 manifest 유형을 보고 골격을 고르므로 유형을 직접 지정할 필요가 없다.
|
||||
|
||||
`docs/editor-spec.md` 만은 **세 유형 공통**이다. 편집기 스펙(`editor-spec.json`)을 두지 않는 확장에도 문서를 두는 것은, 미보유가 정상일 수 있고 그 정상 여부를 적을 자리가 필요하기 때문이다 — "이 확장은 왜 스펙이 없어도 되는가 / 언제 필요해지는가" 가 어디에도 없으면 다음 사람이 그 부재를 누락으로 오해하거나 반대로 필요한 시점을 놓친다.
|
||||
|
||||
이 문서들은 `{ext}/` 안에 있으므로 **릴리즈 페이로드에 실리는 공개 배포물**이다. 확장 저장소를 받은 사람이 그대로 읽는다.
|
||||
|
||||
## 3. 역할 경계와 SSoT
|
||||
|
||||
같은 사실은 한쪽만 SSoT 로 두고 반대쪽은 링크한다. 다만 두 문서 모두 **자기 독자에게는 자족적**이어야 한다 — "저쪽을 보라" 만 남기면 어느 쪽도 읽히지 않는다.
|
||||
|
||||
| README.md (사람) | AGENTS.md (에이전트·확장개발자) |
|
||||
|---|---|
|
||||
| 확장 소개 · 해결하는 문제 **(SSoT)** | 개발 의도 · 설계 원칙 **(SSoT)** |
|
||||
| 핵심 기능 목록 **(SSoT)** | 아키텍처 · 계층 지도 · 디렉토리 맵 |
|
||||
| 동작 방식 다이어그램 (운영자 눈높이) | 핵심 흐름 (코드 경로 · 계층 통과 순서) |
|
||||
| 요구사항 · 의존성 · 연동 확장 **(SSoT)** | 도메인 모델 · 소유 테이블 요약 |
|
||||
| 설치 · 활성화 | 확장점: 발행 훅 · 구독 훅 · 필터 **(SSoT)** |
|
||||
| 관리자 설정 화면 사용법 (템플릿은 이 자리에 **제공 컴포넌트** 요약) | 라우트 · 권한 · 설정 스키마 요약 |
|
||||
| 운영 트러블슈팅 | 프론트 진입점 · 레이아웃 · 핸들러 |
|
||||
| 라이선스 | **수정 시 동반 의무 체크리스트** |
|
||||
|
||||
### 필수 섹션 (유형별)
|
||||
|
||||
`ext:docgen --check` 가 요구하는 헤딩이다. 낱말이 본문 어딘가에 있는 것으로는 충족되지 않고 **헤딩**이어야 한다.
|
||||
|
||||
| 문서 | 필수 섹션 |
|
||||
|---|---|
|
||||
| `AGENTS.md` | `TL;DR (5초 요약)` · `1. 이 확장은 무엇인가` · `2. 디렉토리 지도` · `3. 핵심 흐름` · `4. 확장점` · `5. 수정 시 동반 의무` · `6. 금지 패턴` · `7. 테스트 실행` · `8. 문서 목차` |
|
||||
| `README.md` | `소개` · `주요 기능` · `동작 방식` · `요구 사항` · `설치` · `관리자 설정`(템플릿은 `제공 컴포넌트`) · `사용 방법` · `다른 확장과의 연동` · `문서` · `트러블슈팅` · `변경 이력` · `라이선스` |
|
||||
| `docs/README.md` | `문서 목차` |
|
||||
| `docs/architecture.md` | `설계 의도` · `계층 지도` · `디렉토리` |
|
||||
| `docs/extension-points.md` (모듈·플러그인) | `발행 훅` · `구독 훅` · `훅 리스너` · `레이아웃 확장` · `미들웨어` · `브로드캐스트 채널` · `스케줄` · `알림 정의` |
|
||||
| `docs/data-model.md` (모듈·플러그인) | `모델` · `소유 테이블` · `마이그레이션` · `Enum` · `Repository` |
|
||||
| `docs/settings.md` (모듈·플러그인) | `설정 스키마` · `권한` · `메뉴` · `라우트` · `의존 관계` |
|
||||
| `docs/frontend.md` (모듈·플러그인) | `레이아웃` · `액션 핸들러` · `전역 진입점` · `에셋` |
|
||||
| `docs/components.md` (템플릿) | `제공 컴포넌트` |
|
||||
| `docs/layouts.md` (템플릿) | `레이아웃 목록` · `라우트 매핑` |
|
||||
| `docs/handlers.md` (템플릿) | `템플릿 전용 핸들러` · `부트스트랩` |
|
||||
| `docs/editor-spec.md` | `선언 요약` · `선언 블록` · `컴포넌트 팔레트` · `샘플 데이터와 페이지 상태` · `수정 시 동반 의무` |
|
||||
|
||||
목록의 SSoT 는 생성기이므로, 직접 옮겨 적기보다 `ext:docgen --init` 이 만든 골격에서 시작하는 편이 어긋나지 않는다.
|
||||
|
||||
### AGENTS.md 의 `5. 수정 시 동반 의무`
|
||||
|
||||
이 절이 문서의 실효성 핵심이다. 코어 횡단 규정 중 **그 확장에 실제로 걸리는 것만** 추린다. 전부 나열하면 체크리스트 전체가 형식적으로 읽히고, 정작 걸리는 항목이 묻힌다.
|
||||
|
||||
예를 들어 이커머스는 통화 스냅샷과 주문 통화 전 사슬이, 게시판은 비밀글 게이트의 하위 리소스 재적용이, 결제 플러그인은 청구 금액 계약과 샌드박스 실호출이 그 자리에 온다.
|
||||
|
||||
`ext:docgen --init` 이 확장이 실제로 보유한 표면(마이그레이션 · 발행 훅 · 라우트 · 레이아웃 · 빌드 산출물 · 다국어)만 골라 초안을 만든다. 사람은 거기에 그 확장 고유의 항목을 보탠다.
|
||||
|
||||
### 시각 자료
|
||||
|
||||
스크린샷을 두지 않고 mermaid 다이어그램과 표로 대체한다. 이미지 파일이 없으므로 릴리즈 용량이 늘지 않고, UI 가 바뀔 때마다 다시 촬영할 의무도 없다. GitHub 이 mermaid 를 네이티브로 렌더한다.
|
||||
|
||||
mermaid 문법 오류는 렌더 시점에만 드러나므로, 새 형식을 도입할 때는 실제 렌더를 눈으로 확인한다.
|
||||
|
||||
### README 첫 화면
|
||||
|
||||
확장명은 히어로 이미지 배지가 아니라 평범한 H1 제목으로 적는다. 확장은 서로 대등하게 병렬로
|
||||
존재하는 구성요소이고, 각자가 코어와 같은 히어로 브랜딩을 달면 그 확장 하나가 독립 프로젝트인
|
||||
것처럼 보인다. `@generated:badges` 블록의 버전·유형·코어 제약·라이선스 배지는 manifest 에서
|
||||
오는 정보 표시이므로 그대로 둔다.
|
||||
|
||||
```markdown
|
||||
# 페이지
|
||||
|
||||
**G7 모듈 · sirsoft-page**
|
||||
정적 페이지(정보/정책/안내) 관리 모듈
|
||||
```
|
||||
|
||||
## 4. 자동 생성 블록 규약
|
||||
|
||||
생성기는 **마커 안쪽만** 쓴다.
|
||||
|
||||
```markdown
|
||||
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 훅 이름 | 유형 | 발행 위치 |
|
||||
| ... |
|
||||
<!-- @generated:hooks-published END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이 훅은 ... (사람이 쓰는 영역 — 생성기가 건드리지 않는다)
|
||||
<!-- @intent END -->
|
||||
```
|
||||
|
||||
블록 밖 전부가 사람 영역이다. 계약은 세 가지다.
|
||||
|
||||
1. **블록 안쪽 교체가 유일한 쓰기 동작이다.** 그래서 `--force` 같은 파괴적 플래그가 없다. 신규 파일 생성만 `--init` 으로 분리되어 있고, 그 경로도 기존 파일을 덮어쓰지 않는다.
|
||||
2. **문서에 없는 블록 키는 주입하지 않는다.** 생성기가 임의 위치에 표를 끼워 넣으면 사람이 잡아 둔 문서 구조가 흔들린다. 대신 누락으로 보고하여 사람이 마커 놓을 자리를 정한다.
|
||||
3. **재실행은 멱등이다.** 같은 코드 상태에서 두 번 돌리면 한 글자도 바뀌지 않는다.
|
||||
|
||||
`@intent` 블록은 사람 영역임을 눈에 띄게 표시하는 장치일 뿐이며, 생성기는 `@generated` 마커만 찾는다.
|
||||
|
||||
### 블록 목록
|
||||
|
||||
| 블록 키 | 내용 | 출처 |
|
||||
|---|---|---|
|
||||
| `badges` | 버전 · 유형 · 코어 제약 · 라이선스 · 의존 배지 | manifest |
|
||||
| `requirements` | 코어 버전 · PHP · 의존 확장 · 외부 호스트 | manifest · composer.json |
|
||||
| `install` | 설치 · 활성화 · 업데이트 커맨드 | manifest |
|
||||
| `integrations` · `dependencies` | 정방향/역방향 의존 확장 | 번들 전수 교차 스캔 |
|
||||
| `docs-index` · `doc-toc` | 문서 목차와 작성 상태 | 파일 존재 여부 |
|
||||
| `stats` | 훅 · 구독 훅 · 라우트 · 모델 · 테이블 · 마이그레이션 · 레이아웃 · 핸들러 8지표 실측 집계 | 전 수집기 |
|
||||
| `directory-map` | 경로별 역할과 수정 시 절차 | 디렉토리 구조 |
|
||||
| `extension-points-summary` | 확장점 종류별 개수와 상세 링크 | 훅·선언형 표면 |
|
||||
| `hooks-published` · `hooks-subscribed` · `listeners` | 발행/구독 훅과 리스너 | 소스 스캔 |
|
||||
| `layout-extensions` · `middleware` · `channels` · `schedules` · `notifications` | 선언형 확장점 | 진입 클래스 getter |
|
||||
| `models` · `tables` · `migrations` · `enums` · `repositories` | 데이터 모델 | 소스 파싱 |
|
||||
| `settings-schema` · `settings-summary` · `permissions` · `menus` · `routes` | 설정 스키마 · README 용 설정 요약(키·의미·기본값) · 권한 · 메뉴 · 라우트 | 진입 클래스 getter |
|
||||
| `layouts` · `handlers` · `frontend-entry` · `assets` | 프론트 표면 | 레이아웃/TS 스캔 |
|
||||
| `components` · `layout-map` | 템플릿 컴포넌트·라우트 매핑 | 컴포넌트 스캔 · routes.json |
|
||||
| `test-commands` | 테스트 종류별 개수와 실행 명령 | 테스트 경로 스캔 |
|
||||
|
||||
## 5. 미채움 마커
|
||||
|
||||
생성기가 채울 수 없는 자리 — 코드에서 실측되지 않는 **왜** — 에는 다섯 종류의 마커가 남는다.
|
||||
|
||||
| 마커 | 자리 | 주 위치 |
|
||||
|---|---|---|
|
||||
| `TODO: 의도` | 설계 의도 · 소개 · 주요 기능 | AGENTS `1`, README `소개`·`주요 기능` |
|
||||
| `TODO: 흐름` | 핵심 흐름 · 동작 방식 다이어그램 | AGENTS `3`, README `동작 방식` |
|
||||
| `TODO: 금지패턴` | 금지 패턴 표 | AGENTS `6` |
|
||||
| `TODO: 사용방법` | 운영자 사용 시나리오 | README `사용 방법` |
|
||||
| `TODO: 트러블슈팅` | 증상 → 원인 → 조치 | README `트러블슈팅` |
|
||||
|
||||
마커 종류를 다섯으로 고정하는 이유는 잔량을 집계할 수 있게 하기 위해서다. 자유 문구로 남기면 어느 자리가 비었는지 셀 수 없다.
|
||||
|
||||
`의도` 와 `흐름` 은 두 문서 모두에 나타난다 — 같은 축을 다른 독자에게 서술하는 자리이기 때문이다. 나머지 셋은 한쪽에만 있다.
|
||||
|
||||
골격을 만든 직후에는 마커가 반드시 존재하며, 그 상태로 커밋하는 것이 정상 흐름이다. 결함이 아니라 집필 진행 상태다. 다만 잔량이 **늘어나는 것**은 회귀다.
|
||||
|
||||
## 6. `ext:docgen` 사용법
|
||||
|
||||
```bash
|
||||
php artisan ext:docgen
|
||||
{--scope=all : all | module:{id} | plugin:{id} | template:{id}}
|
||||
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
|
||||
{--check : 생성하지 않고 누락·드리프트만 리포트}
|
||||
{--json : 기계 판독 출력}
|
||||
{--dry-run : 대상과 실측 집계만 출력}
|
||||
```
|
||||
|
||||
전형적인 흐름:
|
||||
|
||||
```bash
|
||||
# 1. 대상과 실측 규모 확인
|
||||
php artisan ext:docgen --scope=module:sirsoft-board --dry-run
|
||||
|
||||
# 2. 골격 생성 (없는 문서만)
|
||||
php artisan ext:docgen --scope=module:sirsoft-board --init
|
||||
|
||||
# 3. TODO 마커 자리를 코드 근거로 채운다 (사람)
|
||||
|
||||
# 4. 표면을 고친 뒤 자동 생성 블록 갱신
|
||||
php artisan ext:docgen --scope=module:sirsoft-board
|
||||
|
||||
# 5. 문서와 코드가 어긋나지 않는지 확인
|
||||
php artisan ext:docgen --check
|
||||
```
|
||||
|
||||
작업 위치는 언제나 `_bundled` 다. 활성 디렉토리 반영은 update 커맨드로만 한다 (문서만 바뀌었다면 빌드는 불필요하다).
|
||||
|
||||
```bash
|
||||
php artisan {module|plugin|template}:update {id} --force
|
||||
```
|
||||
|
||||
### 수집 대상은 `_bundled` 소스다
|
||||
|
||||
선언형 표면(라우트 · 권한 · 메뉴 · 훅 리스너 · 설정 스키마 등)은 진입 클래스의 getter 를 실제로 호출해 읽는다. 정규식으로 소스를 긁는 방식과 달리 상속 기본값까지 정확히 반영된다.
|
||||
|
||||
같은 확장이 활성 디렉토리에서 이미 부팅되어 있으면 PHP 는 같은 클래스를 다시 정의할 수 없으므로, 진입 클래스명만 바꿔 `_bundled` 파일을 메모리에 다시 읽는다. 그래서 활성 디렉토리에 반영하기 전이라도 `_bundled` 의 변경이 문서에 그대로 나타난다.
|
||||
|
||||
getter 하나가 실패해도 나머지 수집은 계속되며, 실패 사유가 리포트에 드러난다 — 조용한 누락이 없다.
|
||||
|
||||
## 7. 갱신 의무
|
||||
|
||||
확장 표면을 바꾸면 같은 작업 단위에서 문서를 함께 갱신한다.
|
||||
|
||||
| 바꾼 것 | 해야 할 것 |
|
||||
|---|---|
|
||||
| 발행 훅 추가 · 이름 변경 | `ext:docgen` 재실행 — **다른 확장이 잡는 계약이 바뀐다** |
|
||||
| 라우트 · 권한 · 메뉴 · 설정 스키마 | `ext:docgen` 재실행 |
|
||||
| 모델 · 마이그레이션 | `ext:docgen` 재실행 + 업그레이드 스텝 |
|
||||
| 레이아웃 · 핸들러 · 에셋 | `ext:docgen` 재실행 |
|
||||
| 설계 방침 · 계층 구조 | 블록 **밖** 서술을 직접 갱신 (생성기가 채우지 못한다) |
|
||||
| 새로 발견한 오용 | `6. 금지 패턴` 에 행 추가 |
|
||||
|
||||
자동 생성 블록이 아무리 정확해도 블록 밖 서술이 낡으면 문서 전체가 의심스러워진다. 생성기 재실행은 갱신의 절반이다.
|
||||
|
||||
### 신규 확장
|
||||
|
||||
새 확장을 만들면 `php artisan ext:docgen --scope={type}:{id} --init` 로 문서 골격을 먼저 만든다. 확장이 문서를 갖고 태어나야 하며, 만들자마자 `TODO` 마커 자리를 채우는 것이 첫 작업이다.
|
||||
|
||||
## 8. 체크리스트
|
||||
|
||||
```text
|
||||
□ AGENTS.md · README.md · docs/ 필수 문서가 유형에 맞게 있는가?
|
||||
□ 필수 섹션 헤딩이 모두 있는가?
|
||||
□ 자동 생성 블록 마커가 모두 있는가?
|
||||
□ `ext:docgen --check` 가 드리프트 0 인가?
|
||||
□ TODO 마커 다섯 자리를 코드 근거로 채웠는가? (추측 서술 금지)
|
||||
□ mermaid 다이어그램이 실제로 렌더되는가? (눈으로 확인)
|
||||
□ 배지 값이 manifest 와 일치하는가? (생성기가 채우므로 직접 쓰지 않는다)
|
||||
□ README 와 AGENTS 가 같은 사실을 각자 서술하고 있지는 않은가? (SSoT 한쪽 + 링크)
|
||||
□ `5. 수정 시 동반 의무` 가 이 확장에 실제로 걸리는 것만 담고 있는가?
|
||||
□ 버전 상향 시 CHANGELOG 에 기재했는가?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 내부 작업 단계·심사 어휘를 남기지 않는다
|
||||
|
||||
확장은 각자 자기 저장소로 배포되고, 그것만 내려받은 개발자에게는 그 문서가 유일한 안내다. 거기에 내부 작업 맥락이 남으면 읽는 쪽은 해석할 근거가 없다.
|
||||
|
||||
| 남기지 않는다 | 대신 |
|
||||
|---|---|
|
||||
| 작업 단계 (`Phase 3`, `1차 작업`, `다음 단계에서 추가`) | 지금 무엇이 있는지만 적는다. 계획은 문서가 아니라 이슈가 담는다 |
|
||||
| 심사 판정 (`— 정당`, `타당함`, `검토 결과 통과`) | 판정이 아니라 사실을 적는다 |
|
||||
| 작업 이력 (`이번 라운드에서`, `재검 결과`) | 결과만 남긴다 |
|
||||
| 항목 ID 나열로 규모를 설명 | 개수와 목록은 생성기가 실측해 싣는다 |
|
||||
|
||||
이 문구들의 공통점은 **쓸 때는 정확했다가 나중에 거짓이 된다**는 것이다. "다음 단계에서 추가" 라고 적어 둔 항목이 실제로 들어온 뒤에도 문구는 그대로 남고, 오류도 경고도 나지 않는다. 문서가 자기 자신을 반박한 채 계속 읽힐 뿐이다.
|
||||
|
||||
코드에서 실측되는 부분은 생성기가 유지하므로 이 문제가 없다. 위험한 자리는 **사람이 쓴 자유 텍스트**이며, 그중에서도 생성기가 문서로 옮겨 싣는 필드가 가장 위험하다 — 한 곳의 낡음이 여러 문서로 퍼진다. 그래서 확장 문서의 편집기 스펙 한 줄 요약은 스펙 파일의 설명을 옮기지 않고 실측에서 만든다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [module-basics.md](module-basics.md) — 모듈 개발 기초
|
||||
- [plugin-development.md](plugin-development.md) — 플러그인 개발 가이드
|
||||
- [template-basics.md](template-basics.md) — 템플릿 시스템 기초
|
||||
- [hooks.md](hooks.md) — 훅 시스템 (발행/구독 규약)
|
||||
- [changelog-rules.md](changelog-rules.md) — CHANGELOG 규칙
|
||||
- [../backend/api-documentation.md](../backend/api-documentation.md) — API 레퍼런스 문서 규정
|
||||
@@ -36,13 +36,6 @@
|
||||
---
|
||||
|
||||
<!-- 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) |
|
||||
|
||||
### 컴포넌트 개발
|
||||
|
||||
| 문서 | 설명 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 컴포넌트 Props 레퍼런스 - Composite
|
||||
|
||||
> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
|
||||
> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -671,7 +671,7 @@ G7에서는 콘텐츠의 렌더링 모드를 DB의 `*_mode` 컬럼(`'text'` / `'
|
||||
- [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md)
|
||||
- [컴포넌트 개발 규칙](components.md)
|
||||
- [컴포넌트 고급 기능](components-advanced.md)
|
||||
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
|
||||
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
|
||||
- [액션 핸들러 - 커스텀 콜백](actions.md#커스텀-이벤트-event-필드)
|
||||
- [보안 가이드 - HTML 렌더링](security.md#htmlcontent--htmleditor-html-렌더링이-필요한-경우)
|
||||
- [에디터 컴포넌트](editors.md)
|
||||
|
||||
@@ -1144,7 +1144,7 @@ id prop 사용: scrollIntoView 등 DOM selector로 접근해야 할 때 필수
|
||||
|
||||
- [컴포넌트 개발 규칙](components.md) - basic, composite, layout 컴포넌트
|
||||
- [레이아웃 JSON 스키마](layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법
|
||||
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 (111개)
|
||||
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개)
|
||||
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
|
||||
- [sirsoft-basic 컴포넌트](../../templates/_bundled/sirsoft-basic/docs/components.md) - User 컴포넌트 목록 (확장 소유)
|
||||
- [에디터 컴포넌트](editors.md) - HtmlEditor, CodeEditor 상세 가이드
|
||||
- [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 컴포넌트 타입별 개발 규칙
|
||||
|
||||
> **메인 문서**: [components.md](components.md)
|
||||
> **관련 문서**: [layout-json-components.md](layout-json-components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
|
||||
> **관련 문서**: [layout-json-components.md](layout-json-components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -452,5 +452,5 @@ export const Container: React.FC<ContainerProps> = ({ children }) => (
|
||||
- [컴포넌트 개발 규칙 인덱스](components.md)
|
||||
- [컴포넌트 패턴](components-patterns.md)
|
||||
- [컴포넌트 고급 기능](components-advanced.md)
|
||||
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
|
||||
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md)
|
||||
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
|
||||
- [sirsoft-basic 컴포넌트](../../templates/_bundled/sirsoft-basic/docs/components.md)
|
||||
|
||||
@@ -25,15 +25,12 @@
|
||||
| [components-advanced.md](components-advanced.md) | componentEvent, 아이콘, 체크리스트 | 이벤트 통신, 아이콘 규칙, 개발 체크리스트 |
|
||||
|
||||
<!-- AUTO-GENERATED-START: frontend-template-reference -->
|
||||
### 템플릿별 레퍼런스
|
||||
|
||||
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|
||||
|--------------|---------|--------|--------|
|
||||
| `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) |
|
||||
|
||||
<!-- AUTO-GENERATED-END: frontend-template-reference -->
|
||||
|
||||
> `sirsoft-admin_basic` 은 문서를 그 템플릿이 직접 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/](../../templates/_bundled/sirsoft-admin_basic/docs/README.md).
|
||||
> 템플릿별 문서 위치는 [templates/README.md](templates/README.md) 를 따릅니다.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
@@ -153,7 +150,7 @@ import { Icon, IconName } from '../basic/Icon';
|
||||
- [g7core-api.md](g7core-api.md) - G7Core 전역 API 레퍼런스
|
||||
- [레이아웃 JSON 스키마](./layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법
|
||||
- [데이터 바인딩](./data-binding.md) - props에서 데이터 바인딩 사용법
|
||||
- [sirsoft-admin_basic 컴포넌트](./templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 (111개)
|
||||
- [sirsoft-basic 컴포넌트](./templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개)
|
||||
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
|
||||
- [sirsoft-basic 컴포넌트](../../templates/_bundled/sirsoft-basic/docs/components.md) - User 컴포넌트 목록 (확장 소유)
|
||||
- [다크 모드](./dark-mode.md) - 컴포넌트 다크 모드 지원 가이드
|
||||
- [상태 관리](./state-management.md) - 전역/로컬 상태 관리 및 동기화 패턴
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| `sirsoft-admin_basic` | 미선언 | ThemeToggle 존재, dark: variant 동작 |
|
||||
| `sirsoft-basic` | `true` | template.json에 선언, 완전 지원 |
|
||||
|
||||
> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md)
|
||||
> 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](../../templates/_bundled/sirsoft-basic/docs/components.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ HtmlEditor는 내부적으로 **DOMPurify**를 사용하여 HTML을 정화합니
|
||||
## 관련 문서
|
||||
|
||||
- [컴포넌트 Props 레퍼런스](component-props.md) - Select, Input, Button Props
|
||||
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록
|
||||
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
|
||||
- [레이아웃 JSON](layout-json.md) - 레이아웃 JSON 스키마
|
||||
- [데이터 바인딩](data-binding.md) - {{}} 표현식, $t: 다국어
|
||||
- [상태 관리](state-management.md) - _local, setState
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| `sirsoft-admin_basic` | 미선언 | 데스크톱 중심, Tailwind responsive 클래스는 동작 |
|
||||
| `sirsoft-basic` | `true` | MobileNav 포함, portable preset 지원 |
|
||||
|
||||
> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md)
|
||||
> 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](../../templates/_bundled/sirsoft-basic/docs/components.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,13 +20,12 @@
|
||||
## 템플릿별 상세 문서
|
||||
|
||||
<!-- AUTO-GENERATED-START: frontend-template-handlers -->
|
||||
| 템플릿 식별자 | 핸들러 문서 | TL;DR 핵심 |
|
||||
|--------------|-----------|----------|
|
||||
| `sirsoft-admin_basic` | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | setLocale: 앱 언어 변경 (locale 파라미터) |
|
||||
| `sirsoft-basic` | [handlers.md](templates/sirsoft-basic/handlers.md) | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
|
||||
|
||||
<!-- AUTO-GENERATED-END: frontend-template-handlers -->
|
||||
|
||||
> `sirsoft-admin_basic` 은 문서를 그 템플릿이 직접 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/](../../templates/_bundled/sirsoft-admin_basic/docs/README.md).
|
||||
> 템플릿별 문서 위치는 [templates/README.md](templates/README.md) 를 따릅니다.
|
||||
|
||||
---
|
||||
|
||||
## 엔진 빌트인 핸들러
|
||||
@@ -55,5 +54,5 @@
|
||||
- [액션 핸들러 개요](actions-handlers.md)
|
||||
- [템플릿 개발 가이드](template-development.md)
|
||||
- [다크 모드 지원](dark-mode.md)
|
||||
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md)
|
||||
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md)
|
||||
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
|
||||
- [sirsoft-basic 컴포넌트](../../templates/_bundled/sirsoft-basic/docs/components.md)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 템플릿별 컴포넌트·핸들러·레이아웃 문서
|
||||
|
||||
번들 템플릿의 컴포넌트/핸들러/레이아웃 상세 문서는 그 템플릿이 소유합니다(#601). 어느
|
||||
템플릿이 어디에 문서를 갖는지는 아래 표를 따릅니다 — 이관이 끝난 템플릿은 코어에 사본을
|
||||
남기지 않습니다.
|
||||
|
||||
| 템플릿 | 상태 | 문서 위치 |
|
||||
|---|---|---|
|
||||
| `sirsoft-admin_basic` | 이관 완료 | [templates/_bundled/sirsoft-admin_basic/docs/](../../../templates/_bundled/sirsoft-admin_basic/docs/README.md) |
|
||||
| `sirsoft-basic` | 이관 완료 | [templates/_bundled/sirsoft-basic/docs/](../../../templates/_bundled/sirsoft-basic/docs/README.md) |
|
||||
|
||||
이관 배경·문서 체계 전반은 [extension-documentation.md](../../extension/extension-documentation.md)
|
||||
를 참고하세요.
|
||||
@@ -1,445 +0,0 @@
|
||||
# sirsoft-admin_basic 핸들러
|
||||
|
||||
> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin)
|
||||
> **관련 문서**: [액션 핸들러 개요](../../actions-handlers.md) | [컴포넌트](./components.md) | [레이아웃](./layouts.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. setLocale: 앱 언어 변경 (locale 파라미터)
|
||||
2. setTheme/initTheme: 다크/라이트 모드 전환 및 초기화
|
||||
3. scrollToSection: 특정 섹션으로 스크롤 이동 (offset 지원)
|
||||
4. initMenuFromUrl: URL 기반 메뉴 활성 상태 초기화
|
||||
5. filterVisibility 4종: 필터 패널 가시성 저장/토글/초기화
|
||||
6. multilingualTag 3종: 다국어 태그 저장/취소/업데이트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [setLocale](#setlocale)
|
||||
2. [setTheme / initTheme](#settheme--inittheme)
|
||||
3. [scrollToSection](#scrolltosection)
|
||||
4. [initMenuFromUrl](#initmenuefromurl)
|
||||
5. [필터 가시성 핸들러](#필터-가시성-핸들러)
|
||||
6. [다국어 태그 핸들러](#다국어-태그-핸들러)
|
||||
7. [핸들러 소스 파일 매핑](#핸들러-소스-파일-매핑)
|
||||
|
||||
---
|
||||
|
||||
## setLocale
|
||||
|
||||
앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다.
|
||||
|
||||
**소스**: `src/handlers/setLocaleHandler.ts`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setLocale",
|
||||
"params": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### params
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
|
||||
|
||||
### 동작
|
||||
|
||||
```text
|
||||
1. 로케일 변경 → 번역 파일 다시 로드
|
||||
2. _global.locale 자동 업데이트
|
||||
3. 모든 $t: 표현식 재평가
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "lang_en_btn",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"text": "English"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setLocale",
|
||||
"params": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## setTheme / initTheme
|
||||
|
||||
### setTheme
|
||||
|
||||
다크/라이트 모드를 전환합니다.
|
||||
|
||||
**소스**: `src/handlers/setThemeHandler.ts`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "dark"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### setTheme params
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
||||
|
||||
### 동작
|
||||
|
||||
```text
|
||||
1. localStorage에 테마 설정 저장
|
||||
2. document.documentElement에 class 적용 (dark/light)
|
||||
3. Tailwind dark: variant 활성화/비활성화
|
||||
```
|
||||
|
||||
### initTheme
|
||||
|
||||
앱 시작 시 저장된 테마 설정을 적용합니다. 주로 `init_actions`에서 사용합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{
|
||||
"handler": "initTheme"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다.
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "theme_toggle",
|
||||
"type": "composite",
|
||||
"name": "ThemeToggle",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## scrollToSection
|
||||
|
||||
특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다.
|
||||
|
||||
**소스**: `src/handlers/scrollToSectionHandler.ts`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "scrollToSection",
|
||||
"params": {
|
||||
"selector": "#features",
|
||||
"offset": -80
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### params
|
||||
|
||||
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
||||
|------|------|------|--------|------|
|
||||
| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) |
|
||||
| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 |
|
||||
|
||||
### 동작
|
||||
|
||||
```text
|
||||
1. document.querySelector(selector)로 대상 요소 검색
|
||||
2. 요소의 위치 계산 + offset 적용
|
||||
3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "nav_features",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"text": "$t:common.features",
|
||||
"className": "cursor-pointer"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "scrollToSection",
|
||||
"params": {
|
||||
"selector": "#features",
|
||||
"offset": -80
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## initMenuFromUrl
|
||||
|
||||
현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다.
|
||||
|
||||
**소스**: `src/handlers/initMenuFromUrlHandler.ts`
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{
|
||||
"handler": "initMenuFromUrl"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### params
|
||||
|
||||
없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다.
|
||||
|
||||
### 동작
|
||||
|
||||
```text
|
||||
1. 현재 URL 경로 (window.location.pathname) 추출
|
||||
2. 사이드바 메뉴 데이터에서 URL 매칭
|
||||
3. 매칭된 메뉴 항목의 is_active 상태 설정
|
||||
4. 부모 메뉴도 자동으로 펼침 상태 설정
|
||||
```
|
||||
|
||||
### 사용 예시 (_admin_base.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{ "handler": "initTheme" },
|
||||
{ "handler": "initMenuFromUrl" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 필터 가시성 핸들러
|
||||
|
||||
목록 화면에서 필터 패널의 가시성(표시/숨김)을 관리합니다. localStorage에 상태를 저장하여 새로고침 후에도 유지합니다.
|
||||
|
||||
**소스**: `src/handlers/filterVisibilityHandler.ts`
|
||||
|
||||
### initFilterVisibility
|
||||
|
||||
저장된 필터 가시성 상태를 `_local`에 복원합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{
|
||||
"handler": "initFilterVisibility"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### saveFilterVisibility
|
||||
|
||||
현재 필터 가시성 상태를 localStorage에 저장합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "saveFilterVisibility",
|
||||
"params": {
|
||||
"filters": "{{_local.filterVisibility}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### toggleFilterVisibility
|
||||
|
||||
특정 필터 키의 가시성을 토글합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "toggleFilterVisibility",
|
||||
"params": {
|
||||
"key": "advancedFilters"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### resetFilterVisibility
|
||||
|
||||
모든 필터 가시성을 초기 상태로 리셋합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "resetFilterVisibility"
|
||||
}
|
||||
```
|
||||
|
||||
### 핸들러 params 요약
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|--------|--------|------|
|
||||
| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 |
|
||||
| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
|
||||
| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 |
|
||||
| `resetFilterVisibility` | 없음 | 전체 초기화 |
|
||||
|
||||
### 사용 예시 (목록 페이지)
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{ "handler": "initFilterVisibility" }
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"id": "filter_toggle_btn",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"text": "$t:common.toggle_filters"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "toggleFilterVisibility",
|
||||
"params": { "key": "advancedFilters" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "filter_section",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.filterVisibility?.advancedFilters}}",
|
||||
"children": [
|
||||
{ "comment": "필터 컴포넌트들" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다국어 태그 핸들러
|
||||
|
||||
다국어 입력 컴포넌트(MultilingualInput)에서 사용하는 태그 관리 핸들러입니다.
|
||||
|
||||
**소스**: `src/handlers/multilingualTagHandler.ts`
|
||||
|
||||
### saveMultilingualTag
|
||||
|
||||
다국어 태그를 저장합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "saveMultilingualTag",
|
||||
"params": {
|
||||
"field": "tags",
|
||||
"locale": "{{_global.locale}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### cancelMultilingualTag
|
||||
|
||||
다국어 태그 편집을 취소합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "cancelMultilingualTag"
|
||||
}
|
||||
```
|
||||
|
||||
### updateMultilingualTagValue
|
||||
|
||||
다국어 태그 값을 업데이트합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "updateMultilingualTagValue",
|
||||
"params": {
|
||||
"field": "tags",
|
||||
"locale": "ko",
|
||||
"value": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 태그 핸들러 params 요약
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|--------|--------|------|
|
||||
| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
|
||||
| `cancelMultilingualTag` | 없음 | 편집 취소 |
|
||||
| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
|
||||
|
||||
---
|
||||
|
||||
## 핸들러 소스 파일 매핑
|
||||
|
||||
| 핸들러명 | 소스 파일 | 등록 함수 |
|
||||
|---------|----------|----------|
|
||||
| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` |
|
||||
| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` |
|
||||
| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` |
|
||||
| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` |
|
||||
| `initFilterVisibility`, `saveFilterVisibility`, `toggleFilterVisibility`, `resetFilterVisibility` | `src/handlers/filterVisibilityHandler.ts` | `initFilterVisibilityHandler` |
|
||||
| `saveMultilingualTag`, `cancelMultilingualTag`, `updateMultilingualTagValue` | `src/handlers/multilingualTagHandler.ts` | `saveMultilingualTagHandler` |
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
```text
|
||||
이 핸들러들은 sirsoft-admin_basic 템플릿에서만 등록됨 (다른 템플릿에서 미지원 가능)
|
||||
범용 핸들러(navigate, apiCall, setState 등)와 달리 템플릿 의존적
|
||||
✅ 커스텀 핸들러이므로 template.json의 핸들러 등록 확인 필요
|
||||
✅ 범용 핸들러는 actions-handlers.md 참조
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [액션 핸들러 개요](../../actions-handlers.md)
|
||||
- [sirsoft-admin_basic 컴포넌트](./components.md)
|
||||
- [sirsoft-admin_basic 레이아웃](./layouts.md)
|
||||
- [sirsoft-basic 핸들러](../sirsoft-basic/handlers.md)
|
||||
@@ -1,368 +0,0 @@
|
||||
# sirsoft-admin_basic 레이아웃
|
||||
|
||||
> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin)
|
||||
> **관련 문서**: [컴포넌트](./components.md) | [핸들러](./handlers.md) | [레이아웃 JSON 스키마](../../layout-json.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯)
|
||||
2. 코어 페이지 17개: 대시보드, 사용자, 역할, 설정, 확장 관리 등
|
||||
3. Partial 70개+: 탭, 모달, 패널, 필터 등
|
||||
4. 에러 페이지 6개: 401, 403, 404, 500, 503, maintenance
|
||||
5. 패턴: 목록(DataGrid+Pagination), 상세(Card), 폼(Form+FormField), 설정(Tab)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [페이지 맵 (트리 구조)](#페이지-맵-트리-구조)
|
||||
2. [카테고리별 가이드](#카테고리별-가이드)
|
||||
- [목록 페이지 패턴](#목록-페이지-패턴)
|
||||
- [상세 페이지 패턴](#상세-페이지-패턴)
|
||||
- [폼 페이지 패턴](#폼-페이지-패턴)
|
||||
- [설정 페이지 패턴](#설정-페이지-패턴)
|
||||
- [확장 관리 패턴](#확장-관리-패턴)
|
||||
- [에러 페이지 패턴](#에러-페이지-패턴)
|
||||
|
||||
---
|
||||
|
||||
## 페이지 맵 (트리 구조)
|
||||
|
||||
```text
|
||||
_admin_base.json (베이스 레이아웃)
|
||||
│
|
||||
├── admin_dashboard.json (대시보드)
|
||||
├── admin_login.json (로그인 — _admin_base 미상속)
|
||||
│
|
||||
├── 사용자 관리
|
||||
│ ├── admin_user_list.json (목록)
|
||||
│ ├── admin_user_form.json (생성/수정)
|
||||
│ └── admin_user_detail.json (상세)
|
||||
│
|
||||
├── 역할 관리
|
||||
│ ├── admin_role_list.json (목록)
|
||||
│ │ └── partials/admin_role_list/_modal_delete.json
|
||||
│ └── admin_role_form.json (생성/수정)
|
||||
│
|
||||
├── 메뉴 관리
|
||||
│ └── admin_menu_list.json (3패널 레이아웃)
|
||||
│ └── partials/admin_menu_list/
|
||||
│ ├── _panel_menu_list.json (좌측: 메뉴 트리)
|
||||
│ ├── _panel_form.json (중앙: 편집 폼)
|
||||
│ ├── _panel_detail.json (중앙: 상세 보기)
|
||||
│ ├── _panel_view.json (우측: 미리보기)
|
||||
│ └── _modal_delete.json
|
||||
│
|
||||
├── 환경 설정
|
||||
│ └── admin_settings.json (탭 네비게이션)
|
||||
│ └── partials/admin_settings/
|
||||
│ ├── _tab_general.json (일반)
|
||||
│ ├── _tab_mail.json (메일 발송 SMTP 설정)
|
||||
│ ├── _tab_notification_definitions.json (알림 정의)
|
||||
│ ├── _tab_security.json (보안)
|
||||
│ ├── _tab_upload.json (업로드)
|
||||
│ ├── _tab_drivers.json (드라이버)
|
||||
│ ├── _tab_seo.json (SEO)
|
||||
│ ├── _tab_advanced.json (고급)
|
||||
│ ├── _tab_info.json (시스템 정보)
|
||||
│ ├── _modal_cache_delete.json
|
||||
│ ├── _modal_core_changelog.json
|
||||
│ ├── _modal_core_update_guide.json
|
||||
│ ├── _modal_core_update_result.json
|
||||
│ ├── _modal_notification_template_form.json
|
||||
│ ├── _modal_notification_template_preview.json
|
||||
│ └── _modal_password_confirm.json
|
||||
│
|
||||
├── 모듈 관리
|
||||
│ └── admin_module_list.json
|
||||
│ └── partials/admin_module_list/
|
||||
│ ├── _modal_detail.json
|
||||
│ ├── _modal_install.json
|
||||
│ ├── _modal_manual_install.json
|
||||
│ ├── _modal_uninstall.json
|
||||
│ ├── _modal_update.json
|
||||
│ ├── _modal_deactivate_warning.json
|
||||
│ ├── _modal_force_activate.json
|
||||
│ ├── _modal_force_deactivate.json
|
||||
│ ├── _modal_extension_license.json
|
||||
│ └── _modal_refresh_layouts.json
|
||||
│
|
||||
├── 플러그인 관리
|
||||
│ └── admin_plugin_list.json
|
||||
│ └── partials/admin_plugin_list/
|
||||
│ ├── (모듈과 동일 구조 — 10개 모달)
|
||||
│ └── ...
|
||||
│
|
||||
├── 템플릿 관리
|
||||
│ ├── admin_template_list.json
|
||||
│ │ └── partials/admin_template_list/
|
||||
│ │ ├── _tab_admin.json (Admin 템플릿 탭)
|
||||
│ │ ├── _tab_user.json (User 템플릿 탭)
|
||||
│ │ ├── _modal_detail.json
|
||||
│ │ ├── _modal_install.json
|
||||
│ │ ├── _modal_manual_install.json
|
||||
│ │ ├── _modal_uninstall.json
|
||||
│ │ ├── _modal_update.json
|
||||
│ │ ├── _modal_activate.json
|
||||
│ │ ├── _modal_deactivate.json
|
||||
│ │ ├── _modal_force_activate.json
|
||||
│ │ ├── _modal_extension_license.json
|
||||
│ │ └── _modal_refresh_layouts.json
|
||||
│ └── admin_template_layout_edit.json (레이아웃 편집기)
|
||||
│ └── partials/admin_template_layout_edit/_modal_version_history.json
|
||||
│
|
||||
├── 스케줄 관리
|
||||
│ └── admin_schedule_list.json
|
||||
│ └── partials/admin_schedule_list/
|
||||
│ ├── _tab_schedules.json
|
||||
│ ├── _modal_form.json
|
||||
│ ├── _modal_delete.json
|
||||
│ ├── _modal_duplicate.json
|
||||
│ ├── _modal_history.json
|
||||
│ └── _modal_run.json
|
||||
│
|
||||
├── 메일 발송 로그
|
||||
│ └── admin_mail_send_log_list.json
|
||||
│ └── partials/admin_mail_send_log_list/
|
||||
│ ├── _partial_datagrid.json
|
||||
│ └── _partial_filter.json
|
||||
│
|
||||
├── 공통 Partial
|
||||
│ ├── partials/_modal_changelog.json
|
||||
│ └── partials/_modal_license.json
|
||||
│
|
||||
├── 에러 페이지
|
||||
│ └── errors/
|
||||
│ ├── 401.json (인증 필요)
|
||||
│ ├── 403.json (접근 거부)
|
||||
│ ├── 404.json (페이지 없음)
|
||||
│ ├── 500.json (서버 오류)
|
||||
│ ├── 503.json (서비스 불가)
|
||||
│ └── maintenance.json (점검 중)
|
||||
│
|
||||
├── 오버라이드
|
||||
│ └── overrides/sirsoft-sample/index.json
|
||||
│
|
||||
└── 테스트
|
||||
└── template_partial_test.json
|
||||
└── partials/template_partial_test/
|
||||
├── _content_section.json
|
||||
├── _header_section.json
|
||||
└── _info_card.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 카테고리별 가이드
|
||||
|
||||
### 목록 페이지 패턴
|
||||
|
||||
**대표**: `admin_user_list.json`, `admin_role_list.json`
|
||||
|
||||
**구성**:
|
||||
```text
|
||||
extends: _admin_base
|
||||
slots.content:
|
||||
└── PageHeader (제목, 액션 버튼)
|
||||
└── FilterGroup (선택적)
|
||||
└── DataGrid (columns, data, pagination)
|
||||
└── Pagination
|
||||
```
|
||||
|
||||
**data_sources**:
|
||||
```json
|
||||
{
|
||||
"id": "users",
|
||||
"endpoint": "/api/admin/users",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"params": { "page": "{{_local.page ?? 1}}", "per_page": "{{_local.per_page ?? 15}}" }
|
||||
}
|
||||
```
|
||||
|
||||
**핸들러 패턴**:
|
||||
- `apiCall` — 삭제, 상태 변경
|
||||
- `navigate` — 상세/수정 페이지 이동
|
||||
- `setState` — 필터, 페이지네이션 상태
|
||||
|
||||
**Partial 구조**: 모달 (삭제 확인, 상세 보기 등)
|
||||
|
||||
---
|
||||
|
||||
### 상세 페이지 패턴
|
||||
|
||||
**대표**: `admin_user_detail.json`
|
||||
|
||||
**구성**:
|
||||
```text
|
||||
extends: _admin_base
|
||||
data_sources: [상세 API (route.id 기반)]
|
||||
slots.content:
|
||||
└── PageHeader
|
||||
└── Card (기본 정보 섹션)
|
||||
└── Card (활동 내역 섹션)
|
||||
└── Card (권한 정보 섹션)
|
||||
```
|
||||
|
||||
**data_sources**:
|
||||
```json
|
||||
{
|
||||
"id": "user",
|
||||
"endpoint": "/api/admin/users/{{route.id}}",
|
||||
"method": "GET",
|
||||
"auto_fetch": true
|
||||
}
|
||||
```
|
||||
|
||||
**핸들러 패턴**:
|
||||
- `apiCall` — 상태 변경 (활성화/비활성화, 역할 변경)
|
||||
- `navigate` — 목록으로 이동, 수정 페이지 이동
|
||||
|
||||
---
|
||||
|
||||
### 폼 페이지 패턴
|
||||
|
||||
**대표**: `admin_user_form.json`, `admin_role_form.json`
|
||||
|
||||
**구성**:
|
||||
```text
|
||||
extends: _admin_base
|
||||
data_sources: [상세 API (수정 시), 참조 데이터 (Select 옵션)]
|
||||
slots.content:
|
||||
└── PageHeader
|
||||
└── Form
|
||||
├── FormField + Input (텍스트)
|
||||
├── FormField + Select (선택)
|
||||
├── FormField + Toggle (토글)
|
||||
└── Button (저장/취소)
|
||||
```
|
||||
|
||||
**핸들러 패턴**:
|
||||
- `apiCall` — 생성 (POST) / 수정 (PUT)
|
||||
- `navigate` — 성공 후 목록/상세로 이동
|
||||
|
||||
**주의사항**:
|
||||
```text
|
||||
✅ Form 내 Button에 type="button" 명시 (submit 방지)
|
||||
✅ 수정 폼은 route.id 존재 여부로 생성/수정 구분
|
||||
✅ FormField에 error prop으로 서버 검증 에러 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 설정 페이지 패턴
|
||||
|
||||
**대표**: `admin_settings.json`
|
||||
|
||||
**구성**:
|
||||
```text
|
||||
extends: _admin_base
|
||||
data_sources: [설정 API]
|
||||
slots.content:
|
||||
└── PageHeader
|
||||
└── TabNavigation (tabs)
|
||||
└── Div (탭별 partial 조건부 렌더링)
|
||||
├── if: activeTab === 'general' → partial: _tab_general.json
|
||||
├── if: activeTab === 'mail' → partial: _tab_mail.json
|
||||
├── if: activeTab === 'security' → partial: _tab_security.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Partial 구조**: `partials/admin_settings/_tab_*.json` (9개 탭)
|
||||
|
||||
**핸들러 패턴**:
|
||||
- `setState` — 탭 전환
|
||||
- `apiCall` — 설정 저장
|
||||
- `openModal` — 확인 다이얼로그
|
||||
|
||||
---
|
||||
|
||||
### 확장 관리 패턴
|
||||
|
||||
**대표**: `admin_module_list.json`, `admin_plugin_list.json`, `admin_template_list.json`
|
||||
|
||||
**구성**:
|
||||
```text
|
||||
extends: _admin_base
|
||||
data_sources: [확장 목록 API]
|
||||
slots.content:
|
||||
└── PageHeader (새로고침, 수동 설치 버튼)
|
||||
└── DataGrid/CardGrid (확장 목록)
|
||||
├── StatusBadge (상태)
|
||||
├── ActionMenu (설치/활성화/비활성화/삭제/업데이트)
|
||||
└── ExtensionBadge (모듈 식별)
|
||||
modals:
|
||||
├── _modal_detail.json (상세 정보)
|
||||
├── _modal_install.json (설치 확인)
|
||||
├── _modal_uninstall.json (삭제 확인)
|
||||
├── _modal_update.json (업데이트)
|
||||
└── _modal_force_activate.json 등
|
||||
```
|
||||
|
||||
**핸들러 패턴**:
|
||||
- `apiCall` — 설치, 활성화, 비활성화, 삭제, 업데이트
|
||||
- `openModal` — 확인 다이얼로그
|
||||
- `setState` — 선택된 확장 정보 저장
|
||||
|
||||
**특수사항**:
|
||||
- 템플릿 관리는 Admin/User 탭 분리 (`_tab_admin.json`, `_tab_user.json`)
|
||||
- 레이아웃 편집기 (`admin_template_layout_edit.json`)는 CodeEditor + 실시간 미리보기
|
||||
|
||||
---
|
||||
|
||||
### 에러 페이지 패턴
|
||||
|
||||
**대표**: `errors/404.json`
|
||||
|
||||
**구성**:
|
||||
```text
|
||||
(extends 없음 — 독립 레이아웃)
|
||||
components:
|
||||
└── Div (전체 화면 중앙 정렬)
|
||||
├── Icon (에러 아이콘)
|
||||
├── H1 (에러 코드)
|
||||
├── P (에러 메시지)
|
||||
└── Button (홈으로 이동)
|
||||
```
|
||||
|
||||
**핸들러 패턴**:
|
||||
- `navigate` — 대시보드/홈으로 이동
|
||||
|
||||
---
|
||||
|
||||
## 베이스 레이아웃 구조
|
||||
|
||||
### _admin_base.json
|
||||
|
||||
모든 관리자 페이지의 공통 구조를 정의합니다.
|
||||
|
||||
```text
|
||||
_admin_base.json
|
||||
├── init_actions: [initTheme, initMenuFromUrl]
|
||||
├── data_sources: [admin_menu, notifications]
|
||||
├── components:
|
||||
│ ├── AdminSidebar (menu: admin_menu.data)
|
||||
│ ├── AdminHeader (user, notifications)
|
||||
│ ├── Toast
|
||||
│ ├── PageTransitionIndicator
|
||||
│ └── Div (content area)
|
||||
│ └── slot: "content" (← 하위 레이아웃이 채움)
|
||||
└── AdminFooter
|
||||
```
|
||||
|
||||
**슬롯**:
|
||||
- `content` — 각 페이지의 메인 콘텐츠가 삽입되는 위치
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [sirsoft-admin_basic 컴포넌트](./components.md)
|
||||
- [sirsoft-admin_basic 핸들러](./handlers.md)
|
||||
- [레이아웃 JSON 스키마](../../layout-json.md)
|
||||
- [레이아웃 상속](../../layout-json-inheritance.md)
|
||||
- [sirsoft-basic 레이아웃](../sirsoft-basic/layouts.md)
|
||||
@@ -1,431 +0,0 @@
|
||||
# sirsoft-basic 핸들러
|
||||
|
||||
> **템플릿 식별자**: `sirsoft-basic` (type: user)
|
||||
> **관련 문서**: [액션 핸들러 개요](../../actions-handlers.md) | [컴포넌트](./components.md) | [레이아웃](./layouts.md)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유)
|
||||
2. 장바구니 6종: 선택/옵션/삭제/재계산 핸들러
|
||||
3. 상품 옵션 2종: 옵션 완료 시 자동 추가/수량 변경
|
||||
4. 다중 통화 5종: 가격 표시/포맷/통화 기호/선호 통화 로드/저장
|
||||
5. 스토리지 6종: 비회원 장바구니 키 관리 (localStorage + API 발급)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [테마 핸들러](#테마-핸들러)
|
||||
2. [장바구니 핸들러](#장바구니-핸들러)
|
||||
3. [장바구니 옵션 변경 핸들러](#장바구니-옵션-변경-핸들러)
|
||||
4. [상품 옵션 핸들러](#상품-옵션-핸들러)
|
||||
5. [다중 통화 핸들러](#다중-통화-핸들러)
|
||||
6. [스토리지 핸들러](#스토리지-핸들러)
|
||||
7. [핸들러 등록 맵](#핸들러-등록-맵)
|
||||
|
||||
---
|
||||
|
||||
## 테마 핸들러
|
||||
|
||||
**소스**: `src/handlers/setThemeHandler.ts`
|
||||
|
||||
sirsoft-admin_basic과 동일한 localStorage 키(`g7_color_scheme`)를 사용하여 테마 설정을 공유합니다.
|
||||
|
||||
### setTheme
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "dark"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
||||
|
||||
### initTheme
|
||||
|
||||
앱 시작 시 `init_actions`에서 호출. params 없음.
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{ "handler": "initTheme" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 장바구니 핸들러
|
||||
|
||||
**소스**: `src/handlers/cartHandlers.ts`
|
||||
|
||||
장바구니 페이지에서 상품 선택, 옵션 변경, 삭제 등을 처리합니다.
|
||||
|
||||
### toggleCartItemSelection
|
||||
|
||||
장바구니 아이템 선택/해제를 토글합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "toggleCartItemSelection",
|
||||
"params": {
|
||||
"itemId": "{{item.id}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### selectAllCartItems
|
||||
|
||||
모든 장바구니 아이템을 선택/해제합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "selectAllCartItems",
|
||||
"params": {
|
||||
"selected": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### setCartOption
|
||||
|
||||
장바구니 아이템의 옵션을 변경합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "setCartOption",
|
||||
"params": {
|
||||
"itemId": "{{item.id}}",
|
||||
"optionId": "{{selectedOption.id}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### openCartDeleteModal
|
||||
|
||||
장바구니 삭제 확인 모달을 엽니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "openCartDeleteModal",
|
||||
"params": {
|
||||
"itemId": "{{item.id}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### openCartOptionModal
|
||||
|
||||
장바구니 옵션 변경 모달을 엽니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "openCartOptionModal",
|
||||
"params": {
|
||||
"itemId": "{{item.id}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### recalculateCart
|
||||
|
||||
장바구니 합계를 재계산합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "recalculateCart"
|
||||
}
|
||||
```
|
||||
|
||||
### 장바구니 핸들러 요약
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|--------|--------|------|
|
||||
| `toggleCartItemSelection` | `{ itemId }` | 아이템 선택 토글 |
|
||||
| `selectAllCartItems` | `{ selected }` | 전체 선택/해제 |
|
||||
| `setCartOption` | `{ itemId, optionId }` | 옵션 변경 |
|
||||
| `openCartDeleteModal` | `{ itemId }` | 삭제 모달 열기 |
|
||||
| `openCartOptionModal` | `{ itemId }` | 옵션 변경 모달 열기 |
|
||||
| `recalculateCart` | 없음 | 합계 재계산 |
|
||||
|
||||
---
|
||||
|
||||
## 장바구니 옵션 변경 핸들러
|
||||
|
||||
**소스**: `src/handlers/cartOptionChange.ts`
|
||||
|
||||
장바구니 옵션 변경 모달에서 옵션 선택 및 매칭을 처리합니다.
|
||||
|
||||
### findMatchingOption
|
||||
|
||||
선택한 옵션 값들로 매칭되는 상품 옵션을 찾습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "findMatchingOption",
|
||||
"params": {
|
||||
"options": "{{_local.options}}",
|
||||
"selection": "{{_local.optionSelection}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### initCartOptionSelection
|
||||
|
||||
옵션 변경 모달 초기화 시 현재 선택된 옵션을 설정합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "initCartOptionSelection",
|
||||
"params": {
|
||||
"currentOption": "{{item.option}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 상품 옵션 핸들러
|
||||
|
||||
**소스**: `src/handlers/productOptions.ts`
|
||||
|
||||
상품 상세 페이지에서 옵션 선택 및 수량 변경을 처리합니다.
|
||||
|
||||
### addSelectedItemIfComplete (sirsoft-basic.addSelectedItemIfComplete)
|
||||
|
||||
모든 옵션 그룹 선택 완료 시 선택 아이템 목록에 자동 추가합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "sirsoft-basic.addSelectedItemIfComplete",
|
||||
"params": {
|
||||
"newGroupName": "{{groupName}}",
|
||||
"newValue": "{{selectedValue}}",
|
||||
"optionGroups": "{{product?.data?.option_groups}}",
|
||||
"options": "{{product?.data?.options}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### updateSelectedItemQuantity (sirsoft-basic.updateSelectedItemQuantity)
|
||||
|
||||
선택된 아이템의 수량을 변경합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "sirsoft-basic.updateSelectedItemQuantity",
|
||||
"params": {
|
||||
"optionId": "{{option.id}}",
|
||||
"quantity": "{{newQuantity}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다중 통화 핸들러
|
||||
|
||||
### getDisplayPrice (sirsoft-basic.getDisplayPrice)
|
||||
|
||||
**소스**: `src/handlers/getDisplayPrice.ts`
|
||||
|
||||
사용자 선호 통화에 맞는 가격을 반환합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "sirsoft-basic.getDisplayPrice",
|
||||
"params": {
|
||||
"product": "{{product.data}}",
|
||||
"priceField": "selling_price"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `product` | object | ✅ | 상품 객체 |
|
||||
| `priceField` | string | ✅ | `"selling_price"` 또는 `"list_price"` |
|
||||
| `currencyCode` | string | ❌ | 통화 코드 (미지정 시 전역 설정 사용) |
|
||||
|
||||
### formatCurrency (sirsoft-basic.formatCurrency)
|
||||
|
||||
**소스**: `src/handlers/formatCurrency.ts`
|
||||
|
||||
숫자 값을 통화 형식 문자열로 변환합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "sirsoft-basic.formatCurrency",
|
||||
"params": {
|
||||
"value": 10000,
|
||||
"currencyCode": "KRW"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `value` | number | ✅ | 포맷팅할 숫자 값 |
|
||||
| `currencyCode` | string | ❌ | 통화 코드 (KRW, USD, JPY, CNY, EUR) |
|
||||
| `locale` | string | ❌ | 로케일 (미지정 시 통화 기본 로케일 사용) |
|
||||
|
||||
지원 통화: KRW (₩), USD ($), JPY (¥), CNY (¥), EUR (€)
|
||||
|
||||
### getCurrencySymbol (sirsoft-basic.getCurrencySymbol)
|
||||
|
||||
통화 기호를 반환합니다.
|
||||
|
||||
### loadPreferredCurrency (sirsoft-basic.loadPreferredCurrency)
|
||||
|
||||
**소스**: `src/handlers/loadPreferredCurrency.ts`
|
||||
|
||||
localStorage에서 선호 통화를 로드하여 전역 상태(`_global.preferredCurrency`)에 설정합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{
|
||||
"handler": "sirsoft-basic.loadPreferredCurrency",
|
||||
"params": { "defaultCurrency": "KRW" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### savePreferredCurrency (sirsoft-basic.savePreferredCurrency)
|
||||
|
||||
선호 통화를 localStorage에 저장합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "sirsoft-basic.savePreferredCurrency",
|
||||
"params": {
|
||||
"currencyCode": "{{selectedCurrency}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 스토리지 핸들러
|
||||
|
||||
**소스**: `src/handlers/storageHandlers.ts`
|
||||
|
||||
비로그인 사용자의 장바구니 키 등 클라이언트 스토리지를 관리합니다.
|
||||
|
||||
### initCartKey
|
||||
|
||||
장바구니 키를 초기화합니다. localStorage에 있으면 로드, 없으면 API를 통해 발급합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{ "handler": "initCartKey" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### getCartKey / clearCartKey / regenerateCartKey
|
||||
|
||||
```json
|
||||
{ "handler": "getCartKey" }
|
||||
{ "handler": "clearCartKey" }
|
||||
{ "handler": "regenerateCartKey" }
|
||||
```
|
||||
|
||||
### saveToStorage / loadFromStorage
|
||||
|
||||
범용 localStorage 저장/로드 핸들러입니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "saveToStorage",
|
||||
"params": {
|
||||
"key": "g7_some_setting",
|
||||
"value": "{{_local.settingValue}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "loadFromStorage",
|
||||
"params": {
|
||||
"key": "g7_some_setting",
|
||||
"stateKey": "savedSetting"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 스토리지 핸들러 요약
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|--------|--------|------|
|
||||
| `initCartKey` | 없음 | 장바구니 키 초기화 (localStorage + API) |
|
||||
| `getCartKey` | 없음 | 현재 장바구니 키 반환 |
|
||||
| `clearCartKey` | 없음 | 장바구니 키 삭제 |
|
||||
| `regenerateCartKey` | 없음 | 장바구니 키 재발급 |
|
||||
| `saveToStorage` | `{ key, value }` | localStorage 저장 |
|
||||
| `loadFromStorage` | `{ key, stateKey }` | localStorage 로드 → 상태에 설정 |
|
||||
|
||||
---
|
||||
|
||||
## 핸들러 등록 맵
|
||||
|
||||
**소스**: `src/handlers/index.ts`
|
||||
|
||||
| 등록 키 | 소스 파일 | 설명 |
|
||||
|---------|----------|------|
|
||||
| `setTheme` | setThemeHandler.ts | 테마 변경 |
|
||||
| `initTheme` | setThemeHandler.ts | 테마 초기화 |
|
||||
| `toggleCartItemSelection` | cartHandlers.ts | 장바구니 아이템 선택 |
|
||||
| `selectAllCartItems` | cartHandlers.ts | 장바구니 전체 선택 |
|
||||
| `setCartOption` | cartHandlers.ts | 장바구니 옵션 변경 |
|
||||
| `openCartDeleteModal` | cartHandlers.ts | 삭제 모달 열기 |
|
||||
| `openCartOptionModal` | cartHandlers.ts | 옵션 모달 열기 |
|
||||
| `recalculateCart` | cartHandlers.ts | 장바구니 재계산 |
|
||||
| `findMatchingOption` | cartOptionChange.ts | 매칭 옵션 검색 |
|
||||
| `initCartOptionSelection` | cartOptionChange.ts | 옵션 선택 초기화 |
|
||||
| `sirsoft-basic.addSelectedItemIfComplete` | productOptions.ts | 옵션 완료 시 자동 추가 |
|
||||
| `sirsoft-basic.updateSelectedItemQuantity` | productOptions.ts | 수량 변경 |
|
||||
| `sirsoft-basic.getDisplayPrice` | getDisplayPrice.ts | 통화별 가격 표시 |
|
||||
| `sirsoft-basic.formatCurrency` | formatCurrency.ts | 통화 포맷팅 |
|
||||
| `sirsoft-basic.getCurrencySymbol` | formatCurrency.ts | 통화 기호 |
|
||||
| `sirsoft-basic.loadPreferredCurrency` | loadPreferredCurrency.ts | 선호 통화 로드 |
|
||||
| `sirsoft-basic.savePreferredCurrency` | loadPreferredCurrency.ts | 선호 통화 저장 |
|
||||
| `initCartKey` | storageHandlers.ts | 장바구니 키 초기화 |
|
||||
| `getCartKey` | storageHandlers.ts | 장바구니 키 조회 |
|
||||
| `clearCartKey` | storageHandlers.ts | 장바구니 키 삭제 |
|
||||
| `regenerateCartKey` | storageHandlers.ts | 장바구니 키 재발급 |
|
||||
| `saveToStorage` | storageHandlers.ts | localStorage 저장 |
|
||||
| `loadFromStorage` | storageHandlers.ts | localStorage 로드 |
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
```text
|
||||
이 핸들러들은 sirsoft-basic 템플릿에서만 등록됨
|
||||
sirsoft-basic. 접두사 핸들러는 풀네임으로 호출해야 함
|
||||
setLocale은 엔진 레벨(ActionDispatcher) 빌트인 — 별도 등록 불필요
|
||||
✅ 범용 핸들러(navigate, apiCall, setState 등)는 actions-handlers.md 참조
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [액션 핸들러 개요](../../actions-handlers.md)
|
||||
- [sirsoft-basic 컴포넌트](./components.md)
|
||||
- [sirsoft-basic 레이아웃](./layouts.md)
|
||||
- [sirsoft-admin_basic 핸들러](../sirsoft-admin_basic/handlers.md)
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.2] - 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가
|
||||
|
||||
## [1.0.1] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -203,6 +203,7 @@ return [
|
||||
'register' => '会員登録',
|
||||
'mypage' => 'マイページ',
|
||||
'mypage_renew_all' => 'マイページ一括再同意',
|
||||
'withdraw' => '会員退会',
|
||||
],
|
||||
'col' => [
|
||||
'created_at' => '時点',
|
||||
|
||||
@@ -238,7 +238,8 @@
|
||||
"preference_center": "環境設定",
|
||||
"mypage": "マイページ",
|
||||
"register": "会員登録",
|
||||
"mypage_renew_all": "マイページ一括再同意"
|
||||
"mypage_renew_all": "マイページ一括再同意",
|
||||
"withdraw": "会員退会"
|
||||
},
|
||||
"col": {
|
||||
"created_at": "時点",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 plugin (sirsoft-gdpr) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-gdpr) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-gdpr",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Hello 모듈 — 에이전트 가이드
|
||||
|
||||
> 이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 유형: 모듈 (gnuboard7-hello_module) — 학습용 최소 샘플. 메모(Memo) 하나로 모듈의 전 계층을 1파일씩 시연한다. 실제 업무 기능 없음, `hidden: true`
|
||||
2. 확장 방식: 발행 훅 1개(`memo.created`) — `gnuboard7-hello_plugin` 이 그것을 구독하고, `gnuboard7-hello_user_template` 은 공개 API 를 소비한다
|
||||
3. 건드리면 안 되는 것: 샘플에 기능 추가(짧게 유지), `hidden` 제거, 검증을 Service 에 넣기, Repository 구체 클래스 주입 — 샘플은 규약의 본보기다
|
||||
4. 작업 위치: `modules/_bundled/gnuboard7-hello_module` — 활성 디렉토리 직접 수정 금지
|
||||
5. 반영: `php artisan module:update gnuboard7-hello_module --force`
|
||||
```
|
||||
|
||||
## 1. 이 확장은 무엇인가
|
||||
|
||||
<!-- @intent START -->
|
||||
**학습용 최소 샘플 모듈**입니다. 실제 업무 기능을 제공하지 않으며, 모듈이 필요로 하는
|
||||
계층을 **하나씩만** 담아 "모듈은 이런 모양이다" 를 보여주는 것이 유일한 목적입니다.
|
||||
|
||||
도메인은 메모(Memo) 하나이고 필드는 셋뿐입니다. 그 위에 Model · Migration · Factory ·
|
||||
Seeder · Repository(인터페이스 + 구현) · Service · FormRequest · Resource · Controller ·
|
||||
Listener · Layout · Test · 다국어(백엔드 PHP + 프론트 JSON)가 각 1개씩 있습니다. 실제
|
||||
모듈은 이 계층을 엔티티 수만큼 늘린 것입니다.
|
||||
|
||||
**설계 원칙: 짧게 유지한다.** 샘플의 가치는 완결성이 아니라 **한눈에 읽히는 것**입니다.
|
||||
여기에 기능을 더하면 계층 구조를 보러 온 사람이 도메인 로직을 읽게 되므로, 새 기능이
|
||||
필요하면 이 샘플이 아니라 별도 확장을 만듭니다.
|
||||
|
||||
`manifest.hidden = true` 라 관리자 UI 의 모듈 목록에 나타나지 않습니다. artisan CLI 로는
|
||||
정상 설치·활성화됩니다 — 학습용이 운영 화면에 섞이지 않게 하면서도 실제로 동작해 봐야
|
||||
학습이 되기 때문입니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: 검색 색인·SEO·알림·스케줄·미들웨어·브로드캐스트. 각 축의 사용법은
|
||||
그것을 실제로 쓰는 확장(게시판·이커머스)의 문서가 다룹니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 2. 디렉토리 지도
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
|
||||
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
|
||||
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
|
||||
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
|
||||
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
|
||||
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update gnuboard7-hello_module --force` (빌드 불필요) |
|
||||
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan module:update gnuboard7-hello_module --force` |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
|
||||
## 3. 핵심 흐름
|
||||
|
||||
<!-- @intent START -->
|
||||
계층 하나씩을 지나는 **가장 짧은 CRUD** 가 이 샘플의 전부입니다.
|
||||
|
||||
**메모 생성**: `Admin\MemoController::store()` → `MemoRequest`(검증) →
|
||||
`MemoService::create()` → `MemoRepositoryInterface`(인터페이스 주입) → `MemoRepository` →
|
||||
`Memo` 모델. 저장 직후 `gnuboard7-hello_module.memo.created` 액션 훅을 발행하고,
|
||||
`LogMemoCreatedListener` 가 그것을 받아 로그를 남깁니다.
|
||||
|
||||
이 한 흐름에 그누보드7 모듈의 규약이 전부 들어 있습니다:
|
||||
|
||||
- 검증은 Service 가 아니라 **FormRequest** 에 둔다
|
||||
- Service 는 구체 Repository 가 아니라 **인터페이스**를 주입받는다
|
||||
- 부가 작업(로그·알림·집계)은 Service 안이 아니라 **훅 리스너**로 뺀다
|
||||
- 응답 형태는 컨트롤러가 조립하지 않고 **Resource** 가 정한다
|
||||
|
||||
**같은 모듈이 자기 훅을 구독하는 것**도 의도된 예시입니다. 실제로는 다른 확장이 구독하지만,
|
||||
샘플 하나만 설치해도 훅 흐름이 눈에 보이게 하려고 리스너를 같이 넣었습니다 —
|
||||
`gnuboard7-hello_plugin` 을 함께 설치하면 **바깥에서 구독하는** 모습도 볼 수 있습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 4. 확장점
|
||||
|
||||
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 확장점 | 수 | 상세 |
|
||||
|---|---|---|
|
||||
| 발행 훅 | 1개 | [발행 훅](docs/extension-points.md#발행-훅) |
|
||||
| 구독 훅 | 1개 | [구독 훅](docs/extension-points.md#구독-훅) |
|
||||
| 훅 리스너 | 1개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
|
||||
| 레이아웃 확장 | 0개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
|
||||
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
|
||||
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
|
||||
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
|
||||
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
|
||||
<!-- @generated:extension-points-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
발행 훅 하나(`memo.created`)가 전부입니다. 실제 모듈이라면 도메인마다
|
||||
`before_*` → `filter_*_data` → `after_*` 3단을 두지만, 샘플에서는 **훅이 무엇인지**만
|
||||
보이면 되므로 하나로 줄였습니다.
|
||||
|
||||
`gnuboard7-hello_plugin` 이 이 훅을 구독합니다. 두 샘플을 함께 설치하면 "모듈이 발행하고
|
||||
플러그인이 받는" 확장 시스템의 기본 관계를 실제로 확인할 수 있습니다 — 플러그인이 그
|
||||
모듈에 `dependencies` 로 묶여 있는 것도 그 관계의 표현입니다.
|
||||
|
||||
`gnuboard7-hello_user_template` 도 이 모듈에 의존합니다. 그쪽은 훅이 아니라 **공개 API 를
|
||||
`data_sources` 로 소비**하는 관계이며, 모듈이 데이터를, 템플릿이 화면을 담당하는 경계를
|
||||
보여줍니다.
|
||||
|
||||
미들웨어·브로드캐스트 채널·스케줄·알림은 없습니다. 샘플에 넣으면 계층 구조를 보러 온 사람이
|
||||
읽어야 할 코드가 늘어납니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 5. 수정 시 동반 의무
|
||||
|
||||
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update gnuboard7-hello_module --force` 로 반영
|
||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
|
||||
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
|
||||
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
|
||||
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:gnuboard7-hello_module` 재실행 + `docs/api/**` 갱신
|
||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
||||
- [ ] 계층을 늘리기 전에 "이것이 샘플에 필요한가" 를 먼저 묻는다 — 샘플의 가치는 한눈에 읽히는 것이다
|
||||
- [ ] `manifest.hidden = true` 를 유지 (복제본에서만 제거)
|
||||
- [ ] 규약(FormRequest 검증 · Repository 인터페이스 주입 · 훅으로 부가작업 분리)이 흐트러지지 않았는지 확인 — 이 코드는 본보기로 읽힌다
|
||||
- [ ] `docs/extension/sample-extensions.md` 의 계층 표와 어긋나지 않는지 확인 (파일을 추가·삭제했다면 그 표도 갱신)
|
||||
- [ ] 발행 훅 이름을 바꾸면 `gnuboard7-hello_plugin` 의 구독이 조용히 끊긴다
|
||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어 `memoData` · `memos` 가 편집기 캔버스에서 빈 화면으로 보인다. `data_source` 를 더 늘리면 그 자리도 같은 상태가 된다
|
||||
|
||||
## 6. 금지 패턴
|
||||
|
||||
<!-- @intent START -->
|
||||
| 금지 | 올바른 사용 | 이유 |
|
||||
|---|---|---|
|
||||
| 이 샘플에 기능을 더해 "쓸모 있게" 만들기 | 짧게 유지하고, 필요한 기능은 별도 확장으로 | 샘플의 가치는 한눈에 읽히는 것이다. 계층을 보러 온 사람이 도메인 로직을 읽게 되면 목적이 사라진다 |
|
||||
| `manifest.hidden` 을 제거 | 그대로 둔다 (복제본에서만 제거) | 학습용 모듈이 운영 사이트의 모듈 목록에 섞인다 |
|
||||
| 복제해 새 모듈을 만들면서 `hidden` 을 남겨 두기 | 복제본에서는 제거하거나 `false` | 새 모듈이 관리자 UI 에 나타나지 않는다 |
|
||||
| 복제 후 식별자·네임스페이스를 부분만 치환 | `gnuboard7-hello_module` · `Gnuboard7\HelloModule` · `hello_module` · `Memo` 계열을 **전부** 치환 | 남은 옛 이름이 오토로드 실패나 테이블 이름 충돌로 나타난다 |
|
||||
| 검증 로직을 `MemoService` 에 넣기 | `MemoRequest` (FormRequest) | 샘플이 잘못된 본을 보이면 그것을 따라 한 모듈이 전부 같은 형태가 된다 |
|
||||
| `MemoService` 가 `MemoRepository` 구체 클래스를 타입힌트 | `MemoRepositoryInterface` | 위와 같은 이유 — 샘플은 규약의 본보기다 |
|
||||
| 훅 발행 없이 Service 안에서 로그·알림을 직접 수행 | 훅 발행 + 리스너 | 부가 작업이 Service 에 쌓이면 그 Service 를 재사용할 수 없다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 7. 테스트 실행
|
||||
|
||||
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 3개 | `modules/_bundled/gnuboard7-hello_module/tests` |
|
||||
| Vitest | 0개 | — |
|
||||
| Playwright | 0개 | — |
|
||||
| 시나리오 매니페스트 | 0개 | — |
|
||||
|
||||
기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
||||
|
||||
```bash
|
||||
# PHPUnit (변경 범위만) (Bash)
|
||||
php vendor/bin/phpunit modules/_bundled/gnuboard7-hello_module/tests --filter='<대상클래스>'
|
||||
|
||||
```
|
||||
|
||||
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
|
||||
<!-- @generated:test-commands END -->
|
||||
|
||||
## 8. 문서 목차
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
@@ -4,6 +4,14 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [0.1.2] - 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||
|
||||
## [0.1.1] - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Hello 모듈
|
||||
|
||||
**그누보드7 모듈 · gnuboard7-hello_module**
|
||||
학습용 최소 샘플 모듈 (Memo CRUD)
|
||||
|
||||
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.1.2-0066FF?style=flat-square" alt="version 0.1.2">
|
||||
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.0-1F883D?style=flat-square" alt="그누보드7 >=7.0.0">
|
||||
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
|
||||
</p>
|
||||
<!-- @generated:badges END -->
|
||||
|
||||
---
|
||||
|
||||
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
|
||||
|
||||
---
|
||||
|
||||
## 소개
|
||||
|
||||
<!-- @intent START -->
|
||||
그누보드7 **모듈이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 업무에 쓰는 기능은
|
||||
없고, 메모를 등록·수정·삭제하는 가장 단순한 화면 하나가 전부입니다.
|
||||
|
||||
모듈을 처음 만들어 보는 개발자가 "무엇을 어디에 두어야 하는가" 를 파악하는 데 쓰거나, 새
|
||||
모듈을 시작할 때 **복제해서 이름만 바꾸는 출발점**으로 씁니다.
|
||||
|
||||
관리자 화면의 모듈 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로는
|
||||
정상적으로 설치·활성화할 수 있으며, 설치하면 관리자에 "Hello 메모" 메뉴가 생깁니다.
|
||||
|
||||
이 샘플은 짧게 유지하는 것이 원칙입니다 — 기능이 늘어나면 구조를 보러 온 사람이 읽어야 할
|
||||
코드가 함께 늘어나기 때문입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 주요 기능
|
||||
|
||||
<!-- @intent START -->
|
||||
| 영역 | 설명 |
|
||||
|---|---|
|
||||
| 메모 관리 | 제목·내용으로 메모를 등록·수정·삭제하는 관리자 화면 |
|
||||
| 메모 목록 | 방문자 화면용 목록 레이아웃 1개 (사용자 템플릿 연동 예시) |
|
||||
| 권한 | 메모 관리 권한 4종(읽기·생성·수정·삭제) |
|
||||
| 다국어 | 한국어·영어 (관리자 문구와 화면 문구 각각) |
|
||||
| 확장 지점 | 메모 생성 시점 알림용 연결점 1개 |
|
||||
| 테스트 | 기능 테스트와 단위 테스트 예시 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 동작 방식
|
||||
|
||||
<!-- @intent START -->
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[운영자] -->|메모 등록| ADM[관리자 화면]
|
||||
ADM --> SVC[메모 처리]
|
||||
SVC --> DB[(메모 데이터)]
|
||||
SVC -->|생성 알림| L[연결된 확장]
|
||||
T[사용자 템플릿] -->|목록 조회| SVC
|
||||
```
|
||||
|
||||
운영자가 메모를 등록하면 저장과 함께 "메모가 생성되었다" 는 신호가 나갑니다. 다른 확장은 그
|
||||
신호를 받아 자기 일을 할 수 있습니다 — 같이 제공되는 학습용 플러그인이 그 예입니다.
|
||||
|
||||
실제 모듈도 구조는 같고, 다루는 대상과 규모만 다릅니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 요구 사항
|
||||
|
||||
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 그누보드7 코어 | `>=7.0.0` |
|
||||
| PHP | `^8.2` |
|
||||
<!-- @generated:requirements END -->
|
||||
|
||||
## 설치
|
||||
|
||||
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
```bash
|
||||
# 번들 설치 (코어에 동봉된 소스에서 설치)
|
||||
php artisan module:install gnuboard7-hello_module
|
||||
|
||||
# 활성화
|
||||
php artisan module:activate gnuboard7-hello_module
|
||||
|
||||
# 업데이트 (번들 소스 기준 강제 반영)
|
||||
php artisan module:update gnuboard7-hello_module --force
|
||||
```
|
||||
<!-- @generated:install END -->
|
||||
|
||||
## 관리자 설정
|
||||
|
||||
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_별도의 관리자 설정 항목이 없습니다._
|
||||
<!-- @generated:settings-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
설정 항목이 없습니다. 이 샘플은 설정 화면 없이도 모듈의 계층 구조를 보여줄 수 있어 일부러
|
||||
두지 않았습니다.
|
||||
|
||||
설정 화면이 있는 모듈의 예를 보려면 함께 제공되는 학습용 플러그인
|
||||
(`gnuboard7-hello_plugin`)을 참고합니다 — 그쪽에 설정 스키마와 설정 화면 예시가 있습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 사용 방법
|
||||
|
||||
<!-- @intent START -->
|
||||
**설치해 보기**: 관리자 화면에는 나타나지 않으므로 명령줄로 설치합니다.
|
||||
|
||||
```bash
|
||||
php artisan module:install gnuboard7-hello_module
|
||||
php artisan module:activate gnuboard7-hello_module
|
||||
```
|
||||
|
||||
활성화하면 관리자에 "Hello 메모" 메뉴가 생깁니다. 메모를 몇 건 등록해 보면 목록·작성 화면과
|
||||
권한이 어떻게 맞물리는지 확인할 수 있습니다.
|
||||
|
||||
**새 모듈의 출발점으로 쓰기**: 이 디렉토리를 복제한 뒤 식별자·네임스페이스·도메인 이름을 모두
|
||||
바꾸고, `hidden` 표시를 지우면 새 모듈이 됩니다. 자세한 절차는 확장 시스템 문서의 "학습용 샘플
|
||||
확장" 항목을 참고합니다.
|
||||
|
||||
**함께 보면 좋은 것**: 학습용 플러그인·관리자 템플릿·사용자 템플릿 샘플이 함께 제공됩니다. 넷을
|
||||
모두 설치하면 모듈이 데이터를, 템플릿이 화면을, 플러그인이 부가 동작을 담당하는 구조를 한 번에
|
||||
볼 수 있습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 다른 확장과의 연동
|
||||
|
||||
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
없음 — 코어만으로 동작합니다.
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
| 확장 | 유형 | 요구 버전 |
|
||||
|---|---|---|
|
||||
| `gnuboard7-hello_plugin` | 플러그인 | `>=0.1.0` |
|
||||
| `gnuboard7-hello_user_template` | 템플릿 | `>=0.1.0` |
|
||||
<!-- @generated:integrations END -->
|
||||
|
||||
## 문서
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
<!-- @intent START -->
|
||||
| 증상 | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
| 관리자 모듈 목록에 이 모듈이 없음 | 학습용이라 목록에서 제외됨 | 정상입니다. 명령줄로 설치·활성화합니다 |
|
||||
| 복제해서 만든 모듈이 관리자 목록에 안 보임 | 복제본에 학습용 표시가 남아 있음 | 복제본의 `hidden` 표시를 지웁니다 |
|
||||
| 복제 후 설치하면 오류가 남 | 식별자·네임스페이스 치환이 일부만 이루어짐 | 옛 이름이 남아 있는지 전체 검색으로 확인하고 오토로드를 갱신합니다 |
|
||||
| "Hello 메모" 메뉴가 보이지 않음 | 그 계정 역할에 메모 관리 권한이 없음 | 역할에 메모 관리 권한을 부여합니다 |
|
||||
| 메모를 등록해도 아무 일도 일어나지 않음 | 학습용 플러그인이 설치되지 않음 | 생성 신호를 받아 동작하는 예시는 그 플러그인에 있습니다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 변경 이력
|
||||
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/gnuboard7-hello_module",
|
||||
"description": "Hello sample module for Gnuboard7 (learning purpose)",
|
||||
"type": "library",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Hello 모듈 개발자 문서
|
||||
|
||||
> modules/_bundled/gnuboard7-hello_module · 모듈
|
||||
|
||||
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**훅 수**: 1 · **구독 훅 수**: 1 · **라우트 수**: 7 · **모델 수**: 1 · **테이블 수**: 1 · **마이그레이션 수**: 1 · **레이아웃 수**: 3 · **핸들러 수**: 0
|
||||
<!-- @generated:stats END -->
|
||||
|
||||
## 문서 목차
|
||||
|
||||
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
|
||||
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
|
||||
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
|
||||
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
|
||||
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
|
||||
| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 |
|
||||
| [api/](api/README.md) | API 레퍼런스 |
|
||||
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
|
||||
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
|
||||
<!-- @generated:doc-toc END -->
|
||||
@@ -0,0 +1,79 @@
|
||||
# Hello 모듈 — 아키텍처
|
||||
|
||||
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설계 의도
|
||||
|
||||
<!-- @intent START -->
|
||||
"모듈이 필요로 하는 계층을 **하나씩만** 담는다" 가 이 확장의 유일한 설계 목표입니다. 도메인은
|
||||
메모 하나, 필드는 셋뿐이고, 그 위에 Model · Migration · Factory · Seeder · Repository(인터페이스
|
||||
+ 구현) · Service · FormRequest · Resource · Controller · Listener · Layout · Test · 다국어가
|
||||
각 1개씩 있습니다. 실제 모듈은 이 계층을 엔티티 수만큼 늘린 것입니다.
|
||||
|
||||
**짧게 유지하는 것이 기능보다 우선입니다.** 샘플의 가치는 완결성이 아니라 한눈에 읽히는
|
||||
것이므로, 기능을 더하면 계층 구조를 보러 온 사람이 도메인 로직을 읽게 됩니다.
|
||||
|
||||
`manifest.hidden = true` 는 학습용이 운영 사이트의 모듈 목록에 섞이지 않게 하면서도 CLI 로는
|
||||
실제로 설치·동작하게 하는 장치입니다 — 읽기만 해서는 학습이 되지 않기 때문입니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: 검색 색인·SEO·알림·스케줄·미들웨어·브로드캐스트·설정 화면. 각
|
||||
축의 사용법은 그것을 실제로 쓰는 확장의 문서가 다룹니다. 설정 화면 예시는 함께 제공되는
|
||||
`gnuboard7-hello_plugin` 에 있습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 계층 지도
|
||||
|
||||
<!-- @intent START -->
|
||||
```
|
||||
module.php 진입 클래스 — 권한 · 메뉴 · 리스너 선언
|
||||
│
|
||||
Http/Controllers/Admin/MemoController RESTful CRUD
|
||||
│
|
||||
Http/Requests/Admin/MemoRequest 검증 (Service 가 아니라 여기)
|
||||
│
|
||||
Services/MemoService 비즈니스 로직 + 훅 발행
|
||||
│ Contracts/Repositories/MemoRepositoryInterface ← 이것을 주입받는다
|
||||
▼
|
||||
Repositories/MemoRepository Eloquent 구현
|
||||
│
|
||||
Models/Memo gnuboard7_hello_module_memos
|
||||
|
||||
Http/Resources/MemoResource 응답 형태 (컨트롤러가 조립하지 않는다)
|
||||
Listeners/LogMemoCreatedListener memo.created 구독 — 부가 작업은 여기
|
||||
resources/layouts/ admin 2 + user 1
|
||||
```
|
||||
|
||||
이 지도가 곧 **그누보드7 모듈의 규약**입니다 — 검증은 FormRequest, 데이터 접근은 Repository
|
||||
인터페이스, 부가 작업은 훅 리스너, 응답 형태는 Resource. 샘플이 잘못된 본을 보이면 그것을 따라
|
||||
한 모듈이 전부 같은 형태가 되므로, 이 네 경계는 편의를 위해서도 흐트러뜨리지 않습니다.
|
||||
|
||||
`user_memo_list` 레이아웃 하나가 `user` 그룹인 것에 주의합니다. 실제 도메인 모듈(게시판·
|
||||
이커머스)은 방문자 화면을 소유하지 않고 템플릿에 맡기지만, 이 샘플은 **모듈도 사용자 레이아웃을
|
||||
가질 수 있다**는 사실을 보이기 위해 하나를 둡니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 디렉토리
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
|
||||
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
|
||||
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
|
||||
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
|
||||
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
|
||||
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update gnuboard7-hello_module --force` (빌드 불필요) |
|
||||
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan module:update gnuboard7-hello_module --force` |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
@@ -0,0 +1,87 @@
|
||||
# Hello 모듈 — 데이터 모델
|
||||
|
||||
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 모델
|
||||
|
||||
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 모델 | 테이블 | fillable | 관계 | 특성 |
|
||||
|---|---|---|---|---|
|
||||
| `Memo` | `gnuboard7_hello_module_memos` | 3 | - | - |
|
||||
<!-- @generated:models END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`Memo` 하나이며 fillable 이 셋뿐입니다. 관계도 특성(SoftDeletes·검색 색인 등)도 없습니다 —
|
||||
"모델은 이런 모양이다" 를 보이는 데 그 이상이 필요하지 않기 때문입니다.
|
||||
|
||||
실제 모듈이 모델에 붙이는 것들(관계·캐스팅·스코프·SoftDeletes·검색 색인·`HasUserOverrides`)은
|
||||
그것을 실제로 쓰는 확장의 문서를 참고합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 소유 테이블
|
||||
|
||||
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 테이블 | 모델 |
|
||||
|---|---|
|
||||
| `gnuboard7_hello_module_memos` | `Memo` |
|
||||
<!-- @generated:tables END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`gnuboard7_hello_module_memos` 하나입니다. 테이블 이름에 **확장 식별자 전체가 접두사로**
|
||||
들어가는 것에 주의합니다 — 확장은 같은 데이터베이스를 공유하므로, 짧은 이름(`memos`)을 쓰면
|
||||
다른 확장과 충돌합니다.
|
||||
|
||||
복제해서 새 모듈을 만들 때 이 접두사도 함께 바꿔야 합니다. 마이그레이션 파일명·클래스 안의
|
||||
테이블 이름·모델의 `$table` 이 모두 대상입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
마이그레이션 1개.
|
||||
|
||||
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|
||||
|---|---|---|---|
|
||||
| `2026_04_21_000001_create_gnuboard7_hello_module_memos_table.php` | `gnuboard7_hello_module_memos` | `gnuboard7_hello_module_memos` | ✅ |
|
||||
<!-- @generated:migrations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
하나이며 테이블 생성뿐입니다. 한국어 `comment` 와 `down()` 이 붙어 있는 것이 규약의
|
||||
본보기입니다.
|
||||
|
||||
실제 모듈에서 새 컬럼을 더할 때는 이 `create_*` 파일을 고치지 않습니다 — 이미 설치된 사이트는
|
||||
그 파일을 다시 실행하지 않으므로 반영되지 않습니다. 새 `add_*` 파일을 더하고, 기존 행을
|
||||
손봐야 하면 `upgrades/` 의 업그레이드 스텝 백필을 함께 씁니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## Enum
|
||||
|
||||
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_Enum 이 없습니다._
|
||||
<!-- @generated:enums END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 메모에는 상태도 분류도 없어 닫힌 어휘가 생기지 않았습니다.
|
||||
|
||||
실제 모듈에서 상태·타입·분류를 다룰 때는 문자열 리터럴이 아니라 Enum 을 단일 출처로 둡니다 —
|
||||
화면 필터 옵션·검증 게이트·실제 기록 값 셋이 같은 Enum 에서 파생되지 않으면, 빠진 값으로
|
||||
기록된 행이 어떤 필터로도 도달할 수 없게 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## Repository
|
||||
|
||||
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 클래스 | 종류 | 설명 |
|
||||
|---|---|---|
|
||||
| `MemoRepository` | 구현 | 메모 Repository 구현체 |
|
||||
| `MemoRepositoryInterface` | 인터페이스 | 메모 Repository 인터페이스 |
|
||||
<!-- @generated:repositories END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
인터페이스와 구현이 1:1 로 짝을 이룹니다. **`MemoService` 는 인터페이스만 주입받습니다** —
|
||||
구체 클래스를 타입힌트하면 그 Service 를 다른 구현으로 바꿀 수 없고, 테스트에서 대역을 끼울
|
||||
수도 없습니다.
|
||||
|
||||
바인딩은 모듈 서비스 프로바이더가 담당합니다. 새 Repository 를 더할 때는 인터페이스·구현·
|
||||
바인딩 셋을 함께 만듭니다 — 바인딩을 빠뜨리면 주입 시점에 해결 실패로 드러납니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,86 @@
|
||||
# Hello 모듈 — 레이아웃 편집기 스펙
|
||||
|
||||
> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 선언 요약
|
||||
|
||||
<!-- @generated:editor-spec-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._
|
||||
<!-- @generated:editor-spec-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
학습용 샘플 모듈이라 편집기 스펙을 일부러 두지 않았습니다. 이 모듈의 목적은 모듈의
|
||||
최소 구조(라우트 → 컨트롤러 → 서비스 → 저장소)를 보여 주는 것이고, 편집기 스펙은 그
|
||||
구조와 무관한 별개 축입니다.
|
||||
|
||||
다만 아래 "샘플 데이터와 페이지 상태" 절이 보여 주듯, 이 모듈에는 프리뷰가 비는 자리가
|
||||
실제로 있습니다. 스펙이 없어도 되는 상태와 스펙이 필요한데 없는 상태는 다릅니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 선언 블록
|
||||
|
||||
<!-- @generated:editor-spec-blocks START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_선언된 편집기 스펙 블록이 없습니다._
|
||||
<!-- @generated:editor-spec-blocks END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
선언한 블록이 없으므로 표가 비어 있습니다. 이것은 "편집기가 이 모듈을 다루지 않는다"
|
||||
가 아니라 "이 모듈이 편집기에 아무것도 알려 주지 않는다" 는 뜻입니다 — 편집기는 여전히
|
||||
이 모듈의 레이아웃을 열 수 있고, 다만 데이터가 붙지 않은 채로 엽니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 컴포넌트 팔레트
|
||||
|
||||
<!-- @generated:editor-spec-palette START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._
|
||||
<!-- @generated:editor-spec-palette END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다.
|
||||
편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도
|
||||
`componentPalette` 는 여전히 비어 있을 것입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 샘플 데이터와 페이지 상태
|
||||
|
||||
<!-- @generated:editor-spec-samples START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._
|
||||
|
||||
**프리뷰 샘플이 없는 `data_source` 2개** — 편집기 캔버스에서 이 자리만 빈 화면이 됩니다. 실제 화면은 정상이라 오류도 경고도 남지 않습니다.
|
||||
|
||||
`memoData` · `memos`
|
||||
<!-- @generated:editor-spec-samples END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`memoData` · `memos` 두 ID 가 미커버입니다. 메모 목록과 폼이 편집기 캔버스에서 빈
|
||||
채로 보인다는 뜻입니다.
|
||||
|
||||
샘플 모듈이므로 이 상태를 그대로 두는 것도 선택입니다 — 다만 그것은 "편집기 스펙이
|
||||
없으면 어떤 화면이 되는가" 를 보여 주는 교보재로서 의도적으로 남긴 것이지, 문제가
|
||||
없다는 뜻이 아닙니다. 스펙을 하나 만들어 보는 것이 이 모듈로 할 수 있는 좋은 연습입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 수정 시 동반 의무
|
||||
|
||||
<!-- @generated:editor-spec-obligations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._
|
||||
|
||||
| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 |
|
||||
|---|---|
|
||||
| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 |
|
||||
| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) |
|
||||
| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 |
|
||||
| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 |
|
||||
| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 |
|
||||
<!-- @generated:editor-spec-obligations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
편집기 스펙을 신설해야 하는 시점은 하나입니다 — **이 확장이 소유한 레이아웃에
|
||||
이 확장만 쓰는 `data_source` 가 생겼을 때**. 그 순간부터 편집기 캔버스의 그 영역은
|
||||
빈 화면이 되고, 실제 화면은 정상 동작하므로 오류도 경고도 남지 않습니다.
|
||||
|
||||
신설 절차는 확장 루트에 `editor-spec.json` 을 만들고 `sampleData.byDataSourceId` 에
|
||||
그 ID 를 넣는 것으로 시작합니다. 팔레트·컨트롤은 템플릿의 일이므로 넣지 않습니다.
|
||||
파일을 만든 뒤 update 커맨드로 활성 디렉토리에 반영해야 편집기가 읽습니다 —
|
||||
`_bundled` 폴백이 없습니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,125 @@
|
||||
# Hello 모듈 — 확장점
|
||||
|
||||
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 발행 훅
|
||||
|
||||
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
발행 훅 1종 / 호출 지점 1곳. 이 중 1종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
|
||||
|
||||
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|
||||
|---|---|---|---|
|
||||
| `gnuboard7-hello_module.memo.created` | action | — | `src/Services/MemoService.php:62` |
|
||||
<!-- @generated:hooks-published END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
하나뿐입니다. 실제 모듈이라면 도메인마다 `before_*` → `filter_*_data` → `after_*` 3단을
|
||||
두지만, 샘플에서는 **훅이 무엇이고 어떻게 발행하는가**만 보이면 되므로 하나로 줄였습니다.
|
||||
|
||||
`MemoService::create()` 가 저장 직후 이 액션을 발행합니다. 발행 지점이 컨트롤러가 아니라
|
||||
Service 인 것이 규약입니다 — 컨트롤러에서 발행하면 같은 로직을 다른 경로(커맨드·시더·다른
|
||||
서비스)에서 부를 때 훅이 발화하지 않습니다.
|
||||
|
||||
`getHooks()` 선언에 없어 소스에서 자동 감지된 상태입니다. 선언에 추가하면 유형과 설명이 표에
|
||||
함께 실리며, 실제 모듈에서는 발행 훅을 선언하는 편이 구독하는 쪽에 계약을 드러냅니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 구독 훅
|
||||
|
||||
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|
||||
|---|---|---|---|---|
|
||||
| `gnuboard7-hello_module.memo.created` | action (미선언) | `LogMemoCreatedListener` | `onMemoCreated` | 10 |
|
||||
<!-- @generated:hooks-subscribed END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
자기가 발행한 훅 하나를 자기가 구독합니다. 실제로는 다른 확장이 구독하는 것이 정상이지만,
|
||||
**샘플 하나만 설치해도 훅 흐름이 눈에 보이도록** 리스너를 같이 넣었습니다.
|
||||
|
||||
`gnuboard7-hello_plugin` 을 함께 설치하면 같은 훅을 **바깥에서 구독하는** 모습을 볼 수
|
||||
있습니다 — 그쪽이 확장 시스템의 실제 사용 형태입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 훅 리스너
|
||||
|
||||
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|
||||
|---|---|---|---|---|
|
||||
| `LogMemoCreatedListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/LogMemoCreatedListener.php` |
|
||||
<!-- @generated:listeners END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`LogMemoCreatedListener` 하나이며 `HookListenerInterface` 를 구현하고
|
||||
`getSubscribedHooks()` 로 자기 구독을 선언합니다(명시 등록).
|
||||
|
||||
하는 일은 로그 한 줄이지만, 그 자리가 중요합니다 — **부가 작업은 Service 안이 아니라 리스너로
|
||||
뺀다**는 규약의 본보기입니다. Service 에 로그·알림을 쌓으면 그 Service 를 다른 맥락에서
|
||||
재사용할 수 없습니다.
|
||||
|
||||
리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 를 직접 부르지 않습니다 — 데이터
|
||||
접근이 필요하면 Repository 인터페이스를 주입받습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 레이아웃 확장
|
||||
|
||||
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_레이아웃 확장이 없습니다._
|
||||
<!-- @generated:layout-extensions END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 샘플은 다른 확장의 화면에 조각을 주입하지 않습니다.
|
||||
|
||||
주입 예시가 필요하면 실제로 그렇게 하는 확장(이커머스의 관리자 대시보드 위젯, 마케팅의 회원가입
|
||||
동의 항목)의 문서를 참고합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 미들웨어
|
||||
|
||||
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 미들웨어가 없습니다._
|
||||
<!-- @generated:middleware END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 코어가 제공하는 인증 미들웨어만 씁니다.
|
||||
|
||||
샘플에 미들웨어를 넣으면 계층 구조를 보러 온 사람이 읽어야 할 코드가 늘어납니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 브로드캐스트 채널
|
||||
|
||||
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 브로드캐스트 채널이 없습니다._
|
||||
<!-- @generated:channels END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 같은 이유로 두지 않았습니다.
|
||||
|
||||
실시간이 필요하면 이 모듈이 발행하는 `memo.created` 를 구독해 소비하는 쪽에서
|
||||
`HookManager::broadcast()` 로 자기 채널에 내보내는 것이 방향입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 스케줄
|
||||
|
||||
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 스케줄이 없습니다._
|
||||
<!-- @generated:schedules END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 샘플에는 시간 축 동작이 없습니다.
|
||||
|
||||
스케줄 선언 형태(`command` · `schedule` · `description` · `enabled_config`)는 실제로 스케줄을
|
||||
쓰는 확장의 문서를 참고합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 알림 정의
|
||||
|
||||
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 알림 정의가 없습니다._
|
||||
<!-- @generated:notifications END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 알림은 수신자 해석·채널 게이트·템플릿까지 함께 필요해 "하나씩만" 원칙으로 담기
|
||||
어렵습니다.
|
||||
|
||||
알림이 필요한 예시는 실제로 알림을 발송하는 확장의 문서를 참고합니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,86 @@
|
||||
# Hello 모듈 — 프론트엔드
|
||||
|
||||
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 레이아웃
|
||||
|
||||
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
레이아웃 3개 (루트: `resources/layouts`).
|
||||
|
||||
| 그룹 | 개수 |
|
||||
|---|---|
|
||||
| `admin` | 2개 |
|
||||
| `user` | 1개 |
|
||||
|
||||
| 레이아웃 | 그룹 | 종류 | extends |
|
||||
|---|---|---|---|
|
||||
| `admin_memo_form` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_memo_list` | `admin` | 화면 | `_admin_base` |
|
||||
| `user_memo_list` | `user` | 화면 | `_user_base` |
|
||||
<!-- @generated:layouts END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
3개이며 그중 하나가 `user` 그룹인 것이 학습 포인트입니다.
|
||||
|
||||
| 레이아웃 | 그룹 | 무엇을 보여주는가 |
|
||||
|---|---|---|
|
||||
| `admin_memo_list` · `admin_memo_form` | `admin` | 관리자 목록·작성 화면의 최소 형태 (`_admin_base` 상속) |
|
||||
| `user_memo_list` | `user` | **모듈도 사용자 레이아웃을 가질 수 있다** (`_user_base` 상속) |
|
||||
|
||||
실제 도메인 모듈(게시판·이커머스)은 방문자 화면을 소유하지 않고 템플릿에 맡깁니다 — 템플릿마다
|
||||
디자인이 달라야 하기 때문입니다. 이 샘플의 `user_memo_list` 는 그 규칙의 예외가 아니라, 구조상
|
||||
가능하다는 사실을 보이는 예시입니다.
|
||||
|
||||
모듈 레이아웃은 위치에 따라 등록 대상이 갈립니다 — `admin/` 하위는 Admin 템플릿에,
|
||||
`user/` 하위는 User 템플릿에 등록됩니다.
|
||||
|
||||
레이아웃 JSON 만 고쳤다면 빌드 없이
|
||||
`php artisan module:update gnuboard7-hello_module --force` 로 반영합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 액션 핸들러
|
||||
|
||||
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 액션 핸들러가 없습니다._
|
||||
<!-- @generated:handlers END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 샘플의 화면은 코어 엔진의 기본 핸들러(`apiCall` · `navigate` · `setState` 등)
|
||||
만으로 충분합니다.
|
||||
|
||||
핸들러를 처음 추가할 때는 셋이 함께 필요합니다 — 엔트리 파일, `window.__[Name].initModule()`
|
||||
재등록 진입점, 그리고 `--production` 으로 구운 `dist/` 커밋. 진입점을 빠뜨리면 로케일 전환
|
||||
직후 그 핸들러들이 오류 없이 무반응이 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 전역 진입점
|
||||
|
||||
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_프론트 엔트리포인트가 없습니다._
|
||||
<!-- @generated:frontend-entry END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다 — 등록할 액션 핸들러가 없기 때문입니다.
|
||||
|
||||
핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록
|
||||
진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부
|
||||
무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅
|
||||
작업을 포함하지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 에셋
|
||||
|
||||
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_프론트 에셋이 없습니다._
|
||||
<!-- @generated:assets END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 샘플의 프론트엔드는 레이아웃 JSON 3개뿐이라 빌드할 실행 코드가 없습니다.
|
||||
|
||||
그래서 반영이 `php artisan module:update gnuboard7-hello_module --force` 하나로 끝납니다.
|
||||
JS 를 더하면 그때 빌드(`module:build --production`)·`dist/` 커밋·전역 진입점 셋이 함께
|
||||
필요해집니다.
|
||||
|
||||
구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도
|
||||
서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,116 @@
|
||||
# Hello 모듈 — 설정·권한·라우트
|
||||
|
||||
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설정 스키마
|
||||
|
||||
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_`getSettingsSchema()` 선언이 없습니다._
|
||||
|
||||
기본값 파일: `config/settings/defaults.json`
|
||||
<!-- @generated:settings-schema END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
설정이 없습니다. 이 샘플은 설정 화면 없이도 모듈의 계층 구조를 보여줄 수 있어 일부러 두지
|
||||
않았습니다.
|
||||
|
||||
설정 스키마와 설정 화면 레이아웃의 예시는 함께 제공되는 `gnuboard7-hello_plugin` 에 있습니다 —
|
||||
`getSettingsSchema()` 선언과 `resources/layouts/admin/plugin_settings.json` 이 짝을 이루는
|
||||
형태입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 권한
|
||||
|
||||
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 카테고리 | 이름 | 액션 | 라우트 키 |
|
||||
|---|---|---|---|
|
||||
| `memos` | 메모 관리 | `read`, `create`, `update`, `delete` | `memo` |
|
||||
<!-- @generated:permissions END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`memos` 하나에 `read`/`create`/`update`/`delete` 네 액션입니다. 라우트 키 `memo` 가 선언되어
|
||||
있어 관리자 라우트에 스코프 미들웨어가 걸립니다.
|
||||
|
||||
권한 이름은 코어가 `{확장식별자}.{카테고리}.{액션}` 으로 조립합니다
|
||||
(`gnuboard7-hello_module.memos.read`). 확장 식별자가 앞에 붙으므로 다른 확장과 이름이 겹칠
|
||||
걱정이 없습니다.
|
||||
|
||||
**권한만 추가하고 메뉴를 빠뜨리면 화면에 도달할 길이 없고, 반대면 눌러도 403 입니다.** 새
|
||||
화면을 더할 때는 권한·메뉴·라우트 이름 셋이 서로를 정확히 가리키는지 함께 확인합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 메뉴
|
||||
|
||||
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 구분 | slug | 이름 | URL | 하위 |
|
||||
|---|---|---|---|---|
|
||||
| 관리자 | `gnuboard7-hello_module` | Hello 메모 | `/admin/memos` | - |
|
||||
<!-- @generated:menus END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
관리자 메뉴 하나(`/admin/memos`)입니다. 하위 메뉴가 없어 최상위 항목이 바로 목록 화면으로
|
||||
갑니다.
|
||||
|
||||
메뉴는 **권한과 짝을 이룰 때만 보입니다** — 그 역할에 `memos.read` 가 없으면 렌더되지
|
||||
않습니다. 설치 직후 메뉴가 보이지 않는다면 대부분 권한 부여가 빠진 것입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 라우트
|
||||
|
||||
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 파일 | URL prefix |
|
||||
|---|---|---|
|
||||
| `api` | `src/routes/api.php` | `/api/modules/gnuboard7-hello_module/...` |
|
||||
| `web` | `src/routes/web.php` | `/modules/gnuboard7-hello_module/...` |
|
||||
|
||||
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
|
||||
<!-- @generated:routes END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
파일이 둘(`api.php` · `web.php`)인 것이 이 샘플의 학습 포인트입니다. 실제 도메인 모듈은
|
||||
대개 `api.php` 만 두지만, **모듈이 web 라우트도 가질 수 있다**는 사실을 보이기 위해 둘 다
|
||||
둡니다.
|
||||
|
||||
두 파일의 URL prefix 가 다릅니다 — API 는 `/api/modules/{id}/`, web 은 `/modules/{id}/`.
|
||||
확장이 다른 확장의 경로를 침범하지 않도록 코어가 강제하는 규칙입니다.
|
||||
|
||||
모든 라우트에 `name()` 이 필요합니다. 이름이 없으면 미들웨어 self-gate 의 `targets` 패턴과
|
||||
IDV 정책의 라우트명 인덱스가 그 라우트를 찾지 못해, 보호가 걸린 것처럼 보이지만 실제로는
|
||||
통과합니다.
|
||||
|
||||
라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 확장 라우트는 활성 상태인 확장의 것만
|
||||
등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 404 가 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 의존 관계
|
||||
|
||||
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
없음 — 코어만으로 동작합니다.
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
| 확장 | 유형 | 요구 버전 |
|
||||
|---|---|---|
|
||||
| `gnuboard7-hello_plugin` | 플러그인 | `>=0.1.0` |
|
||||
| `gnuboard7-hello_user_template` | 템플릿 | `>=0.1.0` |
|
||||
<!-- @generated:dependencies END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이 모듈은 아무 확장에도 의존하지 않습니다. 관계는 한 방향으로 들어옵니다 — 학습용 플러그인과
|
||||
학습용 사용자 템플릿이 이 모듈을 요구합니다.
|
||||
|
||||
**두 의존의 성격이 다른 것이 학습 포인트**입니다:
|
||||
|
||||
| 확장 | 어떻게 묶이는가 |
|
||||
|---|---|
|
||||
| `gnuboard7-hello_plugin` | 이 모듈이 발행하는 훅(`memo.created`)을 구독 — 확장이 다른 확장의 흐름에 끼어드는 형태 |
|
||||
| `gnuboard7-hello_user_template` | 이 모듈의 공개 API 를 `data_sources` 로 소비 — 모듈이 데이터를, 템플릿이 화면을 담당하는 경계 |
|
||||
|
||||
넷을 모두 설치하면 이 세 역할(데이터·화면·부가 동작)이 어떻게 나뉘는지 실제로 확인할 수
|
||||
있습니다.
|
||||
|
||||
발행 훅 이름이나 공개 API 응답 형태를 바꾸면 두 확장이 조용히 끊깁니다 — 샘플에서도 그 규율은
|
||||
같습니다.
|
||||
<!-- @intent END -->
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "Hello 모듈",
|
||||
"en": "Hello Module"
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "학습용 최소 샘플 모듈 (Memo CRUD)",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# 게시판 — 에이전트 가이드
|
||||
|
||||
> 이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 유형: 모듈 (sirsoft-board) — 게시판·게시글·댓글·신고·게시판별 알림설정 도메인. 관리자 CRUD + 공개 API 만 소유하고, 방문자 화면(목록/상세/글쓰기)은 템플릿이 그린다
|
||||
2. 확장 방식: 발행 훅 90개(전량 action/filter) — 새 콘텐츠 타입 연동은 `EcommerceInquiryHookListener` 식 필터 훅 위임 패턴을 참고
|
||||
3. 건드리면 안 되는 것: 비밀글 게이팅(`SecretContentGate`)을 우회하는 신규 조회 경로, `chunk()`(OFFSET) 로 삭제·갱신 순회, count 컬럼(posts_count 등) 직접 갱신
|
||||
4. 작업 위치: `modules/_bundled/sirsoft-board` — 활성 디렉토리 직접 수정 금지
|
||||
5. 반영: `php artisan module:update sirsoft-board --force`
|
||||
```
|
||||
|
||||
## 1. 이 확장은 무엇인가
|
||||
|
||||
<!-- @intent START -->
|
||||
게시판·게시글·댓글·신고·게시판별 알림설정을 소유하는 콘텐츠 도메인 모듈입니다. 운영자가
|
||||
`/admin/boards`에서 게시판을 자유롭게 생성(게시판 유형 `basic`/`gallery`/`card` 선택, 비밀글·
|
||||
답변형·트리거 기반 관리자 알림 등 게시판별 세부 설정)하면, 그 게시판마다 동적 권한
|
||||
(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`/`{slug}.step`)·메뉴가 자동 생성됩니다 —
|
||||
게시판 하나하나가 사실상 독립된 작은 확장처럼 자기 권한 체계를 갖습니다.
|
||||
|
||||
**소유 범위는 관리자 CRUD + 공개 API 까지입니다.** 방문자가 실제로 보는 목록/상세/글쓰기
|
||||
화면은 이 모듈이 그리지 않습니다 — `resources/layouts/` 46개가 전부 `admin` 그룹인 것이
|
||||
그 증거입니다. 공개 조회·작성은 `routes/api.php` 의 `boards.*` 라우트(비회원도 접근하는
|
||||
`optional.sanctum`)로만 나가고, 그 API 를 소비해 실제 화면을 그리는 것은 템플릿(`sirsoft-basic`)
|
||||
쪽 책임입니다. 그래서 이 모듈에 의존하는 확장은 지금 템플릿 하나뿐입니다 — 새 방문자 화면이
|
||||
필요하면 이 모듈이 아니라 그 화면을 쓰는 템플릿/모듈 쪽에 레이아웃을 추가합니다.
|
||||
|
||||
**설계 원칙**: 게시판은 이커머스 문의·후기처럼 "게시판을 흉내 낸" 다른 도메인의 콘텐츠 저장소로도
|
||||
쓰입니다. 그래서 이 모듈을 다른 도메인이 재사용하는 지점은 코드 결합이 아니라 **필터 훅
|
||||
위임**(`EcommerceInquiryHookListener`)입니다 — 이커머스 모듈이 자기 문의 게시판을 만들 때
|
||||
`sirsoft-board` 를 직접 `use` 하지 않고, board 쪽 흐름 중간에서 자기 로직으로 갈아끼웁니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: 게시판 콘텐츠에 대한 실시간 브로드캐스트(WebSocket)는 제공하지
|
||||
않습니다 — 알림은 전부 코어 `GenericNotification`(mail/database) 경유이며, 새로고침 없이
|
||||
갱신되는 목록 같은 기능은 이 모듈의 범위 밖입니다. 또한 비밀글 판정은 이 모듈이 API 계층에서
|
||||
전량 서버측으로 강제합니다(`SecretContentGate`, KVE-2026-1914) — 클라이언트가 비밀글 여부를
|
||||
판단해 화면만 가리는 방식은 쓰지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 2. 디렉토리 지도
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
|
||||
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
|
||||
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
|
||||
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
|
||||
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
|
||||
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
|
||||
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-board --force` (빌드 불필요) |
|
||||
| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-board --force` |
|
||||
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-board --force` |
|
||||
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-board --force` |
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-board --force` |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
|
||||
## 3. 핵심 흐름
|
||||
|
||||
<!-- @intent START -->
|
||||
**게시판 생성**: `Admin\BoardController` → `StoreBoardRequest`(다국어 이름·slug 유일성·게시판
|
||||
유형 검증) → `BoardService::createBoard()` → `BoardRepository` 로 `boards` 행 생성 후, 같은
|
||||
트랜잭션 안에서 `BoardPermissionService`(동적 권한 3계층 생성) → 역할(manager/step) 생성 →
|
||||
관리자 메뉴 등록까지 연쇄 실행됩니다. 이 연쇄 때문에 `getDynamicPermissionIdentifiers()` /
|
||||
`getDynamicRoleIdentifiers()` / `getDynamicMenuSlugs()` 가 `boards` 테이블을 다시 조회해
|
||||
전수를 재구성합니다 — 게시판 삭제·정리(clean-up) 시 "stale 판정"의 기준이 이 세 메서드입니다.
|
||||
|
||||
**게시글 작성 → 비밀글 게이팅**: `User\PostController` → `StorePostRequest`
|
||||
(`sirsoft-board.post.store_validation_rules` 필터로 게시판별 커스텀 규칙 추가) →
|
||||
`PostService::createPost()` (`before_create`→`filter_create_data`→`after_create` 훅 순서,
|
||||
`sirsoft-board.post.user_create` IDV 정책 게이트 통과 후) → `PostRepository`. 조회 시에는
|
||||
`PostResource` 가 `SecretContentGate` 로 비밀글 여부·열람 권한을 판정해 `content`/`title`/
|
||||
`reply`/`attachments` 를 마스킹합니다 — 이 판정은 댓글 목록·첨부 다운로드에도 **개별
|
||||
재적용**됩니다(부모 글에서 한 번 판정하고 끝나지 않음, KVE-2026-1914).
|
||||
|
||||
**신고 접수 → 처리**: `User\ReportController` → `StoreReportRequest` → `ReportService::create()`
|
||||
(`before_create`→`filter_create_data`→`after_create`) → 신고 접수 관리자 알림 발송. 관리자가
|
||||
`Admin\ReportController` 에서 처리(블라인드/삭제/복원)하면 `ReportService` 가 대상 게시글/댓글
|
||||
서비스(`PostService`/`CommentService`)를 호출해 실제 콘텐츠 상태를 바꾸고, 그 결과가
|
||||
`report_action`/`post_action` 알림으로 원 작성자에게 통지됩니다. 신고 삭제·일괄 처리는
|
||||
관리자 민감 작업 IDV 정책(`report.delete`/`report.bulk_action`)이 걸려 있습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 4. 확장점
|
||||
|
||||
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 확장점 | 수 | 상세 |
|
||||
|---|---|---|
|
||||
| 발행 훅 | 90개 | [발행 훅](docs/extension-points.md#발행-훅) |
|
||||
| 구독 훅 | 77개 | [구독 훅](docs/extension-points.md#구독-훅) |
|
||||
| 훅 리스너 | 16개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
|
||||
| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
|
||||
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
|
||||
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
|
||||
| 스케줄 | 2개 | [스케줄](docs/extension-points.md#스케줄) |
|
||||
| 알림 정의 | 7개 | [알림 정의](docs/extension-points.md#알림-정의) |
|
||||
<!-- @generated:extension-points-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
발행 훅 90개는 전부 `action`/`filter` 이며 코어 브로드캐스트 채널은 쓰지 않습니다(구독 훅
|
||||
목록의 이커머스 8개가 예로 보여주듯, **콘텐츠를 다른 도메인이 재사용하는 자리는 훅이지 상속이
|
||||
아닙니다**). 새 게시판 세부 검증 규칙을 추가하려면 `board.store_validation_rules`/
|
||||
`update_validation_rules` filter 를, 게시판 생성 후 부가 리소스를 함께 만들려면
|
||||
`board.after_create` action 을 잡습니다. 게시글/댓글의 `before_*`→`filter_*_data`→`after_*`
|
||||
3단 패턴은 전 도메인(board/comment/report/attachment)에 동일하게 반복되므로, 하나를 배우면
|
||||
나머지에 그대로 적용됩니다. 훅 리스너 16개 중 `EcommerceInquiryHookListener` 는 이 모듈의
|
||||
CRUD 흐름 자체를 **자기 도메인으로 대체**하는 가장 무거운 형태의 확장 사례입니다 — 새로운
|
||||
"게시판을 흉내 낸 도메인"을 만들 때 참고할 선례입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 5. 수정 시 동반 의무
|
||||
|
||||
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-board --force` 로 반영
|
||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
|
||||
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
|
||||
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
|
||||
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-board` 재실행 + `docs/api/**` 갱신
|
||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
||||
- [ ] 비밀글이 관여하는 새 조회 경로(댓글·첨부·검색 결과 등)를 추가할 때 `SecretContentGate` 재적용 (KVE-2026-1914 — 부모에서 한 번 판정하고 끝나지 않는다)
|
||||
- [ ] `boards`/`board_posts`/`board_comments` 의 count 컬럼(`posts_count`/`comments_count`/`replies_count`/`attachments_count`)은 훅 리스너(`*CountSyncListener`)가 갱신 — Service 에서 직접 증감 금지
|
||||
- [ ] 게시판 삭제 시 `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()`/`getDynamicMenuSlugs()` 가 최신 상태를 반영하도록 동일 트랜잭션에서 정리 (module.php `uninstall()` 의 `chunkById` 패턴 참고 — OFFSET 순회로 삭제 금지)
|
||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan module:update sirsoft-board --force`
|
||||
|
||||
## 6. 금지 패턴
|
||||
|
||||
<!-- @intent START -->
|
||||
| 금지 | 올바른 사용 | 이유 |
|
||||
|---|---|---|
|
||||
| 비밀글 상세만 `SecretContentGate` 로 막고 댓글 목록·첨부 다운로드는 그대로 노출 | 댓글 목록은 부모 글 비밀 여부 확인 후 빈 배열, 첨부 서빙은 403 — 두 경로 모두 게이트 재적용 | 상세 API 하나만 막으면 같은 정보가 형제 엔드포인트로 새어나간다 (KVE-2026-1914) |
|
||||
| 게시글/댓글 삭제·복원 시 `posts_count`/`comments_count` 를 Service 에서 `increment()`/`decrement()` | 훅(`after_create`/`after_delete`/`after_restore`) 리스너의 count 동기화에 맡긴다 | 직접 증감은 훅 기반 동기화와 이중 집계되어 카운트가 어긋난다 |
|
||||
| 게시판별 동적 권한/역할을 board 삭제와 별도 시점에 정리 | `BoardService` 삭제 흐름 안에서 즉시 정리(또는 stale cleanup 이 `getDynamicPermissionIdentifiers()` 로 정확히 판정하게 유지) | 정리가 늦으면 존재하지 않는 게시판의 권한이 역할에 남아 관리 화면에 유령 항목이 뜬다 |
|
||||
| 새 콘텐츠 타입을 board 코드에 `if ($type === 'inquiry')` 로 직접 분기 | `EcommerceInquiryHookListener` 처럼 필터 훅으로 CRUD 를 위임 | board 코드가 알지 못하는 도메인이 늘어날수록 분기가 무한 증식한다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 7. 테스트 실행
|
||||
|
||||
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 155개 | `modules/_bundled/sirsoft-board/tests` |
|
||||
| Vitest | 33개 | `vitest.config.ts` |
|
||||
| Playwright | 26개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 34개 | `tests/scenarios` |
|
||||
|
||||
기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
||||
|
||||
```bash
|
||||
# PHPUnit (변경 범위만) (Bash)
|
||||
php vendor/bin/phpunit modules/_bundled/sirsoft-board/tests --filter='<대상클래스>'
|
||||
|
||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||
cd modules/_bundled/sirsoft-board && powershell -Command "npm run test:run -- <대상>"
|
||||
|
||||
# Playwright E2E (Bash)
|
||||
npx playwright test modules/_bundled/sirsoft-board/tests/Playwright/specs/<대상>.spec.ts
|
||||
|
||||
```
|
||||
|
||||
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
|
||||
<!-- @generated:test-commands END -->
|
||||
|
||||
## 8. 문서 목차
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
@@ -4,6 +4,14 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||
|
||||
## [1.1.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# 게시판
|
||||
|
||||
**그누보드7 모듈 · sirsoft-board**
|
||||
게시판 관리를 위한 모듈
|
||||
|
||||
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-1.1.1-0066FF?style=flat-square" alt="version 1.1.1">
|
||||
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.10-1F883D?style=flat-square" alt="그누보드7 >=7.0.10">
|
||||
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
|
||||
</p>
|
||||
<!-- @generated:badges END -->
|
||||
|
||||
---
|
||||
|
||||
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
|
||||
|
||||
---
|
||||
|
||||
## 소개
|
||||
|
||||
<!-- @intent START -->
|
||||
게시판·게시글·댓글·신고를 관리하는 콘텐츠 모듈입니다. 운영자가 관리자 화면에서 자유형(가로형/
|
||||
갤러리형/카드형) 게시판을 원하는 개수만큼 만들고, 게시판마다 비밀글·답변형·본인인증·자동 알림
|
||||
같은 세부 정책을 독립적으로 설정할 수 있습니다.
|
||||
|
||||
이 모듈은 관리자 화면과 공개 API 만 제공합니다. 방문자가 보는 목록·상세·글쓰기 화면은
|
||||
템플릿(`sirsoft-basic`)이 이 모듈의 API 를 호출해 그립니다 — 운영자 입장에서는 "게시판 콘텐츠는
|
||||
여기서 관리하고, 화면 디자인은 템플릿이 담당한다"로 이해하면 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 주요 기능
|
||||
|
||||
<!-- @intent START -->
|
||||
| 영역 | 설명 |
|
||||
|---|---|
|
||||
| 게시판 관리 | 게시판 생성/수정/삭제, 게시판 유형(기본/갤러리/카드) 선택, 게시판별 세부 설정 일괄 적용 |
|
||||
| 게시글·댓글 | 작성/수정/삭제/블라인드/복원, 답변형 게시판(원글-답변 트리), 대댓글, 비밀글 |
|
||||
| 신고 처리 | 사용자 신고 접수 → 관리자 검토 → 블라인드/삭제/복원 처리, 처리 결과 알림 |
|
||||
| 첨부파일 | 업로드/다운로드/순서 변경, 게시판별 허용 확장자·용량 제한 |
|
||||
| 대시보드 | 게시판별 게시글·댓글·신고 현황과 추세, 미처리 신고 요약 |
|
||||
| 알림 | 새 댓글/대댓글/답변글/신고 접수/처리 결과를 메일·앱 내 알림으로 발송, 회원별 수신 여부 설정 |
|
||||
| 본인인증 연동 | 게시글/댓글 삭제, 신고 작성, 첫 글 작성 등 민감 작업에 코어 IDV 정책 적용(기본은 비활성) |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 동작 방식
|
||||
|
||||
<!-- @intent START -->
|
||||
```mermaid
|
||||
flowchart LR
|
||||
V[방문자] -->|공개 API 호출| T[템플릿 화면]
|
||||
T -->|GET boards/posts| API[게시판 공개 API]
|
||||
API --> SVC[PostService/CommentService]
|
||||
SVC --> DB[(board_posts 등)]
|
||||
|
||||
A[운영자] -->|관리자 화면| ADM[게시판 관리 UI]
|
||||
ADM -->|CRUD·블라인드·신고 처리| SVC
|
||||
SVC -->|훅 발행| N[알림/집계/검색색인]
|
||||
```
|
||||
|
||||
방문자는 템플릿이 그린 화면에서 공개 API 만 호출하고, 운영자는 이 모듈이 직접 제공하는
|
||||
관리자 화면을 씁니다. 두 경로 모두 결국 같은 Service 계층을 거치므로 비밀글 게이팅·카운트
|
||||
동기화·훅 발행은 어느 쪽에서 들어오든 동일하게 적용됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 요구 사항
|
||||
|
||||
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 그누보드7 코어 | `>=7.0.10` |
|
||||
| PHP | `^8.2` |
|
||||
<!-- @generated:requirements END -->
|
||||
|
||||
## 설치
|
||||
|
||||
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
```bash
|
||||
# 번들 설치 (코어에 동봉된 소스에서 설치)
|
||||
php artisan module:install sirsoft-board
|
||||
|
||||
# 활성화
|
||||
php artisan module:activate sirsoft-board
|
||||
|
||||
# 업데이트 (번들 소스 기준 강제 반영)
|
||||
php artisan module:update sirsoft-board --force
|
||||
```
|
||||
|
||||
저장소: https://github.com/gnuboard/g7-module-sirsoft-board
|
||||
<!-- @generated:install END -->
|
||||
|
||||
## 관리자 설정
|
||||
|
||||
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_별도의 관리자 설정 항목이 없습니다._
|
||||
<!-- @generated:settings-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
위 표가 비어 있는 이유는 이 모듈에 전역 환경설정(`getSettingsSchema()`) 이 없기 때문입니다 —
|
||||
설정은 전역이 아니라 **게시판 하나하나**에 딸려 있습니다(`/admin/boards/{slug}/settings`).
|
||||
게시판을 만들 때 기본/갤러리/카드 유형을 고르면 그 유형의 기본값이 채워지고, 이후 기본
|
||||
정보·목록 표시·게시글 정책·댓글 정책·첨부 정책·본인인증·알림·SEO 탭에서 게시판별로 따로
|
||||
조정합니다. 여러 게시판에 같은 값을 한 번에 반영하려면 게시판 목록 화면의 "설정 일괄 적용"을
|
||||
씁니다(`settings.before_bulk_apply`/`after_bulk_apply` 훅으로 계측 가능).
|
||||
<!-- @intent END -->
|
||||
|
||||
## 사용 방법
|
||||
|
||||
<!-- @intent START -->
|
||||
**게시판 신설**: `/admin/boards` → "게시판 추가" → 이름·slug·유형 지정 → 저장. 저장 즉시
|
||||
관리자 메뉴·동적 권한(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`)이 자동 생성되므로,
|
||||
바로 이어서 "권한" 탭에서 그 게시판을 담당할 운영자에게 `{slug}.manager` 역할을 부여합니다.
|
||||
|
||||
**신고 처리**: 방문자가 게시글/댓글을 신고하면 `/admin/boards/reports` 에 접수되고 담당자에게
|
||||
메일이 갑니다. 신고 상세에서 신고 사유·신고 이력을 확인한 뒤 블라인드/삭제/복원 중 하나로
|
||||
처리하면, 그 결과가 원 작성자에게 자동으로 통지됩니다 — 별도로 작성자에게 안내 메일을 보낼
|
||||
필요가 없습니다.
|
||||
|
||||
**여러 게시판 설정 일괄 변경**: 예를 들어 전체 게시판의 첨부 용량 상한을 한 번에 올리고 싶으면,
|
||||
게시판 목록에서 대상 게시판을 체크한 뒤 "설정 일괄 적용" 모달에서 첨부 탭 값만 바꿔 적용합니다.
|
||||
다른 탭 값은 그대로 유지되고, 체크한 게시판에만 반영됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 다른 확장과의 연동
|
||||
|
||||
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
없음 — 코어만으로 동작합니다.
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
| 확장 | 유형 | 요구 버전 |
|
||||
|---|---|---|
|
||||
| `sirsoft-basic` | 템플릿 | `>=1.0.0` |
|
||||
<!-- @generated:integrations END -->
|
||||
|
||||
## 문서
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
<!-- @intent START -->
|
||||
| 증상 | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
| 게시판 삭제 후에도 관리자 화면에 그 게시판 권한/역할이 남아 있음 | 정리 배치가 아직 실행되지 않았거나 삭제 트랜잭션이 중간에 실패 | `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()` 는 현재 `boards` 테이블 기준으로 계산되므로, 확장 정리 커맨드를 다시 실행하면 stale 항목이 잡힙니다 |
|
||||
| 검색어에 `+`, `-`, `"` 를 넣으면 결과가 0건으로 나옴 | 코어 검색 정제기가 FULLTEXT 연산자를 제거한 뒤 검색 — 연산자만 입력하면 빈 결과가 정상 동작 | 오류가 아닙니다. 실제 키워드를 함께 입력하면 정상 매칭됩니다 |
|
||||
| 비밀글의 댓글 개수가 0으로 보이는데 실제로는 댓글이 있음 | 열람 권한이 없는 요청에는 댓글 목록이 빈 배열(200)로 마스킹됨(KVE-2026-1914) | 정상 동작입니다. 작성자 본인 또는 `posts.read-secret`/관리 권한으로 조회하면 보입니다 |
|
||||
| 게시판 설정 일괄 적용 후 일부 게시판만 반영됨 | 대상 게시판 중 일부가 적용 도중 실패(예: 유효성 위반) | `settings.after_bulk_apply_aborted` 훅 시점의 로그로 실패한 게시판을 특정한 뒤 개별 재적용 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 변경 이력
|
||||
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-board",
|
||||
"description": "Board module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 게시판 개발자 문서
|
||||
|
||||
> modules/_bundled/sirsoft-board · 모듈
|
||||
|
||||
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**훅 수**: 90 · **구독 훅 수**: 77 · **라우트 수**: 80 · **모델 수**: 9 · **테이블 수**: 10 · **마이그레이션 수**: 30 · **레이아웃 수**: 46 · **핸들러 수**: 0
|
||||
<!-- @generated:stats END -->
|
||||
|
||||
## 문서 목차
|
||||
|
||||
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
|
||||
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
|
||||
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
|
||||
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
|
||||
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
|
||||
| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 |
|
||||
| [api/](api/README.md) | API 레퍼런스 |
|
||||
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
|
||||
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
|
||||
<!-- @generated:doc-toc END -->
|
||||
@@ -0,0 +1,75 @@
|
||||
# 게시판 — 아키텍처
|
||||
|
||||
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설계 의도
|
||||
|
||||
<!-- @intent START -->
|
||||
게시판마다 완결된 권한·역할 체계를 갖게 하면서도, 새 게시판을 만드는 데 코드 변경이 필요 없게
|
||||
하는 것이 이 모듈의 핵심 설계 목표입니다. `boards` 테이블 한 행이 하나의 확장처럼 동작하도록,
|
||||
권한·역할·메뉴는 모두 **런타임에 게시판 데이터로부터 파생**됩니다(`getDynamicPermissionIdentifiers()`
|
||||
등 3개 메서드가 코드가 아니라 DB 를 읽어 계산). 그 대가로 이 세 메서드는 게시판이 하나
|
||||
추가/삭제될 때마다 정확해야 하고, 어긋나면 stale 권한이 남거나 존재하는 게시판의 권한이
|
||||
정리 대상으로 오판됩니다.
|
||||
|
||||
또한 "관리자 백엔드 모듈 + 방문자 화면은 템플릿" 분리를 의도적으로 유지합니다. 방문자 화면을
|
||||
이 모듈 안에 두면 템플릿마다 디자인이 다른 게시판 UI 를 이 모듈이 전부 알아야 하는데,
|
||||
API 로만 노출하면 템플릿 쪽에서 자유롭게 화면을 구성할 수 있습니다. 이 경계 때문에
|
||||
"레이아웃 확장"·"레이아웃"에는 오직 관리자 화면만 나타나며, 그것이 정상입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 계층 지도
|
||||
|
||||
<!-- @intent START -->
|
||||
```
|
||||
Http/Controllers (Admin/ 관리자, User/ 공개 API)
|
||||
│
|
||||
▼
|
||||
FormRequest (검증 + *_validation_rules 필터 훅으로 게시판별 규칙 확장 지점)
|
||||
│
|
||||
▼
|
||||
Services (BoardService/PostService/CommentService/ReportService/AttachmentService 등)
|
||||
│ before_* → filter_*_data → 실행 → after_* (전 도메인 공통 3단 훅 패턴)
|
||||
▼
|
||||
Repositories (RepositoryInterface 경유, 정렬 화이트리스트·컬럼 프루닝)
|
||||
│
|
||||
▼
|
||||
Models (SoftDeletes 적용 — Post/Comment/Attachment/Report)
|
||||
```
|
||||
|
||||
Listeners(`src/Listeners/`)는 이 흐름과 별도 레인입니다 — Service 가 발행한 훅을 받아 카운트
|
||||
동기화(`*CountSyncListener`)·활동 로그·SEO 캐시·검색 색인·알림 데이터 추출을 수행하며, Service
|
||||
자신은 이 부가효과를 알지 못합니다. 이 분리 덕분에 새 부가효과(예: 신규 카운트 컬럼 동기화)는
|
||||
Service 를 건드리지 않고 리스너 추가만으로 끝납니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 디렉토리
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
|
||||
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
|
||||
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
|
||||
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
|
||||
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
|
||||
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
|
||||
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-board --force` (빌드 불필요) |
|
||||
| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-board --force` |
|
||||
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-board --force` |
|
||||
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-board --force` |
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-board --force` |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
@@ -0,0 +1,161 @@
|
||||
# 게시판 — 데이터 모델
|
||||
|
||||
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 모델
|
||||
|
||||
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 모델 | 테이블 | fillable | 관계 | 특성 |
|
||||
|---|---|---|---|---|
|
||||
| `Attachment` | `board_attachments` | 15 | board→Board, post→Post, creator→User | SoftDeletes |
|
||||
| `Board` | `boards` | 37 | creator→User, updater→User, posts→Post, comments→Comment, attachments→Attachment, reports→Report | - |
|
||||
| `BoardStat` | `board_stats` | 3 | - | - |
|
||||
| `BoardType` | `board_types` | 3 | - | HasUserOverrides |
|
||||
| `Comment` | `board_comments` | 14 | board→Board, post→Post, user→User, parent→self, replies→self | SoftDeletes |
|
||||
| `Post` | `board_posts` | 21 | board→Board, user→User, parent→self, replies→self, comments→Comment, attachments→Attachment, 외 1개 | SoftDeletes, 검색 색인 |
|
||||
| `Report` | `boards_reports` | 11 | board→Board, author→User, logs→ReportLog, processor→User | SoftDeletes |
|
||||
| `ReportLog` | `boards_report_logs` | 6 | report→Report, reporter→User | - |
|
||||
| `UserNotificationSetting` | `board_user_notification_settings` | 5 | user→User | - |
|
||||
<!-- @generated:models END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`Post`·`Comment` 모두 `parent`/`replies` 자기참조 관계를 갖습니다 — `Post` 의 자기참조는
|
||||
"답변형 게시판"(원글에 대한 관리자 답변)을, `Comment` 의 자기참조는 대댓글을 표현합니다. 둘은
|
||||
서로 다른 기능이라 관계 이름은 같아도 코드에서 섞어 쓰지 않습니다. `Board` 는 `HasUserOverrides`
|
||||
를 쓰지 **않습니다** — 게시판 자체는 운영자가 직접 소유·수정하는 리소스라 "모듈 재설치 시
|
||||
운영자 수정 보존"이 필요 없는 반면, `BoardType`(게시판 유형 프리셋)은 모듈이 시딩한 기본값을
|
||||
운영자가 손댈 수 있어야 하므로 그 트레이트를 씁니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 소유 테이블
|
||||
|
||||
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 테이블 | 모델 |
|
||||
|---|---|
|
||||
| `board_attachments` | `Attachment` |
|
||||
| `board_comments` | `Comment` |
|
||||
| `board_mail_templates` | - |
|
||||
| `board_posts` | `Post` |
|
||||
| `board_stats` | `BoardStat` |
|
||||
| `board_types` | `BoardType` |
|
||||
| `board_user_notification_settings` | `UserNotificationSetting` |
|
||||
| `boards` | `Board` |
|
||||
| `boards_report_logs` | `ReportLog` |
|
||||
| `boards_reports` | `Report` |
|
||||
<!-- @generated:tables END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`board_mail_templates` 는 모델이 없는 채로 남아 있습니다 — 아래 마이그레이션 표의
|
||||
`drop_board_mail_templates_table`(2026-04-13)이 보여주듯, 메일 템플릿을 자체 테이블로
|
||||
관리하던 초기 설계를 코어 `GenericNotification` 알림 정의(§알림 정의)로 이관하며 테이블만
|
||||
드롭하고 이름은 이력상 남아 있는 상태입니다. 신규 코드에서 이 이름을 참조하지 않습니다.
|
||||
테이블 접두어가 `board_*`와 `boards_*` 두 가지로 섞여 있는 것은 설계 의도가 아니라 이력입니다
|
||||
— 새 테이블을 추가할 때는 `board_*`(단수)를 따릅니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
마이그레이션 30개.
|
||||
|
||||
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|
||||
|---|---|---|---|
|
||||
| `2026_04_01_000001_create_board_types_table.php` | `board_types` | - | ✅ |
|
||||
| `2026_04_01_000002_create_boards_table.php` | `boards` | `boards` | ✅ |
|
||||
| `2026_04_01_000003_create_board_user_notification_settings_table.php` | `board_user_notification_settings` | `board_user_notification_settings` | ✅ |
|
||||
| `2026_04_01_000004_create_board_posts_table.php` | `board_posts` | - | ✅ |
|
||||
| `2026_04_01_000005_create_board_comments_table.php` | `board_comments` | - | ✅ |
|
||||
| `2026_04_01_000006_create_board_attachments_table.php` | `board_attachments` | - | ✅ |
|
||||
| `2026_04_01_000007_create_boards_reports_table.php` | `boards_reports` | `boards_reports` | ✅ |
|
||||
| `2026_04_01_000008_create_boards_report_logs_table.php` | `boards_report_logs` | `boards_report_logs` | ✅ |
|
||||
| `2026_04_01_000009_create_board_mail_templates_table.php` | `board_mail_templates` | - | ✅ |
|
||||
| `2026_04_01_000010_add_fulltext_indexes_to_boards_table.php` | - | `boards` | ✅ |
|
||||
| `2026_04_01_000011_add_fulltext_indexes_to_boards_report_logs_table.php` | - | `boards_report_logs` | ✅ |
|
||||
| `2026_04_01_000012_add_indexes_to_board_posts_table.php` | - | `board_posts` | ✅ |
|
||||
| `2026_04_13_000001_drop_board_mail_templates_table.php` | `board_mail_templates` | - | ✅ |
|
||||
| `2026_04_13_000002_add_user_overrides_to_board_types_table.php` | - | `board_types` | ✅ |
|
||||
| `2026_04_14_000001_drop_channel_columns_from_boards_table.php` | - | `boards` | ✅ |
|
||||
| `2026_04_17_000001_remove_partitions_from_board_tables.php` | - | - | ✅ |
|
||||
| `2026_04_17_000002_add_count_columns_to_board_tables.php` | - | `board_posts`, `board_comments` | ✅ |
|
||||
| `2026_04_17_000003_add_posts_count_and_comments_count_to_boards_table.php` | - | `boards` | ✅ |
|
||||
| `2026_04_17_000004_update_indexes_in_board_tables.php` | - | `board_posts`, `board_comments`, `board_attachments` | ✅ |
|
||||
| `2026_05_29_000001_create_board_stats_table.php` | `board_stats` | - | ✅ |
|
||||
| `2026_06_08_000001_add_recent_across_boards_index_to_board_posts.php` | - | `board_posts` | ✅ |
|
||||
| `2026_06_26_000001_modify_trigger_type_in_board_comments_table.php` | - | - | ✅ |
|
||||
| `2026_06_26_000002_add_trigger_type_to_board_attachments_table.php` | - | `board_attachments` | ✅ |
|
||||
| `2026_08_01_000001_add_tiebreak_to_board_posts_list_index.php` | - | - | ✅ |
|
||||
| `2026_08_01_000002_add_list_sort_index_to_boards_reports_table.php` | - | - | ✅ |
|
||||
| `2026_08_06_000001_update_max_reply_depth_comment_in_boards_table.php` | - | - | ✅ |
|
||||
| `2026_08_06_000002_add_view_count_sort_index_to_board_posts_table.php` | - | - | ✅ |
|
||||
| `2026_08_17_000001_add_reply_delete_policy_to_boards_table.php` | - | `boards` | ✅ |
|
||||
| `2026_08_17_000002_modify_trigger_type_in_board_posts_table.php` | - | - | ✅ |
|
||||
| `2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php` | - | `board_posts` | ✅ |
|
||||
<!-- @generated:migrations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
목록 성능 마이그레이션이 지속적으로 추가되는 것(인덱스 4건 + 정렬 타이브레이크 2건)은 우연이
|
||||
아닙니다 — 게시글 목록은 공지 제외·답변 제외 필터가 항상 걸린 채로 `created_at`/`view_count`
|
||||
2가지 정렬을 지원해야 하므로, 새 정렬 옵션을 추가할 때마다 그 정렬에 맞는 복합 인덱스를 함께
|
||||
마이그레이션합니다(`getBenchmarkProfiles()` 의 `board_posts_by_view_count` 프로파일이 바로 이
|
||||
목적으로 존재합니다). `remove_partitions_from_board_tables`(2026-04-17)는 `board_posts`/`board_comments`/
|
||||
`board_attachments` 3개 테이블에 적용했던 파티셔닝을 되돌린 이력입니다 — 그 마이그레이션의
|
||||
`down()` 은 "파티션 복원은 데이터 재배치가 필요해 자동 롤백 불가"라고 명시하므로, 파티셔닝을
|
||||
다시 도입할 때는 이 파일을 그대로 재실행하는 방식이 아니라 새 마이그레이션으로 설계해야 합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## Enum
|
||||
|
||||
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| Enum | backing | case 수 | case |
|
||||
|---|---|---|---|
|
||||
| `BoardOrderBy` | `string` | 4 | `created_at`, `view_count`, `title`, `author` |
|
||||
| `OrderDirection` | `string` | 2 | `ASC`, `DESC` |
|
||||
| `PostStatus` | `string` | 3 | `published`, `blinded`, `deleted` |
|
||||
| `ReplyDeletePolicy` | `string` | 2 | `block`, `cascade` |
|
||||
| `ReportReasonType` | `string` | 9 | `abuse`, `hate_speech`, `spam`, `copyright`, `privacy`, `misinformation`, `sexual`, `violence`, `외 1개` |
|
||||
| `ReportStatus` | `string` | 5 | `pending`, `review`, `rejected`, `suspended`, `deleted` |
|
||||
| `ReportType` | `string` | 2 | `post`, `comment` |
|
||||
| `SecretMode` | `string` | 3 | `disabled`, `enabled`, `always` |
|
||||
| `TriggerType` | `string` | 6 | `report`, `admin`, `system`, `auto_hide`, `user`, `cascade` |
|
||||
<!-- @generated:enums END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`TriggerType`(6 case: report/admin/system/auto_hide/user/cascade)은 "이 콘텐츠가 왜 지금
|
||||
상태가 됐는가"를 기록하는 감사(audit) 축입니다 — 예를 들어 게시글 블라인드가 `report`(신고
|
||||
처리 결과)인지 `admin`(관리자 직접 조치)인지에 따라 `post_action`/`report_action` 두 알림이
|
||||
갈라집니다(§확장점 "알림 정의" 참고). `cascade` 는 부모(게시글)가 지워질 때 자식(댓글)이
|
||||
함께 지워진 경우이며, `ReplyDeletePolicy`(`block`/`cascade`)가 게시판별로 부모 삭제 시
|
||||
자식을 막을지 함께 지울지를 결정합니다 — 이 정책과 `TriggerType::Cascade` 는 같은 흐름의
|
||||
서로 다른 절반(정책 설정 vs 결과 기록)입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## Repository
|
||||
|
||||
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 클래스 | 종류 | 설명 |
|
||||
|---|---|---|
|
||||
| `AttachmentRepository` | 구현 | 게시판 첨부파일 Repository 구현체 |
|
||||
| `AttachmentRepositoryInterface` | 인터페이스 | 게시판 첨부파일 Repository 인터페이스 |
|
||||
| `BoardRepository` | 구현 | 게시판 Repository |
|
||||
| `BoardRepositoryInterface` | 인터페이스 | 게시판 Repository 인터페이스 |
|
||||
| `BoardStatRepository` | 구현 | 게시판 일별 집계 Repository |
|
||||
| `BoardStatRepositoryInterface` | 인터페이스 | 게시판 일별 집계 Repository 인터페이스 |
|
||||
| `BoardTypeRepository` | 구현 | - |
|
||||
| `BoardTypeRepositoryInterface` | 인터페이스 | - |
|
||||
| `CommentRepository` | 구현 | 댓글 Repository |
|
||||
| `CommentRepositoryInterface` | 인터페이스 | 댓글 Repository 인터페이스 |
|
||||
| `PostRepository` | 구현 | 게시글 Repository |
|
||||
| `PostRepositoryInterface` | 인터페이스 | 게시글 Repository 인터페이스 |
|
||||
| `ReportRepository` | 구현 | 신고 Repository |
|
||||
| `ReportRepositoryInterface` | 인터페이스 | 신고 Repository 인터페이스 |
|
||||
| `UserNotificationSettingRepository` | 구현 | 사용자 알림 설정 Repository |
|
||||
| `UserNotificationSettingRepositoryInterface` | 인터페이스 | 사용자 알림 설정 Repository 인터페이스 |
|
||||
<!-- @generated:repositories END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`BoardStatRepository`(일별 집계)는 별도 Repository 로 분리돼 있습니다 — `sirsoft-board:aggregate-stats`
|
||||
스케줄이 매시간 `board_stats` 를 갱신하는데, 이 집계 쿼리를 `PostRepository`/`CommentRepository`
|
||||
에 섞으면 대시보드 조회 경로와 실시간 CRUD 경로가 같은 클래스 안에서 뒤엉킵니다. 새 Repository
|
||||
를 추가할 때는 반드시 인터페이스를 함께 만들고 `CoreServiceProvider`(또는 이 모듈의
|
||||
서비스 프로바이더)에서 바인딩합니다 — Service 가 구체 클래스를 직접 타입힌트하면 안 됩니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,123 @@
|
||||
# 게시판 — 레이아웃 편집기 스펙
|
||||
|
||||
> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 선언 요약
|
||||
|
||||
<!-- @generated:editor-spec-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| manifest | `modules/_bundled/sirsoft-board/editor-spec.json` |
|
||||
| 형태 | 단일 파일 (인라인) |
|
||||
| 스펙 버전 | `1.0.0` |
|
||||
| 스타일 시스템 | - |
|
||||
| 다크 모드 전략 | - |
|
||||
|
||||
> 단일 파일 · 프리뷰 샘플 23 · 엔드포인트 샘플 4 · 페이지 상태 8
|
||||
<!-- @generated:editor-spec-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
단일 파일로 둔 것은 분량 때문입니다. 게시판 스펙은 도메인 데이터 4블록뿐이라 분할할
|
||||
이유가 없습니다 — 분할은 템플릿 스펙처럼 한 파일이 만 줄 단위로 커질 때의 장치입니다.
|
||||
|
||||
`스타일 시스템`·`다크 모드 전략` 이 비어 있는 것도 의도입니다. 그 둘은 화면을 **그리는**
|
||||
쪽의 결정이라 템플릿 스펙이 소유합니다. 게시판이 여기에 값을 넣으면 어떤 템플릿을 깔든
|
||||
게시판이 스타일 체계를 강제하는 셈이 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 선언 블록
|
||||
|
||||
<!-- @generated:editor-spec-blocks START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 블록 | 역할 | 항목 수 | 출처 |
|
||||
|---|---|---|---|
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 23 | `editor-spec.json (인라인)` |
|
||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 4 | `editor-spec.json (인라인)` |
|
||||
| `sampleGlobal` | `_global.*` 프리뷰 baseline 시드 | 1 | `editor-spec.json (인라인)` |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 8 | `editor-spec.json (인라인)` |
|
||||
<!-- @generated:editor-spec-blocks END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이 네 블록은 "편집기가 게시판 화면을 실제 API 없이 그리려면 무엇이 필요한가" 에서
|
||||
그대로 나옵니다. `byDataSourceId` 23종은 admin 레이아웃의 `data_source` ID 를 전수
|
||||
스캔해 맞춘 것이고, `byEndpointPattern` 4종은 사용자 게시판 페이지처럼 ID 가 아니라
|
||||
호출 주소로 붙는 자리를 덮습니다.
|
||||
|
||||
여기에 없는 것이 무엇인지가 더 중요합니다 — `roles`·`availableChannels`·
|
||||
`identityProviders` 같은 공용 인프라 ID 는 게시판이 쓰지만 게시판이 선언하지 않습니다.
|
||||
그것들은 admin 템플릿 스펙과 코어 프리셋이 채웁니다. 여기에 같이 넣으면 같은 ID 의
|
||||
샘플이 두 곳에 생기고, 둘이 갈라져도 아무 오류가 나지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 컴포넌트 팔레트
|
||||
|
||||
<!-- @generated:editor-spec-palette START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._
|
||||
<!-- @generated:editor-spec-palette END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이
|
||||
제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이
|
||||
확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고
|
||||
**도메인 데이터**(`sampleData`·`states`)만 담습니다.
|
||||
|
||||
팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿
|
||||
(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면
|
||||
템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 샘플 데이터와 페이지 상태
|
||||
|
||||
<!-- @generated:editor-spec-samples START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 자리 | 역할 | 개수 | ID |
|
||||
|---|---|---|---|
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 23 | `posts` · `post` · `reports` · `report_detail` · `reporters_list` · `boards` · `boards_list` · `board_types` · `form_data` · `form_meta` · `settings` · `availableChannels` … 외 11개 |
|
||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 4 | `/api/modules/sirsoft-board/boards/*/posts*` · `/api/modules/sirsoft-board/boards/popular*` · `/api/modules/sirsoft-board/me/*` · `/api/modules/sirsoft-board/users/*/posts*` |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 8 | `/board/:slug/:id` · `/board/:slug` · `/boards` · `/board/:slug/write` · `*/admin/boards/:slug/edit` · `*/admin/board/:slug/:id/edit` · `*/admin/boards/settings` · `*/admin/board/:slug/post/:id` |
|
||||
|
||||
_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._
|
||||
<!-- @generated:editor-spec-samples END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`states.groups` 8종은 게시판 화면 중 **상태에 따라 다르게 보이는 것**만 골랐습니다.
|
||||
비밀글 잠금(`/board/:slug/:id`), 목록의 빈 상태(`/board/:slug`), 작성 폼 등입니다. 상태
|
||||
변종이 없는 화면은 기본 샘플 하나로 충분하므로 등록하지 않습니다.
|
||||
|
||||
게시판 레이아웃에 `data_source` 를 새로 붙일 때는 그 ID 가 공용 인프라인지 게시판
|
||||
도메인인지 먼저 가릅니다. 도메인이면 이 스펙의 `byDataSourceId` 에, 공용이면 템플릿
|
||||
스펙에 갑니다. 잘못 판단해도 편집기 화면만 비므로 실행 중에는 드러나지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 수정 시 동반 의무
|
||||
|
||||
<!-- @generated:editor-spec-obligations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 |
|
||||
|---|---|
|
||||
| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 |
|
||||
| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) |
|
||||
| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 |
|
||||
| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 |
|
||||
| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 |
|
||||
|
||||
편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다:
|
||||
|
||||
```bash
|
||||
php artisan module:update sirsoft-board --force
|
||||
```
|
||||
<!-- @generated:editor-spec-obligations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 —
|
||||
편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을
|
||||
고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은
|
||||
고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다.
|
||||
|
||||
또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는
|
||||
ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로
|
||||
나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한
|
||||
통로입니다.
|
||||
|
||||
게시판은 관리자 화면과 사용자 화면을 모두 갖습니다. 관리자 쪽 `data_source` 는
|
||||
`byDataSourceId` 로 붙지만 사용자 게시판 페이지는 템플릿이 렌더하므로 ID 가 아니라
|
||||
`byEndpointPattern` 으로 붙습니다 — 사용자 화면을 건드렸는데 관리자 쪽 자리만 고치면
|
||||
그 화면은 편집기에서 계속 빈 채로 남습니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,404 @@
|
||||
# 게시판 — 확장점
|
||||
|
||||
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 발행 훅
|
||||
|
||||
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
발행 훅 90종 / 호출 지점 91곳. 이 중 90종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
|
||||
|
||||
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|
||||
|---|---|---|---|
|
||||
| `core.module_settings.after_save` | action | — | `src/Http/Controllers/Admin/BoardSettingsController.php:123` |
|
||||
| `sirsoft-board.attachment.after_delete` | action | — | `src/Services/AttachmentService.php:463` |
|
||||
| `sirsoft-board.attachment.after_download` | action | — | `src/Services/AttachmentService.php:776` |
|
||||
| `sirsoft-board.attachment.after_link` | action | — | `src/Services/AttachmentService.php:217` 외 1곳 |
|
||||
| `sirsoft-board.attachment.after_reorder` | action | — | `src/Services/AttachmentService.php:631` |
|
||||
| `sirsoft-board.attachment.after_upload` | action | — | `src/Services/AttachmentService.php:192` |
|
||||
| `sirsoft-board.attachment.before_delete` | action | — | `src/Services/AttachmentService.php:442` |
|
||||
| `sirsoft-board.attachment.before_reorder` | action | — | `src/Services/AttachmentService.php:626` |
|
||||
| `sirsoft-board.attachment.before_upload` | action | — | `src/Services/AttachmentService.php:117` |
|
||||
| `sirsoft-board.attachment.filter_upload_file` | filter | — | `src/Services/AttachmentService.php:120` |
|
||||
| `sirsoft-board.attachment.reorder_validation_rules` | filter | — | `src/Http/Requests/ReorderAttachmentsRequest.php:35` |
|
||||
| `sirsoft-board.attachment.upload_validation_rules` | filter | — | `src/Http/Requests/UploadAttachmentRequest.php:77` |
|
||||
| `sirsoft-board.board.after_add_to_menu` | action | — | `src/Services/BoardService.php:1059` |
|
||||
| `sirsoft-board.board.after_create` | action | — | `src/Services/BoardService.php:457` |
|
||||
| `sirsoft-board.board.after_delete` | action | — | `src/Services/BoardService.php:623` |
|
||||
| `sirsoft-board.board.after_remove_from_menu` | action | — | `src/Services/BoardService.php:1104` |
|
||||
| `sirsoft-board.board.after_update` | action | — | `src/Services/BoardService.php:543` |
|
||||
| `sirsoft-board.board.before_add_to_menu` | action | — | `src/Services/BoardService.php:1023` |
|
||||
| `sirsoft-board.board.before_copy` | action | — | `src/Services/BoardService.php:646` |
|
||||
| `sirsoft-board.board.before_create` | action | — | `src/Services/BoardService.php:382` |
|
||||
| `sirsoft-board.board.before_delete` | action | — | `src/Services/BoardService.php:574` |
|
||||
| `sirsoft-board.board.before_remove_from_menu` | action | — | `src/Services/BoardService.php:1090` |
|
||||
| `sirsoft-board.board.before_update` | action | — | `src/Services/BoardService.php:495` |
|
||||
| `sirsoft-board.board.filter_copy_data` | filter | — | `src/Services/BoardService.php:703` |
|
||||
| `sirsoft-board.board.filter_create_data` | filter | — | `src/Services/BoardService.php:385` |
|
||||
| `sirsoft-board.board.filter_menu_data` | filter | — | `src/Services/BoardService.php:1053` |
|
||||
| `sirsoft-board.board.filter_update_data` | filter | — | `src/Services/BoardService.php:500` |
|
||||
| `sirsoft-board.board.posts.before_force_delete` | action | — | `src/Services/BoardService.php:593` |
|
||||
| `sirsoft-board.board.store_validation_rules` | filter | — | `src/Http/Requests/StoreBoardRequest.php:225` |
|
||||
| `sirsoft-board.board.update_validation_rules` | filter | — | `src/Http/Requests/UpdateBoardRequest.php:228` |
|
||||
| `sirsoft-board.board_type.after_create` | action | — | `src/Services/BoardTypeService.php:42` |
|
||||
| `sirsoft-board.board_type.after_delete` | action | — | `src/Services/BoardTypeService.php:107` |
|
||||
| `sirsoft-board.board_type.after_update` | action | — | `src/Services/BoardTypeService.php:70` |
|
||||
| `sirsoft-board.board_type.before_create` | action | — | `src/Services/BoardTypeService.php:36` |
|
||||
| `sirsoft-board.board_type.before_delete` | action | — | `src/Services/BoardTypeService.php:103` |
|
||||
| `sirsoft-board.board_type.before_update` | action | — | `src/Services/BoardTypeService.php:62` |
|
||||
| `sirsoft-board.board_type.filter_create_data` | filter | — | `src/Services/BoardTypeService.php:38` |
|
||||
| `sirsoft-board.board_type.filter_update_data` | filter | — | `src/Services/BoardTypeService.php:66` |
|
||||
| `sirsoft-board.comment.after_blind` | action | — | `src/Services/CommentService.php:480` |
|
||||
| `sirsoft-board.comment.after_create` | action | — | `src/Services/CommentService.php:375` |
|
||||
| `sirsoft-board.comment.after_delete` | action | — | `src/Services/CommentService.php:441` |
|
||||
| `sirsoft-board.comment.after_restore` | action | — | `src/Services/CommentService.php:516` |
|
||||
| `sirsoft-board.comment.after_update` | action | — | `src/Services/CommentService.php:410` |
|
||||
| `sirsoft-board.comment.before_blind` | action | — | `src/Services/CommentService.php:471` |
|
||||
| `sirsoft-board.comment.before_create` | action | — | `src/Services/CommentService.php:341` |
|
||||
| `sirsoft-board.comment.before_delete` | action | — | `src/Services/CommentService.php:431` |
|
||||
| `sirsoft-board.comment.before_restore` | action | — | `src/Services/CommentService.php:507` |
|
||||
| `sirsoft-board.comment.before_update` | action | — | `src/Services/CommentService.php:399` |
|
||||
| `sirsoft-board.comment.filter_create_data` | filter | — | `src/Services/CommentService.php:344` |
|
||||
| `sirsoft-board.comment.filter_update_data` | filter | — | `src/Services/CommentService.php:404` |
|
||||
| `sirsoft-board.comment.store_validation_rules` | filter | — | `src/Http/Requests/StoreCommentRequest.php:91` |
|
||||
| `sirsoft-board.comment.update_validation_rules` | filter | — | `src/Http/Requests/UpdateCommentRequest.php:67` |
|
||||
| `sirsoft-board.permissions.after_create` | action | — | `src/Services/BoardService.php:431` |
|
||||
| `sirsoft-board.permissions.after_delete` | action | — | `src/Services/BoardService.php:600` |
|
||||
| `sirsoft-board.permissions.after_update` | action | — | `src/Services/BoardService.php:523` |
|
||||
| `sirsoft-board.post.after_blind` | action | — | `src/Services/PostService.php:500` |
|
||||
| `sirsoft-board.post.after_create` | action | — | `src/Services/PostService.php:275` |
|
||||
| `sirsoft-board.post.after_delete` | action | — | `src/Services/PostService.php:459` |
|
||||
| `sirsoft-board.post.after_restore` | action | — | `src/Services/PostService.php:564` |
|
||||
| `sirsoft-board.post.after_update` | action | — | `src/Services/PostService.php:351` |
|
||||
| `sirsoft-board.post.before_blind` | action | — | `src/Services/PostService.php:491` |
|
||||
| `sirsoft-board.post.before_create` | action | — | `src/Services/PostService.php:250` |
|
||||
| `sirsoft-board.post.before_delete` | action | — | `src/Services/PostService.php:426` |
|
||||
| `sirsoft-board.post.before_restore` | action | — | `src/Services/PostService.php:533` |
|
||||
| `sirsoft-board.post.before_update` | action | — | `src/Services/PostService.php:324` |
|
||||
| `sirsoft-board.post.filter_content_thumbnail` | filter | — | `src/Models/Post.php:138` |
|
||||
| `sirsoft-board.post.filter_create_data` | filter | — | `src/Services/PostService.php:253` |
|
||||
| `sirsoft-board.post.filter_update_data` | filter | — | `src/Services/PostService.php:329` |
|
||||
| `sirsoft-board.post.store_validation_rules` | filter | — | `src/Http/Requests/StorePostRequest.php:120` |
|
||||
| `sirsoft-board.post.update_validation_rules` | filter | — | `src/Http/Requests/UpdatePostRequest.php:77` |
|
||||
| `sirsoft-board.report.after_blind_content` | action | — | `src/Services/ReportService.php:712` |
|
||||
| `sirsoft-board.report.after_bulk_update_status` | action | — | `src/Services/ReportService.php:492` |
|
||||
| `sirsoft-board.report.after_create` | action | — | `src/Services/ReportService.php:292` |
|
||||
| `sirsoft-board.report.after_delete` | action | — | `src/Services/ReportService.php:559` |
|
||||
| `sirsoft-board.report.after_delete_content` | action | — | `src/Services/ReportService.php:880` |
|
||||
| `sirsoft-board.report.after_restore_content` | action | — | `src/Services/ReportService.php:678` |
|
||||
| `sirsoft-board.report.after_update_status` | action | — | `src/Services/ReportService.php:369` |
|
||||
| `sirsoft-board.report.before_bulk_update_status` | action | — | `src/Services/ReportService.php:385` |
|
||||
| `sirsoft-board.report.before_create` | action | — | `src/Services/ReportService.php:203` |
|
||||
| `sirsoft-board.report.before_delete` | action | — | `src/Services/ReportService.php:554` |
|
||||
| `sirsoft-board.report.before_update_status` | action | — | `src/Services/ReportService.php:324` |
|
||||
| `sirsoft-board.report.filter_create_data` | filter | — | `src/Services/ReportService.php:227` |
|
||||
| `sirsoft-board.roles.after_create` | action | — | `src/Services/BoardService.php:411` |
|
||||
| `sirsoft-board.roles.after_delete` | action | — | `src/Services/BoardService.php:604` |
|
||||
| `sirsoft-board.search.post.index_should_update` | filter | — | `src/Models/Post.php:280` |
|
||||
| `sirsoft-board.settings.after_bulk_apply` | action | — | `src/Services/BoardService.php:1368` |
|
||||
| `sirsoft-board.settings.after_bulk_apply_aborted` | action | — | `src/Services/BoardService.php:1359` |
|
||||
| `sirsoft-board.settings.before_bulk_apply` | action | — | `src/Services/BoardService.php:1263` |
|
||||
| `sirsoft-board.user_post.store_validation_rules` | filter | — | `src/Http/Requests/User/StorePostRequest.php:162` |
|
||||
| `sirsoft-board.user_post.update_validation_rules` | filter | — | `src/Http/Requests/User/UpdatePostRequest.php:112` |
|
||||
<!-- @generated:hooks-published END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`{도메인}.{동사}` 이름 규칙 안에서 4개 도메인(board/post/comment/report)이 전부 같은 3단
|
||||
패턴(`before_*` → `filter_*_data` → `after_*`)을 반복합니다. 새 검증 규칙을 게시판별로 다르게
|
||||
걸고 싶으면 `*.store_validation_rules`/`update_validation_rules` filter 를, 저장 직전 데이터를
|
||||
가공하고 싶으면 `filter_*_data` 를, 저장 완료 후 부가 작업(알림·외부 연동)을 붙이고 싶으면
|
||||
`after_*` action 을 잡습니다 — `before_*` 에서 예외를 던지면 저장 자체를 막을 수 있습니다.
|
||||
|
||||
`sirsoft-board.post.filter_content_thumbnail`/`sirsoft-board.search.post.index_should_update`
|
||||
두 필터는 Model(`Post.php`) 안에서 직접 발행되는 예외적인 자리입니다 — Service 를 거치지 않는
|
||||
지연 평가(썸네일 추출, 검색 색인 갱신 여부 판단)라 Model 이 직접 훅을 겁니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 구독 훅
|
||||
|
||||
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|
||||
|---|---|---|---|---|
|
||||
| `core.activity_log.filter_description_params` | filter | `ActivityLogDescriptionResolver` | `resolveDescriptionParams` | 10 |
|
||||
| `core.module_settings.after_save` | action (미선언) | `SeoBoardSettingsCacheListener` | `onModuleSettingsSave` | 20 |
|
||||
| `core.notification.filter_default_definitions` | filter | `BoardNotificationDataListener` | `contributeDefaultDefinitions` | 20 |
|
||||
| `core.search.build_response` | filter | `SearchPostsListener` | `buildPostsResponse` | 10 |
|
||||
| `core.search.index_validation_rules` | filter | `SearchPostsListener` | `addValidationRules` | 10 |
|
||||
| `core.search.results` | filter | `SearchPostsListener` | `searchPosts` | 10 |
|
||||
| `core.user.after_create` | action (미선언) | `UserNotificationSettingsListener` | `afterCreate` | 10 |
|
||||
| `core.user.create_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
|
||||
| `core.user.filter_create_data` | filter | `UserNotificationSettingsListener` | `filterCreateData` | 10 |
|
||||
| `core.user.filter_resource_data` | filter | `UserNotificationSettingsListener` | `filterResourceData` | 10 |
|
||||
| `core.user.filter_update_data` | filter | `UserNotificationSettingsListener` | `filterUpdateData` | 10 |
|
||||
| `core.user.update_profile_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
|
||||
| `core.user.update_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
|
||||
| `sirsoft-board.attachment.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterDelete` | 20 |
|
||||
| `sirsoft-board.attachment.after_delete` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
|
||||
| `sirsoft-board.attachment.after_download` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterDownload` | 20 |
|
||||
| `sirsoft-board.attachment.after_link` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
|
||||
| `sirsoft-board.attachment.after_upload` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterUpload` | 20 |
|
||||
| `sirsoft-board.attachment.after_upload` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
|
||||
| `sirsoft-board.board.after_add_to_menu` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterAddToMenu` | 20 |
|
||||
| `sirsoft-board.board.after_create` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterCreate` | 20 |
|
||||
| `sirsoft-board.board.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterDelete` | 20 |
|
||||
| `sirsoft-board.board.after_remove_from_menu` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterRemoveFromMenu` | 20 |
|
||||
| `sirsoft-board.board.after_update` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterUpdate` | 20 |
|
||||
| `sirsoft-board.board.after_update` | action (미선언) | `SeoBoardCacheListener` | `onBoardUpdate` | 20 |
|
||||
| `sirsoft-board.board_type.after_create` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterCreate` | 20 |
|
||||
| `sirsoft-board.board_type.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterDelete` | 20 |
|
||||
| `sirsoft-board.board_type.after_update` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterUpdate` | 20 |
|
||||
| `sirsoft-board.comment.after_blind` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterBlind` | 20 |
|
||||
| `sirsoft-board.comment.after_create` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterCreate` | 20 |
|
||||
| `sirsoft-board.comment.after_create` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
|
||||
| `sirsoft-board.comment.after_create` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
|
||||
| `sirsoft-board.comment.after_create` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
|
||||
| `sirsoft-board.comment.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterDelete` | 20 |
|
||||
| `sirsoft-board.comment.after_delete` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
|
||||
| `sirsoft-board.comment.after_delete` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
|
||||
| `sirsoft-board.comment.after_delete` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
|
||||
| `sirsoft-board.comment.after_restore` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterRestore` | 20 |
|
||||
| `sirsoft-board.comment.after_restore` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
|
||||
| `sirsoft-board.comment.after_restore` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
|
||||
| `sirsoft-board.comment.after_restore` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
|
||||
| `sirsoft-board.comment.after_update` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterUpdate` | 20 |
|
||||
| `sirsoft-board.notification.channels` | filter | `BoardNotificationChannelListener` | `filterChannels` | 10 |
|
||||
| `sirsoft-board.notification.extract_data` | filter | `BoardNotificationDataListener` | `extractData` | 20 |
|
||||
| `sirsoft-board.post.after_blind` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterBlind` | 20 |
|
||||
| `sirsoft-board.post.after_create` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterCreate` | 20 |
|
||||
| `sirsoft-board.post.after_create` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
|
||||
| `sirsoft-board.post.after_create` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
|
||||
| `sirsoft-board.post.after_create` | action (미선언) | `SeoBoardCacheListener` | `onPostCreate` | 20 |
|
||||
| `sirsoft-board.post.after_delete` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterDelete` | 20 |
|
||||
| `sirsoft-board.post.after_delete` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
|
||||
| `sirsoft-board.post.after_delete` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
|
||||
| `sirsoft-board.post.after_delete` | action (미선언) | `SeoBoardCacheListener` | `onPostDelete` | 20 |
|
||||
| `sirsoft-board.post.after_restore` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterRestore` | 20 |
|
||||
| `sirsoft-board.post.after_restore` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
|
||||
| `sirsoft-board.post.after_restore` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
|
||||
| `sirsoft-board.post.after_update` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterUpdate` | 20 |
|
||||
| `sirsoft-board.post.after_update` | action (미선언) | `SeoBoardCacheListener` | `onPostUpdate` | 20 |
|
||||
| `sirsoft-board.report.after_blind_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterBlindContent` | 20 |
|
||||
| `sirsoft-board.report.after_bulk_update_status` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterBulkUpdateStatus` | 20 |
|
||||
| `sirsoft-board.report.after_create` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterCreate` | 20 |
|
||||
| `sirsoft-board.report.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterDelete` | 20 |
|
||||
| `sirsoft-board.report.after_delete_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterDeleteContent` | 20 |
|
||||
| `sirsoft-board.report.after_restore_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterRestoreContent` | 20 |
|
||||
| `sirsoft-board.report.after_update_status` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterUpdateStatus` | 20 |
|
||||
| `sirsoft-board.settings.after_bulk_apply` | action (미선언) | `BoardActivityLogListener` | `handleSettingsAfterBulkApply` | 20 |
|
||||
| `sirsoft-board.settings.after_bulk_apply` | action (미선언) | `SeoBoardSettingsCacheListener` | `onBulkApply` | 20 |
|
||||
| `sirsoft-board.settings.after_bulk_apply_aborted` | action (미선언) | `BoardActivityLogListener` | `handleSettingsAfterBulkApplyAborted` | 20 |
|
||||
| `sirsoft-ckeditor5.image.filter_reference_sources` | filter | `Ckeditor5ReferenceSourcesListener` | `addBoardSources` | 10 |
|
||||
| `sirsoft-ecommerce.inquiry.count_replies` | filter | `EcommerceInquiryHookListener` | `countReplies` | 10 |
|
||||
| `sirsoft-ecommerce.inquiry.create` | filter | `EcommerceInquiryHookListener` | `createAndReturn` | 10 |
|
||||
| `sirsoft-ecommerce.inquiry.delete` | filter | `EcommerceInquiryHookListener` | `deletePost` | 10 |
|
||||
| `sirsoft-ecommerce.inquiry.delete_reply` | filter | `EcommerceInquiryHookListener` | `deleteReplyPost` | 10 |
|
||||
| `sirsoft-ecommerce.inquiry.get_by_ids` | filter | `EcommerceInquiryHookListener` | `getByIds` | 10 |
|
||||
| `sirsoft-ecommerce.inquiry.get_settings` | filter | `EcommerceInquiryHookListener` | `getBoardSettings` | 10 |
|
||||
| `sirsoft-ecommerce.inquiry.update` | filter | `EcommerceInquiryHookListener` | `updatePost` | 10 |
|
||||
| `sirsoft-ecommerce.inquiry.update_reply` | filter | `EcommerceInquiryHookListener` | `updateReplyPost` | 10 |
|
||||
<!-- @generated:hooks-subscribed END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`core.user.*` 4종을 구독하는 이유는 회원가입/수정 화면에 "댓글 알림 수신 여부" 필드를 끼워
|
||||
넣기 위해서입니다 — 이 필드는 `UserNotificationSetting` 모델(board 소유)에 저장되지만, 입력
|
||||
자체는 코어 회원 폼에서 받습니다. `sirsoft-ckeditor5.image.filter_reference_sources` 구독은
|
||||
board 글 본문(HTML 에디터)에 삽입된 이미지가 삭제 시 함께 정리되도록 참조 소스 목록에 게시글을
|
||||
등록하는 자리입니다. `sirsoft-ecommerce.inquiry.*` 8개는 이커머스 "상품 문의"가 board 의
|
||||
Post/Comment CRUD 를 그대로 재사용하되 저장 로직만 이커머스가 대신 처리하는 위임 지점입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 훅 리스너
|
||||
|
||||
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|
||||
|---|---|---|---|---|
|
||||
| `ActivityLogDescriptionResolver` | 1개 | 명시 등록 | ✅ | `src/Listeners/ActivityLogDescriptionResolver.php` |
|
||||
| `BoardActivityLogListener` | 30개 | 명시 등록 | ✅ | `src/Listeners/BoardActivityLogListener.php` |
|
||||
| `BoardCommentsCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/BoardCommentsCountSyncListener.php` |
|
||||
| `BoardNotificationChannelListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/BoardNotificationChannelListener.php` |
|
||||
| `BoardNotificationDataListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/BoardNotificationDataListener.php` |
|
||||
| `BoardPostsCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/BoardPostsCountSyncListener.php` |
|
||||
| `Ckeditor5ReferenceSourcesListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/Ckeditor5ReferenceSourcesListener.php` |
|
||||
| `CommentReplySyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/CommentReplySyncListener.php` |
|
||||
| `EcommerceInquiryHookListener` | 8개 | 명시 등록 | ✅ | `src/Listeners/EcommerceInquiryHookListener.php` |
|
||||
| `PostAttachmentCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostAttachmentCountSyncListener.php` |
|
||||
| `PostCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostCountSyncListener.php` |
|
||||
| `PostReplySyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostReplySyncListener.php` |
|
||||
| `SearchPostsListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SearchPostsListener.php` |
|
||||
| `SeoBoardCacheListener` | 4개 | 명시 등록 | ✅ | `src/Listeners/SeoBoardCacheListener.php` |
|
||||
| `SeoBoardSettingsCacheListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/SeoBoardSettingsCacheListener.php` |
|
||||
| `UserNotificationSettingsListener` | 7개 | 명시 등록 | ✅ | `src/Listeners/UserNotificationSettingsListener.php` |
|
||||
<!-- @generated:listeners END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`BoardActivityLogListener` 하나가 30개 훅을 구독하는 것이 의도된 형태입니다 — 활동 로그는
|
||||
"무엇이 언제 왜 바뀌었는가"를 도메인 전체에서 일관된 형식으로 남겨야 하므로, 도메인별로
|
||||
리스너를 쪼개면 로그 스키마가 갈라질 위험이 커집니다. 반대로 카운트 동기화(`*CountSyncListener`)
|
||||
는 목적이 하나씩이라 도메인별로 쪼개져 있습니다 — 첨부 개수와 댓글 개수는 서로 독립적으로
|
||||
실패해도 되므로, 한쪽이 예외를 던져도 다른 쪽 동기화는 영향받지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 레이아웃 확장
|
||||
|
||||
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 대상 | 설명 |
|
||||
|---|---|
|
||||
| `resources/extensions/admin-ecommerce-inquiry-settings.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
|
||||
| `resources/extensions/admin_dashboard_community.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
|
||||
| `resources/extensions/admin_dashboard_quick_menu.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
|
||||
| `resources/extensions/user-notification-detail.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
|
||||
| `resources/extensions/user-notification-settings.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
|
||||
<!-- @generated:layout-extensions END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
5개 조각 중 `admin-ecommerce-inquiry-settings.json`·`admin_dashboard_community.json`·
|
||||
`admin_dashboard_quick_menu.json` 은 board 자신의 화면이 아니라 **다른 확장(이커머스 문의
|
||||
설정 화면, 관리자 대시보드)에** 게시판 관련 UI 를 끼워 넣는 조각입니다. 이 모듈이 다른 확장의
|
||||
레이아웃을 코드로 알지 못한 채(레이아웃 확장 시스템을 통해서만) UI 를 주입한다는 뜻입니다.
|
||||
나머지 2개(`user-notification-*`)는 코어 회원 알림 설정 화면에 board 알림 수신 옵션을
|
||||
끼워 넣는 자리입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 미들웨어
|
||||
|
||||
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 미들웨어가 없습니다._
|
||||
<!-- @generated:middleware END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
공개 API 라우트(`optional.sanctum`)와 관리자 API 라우트(코어 `auth`+권한 미들웨어)는 전부
|
||||
코어가 이미 등록한 미들웨어로 충분합니다. board 만의 요청 전처리(예: 게시판별 rate limit)가
|
||||
필요해지면 이 자리에 선언형으로 추가하되, 대상(targets)을 명시해 자기 라우트에만 부착합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 브로드캐스트 채널
|
||||
|
||||
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 브로드캐스트 채널이 없습니다._
|
||||
<!-- @generated:channels END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
실시간 갱신(새 댓글이 열려 있는 화면에 즉시 반영되는 등)은 이 모듈의 범위 밖입니다(§1 참고).
|
||||
필요해지면 `sirsoft-board.{slug}.*` 채널을 신설하되, 게시판별로 채널을 분리해야 방문자가
|
||||
관심 없는 다른 게시판의 이벤트까지 구독하지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 스케줄
|
||||
|
||||
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 스케줄 | 주기 | 설명 |
|
||||
|---|---|---|
|
||||
| `sirsoft-board:aggregate-stats` | `hourly` | 대시보드 게시물 현황 집계 |
|
||||
| `sirsoft-board:prune-attachments --scheduled` | `daily` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 |
|
||||
<!-- @generated:schedules END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`prune-attachments` 는 두 가지 서로 다른 작업을 한 스케줄에 묶습니다 — "방치된 임시 첨부
|
||||
정리"(업로드했지만 게시글 저장까지 이어지지 않은 파일)는 사용자 파일을 지우지 않으므로 항상
|
||||
실행되고, "보존기간 경과 삭제 첨부 영구 정리"(이미 삭제 처리된 첨부의 실제 파일 파기)는
|
||||
`attachment_settings.purge_enabled` 로 게이트됩니다 — module.php 의 `enabled_config: null` 은
|
||||
스케줄 자체는 끌 수 없다는 뜻이고, 실제 파기 여부만 설정으로 조정됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 알림 정의
|
||||
|
||||
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 알림 키 | 채널 |
|
||||
|---|---|
|
||||
| `new_comment` | `mail`, `database` |
|
||||
| `reply_comment` | `mail`, `database` |
|
||||
| `post_reply` | `mail`, `database` |
|
||||
| `post_action` | `mail`, `database` |
|
||||
| `new_post_admin` | `mail`, `database` |
|
||||
| `report_received_admin` | `mail`, `database` |
|
||||
| `report_action` | `mail`, `database` |
|
||||
<!-- @generated:notifications END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
7종 중 `new_comment`/`reply_comment`/`post_reply` 는 회원이 끌 수 있습니다
|
||||
(`UserNotificationSetting`, `core.user.*` 훅으로 회원 폼에 노출) — 반면 관리자 대상 알림
|
||||
(`new_post_admin`/`report_received_admin`)과 신고 처리 결과 알림(`post_action`/`report_action`)
|
||||
은 끌 수 없습니다. `post_action`과 `report_action`이 정확히 같은 훅 6개를 구독하는 것은
|
||||
중복이 아니라 **관점의 차이**입니다 — 관리자가 직접 블라인드했는지, 신고 처리 결과로
|
||||
블라인드됐는지에 따라 원 작성자에게 보이는 문구(원인 설명)가 갈라져야 하기 때문입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 활동 로그 훅
|
||||
|
||||
> 이 확장이 코어 활동 로그(`activity_logs`)에 기록을 남기기 위해 구독하는 훅 30개입니다.
|
||||
> 코어 `docs/backend/activity-log-hooks.md` 에 있던 목록을 이 확장 소유로 옮긴 것입니다(#601) —
|
||||
> 확장이 훅을 더할 때 코어 문서를 고쳐야 하던 역방향 의존을 없애기 위해서입니다. 코어 문서에는
|
||||
> 총계와 이 문서로의 링크만 남습니다.
|
||||
|
||||
> 새 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문,
|
||||
> 그리고 번들 일본어 팩까지 함께 정의해야 합니다 — **모듈 lang 파일에 넣으면 해석되지
|
||||
> 않습니다.**
|
||||
|
||||
### 게시판 모듈 훅 (BoardActivityLogListener)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php`
|
||||
**총 30훅**
|
||||
|
||||
> 이 표에 `before_*` 훅이 없는 것은 누락이 아닙니다. 수정 전 스냅샷은 이 리스너가
|
||||
> `before_*` 훅으로 직접 잡지 않고 **Service 가 잡아 `after_*` 훅의 인자로 넘깁니다**
|
||||
> (`ChangeDetector::detect($model, $snapshot)`). `before_*` 훅 자체는 발행되며 그 목록은
|
||||
> 위 「발행 훅」 절에 있습니다.
|
||||
|
||||
#### Board (7훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.board.after_create` | `handleBoardAfterCreate` | `board.create` | Admin | Board |
|
||||
| `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.board.after_remove_from_menu` | `handleBoardAfterRemoveFromMenu` | `board.remove_from_menu` | Admin | Board |
|
||||
| `sirsoft-board.settings.after_bulk_apply` | `handleSettingsAfterBulkApply` | `board_settings.bulk_apply` | Admin | - |
|
||||
| `sirsoft-board.settings.after_bulk_apply_aborted` | `handleSettingsAfterBulkApplyAborted` | `board_settings.bulk_apply_aborted` | Admin | - |
|
||||
|
||||
#### BoardType (3훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.board_type.after_create` | `handleBoardTypeAfterCreate` | `board_type.create` | Admin | BoardType |
|
||||
| `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 (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.post.after_create` | `handlePostAfterCreate` | `post.create` | Admin | Post |
|
||||
| `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 (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-board.comment.after_create` | `handleCommentAfterCreate` | `comment.create` | Admin | Comment |
|
||||
| `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 (3훅)
|
||||
|
||||
| 훅 이름 | 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 |
|
||||
| `sirsoft-board.attachment.after_download` | `handleAttachmentAfterDownload` | `attachment.download` | Admin / User | Attachment |
|
||||
|
||||
#### Report (7훅)
|
||||
|
||||
| 훅 이름 | 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.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 |
|
||||
@@ -0,0 +1,121 @@
|
||||
# 게시판 — 프론트엔드
|
||||
|
||||
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 레이아웃
|
||||
|
||||
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
레이아웃 46개 (루트: `resources/layouts`).
|
||||
|
||||
| 그룹 | 개수 |
|
||||
|---|---|
|
||||
| `admin` | 46개 |
|
||||
|
||||
| 레이아웃 | 그룹 | 종류 | extends |
|
||||
|---|---|---|---|
|
||||
| `admin_board_form` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_board_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_board_post_detail` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_board_post_form` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_board_posts_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_board_reports_detail` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_board_reports_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_board_settings` | `admin` | 화면 | `_admin_base` |
|
||||
| `_board_type_manage_modal` | `admin` | partial | - |
|
||||
| `_tab_basic` | `admin` | partial | - |
|
||||
| `_tab_list` | `admin` | partial | - |
|
||||
| `_tab_notification` | `admin` | partial | - |
|
||||
| `_tab_permissions` | `admin` | partial | - |
|
||||
| `_tab_post` | `admin` | partial | - |
|
||||
| `_comment` | `admin` | partial | - |
|
||||
| `_comments` | `admin` | partial | - |
|
||||
| `_post_card_content` | `admin` | partial | - |
|
||||
| `_reply_card_content` | `admin` | partial | - |
|
||||
| `_attachments` | `admin` | partial | - |
|
||||
| `_form_fields` | `admin` | partial | - |
|
||||
| `_parent_post` | `admin` | partial | - |
|
||||
| `_alert_status` | `admin` | partial | - |
|
||||
| `_card_history` | `admin` | partial | - |
|
||||
| `_card_report_info` | `admin` | partial | - |
|
||||
| `_bulk_apply_modal` | `admin` | partial | - |
|
||||
| `_modal_identity_policy_delete` | `admin` | partial | - |
|
||||
| `_modal_identity_policy_form` | `admin` | partial | - |
|
||||
| `_modal_mail_template_edit` | `admin` | partial | - |
|
||||
| `_modal_notification_definition_reset` | `admin` | partial | - |
|
||||
| `_modal_notification_template_edit` | `admin` | partial | - |
|
||||
| `_modal_notification_template_preview` | `admin` | partial | - |
|
||||
| `_tab_board_settings_attachment` | `admin` | partial | - |
|
||||
| `_tab_board_settings_basic` | `admin` | partial | - |
|
||||
| `_tab_board_settings_bulk_apply` | `admin` | partial | - |
|
||||
| `_tab_board_settings_comment` | `admin` | partial | - |
|
||||
| `_tab_board_settings_list` | `admin` | partial | - |
|
||||
| `_tab_board_settings_notification` | `admin` | partial | - |
|
||||
| `_tab_board_settings_permissions` | `admin` | partial | - |
|
||||
| `_tab_board_settings_post` | `admin` | partial | - |
|
||||
| `_tab_board_settings_reply` | `admin` | partial | - |
|
||||
| `_tab_general` | `admin` | partial | - |
|
||||
| `_tab_identity_policies` | `admin` | partial | - |
|
||||
| `_tab_notification_definitions` | `admin` | partial | - |
|
||||
| `_tab_report_policy` | `admin` | partial | - |
|
||||
| `_tab_seo` | `admin` | partial | - |
|
||||
| `_tab_spam_security` | `admin` | partial | - |
|
||||
<!-- @generated:layouts END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
46개 레이아웃이 **전부** `admin` 그룹입니다 — 이것은 이 모듈이 방문자 화면을 그리지 않는다는
|
||||
증거입니다(§1, §architecture.md 참고). 새로 방문자용 게시판 화면(예: 다른 스타일의 목록)이
|
||||
필요하면 이 디렉토리가 아니라 그 화면을 쓸 템플릿의 `layouts/` 에 추가합니다. `_tab_*` partial
|
||||
이 24개로 가장 많은 것은 이 모듈에 탭 구조를 쓰는 화면이 최소 세 곳이기 때문입니다 —
|
||||
게시판 생성/수정 폼(`_tab_basic`/`list`/`post`/`permissions`/`notification`), 게시판별
|
||||
개별 설정 화면(`_tab_board_settings_*` 9개 — attachment/basic/bulk_apply/comment/list/
|
||||
notification/permissions/post/reply), 모듈 전역 환경설정 화면(`_tab_general`/
|
||||
`identity_policies`/`notification_definitions`/`report_policy`/`seo`/`spam_security`).
|
||||
세 화면의 탭 이름이 겹치더라도(`_tab_basic`, `_tab_board_settings_basic` 등) 서로 다른
|
||||
partial 파일이므로 한쪽만 고치면 다른 화면은 그대로입니다 — 같은 항목을 여러 화면에
|
||||
반영해야 한다면 파일을 전부 찾아 고쳐야 합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 액션 핸들러
|
||||
|
||||
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 액션 핸들러가 없습니다._
|
||||
<!-- @generated:handlers END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이 모듈의 관리자 레이아웃은 코어 빌트인 핸들러(`apiCall`/`navigate`/`setState` 등)만으로
|
||||
전부 구성됩니다 — 게시판 CRUD·신고 처리·설정 저장은 결국 REST 호출 + 표준 폼 상태 관리라
|
||||
전용 핸들러를 등록할 필요가 없었습니다. 새 관리자 화면을 추가할 때도 먼저 빌트인 핸들러
|
||||
조합으로 가능한지 확인하고, 그래도 부족할 때만(예: 파일 업로드 진행률 같은 복잡한 클라이언트
|
||||
상태) `resources/js/` 에 전용 핸들러를 신설합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 전역 진입점
|
||||
|
||||
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_프론트 엔트리포인트가 없습니다._
|
||||
<!-- @generated:frontend-entry END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
액션 핸들러가 없는 것과 같은 이유로 `window.__[Name]` 재등록 진입점도 없습니다 — 로케일
|
||||
전환 후 재등록해야 할 자체 핸들러가 이 모듈에는 없기 때문입니다. 프론트 전용 코드
|
||||
(`resources/js/`)를 신설하면 그 순간부터 이 자리에 진입점을 만들어야 합니다(§CLAUDE.md
|
||||
"확장 미들웨어는..." 항목 인근의 재등록 진입점 규정 참고) — 없으면 로케일 전환 후 그 확장의
|
||||
액션이 전부 무반응이 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 에셋
|
||||
|
||||
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 구분 |
|
||||
|---|---|
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
|
||||
|
||||
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
|
||||
<!-- @generated:assets END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`editor-spec.json` 이 이 모듈의 유일한 프론트 자산인 것도 위와 같은 이유입니다 — 레이아웃
|
||||
편집기가 게시판 관리 화면의 컴포넌트를 인식하려면 이 선언이 필요하지만, 실행 시점에 로드할
|
||||
JS/CSS 번들은 없습니다. `priority: 100` 은 다른 확장의 에셋 우선순위와 충돌하지 않는 기본값이며,
|
||||
`dependencies: []` 는 이 확장의 에디터 스펙이 다른 확장의 스펙 로드를 전제하지 않는다는 뜻입니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,98 @@
|
||||
# 게시판 — 설정·권한·라우트
|
||||
|
||||
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설정 스키마
|
||||
|
||||
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_`getSettingsSchema()` 선언이 없습니다._
|
||||
|
||||
기본값 파일: `config/settings/defaults.json`
|
||||
<!-- @generated:settings-schema END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`getSettingsSchema()` 가 없는 것은 누락이 아니라 설계입니다 — 이 모듈에는 "전역 설정"이라
|
||||
부를 만한 것이 없습니다. 운영자가 조정하는 값은 전부 **게시판 하나**에 속한 설정
|
||||
(`BoardSettingsService`, `/admin/boards/{slug}/settings`)이라 코어의 전역 설정 스키마
|
||||
메커니즘과 맞지 않습니다. `config/board.php` 는 운영자가 바꾸는 자리가 아니라 개발자가
|
||||
정의하는 상수(첨부 저장 디스크, 게시판별 동적 권한 정의 템플릿)이며, 이 값은 `.env` 로만
|
||||
바꿉니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 권한
|
||||
|
||||
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 카테고리 | 이름 | 액션 | 라우트 키 |
|
||||
|---|---|---|---|
|
||||
| `boards` | 게시판 관리 | `read`, `create`, `update`, `delete` | `board` |
|
||||
| `settings` | 환경설정 | `read`, `update` | - |
|
||||
| `identity.policies` | 게시판 본인인증 정책 | `read`, `update` | - |
|
||||
| `dashboard` | 게시판 대시보드 | `view` | - |
|
||||
| `reports` | 게시판 신고 관리 | `view`, `manage` | `report` |
|
||||
<!-- @generated:permissions END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
위 표는 **모듈 레벨** 권한(게시판 관리 자체를 다루는 관리자 권한)만 보여줍니다. 게시판 하나를
|
||||
만들면 그 게시판 전용 권한이 `config/board.php` 의 `board_permission_definitions` 템플릿을
|
||||
기반으로 추가 생성됩니다(admin.posts.read/write, posts.read-secret 등 — 게시판마다 독립적인
|
||||
권한 묶음). 그 동적 권한은 이 표에 나타나지 않으며 `getDynamicPermissionIdentifiers()` 로만
|
||||
전수를 확인할 수 있습니다. `boards`/`reports` 카테고리에 `resource_route_key`/`owner_key` 가
|
||||
붙어 있는 것은 소유자 기반 스코프 판정(자기 글만 관리 가능한 `manager` 이하 역할 등)이 걸려
|
||||
있다는 뜻입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 메뉴
|
||||
|
||||
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 구분 | slug | 이름 | URL | 하위 |
|
||||
|---|---|---|---|---|
|
||||
| 관리자 | `sirsoft-board` | 게시판 관리 | - | 3개 |
|
||||
<!-- @generated:menus END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
정적 관리자 메뉴는 "게시판 관리" 3개 하위 메뉴(환경설정/목록/신고현황)뿐입니다. 게시판을
|
||||
만들 때마다 생기는 `board-{slug}` 메뉴는 동적 메뉴라 이 표에 없으며
|
||||
`getDynamicMenuSlugs()` 로 전수를 확인합니다. 방문자용 메뉴(사이트 상단 게시판 링크 등)는
|
||||
이 모듈이 등록하지 않습니다 — 템플릿이 공개 API(`boards.board-menu`)를 호출해 직접 구성합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 라우트
|
||||
|
||||
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 파일 | URL prefix |
|
||||
|---|---|---|
|
||||
| `api` | `src/routes/api.php` | `/api/modules/sirsoft-board/...` |
|
||||
|
||||
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
|
||||
<!-- @generated:routes END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
같은 `src/routes/api.php` 파일 안에 관리자 전용 그룹(`/admin/board/{slug}/...`, 권한 미들웨어)과
|
||||
공개 그룹(`/boards/...`, `optional.sanctum`)이 함께 있습니다 — 파일을 분리하지 않은 것은
|
||||
board 의 라우트가 20개 안팎으로 한 파일에서 관리 가능한 규모이기 때문입니다. 새 공개
|
||||
엔드포인트를 추가할 때는 반드시 `optional.sanctum`(비회원도 접근 가능, 회원이면 컨텍스트
|
||||
주입)을 쓰고 `auth:sanctum` 을 쓰지 않습니다 — 게시판 열람은 비회원에게도 열려 있어야 합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 의존 관계
|
||||
|
||||
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
없음 — 코어만으로 동작합니다.
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
| 확장 | 유형 | 요구 버전 |
|
||||
|---|---|---|
|
||||
| `sirsoft-basic` | 템플릿 | `>=1.0.0` |
|
||||
<!-- @generated:dependencies END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이 모듈이 의존하는 확장이 "없음"인 것은 board 가 코어 훅·API 만으로 완결되도록 설계됐다는
|
||||
뜻입니다. 반대로 이 모듈에 의존하는 쪽은 하나(템플릿)뿐이지만, 그보다 결합이 느슨한
|
||||
**필터 훅 위임** 소비자(이커머스 문의)는 `dependencies` 로 선언되지 않습니다 — 이커머스는
|
||||
board 를 자기 도메인으로 대체할 뿐 board API 계약에 실제로 묶여 있지 않기 때문입니다.
|
||||
이 모듈의 공개 표면(라우트·API 응답 구조)을 바꿀 때는 `sirsoft-basic` 의 최소 버전 상향을
|
||||
검토해야 합니다(§CLAUDE.md "확장 → 확장 동기화").
|
||||
<!-- @intent END -->
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"moduleId": "sirsoft-board",
|
||||
"version": "1.0.0",
|
||||
"description": "레이아웃 편집기 스펙 — 게시판 모듈 도메인 sampleData/sampleGlobal/states. 실제 admin 레이아웃 data_source ID 전수 스캔 기반 도메인 ID(posts/post/reports/report_detail/reporters_list/boards/boards_list/board_types/form_data/form_meta/settings) + 사용자 게시판 페이지 byEndpointPattern. 공용 인프라(roles/availableChannels/identityProviders/boardIdentity*/boardNotificationDefinitions)는 admin 템플릿 스펙(roles/availableChannels/identityProviders)·코어 프리셋 폴백이 커버.",
|
||||
"description": "게시판 모듈 레이아웃 편집기 스펙 — 관리자·사용자 게시판 화면의 프리뷰 샘플과 페이지 상태. 공용 인프라(roles·availableChannels·identityProviders 등)는 관리자 템플릿 스펙과 코어 기본값이 커버한다.",
|
||||
"sampleGlobal": {
|
||||
"comment": "게시판 도메인 _global keyspace.",
|
||||
"notifications": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# 이커머스 — 에이전트 가이드
|
||||
|
||||
> 이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 유형: 모듈 (sirsoft-ecommerce) — 상품·주문·결제·배송·취소/환불·쿠폰·마일리지·리뷰·문의 도메인. 관리자 CRUD + 공개 API 만 소유하고, 방문자 쇼핑 화면은 템플릿이 그린다
|
||||
2. 확장 방식: 발행 훅 508개(도메인마다 before_*→filter_*_data→after_*→*_validation_rules 4종 반복). PG 는 플러그인이 카탈로그 능력 선언으로 붙고, 금액 개입은 `calculation.*` 18종
|
||||
3. 건드리면 안 되는 것: `OrderCalculationService` 를 우회한 금액 재계산, 마일리지 잔액 캐시 기반 차감 판정, 과거 주문 표기에 현재 통화 설정 조회(`currency_snapshot` 이 SSoT), 금전 복원 훅의 `sync` 누락
|
||||
4. 작업 위치: `modules/_bundled/sirsoft-ecommerce` — 활성 디렉토리 직접 수정 금지
|
||||
5. 반영: `php artisan module:update sirsoft-ecommerce --force`
|
||||
```
|
||||
|
||||
## 1. 이 확장은 무엇인가
|
||||
|
||||
<!-- @intent START -->
|
||||
상품·주문·결제·배송·취소/환불·쿠폰·마일리지·리뷰·문의를 소유하는 커머스 도메인 모듈입니다.
|
||||
모델 47종·소유 테이블 51개·발행 훅 508종으로 번들 확장 중 가장 큰 표면을 갖습니다.
|
||||
|
||||
**소유 범위는 관리자 CRUD + 공개 API 까지입니다.** 레이아웃 206개가 전부 `admin` 그룹인 것이
|
||||
그 증거입니다 — 방문자가 실제로 보는 상품 목록·상세·장바구니·주문서 화면은 이 모듈이 그리지
|
||||
않고, 템플릿(`sirsoft-basic`)이 이 모듈의 공개 API 를 소비해 그립니다. 그래서 쇼핑 화면의
|
||||
디자인 변경은 이 모듈이 아니라 템플릿 쪽 작업입니다.
|
||||
|
||||
**설계 원칙 넷**:
|
||||
|
||||
1. **PG 는 이 모듈이 알지 않는다.** 결제 연동 코드는 전부 플러그인(`pay_kginicis` ·
|
||||
`pay_nhnkcp` · `pay_nicepayments` · `tosspayments`)에 있고, 그 플러그인들이 이 모듈에
|
||||
의존합니다(역방향 아님). 새 PG 는 이 모듈을 고치지 않고 플러그인 추가만으로 붙습니다 —
|
||||
결제수단 카탈로그에 자기 능력(`needs_pg` / `pg_locked` / `pg_provider`)을 선언하는 것이
|
||||
그 접합면입니다.
|
||||
2. **금액은 계산기 하나만 지난다.** `OrderCalculationService` 의 9단계 계산이 상품 상세·
|
||||
장바구니·체크아웃·주문 생성·결제 완료 검증·부분 취소 **여섯 지점 전부**의 단일 출처입니다.
|
||||
화면마다 금액을 다시 계산하면 같은 장바구니가 화면마다 다른 값을 보이게 되고, 그 어긋남은
|
||||
결제 금액 검증에서야 예외로 드러납니다.
|
||||
3. **통화는 설정이 정하고, 거래 시점에 동결된다.** 기본 통화(저장 기준)·표시 통화(구매자
|
||||
선택)·결제 통화(PG 청구)는 각각 따로 설정되며 셋이 모두 다를 수 있습니다. 주문이 생기면
|
||||
그 시점의 통화·소수 자릿수·절사 규칙·환산 분모가 `currency_snapshot` 에 박제되고, 이후
|
||||
운영자가 통화 설정을 바꿔도 과거 주문의 표기는 변하지 않습니다.
|
||||
4. **마일리지는 원장이 SSoT 다.** `ecommerce_mileage_transactions` 가 원장이고
|
||||
`ecommerce_mileage_balances` 는 단방향 파생 캐시입니다. 차감·검증 같은 금전 판정은 캐시가
|
||||
아니라 원장 `FOR UPDATE` 재검증으로 하고, 캐시는 같은 트랜잭션 마지막 단계에서 재계산합니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: PG 통신 코드·실시간 브로드캐스트(채널 0개)·방문자 쇼핑 화면
|
||||
레이아웃, 그리고 상품 문의 게시판의 **콘텐츠 저장소**입니다. 문의 본문은 게시판 모듈이 글로
|
||||
보관하고 이 모듈은 상품↔글 피벗(`ecommerce_product_inquiries`)만 갖습니다. 그런데도 manifest
|
||||
의존에는 게시판이 없습니다 — 연결이 코드 결합이 아니라 훅 구독이라 게시판이 없으면 문의 기능만
|
||||
비고 나머지는 그대로 동작합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 2. 디렉토리 지도
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
|
||||
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
|
||||
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
|
||||
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
|
||||
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
|
||||
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
|
||||
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-ecommerce --force` (빌드 불필요) |
|
||||
| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
|
||||
## 3. 핵심 흐름
|
||||
|
||||
<!-- @intent START -->
|
||||
**주문 생성 (장바구니 → 결제 완료)**: `User\CheckoutController` / `User\OrderController` →
|
||||
FormRequest → `CheckoutDataService`(주문서에 필요한 배송지·쿠폰·마일리지·결제수단 조립) →
|
||||
`OrderCalculationService::calculate()` 9단계로 최종 결제금액 산출 → `TempOrderService` 가
|
||||
임시 주문(`ecommerce_temp_orders`)으로 그 계산 결과를 보관 → PG 결제창 왕복 →
|
||||
`OrderProcessingService` 가 임시 주문을 실제 주문(`Order` + `OrderOption` + `OrderAddress` +
|
||||
`OrderPayment` + `OrderShipping`)으로 변환합니다. 이때 **결제 직전 계산을 한 번 더 돌려
|
||||
금액이 그대로인지 검증**하고(`OrderAmountChangedException` /
|
||||
`PaymentAmountMismatchException`), 주문번호는 `SequenceService` 가 DB UNIQUE 제약으로 원자
|
||||
채번합니다. 상태 흐름은 `pending_order → pending_payment → payment_complete → …`
|
||||
(`OrderStatusEnum` 10 케이스)입니다.
|
||||
|
||||
**취소·환불**: `Admin\OrderCancelController` / `User\OrderController` → FormRequest →
|
||||
`OrderCancellationService`(취소 단위는 주문이 아니라 **옵션**입니다 — `OrderCancel` +
|
||||
`OrderCancelOption`) → `OrderAdjustmentService` 가 이미 적용된 쿠폰·마일리지의 안분을 되돌리고
|
||||
(`adjustment.filter_restore_promotions` 필터로 그 안분 규칙을 확장할 수 있습니다) →
|
||||
`OrderRefund` + `OrderRefundOption` 생성 → 환불 수단(`RefundMethodEnum`: `pg`/`bank`/`points`)
|
||||
에 따라 PG 플러그인 또는 마일리지 원장으로 실제 반환이 나갑니다. 쿠폰 복원·마일리지 복원 훅은
|
||||
**호출자 트랜잭션과 함께 되돌아가야 하므로 `sync => true` 로 구독**합니다(기본값인 큐 래핑은
|
||||
커밋 뒤에 실행되어 예외를 던져도 롤백되지 않습니다).
|
||||
|
||||
**상품 저장 → 색인·SEO**: `Admin\ProductController` → `StoreProductRequest`
|
||||
(`product.create_validation_rules` 필터로 확장 지점 제공) → `ProductService`
|
||||
(`before_create` → `filter_create_data` → `after_create`) → `ProductRepository`. 이후는 훅
|
||||
리스너 레인입니다 — `SearchProductsListener`(검색 색인) · `SeoProductCacheListener`(봇 화면
|
||||
캐시 무효화) · `ProductActivityLogListener`(활동 로그) · `SyncOptionGroupsListener` /
|
||||
`SyncProductFromOptionListener`(옵션 ↔ 상품 대표값 동기화)가 각자 받아 처리하고, Service 는
|
||||
이 부가효과를 알지 못합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 4. 확장점
|
||||
|
||||
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 확장점 | 수 | 상세 |
|
||||
|---|---|---|
|
||||
| 발행 훅 | 508개 | [발행 훅](docs/extension-points.md#발행-훅) |
|
||||
| 구독 훅 | 142개 | [구독 훅](docs/extension-points.md#구독-훅) |
|
||||
| 훅 리스너 | 33개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
|
||||
| 레이아웃 확장 | 12개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
|
||||
| 미들웨어 | 3개 | [미들웨어](docs/extension-points.md#미들웨어) |
|
||||
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
|
||||
| 스케줄 | 9개 | [스케줄](docs/extension-points.md#스케줄) |
|
||||
| 알림 정의 | 10개 | [알림 정의](docs/extension-points.md#알림-정의) |
|
||||
<!-- @generated:extension-points-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
발행 훅 508종은 도메인별로 같은 모양을 반복합니다 — `before_{동작}` (action) →
|
||||
`filter_{동작}_data` (filter) → 실행 → `after_{동작}` (action), 그리고 FormRequest 쪽의
|
||||
`{동작}_validation_rules` (filter). `brand` 19종을 한 번 읽으면 `product` 44 · `order` 35 ·
|
||||
`coupon` 32 · `category` 28 · `cart` 25 · `shipping_policy` 24 에 그대로 적용됩니다. 도메인
|
||||
CRUD 를 바꾸고 싶으면 이 4종 중 하나를 잡으면 되고, 이 모듈의 소스를 고칠 일은 없습니다.
|
||||
|
||||
그 규칙에서 벗어나는 것이 실제로 중요한 확장점입니다:
|
||||
|
||||
| 목적 | 잡을 훅 |
|
||||
|---|---|
|
||||
| 금액 계산 단계에 개입 | `calculation.after_item_subtotals` · `calculation.after_final_result` 등 `calculation.*` 18종 (9단계 사이사이) |
|
||||
| 취소 시 쿠폰·마일리지 안분 되돌리기 규칙 변경 | `adjustment.filter_restore_promotions` |
|
||||
| 새 PG·결제수단 추가 | 결제수단 카탈로그에 능력 선언 + `payment.*` 6종 (이 모듈 수정 불필요) |
|
||||
| 결제 직전/취소/입금확인에 본인인증 강제 | `getIdentityPolicies()` 가 선언한 4개 정책의 target 훅 (`checkout.before_payment` · `payment.before_cancel` · `payment.before_approve` · `payment.before_confirm_deposit`) — 기본 `enabled: false` |
|
||||
| 상품 문의를 다른 저장소로 | `inquiry.store_validation_rules` · `inquiry.update_validation_rules` + 게시판 측 `sirsoft-board.post.after_*` (`ProductInquiryBoardListener` 가 선례) |
|
||||
| 재고 차감/복원 시점 개입 | `stock.*` 4종 |
|
||||
|
||||
**금전이 움직이는 훅을 구독할 때는 `'sync' => true` 를 붙입니다.** 쿠폰 차감·복원, 마일리지
|
||||
차감·복원이 여기 해당합니다 — 기본값(큐 래핑 + `afterCommit`)으로 두면 커밋 뒤에 실행되어
|
||||
리스너가 예외를 던져도 주문은 이미 확정된 뒤입니다.
|
||||
|
||||
브로드캐스트 채널은 0개입니다. 실시간 반영이 필요한 화면은 이 모듈이 아니라 소비하는
|
||||
템플릿·모듈 쪽에서 폴링·재조회로 해결합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 5. 수정 시 동반 의무
|
||||
|
||||
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-ecommerce --force` 로 반영
|
||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
|
||||
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
|
||||
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
|
||||
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-ecommerce` 재실행 + `docs/api/**` 갱신
|
||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
|
||||
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
||||
- [ ] 금액 계산 경로를 건드렸다면 여섯 소비 지점(상품 상세·장바구니·체크아웃·주문 생성·결제 검증·부분 취소)에 같은 결과가 도달하는지 확인
|
||||
- [ ] 통화가 관여하는 표시·기록을 추가할 때 다국어 문구는 `:amount` 로 중립, 과거 거래 표기는 `currency_snapshot` 경유
|
||||
- [ ] 금전이 움직이는 훅(쿠폰·마일리지 차감/복원)을 구독·발행할 때 `'sync' => true` 확인
|
||||
- [ ] 마일리지 원장에 기록하는 경로를 추가하면 같은 트랜잭션 마지막에 잔액 캐시 재계산 동반
|
||||
- [ ] 목록 응답에 하위 컬렉션(옵션·이미지)을 실을 때 화면이 실제로 그리는 것만 — Repository 의 `relations:` 와 Resource 의 `whenLoaded` 를 함께 본다
|
||||
- [ ] 결제수단·PG 관련 선언을 바꾸면 기설치본 `order_settings.json` 을 정정하는 업그레이드 스텝 동반 (자기 접두사만, 멱등)
|
||||
- [ ] 새 관리자 화면을 추가하면 그 화면의 권한(`getPermissions()`)·메뉴(`getAdminMenus()`)·라우트 이름이 서로 가리키는 대상이 일치하는지 확인
|
||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan module:update sirsoft-ecommerce --force`
|
||||
|
||||
## 6. 금지 패턴
|
||||
|
||||
<!-- @intent START -->
|
||||
| 금지 | 올바른 사용 | 이유 |
|
||||
|---|---|---|
|
||||
| 화면·서비스마다 금액을 다시 계산 (`합계 = 단가 × 수량` 재구현) | `OrderCalculationService::calculate()` 결과만 사용 | 계산이 여섯 지점에 흩어지면 화면 금액과 결제 금액이 갈라지고, 그 어긋남은 결제 완료 검증에서 `PaymentAmountMismatchException` 으로 뒤늦게 나타난다 |
|
||||
| 마일리지 잔액을 `ecommerce_mileage_balances` 에서 읽어 차감 가능 여부 판정 | 원장(`ecommerce_mileage_transactions`) `FOR UPDATE` 재검증 | 캐시는 단방향 파생물이라 동시 요청에서 뒤처질 수 있다 — 캐시를 근거로 차감하면 잔액이 음수가 된다 |
|
||||
| 마일리지 잔액 캐시를 서비스에서 직접 증감 | 원장에 기록한 뒤 같은 트랜잭션 마지막에 캐시 재계산 | 두 곳을 각각 갱신하면 원장 합계와 캐시가 어긋나고, 정합 교정 스케줄(`reconcile-mileage-balance`)이 매번 되돌린다 |
|
||||
| 과거 주문 표시에 현재 통화 설정(`getDecimalPlaces($code)`)을 조회 | 주문의 `currency_snapshot` 을 함께 넘긴다 | 운영자가 그 통화를 삭제하면 폴백(2자리)이 적용되어 `¥14,835` 가 `¥14,835.00` 이 된다. 금액 계산은 스냅샷을 쓰는데 표기만 현재 설정을 따르면 같은 화면 안에서 근거가 갈린다 |
|
||||
| 다국어 문구에 `:amount원` / `:amount円` 처럼 통화 기호를 박기 | 문구는 `:amount` 로 중립, 호출부가 `ecommerce_format_price($amount, $currency)` 로 포맷 | UI 언어가 통화를 결정하게 되어 기본 통화가 다른 상점에서 단위만 틀린 금액이 나간다 |
|
||||
| 쿠폰·마일리지 복원 훅을 기본 설정(큐)으로 구독 | `'sync' => true` | 커밋 뒤 실행이라 예외를 던져도 롤백되지 않는다 — 오류 응답만 나가고 차감된 쿠폰은 그대로 남는다 |
|
||||
| 특정 PG 이름을 이 모듈 코드에 분기로 넣기 (`if ($pg === 'tosspayments')`) | 결제수단 카탈로그 선언(`needs_pg`/`pg_locked`/`pg_provider`)과 `payment.*` 훅 | PG 가 늘 때마다 이 모듈이 커지고, 플러그인만 설치하면 되는 구조가 깨진다 |
|
||||
| 주문번호·상품코드를 `max(id)+1` 이나 타임스탬프 조합으로 직접 생성 | `SequenceService::generateCode()` | 채번은 DB UNIQUE 제약과 함께 원자적으로 수행된다 — 직접 생성은 동시 주문에서 중복을 만든다 |
|
||||
| 취소·환불을 주문 단위로 처리 | 옵션 단위(`OrderCancelOption`/`OrderRefundOption`) | 부분 취소가 이 도메인의 기본이며, 주문 단위로 처리하면 남은 옵션의 안분 금액이 계산되지 않는다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 7. 테스트 실행
|
||||
|
||||
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 400개 | `modules/_bundled/sirsoft-ecommerce/tests` |
|
||||
| Vitest | 140개 | `vitest.config.ts` |
|
||||
| Playwright | 42개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 91개 | `tests/scenarios` |
|
||||
|
||||
기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
||||
|
||||
```bash
|
||||
# PHPUnit (변경 범위만) (Bash)
|
||||
php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests --filter='<대상클래스>'
|
||||
|
||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||
cd modules/_bundled/sirsoft-ecommerce && powershell -Command "npm run test:run -- <대상>"
|
||||
|
||||
# Playwright E2E (Bash)
|
||||
npx playwright test modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/<대상>.spec.ts
|
||||
|
||||
```
|
||||
|
||||
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
|
||||
<!-- @generated:test-commands END -->
|
||||
|
||||
## 8. 문서 목차
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
## [1.2.1] - 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 상세설명을 편집기(HTML)로 작성한 상품을 등록하거나 수정할 때 저장이 실패하던 문제를 수정했습니다. 상품 설명의 보안 정화에 쓰는 구성요소가 모듈 설치 폴더 안에 자기 캐시 파일을 만들려 했기 때문에, 보안상 모듈 폴더에 쓰기를 막아 둔 서버에서는 저장이 항상 오류로 끝났고 다시 시도해도 같은 결과였습니다. 이제 이 캐시는 `storage` 폴더 아래에 만들어지며, 그 위치마저 쓸 수 없는 경우에는 캐시 없이 정화만 수행해 저장이 실패하지 않습니다(설명은 종전과 똑같이 정화됩니다). (#125 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# 이커머스
|
||||
|
||||
**그누보드7 모듈 · sirsoft-ecommerce**
|
||||
그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리
|
||||
|
||||
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-1.2.1-0066FF?style=flat-square" alt="version 1.2.1">
|
||||
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.10-1F883D?style=flat-square" alt="그누보드7 >=7.0.10">
|
||||
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
|
||||
</p>
|
||||
<!-- @generated:badges END -->
|
||||
|
||||
---
|
||||
|
||||
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
|
||||
|
||||
---
|
||||
|
||||
## 소개
|
||||
|
||||
<!-- @intent START -->
|
||||
온라인 상점 운영에 필요한 상품·주문·결제·배송·취소/환불·쿠폰·마일리지·리뷰·문의를 한곳에서
|
||||
관리하는 모듈입니다. 관리자 화면에서 상품을 등록하고 주문을 처리하면, 방문자가 보는 상점
|
||||
화면은 템플릿(`sirsoft-basic`)이 이 모듈의 데이터를 받아 그립니다.
|
||||
|
||||
여러 나라·여러 통화를 동시에 다루도록 설계되어 있습니다. 상품 가격을 저장하는 **기본 통화**,
|
||||
구매자가 화면에서 고르는 **표시 통화**, 결제사에 청구되는 **결제 통화**를 각각 따로 설정할 수
|
||||
있고, 주문이 만들어지는 순간의 통화 정보가 그 주문에 그대로 남습니다 — 나중에 통화 설정을
|
||||
바꿔도 지난 주문의 금액 표기는 변하지 않습니다.
|
||||
|
||||
결제사(PG) 연동은 이 모듈에 들어 있지 않습니다. KG이니시스·NHN KCP·나이스페이먼츠·토스페이먼츠는
|
||||
각각 별도 플러그인이며, 쓰려는 결제사의 플러그인을 설치·활성화한 뒤 환경설정에서 고르면 됩니다.
|
||||
상품 문의 게시판도 마찬가지로 게시판 모듈이 글을 보관하고, 이 모듈은 "어떤 상품의 문의인가"만
|
||||
연결합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 주요 기능
|
||||
|
||||
<!-- @intent START -->
|
||||
| 영역 | 설명 |
|
||||
|---|---|
|
||||
| 상품 | 상품·옵션·추가옵션·이미지 등록, 카테고리/브랜드/라벨 분류, 상품정보제공고시와 공통정보 템플릿, 진열·판매 상태 관리 |
|
||||
| 주문 | 주문 목록·상세, 상태 변경(입금대기 → 결제완료 → 배송준비 → 배송중 → 배송완료 → 구매확정), 관리자 수기 결제, 엑셀 내려받기 |
|
||||
| 결제 | 카드·가상계좌·계좌이체·무통장·휴대폰·마일리지 등 결제수단 관리, 현금영수증·세금계산서 발행 이력, 입금 확인 |
|
||||
| 배송 | 배송정책(국가별 요금·무료배송 기준·구간 요금 14종)·배송사·배송유형·추가배송비 템플릿, 송장 등록과 배송 추적 |
|
||||
| 취소·환불 | 주문 전체/부분 취소, 환불 수단(PG·계좌·마일리지) 선택, 클레임 사유 관리, 이미 적용된 쿠폰·마일리지 자동 되돌림 |
|
||||
| 쿠폰 | 상품/카테고리/주문금액/배송비 대상 쿠폰, 정액·정률 할인, 발급 방식(직접·다운로드·자동), 가입·첫구매·생일 자동 발급 |
|
||||
| 마일리지 | 적립률·적립 시점(배송완료/구매확정)·지연 적립·자동 소멸과 소멸 예정 알림, 통화별 적립 규칙, 관리자 수동 지급·차감 |
|
||||
| 리뷰·문의 | 구매자 리뷰(이미지 첨부·작성 기한·노출 관리), 상품 1:1 문의(게시판 모듈에 글로 보관) |
|
||||
| 회원 | 회원별 배송지, 결제 통화·배송 국가 지정, 장바구니(비로그인 → 로그인 시 자동 병합), 찜 목록 |
|
||||
| 대시보드·통계 | 매출·주문 현황 집계, 미처리 주문 요약, 관리자 대시보드에 커머스 위젯 주입 |
|
||||
| 다국어·다통화 | 기본/표시/결제 통화 분리, 통화별 소수 자릿수·절사 규칙, 주문 시점 통화 정보 보존 |
|
||||
| SEO | 상품·카테고리·검색·상점 첫 화면의 메타 정보와 구조화 데이터 자동 생성 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 동작 방식
|
||||
|
||||
<!-- @intent START -->
|
||||
```mermaid
|
||||
flowchart LR
|
||||
V[구매자] -->|공개 API| T[템플릿 상점 화면]
|
||||
T --> CART[장바구니]
|
||||
CART --> CALC[주문 계산]
|
||||
CALC --> TMP[임시 주문]
|
||||
TMP -->|결제창| PG[PG 플러그인]
|
||||
PG -->|승인 결과| ORD[주문 확정]
|
||||
ORD --> SHIP[배송]
|
||||
A[운영자] -->|관리자 화면| ADM[상품·주문 관리]
|
||||
ADM --> ORD
|
||||
```
|
||||
|
||||
구매자가 보는 화면은 템플릿이 그리고, 금액 계산·주문 확정은 이 모듈이 합니다. 결제창 왕복만
|
||||
결제사 플러그인이 담당하며, 승인 결과가 돌아오면 이 모듈이 다시 금액을 검증한 뒤 주문을
|
||||
확정합니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P1[입금대기] --> P2[결제완료]
|
||||
P2 --> P3[배송준비]
|
||||
P3 --> P4[배송중]
|
||||
P4 --> P5[배송완료]
|
||||
P5 --> P6[구매확정]
|
||||
P2 -.취소.-> C[취소/환불]
|
||||
P3 -.취소.-> C
|
||||
```
|
||||
|
||||
주문 상태는 위 순서로 진행하며, 결제 완료 이후 배송 시작 전까지는 취소가 가능합니다(어느
|
||||
상태까지 취소를 허용할지는 환경설정에서 조정합니다). 취소하면 그 주문에 쓰인 쿠폰과 마일리지가
|
||||
자동으로 되돌아갑니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 요구 사항
|
||||
|
||||
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 그누보드7 코어 | `>=7.0.10` |
|
||||
| PHP | `^8.2` |
|
||||
<!-- @generated:requirements END -->
|
||||
|
||||
## 설치
|
||||
|
||||
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
```bash
|
||||
# 번들 설치 (코어에 동봉된 소스에서 설치)
|
||||
php artisan module:install sirsoft-ecommerce
|
||||
|
||||
# 활성화
|
||||
php artisan module:activate sirsoft-ecommerce
|
||||
|
||||
# 업데이트 (번들 소스 기준 강제 반영)
|
||||
php artisan module:update sirsoft-ecommerce --force
|
||||
```
|
||||
|
||||
저장소: https://github.com/gnuboard/g7-module-sirsoft-ecommerce
|
||||
<!-- @generated:install END -->
|
||||
|
||||
## 관리자 설정
|
||||
|
||||
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_별도의 관리자 설정 항목이 없습니다._
|
||||
<!-- @generated:settings-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
위 표가 비어 있는 이유는 이 모듈의 환경설정이 코드 선언이 아니라 설정 파일
|
||||
(`config/settings/defaults.json`)에서 오기 때문입니다. 실제 설정은 `/admin/ecommerce/settings`
|
||||
한 화면에 9개 탭으로 모여 있습니다.
|
||||
|
||||
| 탭 | 언제 바꾸는가 | 바꾸면 달라지는 것 |
|
||||
|---|---|---|
|
||||
| 기본 정보 | 개점 준비 시 1회 | 상점명·사업자 정보·상점 주소 경로가 상점 화면과 주문서에 반영됩니다 |
|
||||
| 언어·통화 | 판매 국가를 늘릴 때 | 기본 통화와 취급 통화 목록. 기본 통화를 바꿔도 **이미 만들어진 주문의 표기는 그대로**입니다 |
|
||||
| 주문 설정 | 결제사를 도입·교체할 때 | 기본 PG·현금영수증 발행처·결제수단 노출·무통장 계좌·미입금 자동취소 기한·취소 허용 상태 |
|
||||
| 배송 | 해외 배송을 시작할 때 | 기본 배송 국가·취급 국가·무료배송 기준·주소 검증 사용 여부 |
|
||||
| SEO | 검색 노출을 조정할 때 | 상품·카테고리·검색·상점 첫 화면의 제목/설명 서식과 구조화 데이터 사용 여부 |
|
||||
| 리뷰 | 리뷰 정책을 바꿀 때 | 작성 가능 기한(구매 후 N일)·이미지 개수와 용량 제한 |
|
||||
| 문의 | 문의 게시판을 지정할 때 | 상품 문의가 저장될 게시판. **게시판 모듈이 설치·활성화되어 있어야 합니다** |
|
||||
| 알림 | 알림 채널을 조정할 때 | 주문·배송·문의 알림을 메일/앱 내 알림 중 어디로 보낼지 |
|
||||
| 마일리지 | 적립 제도를 운영할 때 | 사용 여부·적립률·적립 시점(배송완료/구매확정)·지연 적립일·통화별 규칙·소멸 기한과 사전 알림 |
|
||||
|
||||
결제수단 목록은 **설치된 결제사 플러그인이 스스로 등록**합니다. 그래서 플러그인을 삭제하거나
|
||||
비활성화하면 그 결제수단은 구매자 화면에서 자동으로 사라집니다 — 설정에서 따로 지울 필요가
|
||||
없습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 사용 방법
|
||||
|
||||
<!-- @intent START -->
|
||||
**개점 준비**: `/admin/ecommerce/settings` 에서 기본 정보와 기본 통화를 정하고, 쓰려는 결제사
|
||||
플러그인을 설치·활성화한 뒤 "주문 설정" 탭에서 기본 PG 와 노출할 결제수단을 고릅니다. 그다음
|
||||
"배송" 탭에서 기본 배송 국가를 정하고 `/admin/ecommerce/shipping-policies` 에서 배송정책을
|
||||
하나 이상 만듭니다 — 배송정책이 없으면 상품을 등록해도 배송비가 계산되지 않습니다. 마지막으로
|
||||
`/admin/ecommerce/categories` 에서 카테고리를 만든 뒤 상품을 등록합니다.
|
||||
|
||||
**주문 처리**: 새 주문이 들어오면 `/admin/ecommerce/orders` 에 뜨고 담당자에게 알림이 갑니다.
|
||||
무통장 입금 주문은 입금을 확인해 "결제완료"로 바꾸고(현금영수증 발행 설정이 켜져 있으면 이때
|
||||
자동 발행됩니다), 상품을 준비한 뒤 송장 번호를 등록하면 상태가 "배송중"으로 넘어가면서 구매자
|
||||
알림이 나갑니다. 배송완료 후 구매확정되면 마일리지 적립 시점 설정에 따라 적립이 이루어집니다.
|
||||
|
||||
**부분 취소·환불**: 주문 상세에서 취소할 **옵션(품목)을 골라** 취소를 진행합니다. 주문 전체가
|
||||
아니라 품목 단위라서, 세 개 중 하나만 취소하면 나머지 두 개에 걸린 할인과 배송비가 자동으로
|
||||
다시 안분됩니다. 환불 수단은 PG 취소·계좌 입금·마일리지 반환 중에서 고르며, 그 주문에 쓰인
|
||||
쿠폰과 마일리지는 취소 처리와 같은 시점에 되돌아갑니다.
|
||||
|
||||
**쿠폰 발행**: `/admin/ecommerce/promotion-coupons` 에서 대상(전체/특정 상품/특정 카테고리)과
|
||||
할인 방식(정액/정률), 적용 대상(상품금액/주문금액/배송비)을 정합니다. 발급 방식을 "자동"으로
|
||||
두고 조건을 가입·첫구매·생일 중에서 고르면 해당 시점에 자동 발급됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 다른 확장과의 연동
|
||||
|
||||
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
없음 — 코어만으로 동작합니다.
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
| 확장 | 유형 | 요구 버전 |
|
||||
|---|---|---|
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | `>=1.1.0` |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | `>=1.1.0` |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | `>=1.1.0` |
|
||||
| `sirsoft-tosspayments` | 플러그인 | `>=1.1.0` |
|
||||
| `sirsoft-basic` | 템플릿 | `>=1.1.0` |
|
||||
<!-- @generated:integrations END -->
|
||||
|
||||
## 문서
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
<!-- @intent START -->
|
||||
| 증상 | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
| 결제 단계에서 결제수단이 하나도 보이지 않음 | 결제사 플러그인이 설치·활성화되지 않았거나, 환경설정 "주문 설정" 탭에서 노출이 꺼져 있음 | 플러그인을 활성화한 뒤 주문 설정 탭에서 해당 결제수단을 켭니다. 플러그인을 지웠다면 그 결제수단은 자동으로 목록에서 빠집니다 |
|
||||
| 상품을 등록했는데 장바구니에서 배송비가 0원 | 그 상품에 배송정책이 지정되지 않았거나, 정책에 현재 배송 국가 설정이 없음 | 배송정책을 만들고 상품 편집 화면에서 지정한 뒤, 정책의 국가별 설정에 해당 국가를 추가합니다 |
|
||||
| 상품 문의 메뉴가 동작하지 않음 | 게시판 모듈이 없거나, 환경설정 "문의" 탭의 게시판이 지정되지 않음 | 게시판 모듈을 활성화하고 문의용 게시판을 만든 뒤 문의 탭에서 그 게시판을 고릅니다 |
|
||||
| 통화 설정을 바꿨는데 지난 주문의 금액 표기가 그대로 | 주문 시점의 통화 정보가 그 주문에 보존됨 | 정상 동작입니다. 지난 거래의 표기가 나중 설정 변경으로 달라지면 정산 근거가 바뀌므로 의도적으로 고정합니다 |
|
||||
| 마일리지 잔액이 내역 합계와 어긋나 보임 | 표시용 잔액이 아직 재계산되지 않음 | 정합 교정 스케줄이 주기적으로 맞춥니다. 즉시 맞추려면 `php artisan sirsoft-ecommerce:reconcile-mileage-balance` 를 실행합니다 |
|
||||
| 소멸 예정 마일리지 알림이 오지 않음 | 스케줄러가 동작하지 않거나 알림 채널이 꺼져 있음 | 서버의 스케줄러 등록을 확인하고, 환경설정 "알림" 탭에서 해당 알림의 채널을 켭니다 |
|
||||
| 미입금 주문이 계속 남아 있음 | 자동 취소가 꺼져 있거나 기한이 길게 설정됨 | 주문 설정 탭의 "미입금 자동취소" 사용 여부와 기한(일)을 확인합니다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 변경 이력
|
||||
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,23 @@
|
||||
# 이커머스 개발자 문서
|
||||
|
||||
> modules/_bundled/sirsoft-ecommerce · 모듈
|
||||
|
||||
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**훅 수**: 508 · **구독 훅 수**: 142 · **라우트 수**: 239 · **모델 수**: 47 · **테이블 수**: 51 · **마이그레이션 수**: 102 · **레이아웃 수**: 206 · **핸들러 수**: 160
|
||||
<!-- @generated:stats END -->
|
||||
|
||||
## 문서 목차
|
||||
|
||||
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
|
||||
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
|
||||
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
|
||||
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
|
||||
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
|
||||
| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 |
|
||||
| [api/](api/README.md) | API 레퍼런스 |
|
||||
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
|
||||
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
|
||||
<!-- @generated:doc-toc END -->
|
||||
@@ -0,0 +1,106 @@
|
||||
# 이커머스 — 아키텍처
|
||||
|
||||
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설계 의도
|
||||
|
||||
<!-- @intent START -->
|
||||
이 모듈의 설계는 "커머스 도메인에서 **변하는 것**과 **변하지 않는 것**을 갈라 두는 것"에
|
||||
집중되어 있습니다. 변하지 않는 것은 금액 계산 규칙·주문 상태 전이·원장 기록이고, 변하는 것은
|
||||
결제사·화면 디자인·나라별 배송 규칙·프로모션 정책입니다. 변하는 축은 전부 이 모듈 **밖**으로
|
||||
빼거나 데이터로 내려서, 새 결제사·새 나라·새 화면이 추가될 때 이 모듈의 소스를 고치지 않아도
|
||||
되게 했습니다.
|
||||
|
||||
- **결제사**: 플러그인이 이 모듈에 의존하지, 이 모듈이 플러그인을 알지 않습니다. 코어
|
||||
`PaymentMethodEnum` 도 확장 결제수단 ID 를 모르므로, 능력(`needs_pg`/`pg_locked`/
|
||||
`pg_provider`)은 등록하는 플러그인이 카탈로그에 선언하고 화면과 서버는 그 선언만 읽습니다.
|
||||
- **화면**: 레이아웃 206개가 전부 관리자 화면입니다. 방문자 상점 화면은 템플릿이 공개 API 를
|
||||
소비해 그리므로, 상점 디자인이 여러 벌 필요해도 이 모듈은 하나로 유지됩니다.
|
||||
- **나라·통화·배송**: `ShippingPolicy` + `ShippingPolicyCountrySetting` 조합으로 국가별 요금
|
||||
규칙을 데이터로 표현하고(`ChargePolicyEnum` 14종), 통화는 설정에서 읽습니다. 코드에 통화
|
||||
코드나 국가 코드를 박지 않는 것이 규칙입니다.
|
||||
- **프로모션**: 쿠폰의 대상 범위(`CouponTargetScope`)·대상 금액(`CouponTargetType`)·할인 방식
|
||||
(`CouponDiscountType`)·발급 방식(`CouponIssueMethod`)이 전부 Enum + 데이터 조합이라, 새
|
||||
프로모션 유형 대부분은 코드 없이 관리자 화면에서 만들어집니다.
|
||||
|
||||
그 대가로 **계산기 하나가 무거워집니다.** `OrderCalculationService` 는 9단계(옵션 금액 → 상품·
|
||||
카테고리 쿠폰 → 배송비 → 배송비 쿠폰 → 주문금액 쿠폰 → 적립 마일리지 → 결제금액 → 마일리지
|
||||
사용 → 최종 지불금액)를 한 번에 수행하며, 상품 상세·장바구니·체크아웃·주문 생성·결제 완료
|
||||
검증·부분 취소 여섯 지점이 모두 이 하나를 부릅니다. 이 집중은 의도된 것입니다 — 계산이 흩어지면
|
||||
화면 금액과 청구 금액이 갈라지고, 그 어긋남은 결제가 끝난 뒤에야 예외로 드러납니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: 실시간 브로드캐스트(채널 0개)·PG 통신·방문자 화면 소유·문의 본문
|
||||
저장. 문의는 게시판 모듈이 글로 보관하고 이 모듈은 상품↔글 피벗만 갖는데, manifest 의존에는
|
||||
게시판이 없습니다. 연결이 코드 결합이 아니라 훅 구독이라 게시판이 없으면 문의 기능만 비고
|
||||
나머지는 그대로 동작합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 계층 지도
|
||||
|
||||
<!-- @intent START -->
|
||||
```
|
||||
Http/Controllers (Admin/ 관리자, User/ 구매자, Guest/ 비회원 주문조회)
|
||||
│
|
||||
▼
|
||||
FormRequest (검증 + *_validation_rules 필터 훅으로 확장 지점 제공)
|
||||
│
|
||||
▼
|
||||
Services 48종
|
||||
│ ├─ 계산 레인: OrderCalculationService(9단계) · CurrencyConversionService
|
||||
│ │ · ShippingPolicyResolver · OrderAdjustmentService
|
||||
│ ├─ 흐름 레인: CheckoutDataService → TempOrderService → OrderProcessingService
|
||||
│ │ → OrderCancellationService
|
||||
│ └─ 도메인 CRUD 레인: Product/Category/Brand/Coupon/Review/... Service
|
||||
│ before_* → filter_*_data → 실행 → after_* (도메인 공통 3단 훅 패턴)
|
||||
▼
|
||||
Repositories (Interface 경유 — 목록은 컬럼 프루닝·정렬 화이트리스트)
|
||||
│
|
||||
▼
|
||||
Models 47종 (Order/OrderOption/Product/... — 주문 계열은 SoftDeletes)
|
||||
```
|
||||
|
||||
Support 클래스(`src/Support/`)는 계층이 아니라 **규칙의 단일 출처**입니다 — `VatCalculator`
|
||||
(과세/면세 안분) · `MileageRounding`(적립 절사) · `ShippingPolicySnapshot`(주문 시점 배송정책
|
||||
동결) · `CurrencySettingsCache`(통화 설정 조회) · `ReviewWritePolicy`(리뷰 작성 가능 판정) ·
|
||||
`ShopPathResolver`(상점 경로). 같은 규칙을 서비스마다 다시 구현하지 않기 위한 자리이므로,
|
||||
새 서비스가 반올림·안분·경로 조립을 직접 하고 있으면 여기로 올려야 하는 신호입니다.
|
||||
|
||||
Listeners 33종은 이 흐름과 **별도 레인**입니다. Service 가 발행한 훅을 받아 활동 로그·검색
|
||||
색인·SEO 캐시 무효화·카테고리 트리 캐시·마일리지 적립·현금영수증 발행·알림 데이터 추출·장바구니
|
||||
병합을 수행하며, Service 자신은 이 부가효과를 알지 못합니다. 그래서 새 부가효과는 Service 를
|
||||
건드리지 않고 리스너 추가만으로 끝납니다 — 단, 금전이 되돌아가야 하는 리스너(쿠폰·마일리지
|
||||
복원)는 `'sync' => true` 로 구독해야 호출자 트랜잭션과 함께 롤백됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 디렉토리
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
|
||||
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
|
||||
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
|
||||
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
|
||||
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
|
||||
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
|
||||
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-ecommerce --force` (빌드 불필요) |
|
||||
| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan module:update sirsoft-ecommerce --force` |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
@@ -0,0 +1,468 @@
|
||||
# 이커머스 — 데이터 모델
|
||||
|
||||
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 모델
|
||||
|
||||
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 모델 | 테이블 | fillable | 관계 | 특성 |
|
||||
|---|---|---|---|---|
|
||||
| `Brand` | `ecommerce_brands` | 7 | creator→User, updater→User, products→Product | SoftDeletes, 검색 색인 |
|
||||
| `Cart` | `ecommerce_carts` | 6 | user→User, product→Product, productOption→ProductOption | - |
|
||||
| `Category` | `ecommerce_categories` | 10 | parent→self, children→self, descendants→self, images→CategoryImage, products→Product | 검색 색인 |
|
||||
| `CategoryImage` | `ecommerce_category_images` | 15 | category→Category, creator→User | SoftDeletes |
|
||||
| `ClaimReason` | `ecommerce_claim_reasons` | 10 | creator→User, updater→User | HasUserOverrides |
|
||||
| `Coupon` | `ecommerce_promotion_coupons` | 22 | issues→CouponIssue, products→Product, includedProducts→Product, excludedProducts→Product, categories→Category, includedCategories→Category, 외 2개 | SoftDeletes, 검색 색인 |
|
||||
| `CouponIssue` | `ecommerce_promotion_coupon_issues` | 9 | coupon→Coupon, user→User, order→Order | - |
|
||||
| `EcommerceStat` | `ecommerce_stats` | 4 | - | - |
|
||||
| `EcommerceUserProfile` | `ecommerce_user_profiles` | 3 | user→User | - |
|
||||
| `ExtraFeeTemplate` | `ecommerce_shipping_policy_extra_fee_templates` | 7 | creator→User, updater→User | - |
|
||||
| `HasDirectAssetUrl` | (규약) | - | - | - |
|
||||
| `MileageBalance` | `ecommerce_mileage_balances` | 9 | user→User | - |
|
||||
| `MileageTransaction` | `ecommerce_mileage_transactions` | 16 | user→User, order→Order, orderOption→OrderOption, grantedByUser→User, sourceTransaction→self | - |
|
||||
| `Order` | `ecommerce_orders` | 65 | user→User, options→OrderOption, firstOption→OrderOption, addresses→OrderAddress, shippingAddress→OrderAddress, billingAddress→OrderAddress, 외 8개 | SoftDeletes |
|
||||
| `OrderAddress` | `ecommerce_order_addresses` | 23 | order→Order | - |
|
||||
| `OrderCancel` | `ecommerce_order_cancels` | 10 | order→Order, cancelOptions→OrderCancelOption, refund→OrderRefund, cancelledByUser→User | - |
|
||||
| `OrderCancelOption` | `ecommerce_order_cancel_options` | 10 | orderCancel→OrderCancel, order→Order, orderOption→OrderOption, processedByUser→User | - |
|
||||
| `OrderCashReceipt` | `ecommerce_order_cash_receipts` | 16 | order→Order, payment→OrderPayment | - |
|
||||
| `OrderOption` | `ecommerce_order_options` | 57 | order→Order, parentOption→self, childOptions→self, splitOptions→self, product→Product, productOption→ProductOption, 외 4개 | - |
|
||||
| `OrderPayment` | `ecommerce_order_payments` | 58 | order→Order, taxInvoices→OrderTaxInvoice, cashReceipts→OrderCashReceipt | - |
|
||||
| `OrderRefund` | `ecommerce_order_refunds` | 25 | order→Order, orderCancel→OrderCancel, refundOptions→OrderRefundOption, processedByUser→User | - |
|
||||
| `OrderRefundOption` | `ecommerce_order_refund_options` | 12 | orderRefund→OrderRefund, order→Order, orderOption→OrderOption, processedByUser→User | - |
|
||||
| `OrderShipping` | `ecommerce_order_shippings` | 32 | order→Order, orderOption→OrderOption, shippingPolicy→ShippingPolicy, carrier→ShippingCarrier | - |
|
||||
| `OrderTaxInvoice` | `ecommerce_order_tax_invoices` | 21 | order→Order, payment→OrderPayment | - |
|
||||
| `Product` | `ecommerce_products` | 34 | options→ProductOption, images→ProductImage, categories→Category, brand→Brand, commonInfo→ProductCommonInfo, shippingPolicy→ShippingPolicy, 외 6개 | SoftDeletes, 검색 색인 |
|
||||
| `ProductAdditionalOption` | `ecommerce_product_additional_options` | 4 | product→Product, values→ProductAdditionalOptionValue | - |
|
||||
| `ProductAdditionalOptionValue` | `ecommerce_product_additional_option_values` | 8 | additionalOption→ProductAdditionalOption | - |
|
||||
| `ProductCommonInfo` | `ecommerce_product_common_infos` | 6 | products→Product | 검색 색인 |
|
||||
| `ProductImage` | `ecommerce_product_images` | 16 | product→Product, creator→User | SoftDeletes |
|
||||
| `ProductInquiry` | `ecommerce_product_inquiries` | 7 | product→Product, user→User, inquirable→? | SoftDeletes |
|
||||
| `ProductLabel` | `ecommerce_product_labels` | 4 | assignments→ProductLabelAssignment | - |
|
||||
| `ProductLabelAssignment` | `ecommerce_product_label_assignments` | 4 | product→Product, label→ProductLabel | - |
|
||||
| `ProductNotice` | `ecommerce_product_notices` | 2 | product→Product | - |
|
||||
| `ProductNoticeTemplate` | `ecommerce_product_notice_templates` | 5 | - | - |
|
||||
| `ProductOption` | `ecommerce_product_options` | 18 | product→Product | - |
|
||||
| `ProductReview` | `ecommerce_product_reviews` | 13 | product→Product, orderOption→OrderOption, user→User, replyAdmin→User, images→ProductReviewImage | SoftDeletes |
|
||||
| `ProductReviewImage` | `ecommerce_product_review_images` | 15 | review→ProductReview, creator→User | SoftDeletes |
|
||||
| `ProductWishlist` | `ecommerce_product_wishlists` | 2 | user→User, product→Product | - |
|
||||
| `SearchPreset` | `ecommerce_search_presets` | 6 | user→User | - |
|
||||
| `Sequence` | `ecommerce_sequences` | 12 | - | - |
|
||||
| `SequenceCode` | `ecommerce_sequence_codes` | 2 | - | - |
|
||||
| `ShippingCarrier` | `ecommerce_shipping_carriers` | 9 | creator→User, updater→User | HasUserOverrides |
|
||||
| `ShippingPolicy` | `ecommerce_shipping_policies` | 6 | countrySettings→ShippingPolicyCountrySetting, listCountrySettings→ShippingPolicyCountrySetting | - |
|
||||
| `ShippingPolicyCountrySetting` | `ecommerce_shipping_policy_country_settings` | 17 | shippingPolicy→ShippingPolicy | - |
|
||||
| `ShippingType` | `ecommerce_shipping_types` | 8 | creator→User, updater→User | HasUserOverrides |
|
||||
| `TempOrder` | `ecommerce_temp_orders` | 6 | user→User | - |
|
||||
| `UserAddress` | `ecommerce_user_addresses` | 16 | user→User | - |
|
||||
<!-- @generated:models END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
47개 모델은 다섯 계열로 읽습니다.
|
||||
|
||||
| 계열 | 모델 | 읽는 요령 |
|
||||
|---|---|---|
|
||||
| 카탈로그 | `Product` · `ProductOption` · `ProductAdditionalOption(Value)` · `ProductImage` · `Category` · `CategoryImage` · `Brand` · `ProductLabel(Assignment)` · `ProductCommonInfo` · `ProductNotice(Template)` | 판매 단위는 `Product` 가 아니라 **`ProductOption`** 입니다. 재고·가격·주문 연결이 전부 옵션에 걸립니다 |
|
||||
| 주문 | `Order` · `OrderOption` · `OrderAddress` · `OrderPayment` · `OrderShipping` · `OrderCashReceipt` · `OrderTaxInvoice` | `Order` fillable 65 · `OrderOption` 57 · `OrderPayment` 58 — 이 셋이 큰 이유는 **주문 시점의 값을 전부 스냅샷으로 복사**하기 때문입니다. 상품명·가격·배송정책·통화 정보가 원본을 참조하지 않고 복제됩니다 |
|
||||
| 취소·환불 | `OrderCancel(Option)` · `OrderRefund(Option)` · `ClaimReason` | 단위가 주문이 아니라 **옵션**입니다. `*Option` 쪽이 실제 처리 단위이고 상위는 묶음입니다 |
|
||||
| 프로모션·적립 | `Coupon` · `CouponIssue` · `MileageTransaction` · `MileageBalance` | `MileageTransaction` 이 원장(SSoT), `MileageBalance` 는 단방향 파생 캐시입니다 |
|
||||
| 배송·회원·기타 | `ShippingPolicy(CountrySetting)` · `ShippingCarrier` · `ShippingType` · `ExtraFeeTemplate` · `UserAddress` · `Cart` · `TempOrder` · `ProductWishlist` · `ProductReview(Image)` · `ProductInquiry` · `SearchPreset` · `Sequence(Code)` · `EcommerceStat` · `EcommerceUserProfile` | `TempOrder` 는 결제창 왕복 동안만 사는 임시 저장소이며 스케줄이 정리합니다 |
|
||||
|
||||
`HasDirectAssetUrl` 은 모델이 아니라 **규약(trait)** 입니다 — 이미지 계열 모델이 저장소 종류에
|
||||
관계없이 같은 방식으로 자산 URL 을 내도록 묶습니다. 표에 모델처럼 잡힌 것은 수집기가 클래스
|
||||
파일 단위로 세기 때문이며, 테이블이 `(규약)` 인 것이 그 표식입니다.
|
||||
|
||||
**`HasUserOverrides` 를 쓰는 셋**(`ClaimReason` · `ShippingCarrier` · `ShippingType`)은 시더가
|
||||
기본값을 넣지만 운영자가 고칠 수 있는 테이블입니다. 시더를 다시 돌려도 운영자 수정분은
|
||||
보존되므로, 이 셋의 기본 데이터를 바꿀 때는 시더만 고쳐서는 기설치본에 반영되지 않습니다.
|
||||
|
||||
**검색 색인 대상**은 `Product` · `Category` · `Brand` · `Coupon` · `ProductCommonInfo` 다섯이며,
|
||||
색인 갱신은 서비스가 아니라 `SearchProductsListener` 가 훅으로 받아 처리합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 소유 테이블
|
||||
|
||||
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 테이블 | 모델 |
|
||||
|---|---|
|
||||
| `ecommerce_brands` | `Brand` |
|
||||
| `ecommerce_carts` | `Cart` |
|
||||
| `ecommerce_categories` | `Category` |
|
||||
| `ecommerce_category_images` | `CategoryImage` |
|
||||
| `ecommerce_claim_reasons` | `ClaimReason` |
|
||||
| `ecommerce_mail_templates` | - |
|
||||
| `ecommerce_mileage_balances` | `MileageBalance` |
|
||||
| `ecommerce_mileage_transactions` | `MileageTransaction` |
|
||||
| `ecommerce_order_addresses` | `OrderAddress` |
|
||||
| `ecommerce_order_cancel_options` | `OrderCancelOption` |
|
||||
| `ecommerce_order_cancels` | `OrderCancel` |
|
||||
| `ecommerce_order_cash_receipts` | `OrderCashReceipt` |
|
||||
| `ecommerce_order_options` | `OrderOption` |
|
||||
| `ecommerce_order_payments` | `OrderPayment` |
|
||||
| `ecommerce_order_refund_options` | `OrderRefundOption` |
|
||||
| `ecommerce_order_refunds` | `OrderRefund` |
|
||||
| `ecommerce_order_shippings` | `OrderShipping` |
|
||||
| `ecommerce_order_tax_invoices` | `OrderTaxInvoice` |
|
||||
| `ecommerce_orders` | `Order` |
|
||||
| `ecommerce_product_additional_option_values` | `ProductAdditionalOptionValue` |
|
||||
| `ecommerce_product_additional_options` | `ProductAdditionalOption` |
|
||||
| `ecommerce_product_categories` | - |
|
||||
| `ecommerce_product_common_infos` | `ProductCommonInfo` |
|
||||
| `ecommerce_product_images` | `ProductImage` |
|
||||
| `ecommerce_product_inquiries` | `ProductInquiry` |
|
||||
| `ecommerce_product_label_assignments` | `ProductLabelAssignment` |
|
||||
| `ecommerce_product_labels` | `ProductLabel` |
|
||||
| `ecommerce_product_logs` | - |
|
||||
| `ecommerce_product_notice_templates` | `ProductNoticeTemplate` |
|
||||
| `ecommerce_product_notices` | `ProductNotice` |
|
||||
| `ecommerce_product_options` | `ProductOption` |
|
||||
| `ecommerce_product_review_images` | `ProductReviewImage` |
|
||||
| `ecommerce_product_reviews` | `ProductReview` |
|
||||
| `ecommerce_product_wishlists` | `ProductWishlist` |
|
||||
| `ecommerce_products` | `Product` |
|
||||
| `ecommerce_promotion_coupon_categories` | - |
|
||||
| `ecommerce_promotion_coupon_issues` | `CouponIssue` |
|
||||
| `ecommerce_promotion_coupon_products` | - |
|
||||
| `ecommerce_promotion_coupons` | `Coupon` |
|
||||
| `ecommerce_search_presets` | `SearchPreset` |
|
||||
| `ecommerce_sequence_codes` | `SequenceCode` |
|
||||
| `ecommerce_sequences` | `Sequence` |
|
||||
| `ecommerce_shipping_carriers` | `ShippingCarrier` |
|
||||
| `ecommerce_shipping_policies` | `ShippingPolicy` |
|
||||
| `ecommerce_shipping_policy_country_settings` | `ShippingPolicyCountrySetting` |
|
||||
| `ecommerce_shipping_policy_extra_fee_templates` | `ExtraFeeTemplate` |
|
||||
| `ecommerce_shipping_types` | `ShippingType` |
|
||||
| `ecommerce_stats` | `EcommerceStat` |
|
||||
| `ecommerce_temp_orders` | `TempOrder` |
|
||||
| `ecommerce_user_addresses` | `UserAddress` |
|
||||
| `ecommerce_user_profiles` | `EcommerceUserProfile` |
|
||||
<!-- @generated:tables END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
51개 테이블은 전부 `ecommerce_` 접두사를 갖습니다. 모델 열이 `-` 인 다섯은 각각 이유가 있습니다:
|
||||
|
||||
| 테이블 | 왜 모델이 없는가 |
|
||||
|---|---|
|
||||
| `ecommerce_product_categories` | 상품↔카테고리 다대다 피벗 (관계로만 접근) |
|
||||
| `ecommerce_promotion_coupon_products` · `ecommerce_promotion_coupon_categories` | 쿠폰의 적용/제외 대상 피벗 — 한 쿠폰이 포함·제외 두 방향을 함께 갖습니다 |
|
||||
| `ecommerce_product_logs` | 상품 변경 이력 적재 전용 (읽기는 집계 쿼리로) |
|
||||
| `ecommerce_mail_templates` | 알림 본문 템플릿. 알림 정의 10종이 여기서 본문을 찾습니다 |
|
||||
|
||||
테이블을 추가·변경할 때 **DB CASCADE 에 삭제를 맡기지 않습니다.** 주문·상품 삭제는 훅 발행·
|
||||
파일 정리·활동 로그가 함께 일어나야 하므로 Service 가 명시적으로 지웁니다 — CASCADE 로 지우면
|
||||
그 부가 처리가 통째로 건너뛰어지고 아무 오류도 남지 않습니다.
|
||||
|
||||
주문 계열(`ecommerce_orders` · `ecommerce_order_*`)과 상품·리뷰·문의 계열은 SoftDeletes 를
|
||||
씁니다. 목록 쿼리를 새로 만들 때 `withTrashed()` 를 습관적으로 붙이면 취소·삭제된 행이 매출
|
||||
집계에 섞이므로 주의합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
마이그레이션 102개.
|
||||
|
||||
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|
||||
|---|---|---|---|
|
||||
| `2026_04_01_000001_create_ecommerce_categories_table.php` | `ecommerce_categories` | - | ✅ |
|
||||
| `2026_04_01_000002_create_ecommerce_category_images_table.php` | `ecommerce_category_images` | - | ✅ |
|
||||
| `2026_04_01_000003_create_ecommerce_brands_table.php` | `ecommerce_brands` | - | ✅ |
|
||||
| `2026_04_01_000004_create_ecommerce_search_presets_table.php` | `ecommerce_search_presets` | - | ✅ |
|
||||
| `2026_04_01_000005_create_ecommerce_products_table.php` | `ecommerce_products` | `ecommerce_products` | ✅ |
|
||||
| `2026_04_01_000006_create_ecommerce_product_images_table.php` | `ecommerce_product_images` | - | ✅ |
|
||||
| `2026_04_01_000007_create_ecommerce_product_options_table.php` | `ecommerce_product_options` | - | ✅ |
|
||||
| `2026_04_01_000008_create_ecommerce_product_additional_options_table.php` | `ecommerce_product_additional_options` | - | ✅ |
|
||||
| `2026_04_01_000009_create_ecommerce_product_labels_table.php` | `ecommerce_product_labels` | - | ✅ |
|
||||
| `2026_04_01_000010_create_ecommerce_product_label_assignments_table.php` | `ecommerce_product_label_assignments` | - | ✅ |
|
||||
| `2026_04_01_000011_create_ecommerce_product_logs_table.php` | `ecommerce_product_logs` | - | ✅ |
|
||||
| `2026_04_01_000012_create_ecommerce_product_notice_templates_table.php` | `ecommerce_product_notice_templates` | - | ✅ |
|
||||
| `2026_04_01_000013_create_ecommerce_product_notices_table.php` | `ecommerce_product_notices` | - | ✅ |
|
||||
| `2026_04_01_000014_create_ecommerce_product_common_infos_table.php` | `ecommerce_product_common_infos` | - | ✅ |
|
||||
| `2026_04_01_000015_create_ecommerce_product_categories_table.php` | `ecommerce_product_categories` | - | ✅ |
|
||||
| `2026_04_01_000016_create_ecommerce_product_wishlists_table.php` | `ecommerce_product_wishlists` | - | ✅ |
|
||||
| `2026_04_01_000017_create_ecommerce_orders_table.php` | `ecommerce_orders` | - | ✅ |
|
||||
| `2026_04_01_000018_create_ecommerce_order_options_table.php` | `ecommerce_order_options` | - | ✅ |
|
||||
| `2026_04_01_000019_create_ecommerce_order_addresses_table.php` | `ecommerce_order_addresses` | - | ✅ |
|
||||
| `2026_04_01_000020_create_ecommerce_order_payments_table.php` | `ecommerce_order_payments` | - | ✅ |
|
||||
| `2026_04_01_000021_create_ecommerce_order_shippings_table.php` | `ecommerce_order_shippings` | - | ✅ |
|
||||
| `2026_04_01_000022_create_ecommerce_order_tax_invoices_table.php` | `ecommerce_order_tax_invoices` | - | ✅ |
|
||||
| `2026_04_01_000023_create_ecommerce_carts_table.php` | `ecommerce_carts` | - | ✅ |
|
||||
| `2026_04_01_000024_create_ecommerce_temp_orders_table.php` | `ecommerce_temp_orders` | - | ✅ |
|
||||
| `2026_04_01_000025_create_ecommerce_shipping_policies_table.php` | `ecommerce_shipping_policies` | - | ✅ |
|
||||
| `2026_04_01_000026_create_ecommerce_shipping_policy_extra_fee_templates_table.php` | `ecommerce_shipping_policy_extra_fee_templates` | - | ✅ |
|
||||
| `2026_04_01_000027_create_ecommerce_shipping_policy_country_settings_table.php` | `ecommerce_shipping_policy_country_settings` | - | ✅ |
|
||||
| `2026_04_01_000028_create_ecommerce_shipping_carriers_table.php` | `ecommerce_shipping_carriers` | - | ✅ |
|
||||
| `2026_04_01_000029_create_ecommerce_promotion_coupons_table.php` | `ecommerce_promotion_coupons` | - | ✅ |
|
||||
| `2026_04_01_000030_add_vat_amount_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
|
||||
| `2026_04_01_000031_create_ecommerce_promotion_coupon_issues_table.php` | `ecommerce_promotion_coupon_issues` | - | ✅ |
|
||||
| `2026_04_01_000032_create_ecommerce_promotion_coupon_products_table.php` | `ecommerce_promotion_coupon_products` | - | ✅ |
|
||||
| `2026_04_01_000033_create_ecommerce_promotion_coupon_categories_table.php` | `ecommerce_promotion_coupon_categories` | - | ✅ |
|
||||
| `2026_04_01_000034_create_ecommerce_sequences_table.php` | `ecommerce_sequences` | - | ✅ |
|
||||
| `2026_04_01_000035_create_ecommerce_sequence_codes_table.php` | `ecommerce_sequence_codes` | - | ✅ |
|
||||
| `2026_04_01_000036_create_ecommerce_user_addresses_table.php` | `ecommerce_user_addresses` | - | ✅ |
|
||||
| `2026_04_01_000037_create_ecommerce_mail_templates_table.php` | `ecommerce_mail_templates` | - | ✅ |
|
||||
| `2026_04_01_000038_change_ecommerce_user_addresses_name_to_string.php` | - | `ecommerce_user_addresses` | ✅ |
|
||||
| `2026_04_01_000039_create_ecommerce_product_reviews_table.php` | `ecommerce_product_reviews` | `ecommerce_product_reviews` | ✅ |
|
||||
| `2026_04_01_000040_create_ecommerce_product_review_images_table.php` | `ecommerce_product_review_images` | `ecommerce_product_review_images` | ✅ |
|
||||
| `2026_04_01_000041_create_ecommerce_order_cancels_table.php` | `ecommerce_order_cancels` | - | ✅ |
|
||||
| `2026_04_01_000042_create_ecommerce_order_cancel_options_table.php` | `ecommerce_order_cancel_options` | - | ✅ |
|
||||
| `2026_04_01_000043_create_ecommerce_order_refunds_table.php` | `ecommerce_order_refunds` | - | ✅ |
|
||||
| `2026_04_01_000044_create_ecommerce_order_refund_options_table.php` | `ecommerce_order_refund_options` | - | ✅ |
|
||||
| `2026_04_01_000045_add_cancellation_columns_to_ecommerce_orders_and_options.php` | - | `ecommerce_orders`, `ecommerce_order_options` | ✅ |
|
||||
| `2026_04_01_000046_add_mc_refund_columns_to_ecommerce_order_refunds_table.php` | - | `ecommerce_order_refunds` | ✅ |
|
||||
| `2026_04_01_000047_modify_i18n_columns_in_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
|
||||
| `2026_04_01_000048_create_ecommerce_claim_reasons_table.php` | `ecommerce_claim_reasons` | - | ✅ |
|
||||
| `2026_04_01_000049_add_confirmed_at_to_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
|
||||
| `2026_04_01_000050_drop_ecommerce_product_logs_table.php` | `ecommerce_product_logs` | - | ✅ |
|
||||
| `2026_04_01_000051_remove_url_from_ecommerce_product_images_and_review_images_table.php` | - | `ecommerce_product_images`, `ecommerce_product_review_images` | ✅ |
|
||||
| `2026_04_01_000052_create_ecommerce_product_inquiries_table.php` | `ecommerce_product_inquiries` | `ecommerce_product_inquiries` | ✅ |
|
||||
| `2026_04_01_000053_add_indexes_to_ecommerce_products_table.php` | - | `ecommerce_products` | ✅ |
|
||||
| `2026_04_01_000054_add_indexes_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
|
||||
| `2026_04_01_000055_add_indexes_to_ecommerce_order_addresses_table.php` | - | `ecommerce_order_addresses` | ✅ |
|
||||
| `2026_04_01_000056_drop_carrier_name_from_ecommerce_order_shippings_table.php` | - | `ecommerce_order_shippings` | ✅ |
|
||||
| `2026_04_01_000057_add_fulltext_indexes_to_ecommerce_products_table.php` | - | `ecommerce_products` | ✅ |
|
||||
| `2026_04_01_000058_add_fulltext_indexes_to_ecommerce_categories_table.php` | - | `ecommerce_categories` | ✅ |
|
||||
| `2026_04_01_000059_add_fulltext_indexes_to_ecommerce_brands_table.php` | - | `ecommerce_brands` | ✅ |
|
||||
| `2026_04_01_000060_add_fulltext_indexes_to_ecommerce_promotion_coupons_table.php` | - | `ecommerce_promotion_coupons` | ✅ |
|
||||
| `2026_04_01_000061_add_fulltext_indexes_to_ecommerce_product_common_infos_table.php` | - | `ecommerce_product_common_infos` | ✅ |
|
||||
| `2026_04_01_000062_create_ecommerce_shipping_types_table.php` | `ecommerce_shipping_types` | - | ✅ |
|
||||
| `2026_04_01_000063_add_custom_shipping_name_to_country_settings.php` | - | `ecommerce_shipping_policy_country_settings` | ✅ |
|
||||
| `2026_04_13_000001_drop_ecommerce_mail_templates_table.php` | `ecommerce_mail_templates` | - | ✅ |
|
||||
| `2026_04_13_000002_add_user_overrides_to_ecommerce_claim_reasons_table.php` | - | `ecommerce_claim_reasons` | ✅ |
|
||||
| `2026_04_19_000000_add_user_overrides_to_ecommerce_shipping_types_table.php` | - | `ecommerce_shipping_types` | ✅ |
|
||||
| `2026_04_20_000001_add_user_overrides_to_ecommerce_shipping_carriers_table.php` | - | `ecommerce_shipping_carriers` | ✅ |
|
||||
| `2026_05_16_000001_add_unique_index_to_transaction_id_on_ecommerce_order_payments_table.php` | - | - | ✅ |
|
||||
| `2026_06_02_000001_add_guest_lookup_password_hash_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
|
||||
| `2026_06_11_000001_create_ecommerce_mileage_transactions_table.php` | `ecommerce_mileage_transactions` | - | ✅ |
|
||||
| `2026_06_11_000002_add_delivered_at_to_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
|
||||
| `2026_06_11_000003_add_mc_subtotal_earned_points_amount_to_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
|
||||
| `2026_06_11_000004_create_ecommerce_mileage_balances_table.php` | `ecommerce_mileage_balances` | - | ✅ |
|
||||
| `2026_06_16_000001_add_is_mileage_deducted_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
|
||||
| `2026_06_16_000001_create_ecommerce_stats_table.php` | `ecommerce_stats` | - | ✅ |
|
||||
| `2026_06_22_000001_add_api_config_to_ecommerce_shipping_policy_country_settings_table.php` | - | `ecommerce_shipping_policy_country_settings` | ✅ |
|
||||
| `2026_06_22_000001_add_cancelled_at_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
|
||||
| `2026_06_23_000001_add_seo_sync_flags_to_ecommerce_products_table.php` | - | `ecommerce_products` | ✅ |
|
||||
| `2026_06_24_000001_convert_seo_meta_to_multilingual_json.php` | - | - | ✅ |
|
||||
| `2026_06_24_000001_create_ecommerce_user_profiles_table.php` | `ecommerce_user_profiles` | - | ✅ |
|
||||
| `2026_06_24_000010_create_ecommerce_product_additional_option_values_table.php` | `ecommerce_product_additional_option_values` | - | ✅ |
|
||||
| `2026_06_24_000011_add_additional_option_selections_to_ecommerce_carts_table.php` | - | `ecommerce_carts` | ✅ |
|
||||
| `2026_06_24_000012_add_additional_options_columns_to_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
|
||||
| `2026_06_25_000001_add_preferred_shipping_country_to_ecommerce_user_profiles_table.php` | - | `ecommerce_user_profiles` | ✅ |
|
||||
| `2026_06_25_000001_change_ecommerce_product_prices_to_decimal.php` | - | `ecommerce_products`, `ecommerce_product_options` | ✅ |
|
||||
| `2026_06_25_000002_add_shipping_snapshot_to_ecommerce_order_cancels_table.php` | - | `ecommerce_order_cancels` | ✅ |
|
||||
| `2026_06_25_000013_add_allow_custom_text_to_ecommerce_product_additional_option_values_table.php` | - | `ecommerce_product_additional_option_values` | ✅ |
|
||||
| `2026_06_26_000001_add_delivery_memo_label_to_ecommerce_order_addresses_table.php` | - | `ecommerce_order_addresses` | ✅ |
|
||||
| `2026_06_26_000002_add_orderer_locale_to_ecommerce_order_addresses_table.php` | - | `ecommerce_order_addresses` | ✅ |
|
||||
| `2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php` | `ecommerce_order_cash_receipts` | - | ✅ |
|
||||
| `2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php` | - | `ecommerce_orders`, `ecommerce_order_options` | ✅ |
|
||||
| `2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php` | - | `ecommerce_order_payments` | ✅ |
|
||||
| `2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php` | - | `ecommerce_order_payments` | ✅ |
|
||||
| `2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
|
||||
| `2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php` | - | `ecommerce_product_reviews` | ✅ |
|
||||
| `2026_07_31_000001_add_shipped_at_index_to_ecommerce_order_shippings_table.php` | - | `ecommerce_order_shippings` | ✅ |
|
||||
| `2026_08_01_000001_add_list_sort_indexes_to_ecommerce_tables.php` | - | - | ✅ |
|
||||
| `2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php` | - | - | ✅ |
|
||||
| `2026_08_11_000001_fix_weight_volume_unit_comments_in_ecommerce_order_tables.php` | - | `ecommerce_orders`, `ecommerce_order_options` | ✅ |
|
||||
| `2026_08_17_000001_add_soft_deletes_to_ecommerce_product_inquiries_table.php` | - | `ecommerce_product_inquiries` | ✅ |
|
||||
| `2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php` | - | - | ✅ |
|
||||
| `2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php` | - | `ecommerce_products` | ✅ |
|
||||
<!-- @generated:migrations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
102개는 초기 스키마 한 벌이 아니라 **누적된 변경 이력**입니다. 새 컬럼을 추가할 때 초기
|
||||
`create_*` 파일을 고치는 것이 아니라 새 `add_*`/`change_*` 파일을 더합니다 — 이미 설치된
|
||||
사이트는 초기 마이그레이션을 다시 실행하지 않기 때문입니다.
|
||||
|
||||
같은 이유로 **소스만 고쳐서는 기설치본이 낫지 않습니다.** 컬럼 기본값·comment·데이터 형태를
|
||||
바로잡는 변경은 마이그레이션과 함께 `upgrades/` 의 업그레이드 스텝에 백필을 써야 이미 운영
|
||||
중인 사이트에 반영됩니다.
|
||||
|
||||
작성 규칙 셋(코어 공통이지만 이 모듈에서 특히 자주 걸립니다):
|
||||
|
||||
- 모든 컬럼에 한국어 `comment` 와 `down()` 구현
|
||||
- FK 컬럼의 `->comment()` 는 `->constrained()` **앞**에 둡니다 (뒤에 두면 comment 가 컬럼이
|
||||
아니라 FK 정의에 붙어 조용히 사라집니다)
|
||||
- 데이터를 순회하며 그 행을 갱신·삭제하는 백필은 `chunkById()` — `chunk()` 계열은 OFFSET
|
||||
기반이라 처리된 행이 필터에서 이탈한 만큼 커서가 밀려 미처리 행을 조용히 건너뜁니다
|
||||
<!-- @intent END -->
|
||||
|
||||
## Enum
|
||||
|
||||
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| Enum | backing | case 수 | case |
|
||||
|---|---|---|---|
|
||||
| `AdjustmentType` | `string` | 1 | `cancel` |
|
||||
| `CancelOptionStatusEnum` | `string` | 2 | `requested`, `completed` |
|
||||
| `CancelStatusEnum` | `string` | 2 | `requested`, `completed` |
|
||||
| `CancelTypeEnum` | `string` | 2 | `full`, `partial` |
|
||||
| `CashReceiptIdentifierType` | `string` | 3 | `phone`, `card`, `business` |
|
||||
| `CashReceiptIssueStatus` | `string` | 3 | `IN_PROGRESS`, `COMPLETED`, `FAILED` |
|
||||
| `CashReceiptTransactionType` | `string` | 2 | `issue`, `cancel` |
|
||||
| `CashReceiptType` | `string` | 2 | `income`, `expense` |
|
||||
| `ChargePolicyEnum` | `string` | 14 | `free`, `fixed`, `conditional_free`, `range_amount`, `range_quantity`, `range_weight`, `range_volume`, `range_volume_weight`, `외 6개` |
|
||||
| `ClaimReasonFaultTypeEnum` | `string` | 3 | `customer`, `seller`, `carrier` |
|
||||
| `ClaimReasonTypeEnum` | `string` | 1 | `refund` |
|
||||
| `CouponDiscountType` | `string` | 2 | `fixed`, `rate` |
|
||||
| `CouponIssueCondition` | `string` | 4 | `manual`, `signup`, `first_purchase`, `birthday` |
|
||||
| `CouponIssueMethod` | `string` | 3 | `direct`, `download`, `auto` |
|
||||
| `CouponIssueRecordStatus` | `string` | 4 | `available`, `used`, `expired`, `cancelled` |
|
||||
| `CouponIssueStatus` | `string` | 2 | `issuing`, `stopped` |
|
||||
| `CouponTargetScope` | `string` | 3 | `all`, `products`, `categories` |
|
||||
| `CouponTargetType` | `string` | 3 | `product_amount`, `order_amount`, `shipping_fee` |
|
||||
| `DeliveryMemoPresetEnum` | `string` | 4 | `door`, `security`, `parcel_box`, `call` |
|
||||
| `DeviceTypeEnum` | `string` | 6 | `pc`, `mobile`, `app_ios`, `app_android`, `admin`, `api` |
|
||||
| `MileageEarnTriggerEnum` | `string` | 2 | `delivered`, `confirmed` |
|
||||
| `MileageTransactionTypeEnum` | `string` | 8 | `purchase_earn`, `admin_earn`, `order_use`, `admin_deduct`, `expired`, `refund_restore`, `order_cancel_restore`, `earn_cancel` |
|
||||
| `OrderDateTypeEnum` | `string` | 5 | `ordered_at`, `paid_at`, `confirmed_at`, `delivered_at`, `cancelled_at` |
|
||||
| `OrderOptionSourceTypeEnum` | `string` | 3 | `order`, `exchange`, `split` |
|
||||
| `OrderStatusEnum` | `string` | 10 | `pending_order`, `pending_payment`, `payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered`, `외 2개` |
|
||||
| `PaymentMethodEnum` | `string` | 8 | `card`, `vbank`, `dbank`, `bank`, `phone`, `point`, `deposit`, `free` |
|
||||
| `PaymentStatusEnum` | `string` | 8 | `ready`, `in_progress`, `waiting_deposit`, `paid`, `partial_cancelled`, `cancelled`, `failed`, `expired` |
|
||||
| `ProductDateType` | `string` | 2 | `created_at`, `updated_at` |
|
||||
| `ProductDisplayStatus` | `string` | 2 | `visible`, `hidden` |
|
||||
| `ProductImageCollection` | `string` | 3 | `main`, `detail`, `additional` |
|
||||
| `ProductPriceType` | `string` | 3 | `selling_price`, `supply_price`, `list_price` |
|
||||
| `ProductSalesStatus` | `string` | 4 | `on_sale`, `suspended`, `sold_out`, `coming_soon` |
|
||||
| `ProductTaxStatus` | `string` | 2 | `taxable`, `tax_free` |
|
||||
| `RefundMethodEnum` | `string` | 3 | `pg`, `bank`, `points` |
|
||||
| `RefundOptionStatusEnum` | `string` | 6 | `requested`, `approved`, `processing`, `on_hold`, `completed`, `rejected` |
|
||||
| `RefundPriorityEnum` | `string` | 2 | `pg_first`, `points_first` |
|
||||
| `RefundStatusEnum` | `string` | 6 | `requested`, `approved`, `processing`, `on_hold`, `completed`, `rejected` |
|
||||
| `ReviewStatus` | `string` | 2 | `visible`, `hidden` |
|
||||
| `SearchPresetTargetScreen` | `string` | 3 | `products`, `orders`, `customers` |
|
||||
| `SequenceAlgorithm` | `string` | 5 | `hybrid`, `sequential`, `daily`, `timestamp`, `nanoid` |
|
||||
| `SequenceType` | `string` | 5 | `product`, `order`, `shipping`, `cancel`, `refund` |
|
||||
| `ShippingApiAuthType` | `string` | 3 | `none`, `bearer`, `custom_header` |
|
||||
| `ShippingApiHttpMethod` | `string` | 2 | `GET`, `POST` |
|
||||
| `ShippingApiRequestField` | `string` | 5 | `policy_id`, `country_code`, `items`, `group_total`, `total_quantity` |
|
||||
| `ShippingApiResponseType` | `string` | 2 | `json`, `text` |
|
||||
| `ShippingCountryEnum` | `string` | 4 | `KR`, `US`, `CN`, `JP` |
|
||||
| `ShippingFeeTaxPolicy` | `string` | 3 | `proportional`, `taxable`, `follow_main_item` |
|
||||
| `ShippingStatusEnum` | `string` | 11 | `pending`, `preparing`, `ready`, `shipped`, `in_transit`, `out_for_delivery`, `delivered`, `failed`, `외 3개` |
|
||||
| `TaxInvoiceStatusEnum` | `string` | 5 | `pending`, `processing`, `issued`, `failed`, `cancelled` |
|
||||
<!-- @generated:enums END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
Enum 49종이 이 도메인의 **어휘 전체**입니다. 상태·분류를 문자열 리터럴로 비교하는 코드가 있으면
|
||||
그 자리는 Enum 으로 바꿔야 하는 신호입니다 — 화면 필터 옵션·검증 게이트·실제 기록 값 셋이
|
||||
같은 Enum 에서 파생되지 않으면, 빠진 값으로 기록된 행이 어떤 필터로도 도달할 수 없게 됩니다.
|
||||
|
||||
먼저 읽어야 하는 것들:
|
||||
|
||||
| Enum | 왜 중요한가 |
|
||||
|---|---|
|
||||
| `OrderStatusEnum` (10) | 주문 상태 전이의 SSoT. 어느 상태까지 취소를 허용할지는 설정(`cancellable_statuses`)이 이 케이스 이름으로 정합니다 |
|
||||
| `PaymentStatusEnum` (8) · `PaymentMethodEnum` (8) | 결제 상태와 **코어 기본 결제수단**. 플러그인이 추가하는 결제수단(`kginicis_naverpay` 등)은 여기에 없고 카탈로그 선언으로만 존재합니다 — 이 Enum 을 결제수단의 전체 목록으로 오해하지 않습니다 |
|
||||
| `ChargePolicyEnum` (14) | 배송비 산정 방식. 무료·정액·조건부무료·금액/수량/무게/부피 구간 등 국가별 요금 규칙이 전부 이 하나로 표현됩니다 |
|
||||
| `MileageTransactionTypeEnum` (8) | 원장 기록의 종류. 적립·사용·소멸·환불복원·취소복원이 모두 별개 케이스라 원장만 보고 잔액을 재구성할 수 있습니다 |
|
||||
| `RefundMethodEnum` (3) · `RefundPriorityEnum` (2) | 환불을 어디로 돌려줄지와 그 우선순위 |
|
||||
| `SequenceType` (5) · `SequenceAlgorithm` (5) | 채번 대상과 방식. 주문번호·상품코드 형식을 바꾸는 자리입니다 |
|
||||
| `ShippingStatusEnum` (11) · `ShippingCountryEnum` (4) | 배송 진행 상태와 기본 제공 국가 |
|
||||
|
||||
`ShippingCountryEnum` 이 4개뿐인 것은 **기본 제공 목록**이기 때문입니다. 취급 국가는 환경설정과
|
||||
배송정책 국가별 설정이 정하므로, 나라를 늘리는 것은 이 Enum 을 고치는 일이 아닙니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## Repository
|
||||
|
||||
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 클래스 | 종류 | 설명 |
|
||||
|---|---|---|
|
||||
| `BrandRepository` | 구현 | 브랜드 Repository 구현체 |
|
||||
| `BrandRepositoryInterface` | 인터페이스 | 브랜드 Repository 인터페이스 |
|
||||
| `CartRepository` | 구현 | 장바구니 Repository 구현체 |
|
||||
| `CartRepositoryInterface` | 인터페이스 | 장바구니 Repository 인터페이스 |
|
||||
| `CategoryImageRepository` | 구현 | 카테고리 이미지 Repository 구현체 |
|
||||
| `CategoryImageRepositoryInterface` | 인터페이스 | 카테고리 이미지 Repository 인터페이스 |
|
||||
| `CategoryRepository` | 구현 | 카테고리 Repository 구현체 |
|
||||
| `CategoryRepositoryInterface` | 인터페이스 | 카테고리 Repository 인터페이스 |
|
||||
| `ClaimReasonRepository` | 구현 | 클레임 사유 Repository 구현체 |
|
||||
| `ClaimReasonRepositoryInterface` | 인터페이스 | 클레임 사유 Repository 인터페이스 |
|
||||
| `CouponIssueRepository` | 구현 | 쿠폰 발급 Repository 구현체 |
|
||||
| `CouponIssueRepositoryInterface` | 인터페이스 | 쿠폰 발급 Repository 인터페이스 |
|
||||
| `CouponRepository` | 구현 | 쿠폰 Repository 구현체 |
|
||||
| `CouponRepositoryInterface` | 인터페이스 | 쿠폰 Repository 인터페이스 |
|
||||
| `EcommerceStatRepository` | 구현 | 이커머스 일별 판매 집계 Repository |
|
||||
| `EcommerceStatRepositoryInterface` | 인터페이스 | 이커머스 일별 판매 집계 Repository 계약 |
|
||||
| `EcommerceUserProfileRepository` | 구현 | 이커머스 사용자 프로필 Repository 구현체 (A3) |
|
||||
| `EcommerceUserProfileRepositoryInterface` | 인터페이스 | 이커머스 사용자 프로필 Repository 인터페이스 (A3) |
|
||||
| `ExtraFeeTemplateRepository` | 구현 | 추가배송비 템플릿 Repository 구현체 |
|
||||
| `ExtraFeeTemplateRepositoryInterface` | 인터페이스 | 추가배송비 템플릿 Repository 인터페이스 |
|
||||
| `MileageBalanceRepository` | 구현 | 마일리지 잔액 캐시 Repository 구현체 (단방향 파생 — 원장/옵션 → 캐시) |
|
||||
| `MileageBalanceRepositoryInterface` | 인터페이스 | 마일리지 잔액 캐시 Repository 인터페이스 (파생 캐시) |
|
||||
| `MileageTransactionRepository` | 구현 | 마일리지 거래(원장) Repository 구현체 |
|
||||
| `MileageTransactionRepositoryInterface` | 인터페이스 | 마일리지 거래(원장) Repository 인터페이스 |
|
||||
| `OrderCancelOptionRepository` | 구현 | 주문 취소 옵션 리포지토리 구현체 |
|
||||
| `OrderCancelOptionRepositoryInterface` | 인터페이스 | 주문 취소 옵션 리포지토리 인터페이스 |
|
||||
| `OrderCancelRepository` | 구현 | 주문 취소 리포지토리 구현체 |
|
||||
| `OrderCancelRepositoryInterface` | 인터페이스 | 주문 취소 리포지토리 인터페이스 |
|
||||
| `OrderCashReceiptRepository` | 구현 | 주문 현금영수증 이력 Repository 구현체 |
|
||||
| `OrderCashReceiptRepositoryInterface` | 인터페이스 | 주문 현금영수증 이력 Repository 인터페이스 |
|
||||
| `OrderOptionRepository` | 구현 | 주문 옵션 리포지토리 |
|
||||
| `OrderOptionRepositoryInterface` | 인터페이스 | 주문 옵션 리포지토리 인터페이스 |
|
||||
| `OrderPaymentRepository` | 구현 | 주문 결제 Repository 구현체 |
|
||||
| `OrderPaymentRepositoryInterface` | 인터페이스 | 주문 결제 Repository 인터페이스 |
|
||||
| `OrderRefundOptionRepository` | 구현 | 주문 환불 옵션 리포지토리 구현체 |
|
||||
| `OrderRefundOptionRepositoryInterface` | 인터페이스 | 주문 환불 옵션 리포지토리 인터페이스 |
|
||||
| `OrderRefundRepository` | 구현 | 주문 환불 리포지토리 구현체 |
|
||||
| `OrderRefundRepositoryInterface` | 인터페이스 | 주문 환불 리포지토리 인터페이스 |
|
||||
| `OrderRepository` | 구현 | 주문 Repository 구현체 |
|
||||
| `OrderRepositoryInterface` | 인터페이스 | 주문 Repository 인터페이스 |
|
||||
| `OrderShippingRepository` | 구현 | 주문 배송 리포지토리 구현체 |
|
||||
| `OrderShippingRepositoryInterface` | 인터페이스 | 주문 배송 리포지토리 인터페이스 |
|
||||
| `ProductAdditionalOptionValueRepository` | 구현 | 상품 추가옵션 선택지 Repository 구현체 |
|
||||
| `ProductAdditionalOptionValueRepositoryInterface` | 인터페이스 | 상품 추가옵션 선택지 Repository 인터페이스 |
|
||||
| `ProductCommonInfoRepository` | 구현 | 공통정보 Repository 구현체 |
|
||||
| `ProductCommonInfoRepositoryInterface` | 인터페이스 | 공통정보 Repository 인터페이스 |
|
||||
| `ProductImageRepository` | 구현 | 상품 이미지 Repository 구현체 |
|
||||
| `ProductImageRepositoryInterface` | 인터페이스 | 상품 이미지 Repository 인터페이스 |
|
||||
| `ProductInquiryRepository` | 구현 | 상품 1:1 문의 Repository 구현체 |
|
||||
| `ProductInquiryRepositoryInterface` | 인터페이스 | 상품 1:1 문의 Repository 인터페이스 |
|
||||
| `ProductLabelRepository` | 구현 | 상품 라벨 Repository 구현체 |
|
||||
| `ProductLabelRepositoryInterface` | 인터페이스 | 상품 라벨 Repository 인터페이스 |
|
||||
| `ProductNoticeTemplateRepository` | 구현 | 상품정보제공고시 템플릿 Repository 구현체 |
|
||||
| `ProductNoticeTemplateRepositoryInterface` | 인터페이스 | 상품정보제공고시 템플릿 Repository 인터페이스 |
|
||||
| `ProductOptionRepository` | 구현 | 상품 옵션 Repository 구현체 |
|
||||
| `ProductOptionRepositoryInterface` | 인터페이스 | 상품 옵션 Repository 인터페이스 |
|
||||
| `ProductRepository` | 구현 | 상품 Repository 구현체 |
|
||||
| `ProductRepositoryInterface` | 인터페이스 | 상품 Repository 인터페이스 |
|
||||
| `ProductReviewImageRepository` | 구현 | 상품 리뷰 이미지 Repository 구현체 |
|
||||
| `ProductReviewImageRepositoryInterface` | 인터페이스 | 상품 리뷰 이미지 Repository 인터페이스 |
|
||||
| `ProductReviewRepository` | 구현 | 상품 리뷰 Repository 구현체 |
|
||||
| `ProductReviewRepositoryInterface` | 인터페이스 | 상품 리뷰 Repository 인터페이스 |
|
||||
| `ProductWishlistRepository` | 구현 | 상품 찜 Repository 구현체 |
|
||||
| `ProductWishlistRepositoryInterface` | 인터페이스 | 상품 찜 Repository 인터페이스 |
|
||||
| `SearchPresetRepository` | 구현 | 검색 프리셋 Repository 구현체 |
|
||||
| `SearchPresetRepositoryInterface` | 인터페이스 | 검색 프리셋 Repository 인터페이스 |
|
||||
| `SequenceRepository` | 구현 | 시퀀스 Repository 구현체 |
|
||||
| `SequenceRepositoryInterface` | 인터페이스 | 시퀀스 Repository 인터페이스 |
|
||||
| `ShippingCarrierRepository` | 구현 | 배송사 Repository 구현체 |
|
||||
| `ShippingCarrierRepositoryInterface` | 인터페이스 | 배송사 Repository 인터페이스 |
|
||||
| `ShippingPolicyRepository` | 구현 | 배송정책 Repository 구현체 |
|
||||
| `ShippingPolicyRepositoryInterface` | 인터페이스 | 배송정책 Repository 인터페이스 |
|
||||
| `ShippingTypeRepository` | 구현 | 배송유형 Repository 구현체 |
|
||||
| `ShippingTypeRepositoryInterface` | 인터페이스 | 배송유형 Repository 인터페이스 |
|
||||
| `TempOrderRepository` | 구현 | 임시 주문 Repository 구현체 |
|
||||
| `TempOrderRepositoryInterface` | 인터페이스 | 임시 주문 Repository 인터페이스 |
|
||||
| `UserAddressRepository` | 구현 | 사용자 배송지 Repository 구현체 |
|
||||
| `UserAddressRepositoryInterface` | 인터페이스 | 사용자 배송지 Repository 인터페이스 |
|
||||
<!-- @generated:repositories END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
Repository 는 인터페이스와 구현이 1:1 로 짝을 이루며, 서비스는 **인터페이스만 주입**받습니다
|
||||
(구체 클래스 타입힌트 금지). 바인딩은 모듈 서비스 프로바이더가 담당합니다.
|
||||
|
||||
이 모듈에서 Repository 를 손댈 때 특히 걸리는 것 셋:
|
||||
|
||||
- **목록 쿼리의 컬럼 프루닝** — `paginate()` 에 컬럼 목록을 주고, 목록이 실제로 그리는 것만
|
||||
싣습니다. 상품 목록에 옵션 전체를 실으면 상품 100건 × 옵션 20건이 한 응답에 나갑니다.
|
||||
- **정렬 컬럼 화이트리스트** — 요청에서 온 정렬 컬럼을 그대로 `orderBy` 에 넘기지 않습니다.
|
||||
화면의 정렬 옵션 ⊆ FormRequest 게이트 ⊆ Repository 화이트리스트 순서로 포함 관계가
|
||||
유지되어야 하며, 어긋나면 422 뒤에 직전 목록이 남아 **정렬된 것처럼 보입니다.**
|
||||
- **마일리지 두 Repository 의 역할 차이** — `MileageTransactionRepository` 는 원장이고
|
||||
`MileageBalanceRepository` 는 파생 캐시입니다. 차감 가능 여부 판정은 반드시 원장
|
||||
`FOR UPDATE` 로 하고, 캐시는 같은 트랜잭션 마지막에 재계산합니다. 캐시를 근거로 차감하면
|
||||
동시 요청에서 잔액이 음수가 됩니다.
|
||||
|
||||
`EcommerceStatRepository` 만 성격이 다릅니다 — 대시보드용 일별 집계 테이블을 읽고 쓰며, 원본
|
||||
주문에서 매번 집계하지 않기 위한 자리입니다. 집계를 채우는 것은 `aggregate-stats` 스케줄입니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,125 @@
|
||||
# 이커머스 — 레이아웃 편집기 스펙
|
||||
|
||||
> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 선언 요약
|
||||
|
||||
<!-- @generated:editor-spec-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| manifest | `modules/_bundled/sirsoft-ecommerce/editor-spec.json` |
|
||||
| 형태 | 단일 파일 (인라인) |
|
||||
| 스펙 버전 | `1.0.0` |
|
||||
| 스타일 시스템 | - |
|
||||
| 다크 모드 전략 | - |
|
||||
|
||||
> 단일 파일 · 프리뷰 샘플 51 · 엔드포인트 샘플 7 · 페이지 상태 17 · 액션 레시피 1
|
||||
<!-- @generated:editor-spec-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이커머스는 저장소에서 가장 큰 모듈이지만 편집기 스펙은 여전히 단일 파일입니다. 스펙
|
||||
분량을 키우는 것은 팔레트·컨트롤·컴포넌트 역량인데 그 셋은 템플릿이 소유하기 때문입니다.
|
||||
모듈 쪽에 남는 것은 도메인 데이터라 라우트 239개·레이아웃 206개 규모에도 한 파일에
|
||||
들어갑니다.
|
||||
|
||||
이 사실이 곧 설계 원칙입니다 — 확장이 커진다고 편집기 스펙이 따라 커지지 않습니다.
|
||||
커진다면 그 확장이 템플릿의 일을 하고 있다는 신호입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 선언 블록
|
||||
|
||||
<!-- @generated:editor-spec-blocks START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 블록 | 역할 | 항목 수 | 출처 |
|
||||
|---|---|---|---|
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 51 | `editor-spec.json (인라인)` |
|
||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 7 | `editor-spec.json (인라인)` |
|
||||
| `sampleGlobal` | `_global.*` 프리뷰 baseline 시드 | 12 | `editor-spec.json (인라인)` |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 17 | `editor-spec.json (인라인)` |
|
||||
| `actionRecipes` | 친화 명칭 → 액션 JSON 레시피 | 1 | `editor-spec.json (인라인)` |
|
||||
| `actionChipCandidates` | 동작 데이터 칩 컨텍스트 후보 | 1 | `editor-spec.json (인라인)` |
|
||||
<!-- @generated:editor-spec-blocks END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`actionRecipes` 와 `actionChipCandidates` 를 각 1건씩 둔 것이 다른 모듈과 다른
|
||||
지점입니다. 이커머스에는 운영자가 편집기에서 직접 조립하기 어려운 동작(장바구니·주문
|
||||
흐름에 얽힌 것)이 있어, 친화 명칭으로 미리 만들어 둔 레시피가 필요합니다.
|
||||
|
||||
나머지 네 블록은 게시판과 같은 원리입니다 — admin 레이아웃 `data_source` ID 51종을
|
||||
전수로 덮고, 사용자 페이지 7종은 호출 주소로 덮습니다. `sampleGlobal` 12종은 통화·로케일
|
||||
같은 값이 `_global` 에 없으면 상품 카드가 통째로 깨지기 때문에 baseline 으로 박아
|
||||
둔 것입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 컴포넌트 팔레트
|
||||
|
||||
<!-- @generated:editor-spec-palette START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._
|
||||
<!-- @generated:editor-spec-palette END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이
|
||||
제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이
|
||||
확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고
|
||||
**도메인 데이터**(`sampleData`·`states`)만 담습니다.
|
||||
|
||||
팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿
|
||||
(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면
|
||||
템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 샘플 데이터와 페이지 상태
|
||||
|
||||
<!-- @generated:editor-spec-samples START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 자리 | 역할 | 개수 | ID |
|
||||
|---|---|---|---|
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 51 | `transactions` · `products` · `product` · `categories` · `orders` · `order` · `reviews` · `brands` · `carriers` · `active_carriers` · `coupons` · `coupon` … 외 39개 |
|
||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 7 | `/api/modules/sirsoft-ecommerce/products*` · `/api/modules/sirsoft-ecommerce/cart*` · `/api/modules/sirsoft-ecommerce/checkout*` · `/api/modules/sirsoft-ecommerce/wishlist*` · `/api/modules/sirsoft-ecommerce/user/orders*` · `/api/modules/sirsoft-ecommerce/user/addresses*` · `/api/modules/sirsoft-ecommerce/user/inquiries*` |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 17 | `/*?/products/:product_code` · `/*?/products` · `/*?/cart` · `/*?/checkout` · `/*?/orders/:id/complete` · `/*?/reorder/:id` · `*/admin/ecommerce/products/:itemCode/edit` · `*/admin/ecommerce/promotion-coupons/:id/edit` · `*/admin/ecommerce/shipping-policies/:id/edit` · `*/admin/ecommerce/settings` · `*/admin/ecommerce/mileage-deposit-settings` · `/*?/guest/orders` … 외 5개 |
|
||||
|
||||
_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._
|
||||
<!-- @generated:editor-spec-samples END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`states.groups` 17종은 이커머스에서 상태가 실제로 화면을 가르는 자리입니다 — 품절
|
||||
상품, 빈 장바구니, 비회원 주문 조회, 재주문 등입니다. 상태를 늘리는 기준은 "그 상태에서
|
||||
운영자가 화면을 따로 손봐야 하는가" 입니다. 값만 다르고 구조가 같은 경우는 변종을
|
||||
만들지 않습니다.
|
||||
|
||||
`sampleGlobal` 에 통화 관련 값을 둘 때는 특정 통화를 정답으로 박지 않도록 주의합니다.
|
||||
기본 통화는 설정이 정하므로, 샘플이 특정 통화를 전제하면 편집기 프리뷰만 그 통화로
|
||||
고정되어 다른 통화 상점의 운영자에게 잘못된 화면을 보여 줍니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 수정 시 동반 의무
|
||||
|
||||
<!-- @generated:editor-spec-obligations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 |
|
||||
|---|---|
|
||||
| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 |
|
||||
| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) |
|
||||
| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 |
|
||||
| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 |
|
||||
| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 |
|
||||
|
||||
편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다:
|
||||
|
||||
```bash
|
||||
php artisan module:update sirsoft-ecommerce --force
|
||||
```
|
||||
<!-- @generated:editor-spec-obligations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 —
|
||||
편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을
|
||||
고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은
|
||||
고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다.
|
||||
|
||||
또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는
|
||||
ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로
|
||||
나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한
|
||||
통로입니다.
|
||||
|
||||
이커머스는 `sampleGlobal` 이 12종으로 가장 많습니다. 레이아웃이 `_global.*` 을 새로
|
||||
읽기 시작했는데 baseline 을 안 넣으면 그 값이 `undefined` 가 되어, 표현식이 통째로
|
||||
falsy 로 떨어지며 **영역 전체가 사라집니다.** 값 하나가 비는 것보다 알아채기 어렵습니다.
|
||||
<!-- @intent END -->
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
# 이커머스 — 프론트엔드
|
||||
|
||||
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 레이아웃
|
||||
|
||||
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
레이아웃 206개 (루트: `resources/layouts`).
|
||||
|
||||
| 그룹 | 개수 |
|
||||
|---|---|
|
||||
| `admin` | 206개 |
|
||||
|
||||
| 레이아웃 | 그룹 | 종류 | extends |
|
||||
|---|---|---|---|
|
||||
| `admin_ecommerce_brand_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_category_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_deposit_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_excel_download_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_main_banner_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_mileage_transaction_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_order_detail` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_order_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_order_list` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_payment_failure_history` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_personal_payment` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_personal_payment_create` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_personal_payment_detail` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_product_common_info_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_product_form` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_product_list` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_product_notice_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_product_review_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_promotion_coupon_form` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_promotion_coupon_list` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_promotion_discount_code_create` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_promotion_discount_code_index` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_settings` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_shipping_policy_form` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_ecommerce_shipping_policy_list` | `admin` | 화면 | `_admin_base` |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_editing_confirm` | `admin` | partial | - |
|
||||
| `_panel_brand_list` | `admin` | partial | - |
|
||||
| `_panel_detail` | `admin` | partial | - |
|
||||
| `_panel_form` | `admin` | partial | - |
|
||||
| `_panel_view` | `admin` | partial | - |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_image_preview` | `admin` | partial | - |
|
||||
| `_panel_category_list` | `admin` | partial | - |
|
||||
| `_panel_detail` | `admin` | partial | - |
|
||||
| `_panel_form` | `admin` | partial | - |
|
||||
| `_panel_view` | `admin` | partial | - |
|
||||
| `_modal_bulk_match` | `admin` | partial | - |
|
||||
| `_modal_manual_match` | `admin` | partial | - |
|
||||
| `_modal_process_history` | `admin` | partial | - |
|
||||
| `_modal_download_history` | `admin` | partial | - |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_edit_cancel` | `admin` | partial | - |
|
||||
| `_modal_status_change` | `admin` | partial | - |
|
||||
| `_partial_banner_detail` | `admin` | partial | - |
|
||||
| `_partial_banner_form` | `admin` | partial | - |
|
||||
| `_partial_banner_list` | `admin` | partial | - |
|
||||
| `_partial_preview_slider` | `admin` | partial | - |
|
||||
| `_filters` | `admin` | partial | - |
|
||||
| `_modal_edit_transaction` | `admin` | partial | - |
|
||||
| `_modal_extend_expiry` | `admin` | partial | - |
|
||||
| `_modal_manual_transaction` | `admin` | partial | - |
|
||||
| `_transactions_table` | `admin` | partial | - |
|
||||
| `_modal_batch_change_confirm` | `admin` | partial | - |
|
||||
| `_modal_cancel_order` | `admin` | partial | - |
|
||||
| `_modal_confirm_deposit` | `admin` | partial | - |
|
||||
| `_modal_issue_cash_receipt` | `admin` | partial | - |
|
||||
| `_modal_reset_guest_password` | `admin` | partial | - |
|
||||
| `_modal_send_email` | `admin` | partial | - |
|
||||
| `_modal_send_sms` | `admin` | partial | - |
|
||||
| `_partial_activity_log` | `admin` | partial | - |
|
||||
| `_partial_claim_history` | `admin` | partial | - |
|
||||
| `_partial_order_info` | `admin` | partial | - |
|
||||
| `_partial_payment_info` | `admin` | partial | - |
|
||||
| `_tab_claim_exchange` | `admin` | partial | - |
|
||||
| `_tab_claim_refund` | `admin` | partial | - |
|
||||
| `_tab_claim_return` | `admin` | partial | - |
|
||||
| `_modal_excel_download` | `admin` | partial | - |
|
||||
| `_modal_preset_manage` | `admin` | partial | - |
|
||||
| `_modal_preset_save` | `admin` | partial | - |
|
||||
| `_modal_bulk_confirm` | `admin` | partial | - |
|
||||
| `_modal_excel_download` | `admin` | partial | - |
|
||||
| `_modal_preset_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_preset_edit` | `admin` | partial | - |
|
||||
| `_modal_preset_manage` | `admin` | partial | - |
|
||||
| `_modal_preset_save` | `admin` | partial | - |
|
||||
| `_partial_bulk_action_section` | `admin` | partial | - |
|
||||
| `_partial_filter_section` | `admin` | partial | - |
|
||||
| `_partial_order_datagrid` | `admin` | partial | - |
|
||||
| `_partial_preset_section` | `admin` | partial | - |
|
||||
| `_modal_member_search` | `admin` | partial | - |
|
||||
| `_modal_order_search` | `admin` | partial | - |
|
||||
| `_modal_content_mode_change` | `admin` | partial | - |
|
||||
| `_modal_copy_confirm` | `admin` | partial | - |
|
||||
| `_modal_default_confirm` | `admin` | partial | - |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_editing_confirm` | `admin` | partial | - |
|
||||
| `_modal_set_default_confirm` | `admin` | partial | - |
|
||||
| `_panel_detail` | `admin` | partial | - |
|
||||
| `_panel_form` | `admin` | partial | - |
|
||||
| `_panel_list` | `admin` | partial | - |
|
||||
| `_panel_view` | `admin` | partial | - |
|
||||
| `_partial_common_info_detail` | `admin` | partial | - |
|
||||
| `_partial_common_info_form` | `admin` | partial | - |
|
||||
| `_modal_add_language` | `admin` | partial | - |
|
||||
| `_modal_additional_options_clear` | `admin` | partial | - |
|
||||
| `_modal_confirm_regenerate` | `admin` | partial | - |
|
||||
| `_modal_copy_product` | `admin` | partial | - |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_label_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_label_form` | `admin` | partial | - |
|
||||
| `_modal_label_uncheck_confirm` | `admin` | partial | - |
|
||||
| `_modal_multilingual_tag_edit` | `admin` | partial | - |
|
||||
| `_modal_notice_bulk_change` | `admin` | partial | - |
|
||||
| `_modal_notice_template_confirm` | `admin` | partial | - |
|
||||
| `_modal_save_template` | `admin` | partial | - |
|
||||
| `_partial_activity_log` | `admin` | partial | - |
|
||||
| `_partial_basic_info` | `admin` | partial | - |
|
||||
| `_partial_common_info` | `admin` | partial | - |
|
||||
| `_partial_description` | `admin` | partial | - |
|
||||
| `_partial_identification_codes` | `admin` | partial | - |
|
||||
| `_partial_image_upload` | `admin` | partial | - |
|
||||
| `_partial_other_info` | `admin` | partial | - |
|
||||
| `_partial_product_notice` | `admin` | partial | - |
|
||||
| `_partial_product_options` | `admin` | partial | - |
|
||||
| `_partial_sales_info` | `admin` | partial | - |
|
||||
| `_partial_seo_settings` | `admin` | partial | - |
|
||||
| `_partial_shipping` | `admin` | partial | - |
|
||||
| `_partial_shopping_integration` | `admin` | partial | - |
|
||||
| `_modal_bulk_confirm` | `admin` | partial | - |
|
||||
| `_modal_bulk_price` | `admin` | partial | - |
|
||||
| `_modal_bulk_stock` | `admin` | partial | - |
|
||||
| `_modal_copy_product` | `admin` | partial | - |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_excel_download` | `admin` | partial | - |
|
||||
| `_modal_preset_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_preset_edit` | `admin` | partial | - |
|
||||
| `_modal_preset_manage` | `admin` | partial | - |
|
||||
| `_modal_preset_save` | `admin` | partial | - |
|
||||
| `_partial_filter_section` | `admin` | partial | - |
|
||||
| `_partial_product_datagrid` | `admin` | partial | - |
|
||||
| `_modal_bulk_change` | `admin` | partial | - |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_editing_confirm` | `admin` | partial | - |
|
||||
| `_panel_detail` | `admin` | partial | - |
|
||||
| `_panel_form` | `admin` | partial | - |
|
||||
| `_panel_list` | `admin` | partial | - |
|
||||
| `_panel_view` | `admin` | partial | - |
|
||||
| `_modal_image_preview` | `admin` | partial | - |
|
||||
| `_modal_reply_delete` | `admin` | partial | - |
|
||||
| `_modal_status_change` | `admin` | partial | - |
|
||||
| `_partial_basic_info` | `admin` | partial | - |
|
||||
| `_partial_benefit_settings` | `admin` | partial | - |
|
||||
| `_partial_issue_settings` | `admin` | partial | - |
|
||||
| `_partial_usage_conditions` | `admin` | partial | - |
|
||||
| `_modal_cancel_issue_confirm` | `admin` | partial | - |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_direct_issue` | `admin` | partial | - |
|
||||
| `_modal_issue_history` | `admin` | partial | - |
|
||||
| `_modal_status_change_confirm` | `admin` | partial | - |
|
||||
| `_partial_coupon_datagrid` | `admin` | partial | - |
|
||||
| `_partial_filter_section` | `admin` | partial | - |
|
||||
| `_modal_delete_confirm` | `admin` | partial | - |
|
||||
| `_modal_status_change` | `admin` | partial | - |
|
||||
| `_bank_accounts_cards` | `admin` | partial | - |
|
||||
| `_bank_accounts_table` | `admin` | partial | - |
|
||||
| `_bank_management_modal` | `admin` | partial | - |
|
||||
| `_currency_exchange_cards` | `admin` | partial | - |
|
||||
| `_currency_exchange_table` | `admin` | partial | - |
|
||||
| `_disable_international_shipping_modal` | `admin` | partial | - |
|
||||
| `_modal_clear_seo_cache` | `admin` | partial | - |
|
||||
| `_modal_identity_policy_delete` | `admin` | partial | - |
|
||||
| `_modal_identity_policy_form` | `admin` | partial | - |
|
||||
| `_modal_mail_template_edit` | `admin` | partial | - |
|
||||
| `_modal_notification_definition_reset` | `admin` | partial | - |
|
||||
| `_modal_notification_template_edit` | `admin` | partial | - |
|
||||
| `_modal_notification_template_preview` | `admin` | partial | - |
|
||||
| `_payment_methods_cards` | `admin` | partial | - |
|
||||
| `_payment_methods_list` | `admin` | partial | - |
|
||||
| `_refund_reason_cards` | `admin` | partial | - |
|
||||
| `_refund_reason_section` | `admin` | partial | - |
|
||||
| `_shipping_carrier_cards` | `admin` | partial | - |
|
||||
| `_shipping_carrier_section` | `admin` | partial | - |
|
||||
| `_shipping_country_cards` | `admin` | partial | - |
|
||||
| `_shipping_country_table` | `admin` | partial | - |
|
||||
| `_shipping_type_cards` | `admin` | partial | - |
|
||||
| `_shipping_type_section` | `admin` | partial | - |
|
||||
| `_tab_basic_info` | `admin` | partial | - |
|
||||
| `_tab_claim` | `admin` | partial | - |
|
||||
| `_tab_identity_policies` | `admin` | partial | - |
|
||||
| `_tab_language_currency` | `admin` | partial | - |
|
||||
| `_tab_mileage` | `admin` | partial | - |
|
||||
| `_tab_mileage_basic_card` | `admin` | partial | - |
|
||||
| `_tab_mileage_currency_cards` | `admin` | partial | - |
|
||||
| `_tab_mileage_currency_table` | `admin` | partial | - |
|
||||
| `_tab_mileage_expiry_card` | `admin` | partial | - |
|
||||
| `_tab_mileage_notification_card` | `admin` | partial | - |
|
||||
| `_tab_notification_definitions` | `admin` | partial | - |
|
||||
| `_tab_order_settings` | `admin` | partial | - |
|
||||
| `_tab_review_settings` | `admin` | partial | - |
|
||||
| `_tab_seo` | `admin` | partial | - |
|
||||
| `_tab_shipping` | `admin` | partial | - |
|
||||
| `_modal_extra_fee_template` | `admin` | partial | - |
|
||||
| `_partial_basic_info` | `admin` | partial | - |
|
||||
| `_partial_charge_settings` | `admin` | partial | - |
|
||||
| `_partial_country_basic_fields` | `admin` | partial | - |
|
||||
| `_partial_country_tabs` | `admin` | partial | - |
|
||||
| `_partial_extra_fee` | `admin` | partial | - |
|
||||
| `_modal_bulk_delete` | `admin` | partial | - |
|
||||
| `_modal_bulk_toggle` | `admin` | partial | - |
|
||||
| `_modal_copy` | `admin` | partial | - |
|
||||
| `_modal_delete` | `admin` | partial | - |
|
||||
| `_modal_set_default` | `admin` | partial | - |
|
||||
| `_partial_bulk_actions` | `admin` | partial | - |
|
||||
| `_partial_datagrid` | `admin` | partial | - |
|
||||
| `_partial_filter` | `admin` | partial | - |
|
||||
<!-- @generated:layouts END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
206개가 **전부 `admin` 그룹**입니다(화면 25 + 부분 레이아웃 181). `resources/layouts/user/`
|
||||
디렉토리는 있지만 비어 있습니다 — 이 모듈이 방문자 쇼핑 화면을 소유하지 않는다는 설계가
|
||||
디렉토리 구조에 그대로 드러난 자리입니다. 상품 목록·상세·장바구니·주문서·마이페이지는
|
||||
템플릿(`sirsoft-basic`)의 레이아웃이며, 그 화면들은 이 모듈의 공개 API 와 아래 액션 핸들러를
|
||||
씁니다.
|
||||
|
||||
화면 25개에 부분 레이아웃 181개가 붙는 비율(1:7)은 화면이 크기 때문입니다. 상품 등록 폼·주문
|
||||
상세·환경설정처럼 탭이 여러 개인 화면은 탭마다 파일을 나눠 두었습니다
|
||||
(`partials/{화면이름}/_tab_*.json`). 화면 하나를 고칠 때는 그 화면 이름의 partials 디렉토리를
|
||||
함께 열어야 전체가 보입니다.
|
||||
|
||||
부분 레이아웃에서는 `{{props.*}}` 를 쓰지 않고 데이터소스 ID 를 직접 참조합니다. 그리고
|
||||
부모–자식 레이아웃 사이에 데이터소스 ID 가 겹치면 안 됩니다 — 겹치면 한쪽이 조용히 다른 쪽의
|
||||
응답을 덮어씁니다.
|
||||
|
||||
레이아웃 JSON 만 고쳤다면 빌드는 필요 없고 `php artisan module:update sirsoft-ecommerce --force`
|
||||
로 활성 디렉토리에 반영합니다. 다만 **새로 쓴 Tailwind 클래스가 빌드된 CSS 에 없으면** 그
|
||||
스타일만 조용히 빠지므로, 기존 레이아웃에 쓰이지 않던 클래스를 도입할 때는 확인이 필요합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 액션 핸들러
|
||||
|
||||
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
핸들러 160개 (정의: `resources/js/handlers/index.ts`).
|
||||
|
||||
| 핸들러 | 레이아웃에서 부르는 이름 |
|
||||
|---|---|
|
||||
| `updateProductField` | `sirsoft-ecommerce.updateProductField` |
|
||||
| `updateOptionField` | `sirsoft-ecommerce.updateOptionField` |
|
||||
| `calculateCurrencyPrices` | `sirsoft-ecommerce.calculateCurrencyPrices` |
|
||||
| `initPreferredCurrency` | `sirsoft-ecommerce.initPreferredCurrency` |
|
||||
| `initPreferredShippingCountry` | `sirsoft-ecommerce.initPreferredShippingCountry` |
|
||||
| `setDateRange` | `sirsoft-ecommerce.setDateRange` |
|
||||
| `setDefaultOption` | `sirsoft-ecommerce.setDefaultOption` |
|
||||
| `toggleOption` | `sirsoft-ecommerce.toggleOption` |
|
||||
| `toggleProductOptions` | `sirsoft-ecommerce.toggleProductOptions` |
|
||||
| `toggleAllOptionsInRow` | `sirsoft-ecommerce.toggleAllOptionsInRow` |
|
||||
| `getProductOptionStates` | `sirsoft-ecommerce.getProductOptionStates` |
|
||||
| `syncProductSelection` | `sirsoft-ecommerce.syncProductSelection` |
|
||||
| `loadExpandedOptions` | `sirsoft-ecommerce.loadExpandedOptions` |
|
||||
| `retryExpandedOptions` | `sirsoft-ecommerce.retryExpandedOptions` |
|
||||
| `generateCopyProductCode` | `sirsoft-ecommerce.generateCopyProductCode` |
|
||||
| `copyProduct` | `sirsoft-ecommerce.copyProduct` |
|
||||
| `selectCategory` | `sirsoft-ecommerce.selectCategory` |
|
||||
| `selectCategoryMobile` | `sirsoft-ecommerce.selectCategoryMobile` |
|
||||
| `addCategoryToSelection` | `sirsoft-ecommerce.addCategoryToSelection` |
|
||||
| `removeCategoryFromSelection` | `sirsoft-ecommerce.removeCategoryFromSelection` |
|
||||
| `getCategoryBreadcrumb` | `sirsoft-ecommerce.getCategoryBreadcrumb` |
|
||||
| `validateCategoryPath` | `sirsoft-ecommerce.validateCategoryPath` |
|
||||
| `initCategoryInfosFromProduct` | `sirsoft-ecommerce.initCategoryInfosFromProduct` |
|
||||
| `getBrandName` | `sirsoft-ecommerce.getBrandName` |
|
||||
| `getBrandDescription` | `sirsoft-ecommerce.getBrandDescription` |
|
||||
| `updatePrice` | `sirsoft-ecommerce.updatePrice` |
|
||||
| `calculateTotalOptionStock` | `sirsoft-ecommerce.calculateTotalOptionStock` |
|
||||
| `validatePriceRelation` | `sirsoft-ecommerce.validatePriceRelation` |
|
||||
| `addOptionInput` | `sirsoft-ecommerce.addOptionInput` |
|
||||
| `removeOptionInput` | `sirsoft-ecommerce.removeOptionInput` |
|
||||
| `updateOptionInput` | `sirsoft-ecommerce.updateOptionInput` |
|
||||
| `generateOptions` | `sirsoft-ecommerce.generateOptions` |
|
||||
| `deleteOption` | `sirsoft-ecommerce.deleteOption` |
|
||||
| `applyOptionAddTool` | `sirsoft-ecommerce.applyOptionAddTool` |
|
||||
| `addRequiredItem` | `sirsoft-ecommerce.addRequiredItem` |
|
||||
| `updateRequiredItem` | `sirsoft-ecommerce.updateRequiredItem` |
|
||||
| `removeRequiredItem` | `sirsoft-ecommerce.removeRequiredItem` |
|
||||
| `reorderRequiredItems` | `sirsoft-ecommerce.reorderRequiredItems` |
|
||||
| `addAdditionalOption` | `sirsoft-ecommerce.addAdditionalOption` |
|
||||
| `updateAdditionalOption` | `sirsoft-ecommerce.updateAdditionalOption` |
|
||||
| `removeAdditionalOption` | `sirsoft-ecommerce.removeAdditionalOption` |
|
||||
| `reorderAdditionalOptions` | `sirsoft-ecommerce.reorderAdditionalOptions` |
|
||||
| `clearAdditionalOptions` | `sirsoft-ecommerce.clearAdditionalOptions` |
|
||||
| `addAdditionalOptionValue` | `sirsoft-ecommerce.addAdditionalOptionValue` |
|
||||
| `updateAdditionalOptionValue` | `sirsoft-ecommerce.updateAdditionalOptionValue` |
|
||||
| `removeAdditionalOptionValue` | `sirsoft-ecommerce.removeAdditionalOptionValue` |
|
||||
| `uploadImages` | `sirsoft-ecommerce.uploadImages` |
|
||||
| `setThumbnail` | `sirsoft-ecommerce.setThumbnail` |
|
||||
| `reorderImages` | `sirsoft-ecommerce.reorderImages` |
|
||||
| `updateDescription` | `sirsoft-ecommerce.updateDescription` |
|
||||
| `confirmSelectNoticeTemplate` | `sirsoft-ecommerce.confirmSelectNoticeTemplate` |
|
||||
| `selectNoticeTemplate` | `sirsoft-ecommerce.selectNoticeTemplate` |
|
||||
| `updateNoticeItem` | `sirsoft-ecommerce.updateNoticeItem` |
|
||||
| `removeNoticeItem` | `sirsoft-ecommerce.removeNoticeItem` |
|
||||
| `reorderNoticeItems` | `sirsoft-ecommerce.reorderNoticeItems` |
|
||||
| `fillNoticeWithValue` | `sirsoft-ecommerce.fillNoticeWithValue` |
|
||||
| `switchNoticeMode` | `sirsoft-ecommerce.switchNoticeMode` |
|
||||
| `updateNewTemplateName` | `sirsoft-ecommerce.updateNewTemplateName` |
|
||||
| `addNoticeItem` | `sirsoft-ecommerce.addNoticeItem` |
|
||||
| `updateNoticeItemName` | `sirsoft-ecommerce.updateNoticeItemName` |
|
||||
| `saveAsNoticeTemplate` | `sirsoft-ecommerce.saveAsNoticeTemplate` |
|
||||
| `confirmSaveNoticeTemplate` | `sirsoft-ecommerce.confirmSaveNoticeTemplate` |
|
||||
| `fillTemplateFieldsWithDetailReference` | `sirsoft-ecommerce.fillTemplateFieldsWithDetailReference` |
|
||||
| `fillNoticeItemsWithDetailReference` | `sirsoft-ecommerce.fillNoticeItemsWithDetailReference` |
|
||||
| `toggleLabel` | `sirsoft-ecommerce.toggleLabel` |
|
||||
| `generateProductCode` | `sirsoft-ecommerce.generateProductCode` |
|
||||
| `getShippingPolicyInfo` | `sirsoft-ecommerce.getShippingPolicyInfo` |
|
||||
| `getCommonInfoContent` | `sirsoft-ecommerce.getCommonInfoContent` |
|
||||
| `updateShoppingIntegration` | `sirsoft-ecommerce.updateShoppingIntegration` |
|
||||
| `updateShippingType` | `sirsoft-ecommerce.updateShippingType` |
|
||||
| `updateIdentificationCode` | `sirsoft-ecommerce.updateIdentificationCode` |
|
||||
| `openLabelPeriodModal` | `sirsoft-ecommerce.openLabelPeriodModal` |
|
||||
| `saveLabelPeriod` | `sirsoft-ecommerce.saveLabelPeriod` |
|
||||
| `removeLabelPeriod` | `sirsoft-ecommerce.removeLabelPeriod` |
|
||||
| `updateActivityLogSort` | `sirsoft-ecommerce.updateActivityLogSort` |
|
||||
| `updateActivityLogPerPage` | `sirsoft-ecommerce.updateActivityLogPerPage` |
|
||||
| `setDefaultShippingPolicy` | `sirsoft-ecommerce.setDefaultShippingPolicy` |
|
||||
| `setLabelDatePreset` | `sirsoft-ecommerce.setLabelDatePreset` |
|
||||
| `toggleDefaultShippingPolicy` | `sirsoft-ecommerce.toggleDefaultShippingPolicy` |
|
||||
| `toggleLabelAssignment` | `sirsoft-ecommerce.toggleLabelAssignment` |
|
||||
| `saveLabelSettings` | `sirsoft-ecommerce.saveLabelSettings` |
|
||||
| `deleteLabel` | `sirsoft-ecommerce.deleteLabel` |
|
||||
| `updateLabelPeriodInline` | `sirsoft-ecommerce.updateLabelPeriodInline` |
|
||||
| `setLabelDatePresetInline` | `sirsoft-ecommerce.setLabelDatePresetInline` |
|
||||
| `confirmUncheckLabel` | `sirsoft-ecommerce.confirmUncheckLabel` |
|
||||
| `removeDescriptionLocale` | `sirsoft-ecommerce.removeDescriptionLocale` |
|
||||
| `showAddLocaleModal` | `sirsoft-ecommerce.showAddLocaleModal` |
|
||||
| `addDescriptionLocale` | `sirsoft-ecommerce.addDescriptionLocale` |
|
||||
| `setDefaultOptionFromGrid` | `sirsoft-ecommerce.setDefaultOptionFromGrid` |
|
||||
| `addOptionRow` | `sirsoft-ecommerce.addOptionRow` |
|
||||
| `updateFormOptionField` | `sirsoft-ecommerce.updateFormOptionField` |
|
||||
| `recalculateOptionPriceAdjustments` | `sirsoft-ecommerce.recalculateOptionPriceAdjustments` |
|
||||
| `bulkUpdate` | `sirsoft-ecommerce.bulkUpdate` |
|
||||
| `buildConfirmData` | `sirsoft-ecommerce.buildConfirmData` |
|
||||
| `buildOrderColumns` | `sirsoft-ecommerce.buildOrderColumns` |
|
||||
| `toggleArrayValue` | `sirsoft-ecommerce.toggleArrayValue` |
|
||||
| `toggleVisibleFilter` | `sirsoft-ecommerce.toggleVisibleFilter` |
|
||||
| `syncOrderSelection` | `sirsoft-ecommerce.syncOrderSelection` |
|
||||
| `handleOrderRowAction` | `sirsoft-ecommerce.handleOrderRowAction` |
|
||||
| `processOrderBulkAction` | `sirsoft-ecommerce.processOrderBulkAction` |
|
||||
| `buildOrderBulkConfirmData` | `sirsoft-ecommerce.buildOrderBulkConfirmData` |
|
||||
| `executeOrderBulkAction` | `sirsoft-ecommerce.executeOrderBulkAction` |
|
||||
| `downloadOrderExcel` | `sirsoft-ecommerce.downloadOrderExcel` |
|
||||
| `saveVisibleColumns` | `sirsoft-ecommerce.saveVisibleColumns` |
|
||||
| `loadVisibleColumns` | `sirsoft-ecommerce.loadVisibleColumns` |
|
||||
| `loadVisibleFilters` | `sirsoft-ecommerce.loadVisibleFilters` |
|
||||
| `handleProductRowAction` | `sirsoft-ecommerce.handleProductRowAction` |
|
||||
| `initOrderDetailForm` | `sirsoft-ecommerce.initOrderDetailForm` |
|
||||
| `toggleProductSelection` | `sirsoft-ecommerce.toggleProductSelection` |
|
||||
| `toggleAllProducts` | `sirsoft-ecommerce.toggleAllProducts` |
|
||||
| `buildOrderDetailBulkConfirmData` | `sirsoft-ecommerce.buildOrderDetailBulkConfirmData` |
|
||||
| `processOrderDetailBulkChange` | `sirsoft-ecommerce.processOrderDetailBulkChange` |
|
||||
| `saveAdminMemo` | `sirsoft-ecommerce.saveAdminMemo` |
|
||||
| `updateChangeQuantity` | `sirsoft-ecommerce.updateChangeQuantity` |
|
||||
| `openConfirmDepositModal` | `sirsoft-ecommerce.openConfirmDepositModal` |
|
||||
| `confirmDeposit` | `sirsoft-ecommerce.confirmDeposit` |
|
||||
| `initShippingPolicyForm` | `sirsoft-ecommerce.initShippingPolicyForm` |
|
||||
| `addCountrySetting` | `sirsoft-ecommerce.addCountrySetting` |
|
||||
| `removeCountrySetting` | `sirsoft-ecommerce.removeCountrySetting` |
|
||||
| `switchCountryTab` | `sirsoft-ecommerce.switchCountryTab` |
|
||||
| `updateCountryField` | `sirsoft-ecommerce.updateCountryField` |
|
||||
| `onChargePolicyChange` | `sirsoft-ecommerce.onChargePolicyChange` |
|
||||
| `addRangeTier` | `sirsoft-ecommerce.addRangeTier` |
|
||||
| `removeRangeTier` | `sirsoft-ecommerce.removeRangeTier` |
|
||||
| `updateRangeTierField` | `sirsoft-ecommerce.updateRangeTierField` |
|
||||
| `validateRangeTiers` | `sirsoft-ecommerce.validateRangeTiers` |
|
||||
| `addExtraFeeRow` | `sirsoft-ecommerce.addExtraFeeRow` |
|
||||
| `removeExtraFeeRow` | `sirsoft-ecommerce.removeExtraFeeRow` |
|
||||
| `applyExtraFeeTemplate` | `sirsoft-ecommerce.applyExtraFeeTemplate` |
|
||||
| `updateUnitValue` | `sirsoft-ecommerce.updateUnitValue` |
|
||||
| `addApiRequestField` | `sirsoft-ecommerce.addApiRequestField` |
|
||||
| `updateApiRequestField` | `sirsoft-ecommerce.updateApiRequestField` |
|
||||
| `removeApiRequestField` | `sirsoft-ecommerce.removeApiRequestField` |
|
||||
| `toggleApiRequestField` | `sirsoft-ecommerce.toggleApiRequestField` |
|
||||
| `updateApiConfigField` | `sirsoft-ecommerce.updateApiConfigField` |
|
||||
| `updateApiFieldMap` | `sirsoft-ecommerce.updateApiFieldMap` |
|
||||
| `testShippingApi` | `sirsoft-ecommerce.testShippingApi` |
|
||||
| `updateExtraFeeField` | `sirsoft-ecommerce.updateExtraFeeField` |
|
||||
| `updateCancelQuantity` | `sirsoft-ecommerce.updateCancelQuantity` |
|
||||
| `estimateRefundAmount` | `sirsoft-ecommerce.estimateRefundAmount` |
|
||||
| `changeRefundPriority` | `sirsoft-ecommerce.changeRefundPriority` |
|
||||
| `executeCancelOrder` | `sirsoft-ecommerce.executeCancelOrder` |
|
||||
| `clearCancelOrderTimers` | `sirsoft-ecommerce.clearCancelOrderTimers` |
|
||||
| `toggleItemSelection` | `sirsoft-ecommerce.toggleItemSelection` |
|
||||
| `toggleSelectAllItems` | `sirsoft-ecommerce.toggleSelectAllItems` |
|
||||
| `initUserCancelItems` | `sirsoft-ecommerce.initUserCancelItems` |
|
||||
| `toggleUserCancelItem` | `sirsoft-ecommerce.toggleUserCancelItem` |
|
||||
| `toggleUserCancelSelectAll` | `sirsoft-ecommerce.toggleUserCancelSelectAll` |
|
||||
| `updateUserCancelQuantity` | `sirsoft-ecommerce.updateUserCancelQuantity` |
|
||||
| `estimateUserRefund` | `sirsoft-ecommerce.estimateUserRefund` |
|
||||
| `changeUserRefundPriority` | `sirsoft-ecommerce.changeUserRefundPriority` |
|
||||
| `executeUserCancelOrder` | `sirsoft-ecommerce.executeUserCancelOrder` |
|
||||
| `clearUserCancelOrderTimers` | `sirsoft-ecommerce.clearUserCancelOrderTimers` |
|
||||
| `confirmOrderOption` | `sirsoft-ecommerce.confirmOrderOption` |
|
||||
| `changeShippingAddress` | `sirsoft-ecommerce.changeShippingAddress` |
|
||||
| `submitReview` | `sirsoft-ecommerce.submitReview` |
|
||||
| `initCategoryFromUrl` | `sirsoft-ecommerce.initCategoryFromUrl` |
|
||||
| `initBrandFromUrl` | `sirsoft-ecommerce.initBrandFromUrl` |
|
||||
| `initCommonInfoFromUrl` | `sirsoft-ecommerce.initCommonInfoFromUrl` |
|
||||
| `initNoticeFromUrl` | `sirsoft-ecommerce.initNoticeFromUrl` |
|
||||
<!-- @generated:handlers END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
핸들러 160개는 레이아웃 JSON 에서 `sirsoft-ecommerce.{이름}` 으로 부릅니다. 관리자 화면 전용이
|
||||
아니라 **템플릿의 방문자 화면도 이 핸들러를 씁니다** — `initUserCancelItems` ·
|
||||
`toggleUserCancelItem` · `estimateUserRefund` · `submitReview` · `changeShippingAddress` ·
|
||||
`confirmOrderOption` 처럼 `User`/`user` 가 붙은 것들이 그 무리입니다. 그래서 이 핸들러들의
|
||||
이름·시그니처는 템플릿과의 계약이며, 바꾸면 템플릿 화면이 조용히 무반응이 됩니다.
|
||||
|
||||
역할별로 네 무리입니다:
|
||||
|
||||
| 무리 | 예 | 하는 일 |
|
||||
|---|---|---|
|
||||
| 상품 편집 | `updateProductField` · `toggleOption` · `loadExpandedOptions` | 옵션이 많은 상품 폼의 부분 상태 갱신 |
|
||||
| 통화 | `calculateCurrencyPrices` · `initPreferredCurrency` | 표시 통화 전환과 통화별 가격 재계산 |
|
||||
| 취소·환불 | `updateCancelQuantity` · `estimateRefundAmount` · `changeRefundPriority` | 옵션 단위 취소 수량 조정과 예상 환불액 조회 (관리자·구매자 두 벌) |
|
||||
| 배송정책·주소 | `testShippingApi` · `updateExtraFeeField` · `changeShippingAddress` | 외부 배송비 API 시험 호출과 주소 변경 |
|
||||
|
||||
핸들러 TS 를 고치면 **빌드가 필요합니다** — `php artisan module:build` 후
|
||||
`module:update --force`. 커밋되는 `dist/` 는 배포 산출물이므로 `--production` 으로 굽고
|
||||
`sourceMappingURL` 이 남지 않아야 합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 전역 진입점
|
||||
|
||||
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 엔트리 파일 | `resources/js/index.ts` |
|
||||
| 전역 객체 | `window.__SirsoftEcommerce` |
|
||||
| 재등록 진입점 | `initModule()` |
|
||||
|
||||
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
|
||||
<!-- @generated:frontend-entry END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`window.__SirsoftEcommerce.initModule()` 이 재등록 진입점입니다. 로케일을 전환하면 코어가 이
|
||||
함수를 다시 불러 핸들러를 재등록하는데, **이 함수가 없거나 이름이 다르면 로케일 전환 직후
|
||||
이 모듈의 액션 160개가 전부 무반응이 됩니다** — 오류도 토스트도 없이 버튼만 동작하지 않습니다.
|
||||
|
||||
그래서 이 진입점은 **핸들러 재등록만** 수행합니다. 1회성 부팅 작업(초기 상태 시드·전역 이벤트
|
||||
구독 등)을 여기 넣으면 로케일을 바꿀 때마다 다시 실행되어 상태가 초기화되거나 리스너가
|
||||
중복 등록됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 에셋
|
||||
|
||||
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 구분 |
|
||||
|---|---|
|
||||
| `dist/css/module.css` | 빌드 산출물 (커밋 대상) |
|
||||
| `dist/js/module.iife.js` | 빌드 산출물 (커밋 대상) |
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
|
||||
|
||||
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
|
||||
<!-- @generated:assets END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
로딩 전략이 `global` 이라 이 모듈의 JS·CSS 는 **모든 페이지에서 로드**됩니다. 관리자 화면
|
||||
전용이 아니라 템플릿의 방문자 화면도 이 모듈의 핸들러를 쓰기 때문입니다. `priority: 100` 은
|
||||
확장 번들 안에서의 실행 순서로, 다른 확장이 이보다 먼저 나가야 한다면 그쪽이 더 작은 값을
|
||||
선언합니다 — 특정 확장 이름을 지목하는 분기를 두지 않는 것이 규칙입니다.
|
||||
|
||||
`dist/` 는 **커밋되는 배포 산출물**입니다. 소스(`resources/js/**`)를 고치면 `--production`
|
||||
으로 다시 굽고 그 결과를 함께 커밋합니다. 새 소스 리터럴이 `dist/` 에 없으면 stale 빌드이며,
|
||||
브라우저가 받는 것은 커밋된 `dist/` 이므로 소스만 고친 변경은 사이트에 반영되지 않습니다.
|
||||
|
||||
구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다. CDN 도달 실패는 예외도
|
||||
서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다. 자산 URL 을 문자열로 조립하지
|
||||
않고 `G7Core.asset.module` 을 쓰는 것도 같은 이유입니다 — 확장자를 정적 location 이 가로채는
|
||||
서버에서는 조립한 URL 만 404 가 됩니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,179 @@
|
||||
# 이커머스 — 설정·권한·라우트
|
||||
|
||||
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설정 스키마
|
||||
|
||||
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_`getSettingsSchema()` 선언이 없습니다._
|
||||
|
||||
기본값 파일: `config/settings/defaults.json`
|
||||
<!-- @generated:settings-schema END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`getSettingsSchema()` 선언이 없는 것은 누락이 아닙니다. 이 모듈의 설정은 코드가 아니라
|
||||
`config/settings/defaults.json` 이 SSoT 이며, 그 파일 하나가 세 가지를 함께 담습니다:
|
||||
|
||||
| 키 | 역할 |
|
||||
|---|---|
|
||||
| `_meta.categories` | 설정 그룹 9개의 목록과 순서 (`basic_info` · `language_currency` · `order_settings` · `shipping` · `seo` · `review_settings` · `inquiry` · `notifications` · `mileage`) |
|
||||
| `defaults` | 그룹별 기본값. 설치 시 `storage/app/settings/` 로 동기화되어 `module_setting()` 이 읽는 값이 됩니다 |
|
||||
| `frontend_schema` | 관리자 화면이 자동으로 그리는 입력 폼 정의 |
|
||||
|
||||
**`frontend_schema` 는 8개 그룹뿐이고 `mileage` 가 없습니다.** 자동 생성 폼으로는 표현할 수 없는
|
||||
입력(통화별 적립 규칙 표 등)이 있어서 마일리지 탭만 레이아웃 JSON 으로 직접 그리기 때문입니다
|
||||
(`resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage*.json` 6개). 새 그룹을
|
||||
추가할 때는 이 셋 중 어디까지 손댈지를 먼저 정합니다 — `_meta.categories` 에만 넣고 `defaults`
|
||||
를 빠뜨리면 그 그룹은 화면에 뜨지만 저장할 값이 없습니다.
|
||||
|
||||
설정 값을 코드에서 읽을 때는 `EcommerceSettingsService` 를 거칩니다. 통화 설정처럼 요청마다
|
||||
여러 번 읽히는 값은 `CurrencySettingsCache` 가 따로 캐시하며, 설정 저장 후 캐시를 비우는 것은
|
||||
`core.module_settings.after_save` 훅을 받는 리스너들입니다 — 설정을 직접 파일에서 읽으면 그
|
||||
무효화 경로를 타지 않아 화면과 서버가 서로 다른 값을 봅니다.
|
||||
|
||||
결제수단 목록(`order_settings.payment_methods`)만은 성격이 다릅니다. **저장값과 플러그인이
|
||||
등록한 카탈로그의 병합**이라, 플러그인을 삭제·비활성화하면 저장값은 남아 있는데 카탈로그에서
|
||||
사라지는 고아 항목이 생깁니다. 공개 응답은 고아 항목을 걸러 내보내고 관리자 응답은 그대로
|
||||
노출하는 것이 규칙입니다 — 운영자는 그것을 보고 지워야 하기 때문입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 권한
|
||||
|
||||
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 카테고리 | 이름 | 액션 | 라우트 키 |
|
||||
|---|---|---|---|
|
||||
| `products` | 상품 관리 | `read`, `create`, `update`, `delete` | `product` |
|
||||
| `orders` | 주문 관리 | `read`, `update` | `order` |
|
||||
| `categories` | 카테고리 관리 | `read`, `create`, `update`, `delete` | - |
|
||||
| `brands` | 브랜드 관리 | `read`, `create`, `update`, `delete` | `brand` |
|
||||
| `product-notice-templates` | 상품정보제공고시 관리 | `read`, `create`, `update`, `delete` | - |
|
||||
| `product-common-infos` | 공통정보 관리 | `read`, `create`, `update`, `delete` | - |
|
||||
| `settings` | 환경설정 | `read`, `update` | - |
|
||||
| `promotion-coupon` | 쿠폰 관리 | `read`, `create`, `update`, `delete` | `coupon` |
|
||||
| `shipping-policies` | 배송정책 관리 | `read`, `create`, `update`, `delete` | `shippingPolicy` |
|
||||
| `product-labels` | 상품 라벨 관리 | `read`, `create`, `update`, `delete` | - |
|
||||
| `identity.policies` | 이커머스 본인인증 정책 | `read`, `update` | - |
|
||||
| `reviews` | 리뷰 관리 | `read`, `update`, `delete` | `review` |
|
||||
| `inquiries` | 문의 관리 | `update`, `delete` | - |
|
||||
| `dashboard` | 대시보드 | `view` | - |
|
||||
| `user-products` | 사용자 상품 | `read` | - |
|
||||
| `user-orders` | 사용자 주문 | `create`, `cancel`, `confirm` | - |
|
||||
| `user-reviews` | 사용자 리뷰 | `write` | - |
|
||||
| `mileage` | 마일리지 관리 | `read`, `manage` | `mileage-transaction` |
|
||||
| `user-currency` | 회원 결제 통화 관리 | `manage` | - |
|
||||
| `user-shipping-country` | 회원 배송국가 관리 | `manage` | - |
|
||||
<!-- @generated:permissions END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
권한 20종은 세 무리로 갈립니다.
|
||||
|
||||
- **관리자 CRUD 12종** (`products` · `orders` · `categories` · `brands` ·
|
||||
`product-notice-templates` · `product-common-infos` · `promotion-coupon` ·
|
||||
`shipping-policies` · `product-labels` · `reviews` · `inquiries` · `mileage`): 관리자 화면과
|
||||
1:1 대응하며, 라우트 키가 있는 것은 그 라우트에 스코프 미들웨어가 걸립니다.
|
||||
- **사용자 측 5종** (`user-products` · `user-orders` · `user-reviews` · `user-currency` ·
|
||||
`user-shipping-country`): 구매자가 자기 자원에 대해 갖는 권한입니다. 관리자 권한과 이름이
|
||||
겹치지 않도록 `user-` 접두사를 씁니다.
|
||||
- **횡단 3종** (`settings` · `dashboard` · `identity.policies`): 화면 하나에 대응합니다.
|
||||
|
||||
`orders` 에 `create`/`delete` 가 없는 것은 의도입니다 — 주문은 구매자 결제로 생기고
|
||||
(`user-orders.create`), 삭제 대신 취소·환불로 처리합니다. `inquiries` 에 `read` 가 없는 것도
|
||||
같은 성격입니다: 문의 **본문은 게시판 모듈이 소유**하므로 읽기 권한은 그 게시판의 권한이
|
||||
정하고, 이 모듈은 답변·삭제만 관장합니다.
|
||||
|
||||
새 관리자 화면을 추가하면 권한·메뉴·라우트 이름 셋이 서로를 정확히 가리키는지 확인합니다.
|
||||
권한만 추가하고 메뉴를 빠뜨리면 화면에 도달할 길이 없고, 반대면 눌러도 403 입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 메뉴
|
||||
|
||||
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 구분 | slug | 이름 | URL | 하위 |
|
||||
|---|---|---|---|---|
|
||||
| 관리자 | `sirsoft-ecommerce` | 이커머스 | - | 11개 |
|
||||
<!-- @generated:menus END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
최상위 `sirsoft-ecommerce` 아래 11개 하위 메뉴가 붙습니다 — 환경설정 · 상품 · 카테고리 ·
|
||||
브랜드 · 상품정보제공고시 · 공통정보 · 주문 · 쿠폰 · 배송정책 · 리뷰 · 마일리지 내역.
|
||||
|
||||
메뉴는 **권한과 짝을 이룰 때만 보입니다.** 운영자에게 역할이 부여되어도 그 역할에 해당 권한이
|
||||
없으면 메뉴가 렌더되지 않으므로, 새 화면을 추가할 때는 `getPermissions()` 와 `getAdminMenus()`
|
||||
를 함께 바꿉니다.
|
||||
|
||||
권한 표에는 있는데 메뉴가 없는 것들(`dashboard` · `identity.policies` · `product-labels` 등)은
|
||||
독립 메뉴가 아니라 다른 화면 안에 들어 있기 때문입니다 — 대시보드는 코어 관리자 첫 화면에
|
||||
레이아웃 조각으로 주입되고, 본인인증 정책은 코어 IDV 설정 화면에서 함께 다뤄지며, 상품 라벨은
|
||||
상품 관리 화면 안에 있습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 라우트
|
||||
|
||||
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 파일 | URL prefix |
|
||||
|---|---|---|
|
||||
| `api` | `src/routes/api.php` | `/api/modules/sirsoft-ecommerce/...` |
|
||||
|
||||
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
|
||||
<!-- @generated:routes END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
라우트 239개가 파일 하나(`src/routes/api.php`)에 모여 있고 전부 `/api/modules/sirsoft-ecommerce/`
|
||||
아래로 나갑니다. 화면용 라우트는 없습니다 — 관리자 화면은 레이아웃 JSON 이 이 API 를 호출해
|
||||
그리고, 방문자 화면은 템플릿이 같은 API 를 씁니다.
|
||||
|
||||
대상별로 셋으로 갈립니다:
|
||||
|
||||
| 무리 | 인증 | 비고 |
|
||||
|---|---|---|
|
||||
| `admin.*` | 관리자 인증 + 권한 스코프 | 라우트 키가 선언된 권한이 여기에 걸립니다 |
|
||||
| `user.*` · 공개 조회 | Sanctum(일부는 `optional.sanctum`) | 비로그인도 상품·카테고리는 봅니다 |
|
||||
| `guest.orders.*` | `VerifyGuestOrderToken` | 비회원 주문 조회·취소. 토큰이 곧 신원이므로 **새 라우트를 추가하면 미들웨어 선언(`getMiddleware()`)에도 그 이름을 반드시 추가**합니다 |
|
||||
|
||||
라우트를 추가·변경한 뒤에는 라우트 캐시를 다시 구워야 합니다. 확장 라우트는 **활성 상태인
|
||||
확장의 것만** 등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 그대로 404 가 됩니다.
|
||||
|
||||
모든 라우트에 `name()` 이 필요합니다 — 이름이 없으면 미들웨어 self-gate 의 `targets` 패턴과
|
||||
IDV 정책의 라우트명 인덱스가 그 라우트를 찾지 못해, 보호가 걸린 것처럼 보이지만 실제로는
|
||||
통과합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 의존 관계
|
||||
|
||||
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
없음 — 코어만으로 동작합니다.
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
| 확장 | 유형 | 요구 버전 |
|
||||
|---|---|---|
|
||||
| `sirsoft-pay_kginicis` | 플러그인 | `>=1.1.0` |
|
||||
| `sirsoft-pay_nhnkcp` | 플러그인 | `>=1.1.0` |
|
||||
| `sirsoft-pay_nicepayments` | 플러그인 | `>=1.1.0` |
|
||||
| `sirsoft-tosspayments` | 플러그인 | `>=1.1.0` |
|
||||
| `sirsoft-basic` | 템플릿 | `>=1.1.0` |
|
||||
<!-- @generated:dependencies END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이 모듈은 **아무 확장에도 의존하지 않습니다.** 코어만 있으면 동작하며, 관계는 전부 한 방향으로
|
||||
들어옵니다 — 결제 플러그인 4종과 템플릿 `sirsoft-basic` 이 이 모듈을 요구합니다.
|
||||
|
||||
그 방향이 뒤집히지 않게 유지하는 것이 이 모듈 설계의 핵심입니다. PG 이름을 이 모듈 코드에 넣는
|
||||
순간 의존이 양방향이 되고, 새 PG 를 붙일 때마다 이 모듈을 고쳐야 합니다.
|
||||
|
||||
manifest 에는 없지만 **실제로 맞물리는 확장이 둘 더** 있습니다:
|
||||
|
||||
| 확장 | 무엇으로 연결되는가 | 없으면 |
|
||||
|---|---|---|
|
||||
| `sirsoft-board` | 훅 3종 구독 (문의 글 삭제·복원·일괄삭제 시 피벗 정리) + 설정 `inquiry.board_slug` | 상품 문의 기능만 비고 나머지는 정상 |
|
||||
| `sirsoft-ckeditor5` | 훅 1종 (편집기가 참조할 이커머스 리소스 목록 제공) | 편집기에서 상품 링크를 고를 수 없을 뿐 |
|
||||
|
||||
훅 구독은 상대가 없으면 발화하지 않으므로 이 둘을 manifest 의존으로 올리지 않는 것이 맞습니다.
|
||||
다만 그 대가로 **상대 확장이 훅 이름을 바꾸면 예외 없이 조용히 연동이 끊깁니다** — 상대의
|
||||
`docs/extension-points.md` 를 함께 확인해야 하는 이유입니다.
|
||||
|
||||
이 모듈의 공개 표면(Service·Repository·Contracts·라우트·발행 훅)을 바꿀 때는 위 4+1 개 확장의
|
||||
`dependencies` 최소 버전 상향이 필요한지 검토합니다.
|
||||
<!-- @intent END -->
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"moduleId": "sirsoft-ecommerce",
|
||||
"version": "1.0.0",
|
||||
"description": "레이아웃 편집기 스펙 — 이커머스 모듈 도메인 sampleData/sampleGlobal/states. admin 레이아웃 data_source ID 전수 스캔 기반 도메인 ID 28종(상품·주문·브랜드·쿠폰·배송정책·정산·설정 등) byDataSourceId + 사용자 페이지(템플릿 렌더) byEndpointPattern. 공용 인프라(roles/availableChannels/identityProviders/ecommerceIdentity*/ecommerceNotificationDefinitions)는 admin 템플릿 스펙·코어 프리셋 폴백이 커버.",
|
||||
"description": "이커머스 모듈 레이아웃 편집기 스펙 — 관리자·사용자 상점 화면의 프리뷰 샘플과 페이지 상태. 공용 인프라(roles·availableChannels·identityProviders 등)는 관리자 템플릿 스펙과 코어 기본값이 커버한다.",
|
||||
"actionRecipes": {
|
||||
"comment": "이커머스 모듈 소유 친화 액션 레시피. 코어 시드 위에 module 단계로 병합(__source:{kind:module,id:sirsoft-ecommerce}, 편집기 〔이커머스〕 배지). 라벨은 모듈 격리 네임스페이스($t:sirsoft-ecommerce.editor.action.*) — 편집기 t()가 모듈 lang 통짜 ko.json/en.json 의 editor.action 을 sirsoft-ecommerce.editor.action 으로 해석한다(코어/템플릿 editor 와 격리). 결제(PG) 진입은 커머스 도메인이라 코어가 아닌 모듈이 소유한다(provider-agnostic — 핸들러명을 백엔드 응답값으로 받음).",
|
||||
"requestPgPayment": {
|
||||
|
||||
@@ -23,7 +23,7 @@ modules/_bundled/sirsoft-ecommerce/
|
||||
## 데이터 생성 위치 분리 (CRITICAL)
|
||||
|
||||
E2E spec 이 "특정 유저 + 특정 역할 + 특정 도메인 상황" 에서 동작하려면 백엔드 데이터 생성 코드가
|
||||
필요하다. **그 코드는 데이터를 소유한 영역에 위치해야 한다** — 기존 G7 의 Seeder/Factory 분리 원칙과 동일.
|
||||
필요하다. **그 코드는 데이터를 소유한 영역에 위치해야 한다** — 기존 그누보드7 의 Seeder/Factory 분리 원칙과 동일.
|
||||
|
||||
| 데이터 종류 | 위치 |
|
||||
|---|---|
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# 페이지 — 에이전트 가이드
|
||||
|
||||
> 이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 유형: 모듈 (sirsoft-page) — 고정 주소를 갖는 단일 문서(회사소개·약관 등). slug 가 주소이고 모든 수정이 버전으로 쌓인다. 관리자 CRUD + 공개 조회 API 만 소유
|
||||
2. 확장 방식: 발행 훅 21개(`page` 14 · `attachment` 7 의 before/filter/after 3단). 본문 썸네일 추출은 `page.filter_content_thumbnail`, 색인 조건은 `search.page.index_should_update`
|
||||
3. 건드리면 안 되는 것: 버전 스냅샷을 남기지 않는 저장 경로, 현재 행 덮어쓰기식 버전 복원, 첨부 `preview`/`download` 중 한쪽만 거는 발행 게이트, 소프트 삭제 재도입
|
||||
4. 작업 위치: `modules/_bundled/sirsoft-page` — 활성 디렉토리 직접 수정 금지
|
||||
5. 반영: `php artisan module:update sirsoft-page --force`
|
||||
```
|
||||
|
||||
## 1. 이 확장은 무엇인가
|
||||
|
||||
<!-- @intent START -->
|
||||
회사소개·이용약관·개인정보처리방침처럼 **고정된 주소를 갖는 단일 문서**를 관리하는 모듈입니다.
|
||||
게시판이 "여러 글이 목록을 이루는 것"이라면 이 모듈은 "글 하나가 곧 하나의 주소"이며, 그
|
||||
차이가 설계의 대부분을 설명합니다 — 목록·댓글·신고·카테고리가 없고 대신 **slug 와 버전 이력**이
|
||||
있습니다.
|
||||
|
||||
**소유 범위는 관리자 CRUD + 공개 조회 API 까지입니다.** 레이아웃 3개가 전부 관리자 화면이며,
|
||||
방문자가 보는 페이지 화면은 템플릿(`sirsoft-basic`)이 `GET /pages/{slug}` 를 호출해 그립니다.
|
||||
|
||||
**설계 원칙 셋**:
|
||||
|
||||
1. **모든 수정이 버전을 남긴다.** 저장할 때마다 `page_versions` 에 스냅샷이 쌓이고
|
||||
`current_version` 이 올라갑니다. 과거 버전으로 되돌리는 것도 **덮어쓰기가 아니라 새 버전
|
||||
생성**입니다(복원 후 `current_version` 이 또 1 증가) — 되돌린 사실 자체가 이력에 남아야
|
||||
하기 때문입니다.
|
||||
2. **소프트 삭제를 쓰지 않는다.** 초기 스키마에는 있었지만 마이그레이션 두 개
|
||||
(`2026_06_29_*`)로 걷어냈습니다. slug 가 주소이므로, 지운 페이지가 보이지 않게 남아 있으면
|
||||
같은 slug 를 다시 쓸 수 없습니다. 되돌리기의 책임은 삭제가 아니라 버전 이력이 집니다.
|
||||
3. **검색·SEO 는 코어에 붙는다.** 이 모듈은 자기 검색 화면을 만들지 않고 코어 통합 검색
|
||||
(`core.search.*` 훅 3종)에 페이지를 얹으며, 봇 화면 캐시도 코어 SEO 가 관리합니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: 관리자 설정 화면(첨부 제한은 파일 설정이며 UI 가 없습니다)·
|
||||
알림·브로드캐스트·미들웨어·레이아웃 확장. 이 모듈은 다른 확장 화면에 무엇도 주입하지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 2. 디렉토리 지도
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
|
||||
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
|
||||
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
|
||||
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
|
||||
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
|
||||
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
|
||||
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-page --force` (빌드 불필요) |
|
||||
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan module:update sirsoft-page --force` |
|
||||
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-page --force` |
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-page --force` |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
|
||||
## 3. 핵심 흐름
|
||||
|
||||
<!-- @intent START -->
|
||||
**페이지 저장 → 버전 적재**: `Admin\PageController` → `Store`/`UpdatePageRequest`(slug 유일성·
|
||||
다국어 제목) → `PageService::create()`/`update()`(`before_*` → `filter_*_data` → `after_*`) →
|
||||
`PageRepository` 로 `pages` 갱신 + **같은 트랜잭션에서 `page_versions` 스냅샷 적재 +
|
||||
`current_version` 증가**. 이후는 리스너 레인입니다 — `PageActivityLogListener`(활동 로그) ·
|
||||
`SeoPageCacheListener`(봇 화면 캐시 무효화)가 `after_*` 를 받아 처리합니다.
|
||||
|
||||
**버전 복원**: `POST /admin/pages/{page}/versions/{versionId}/restore` →
|
||||
`PageService::restoreVersion()` → 그 버전의 `title`/`content`/`content_mode`/`seo_meta` 를
|
||||
현재 페이지에 쓰고 **`current_version` 을 다시 +1** 한 뒤 스냅샷을 한 번 더 남깁니다. 그래서
|
||||
"3번 버전으로 되돌림"은 3번이 되는 것이 아니라 3번의 내용을 담은 5번이 생기는 것입니다.
|
||||
|
||||
**첨부 업로드 → 공개 서빙**: `Admin\PageAttachmentController` → `UploadPageAttachmentRequest`
|
||||
(설정 `attachment.max_size_mb` · `allowed_types`) → `PageAttachmentService`
|
||||
(`before_upload` → `filter_upload_file` → `after_upload`, 개수 상한 `attachment.max_count`
|
||||
초과 시 `AttachmentLimitExceededException`) → 저장. 공개 서빙은 `PublicPageAttachmentController`
|
||||
가 **해시**로 받습니다(`/pages/attachment/{hash}`, `/preview`) — 순번 ID 를 노출하지 않기
|
||||
위한 선택이며, 그래서 이 두 경로는 각각 발행 상태 게이트를 **자기 자리에서** 확인해야 합니다.
|
||||
|
||||
**공개 조회**: `PublicPageController::show(slug)` — `optional.sanctum` 이라 비로그인도
|
||||
접근하며, 미발행 페이지는 **읽기 권한을 가진 운영자에게만 미리보기로** 열리고 그 외에는
|
||||
404 입니다. 첨부 서빙 두 경로도 같은 판정을 각자 재적용합니다. 목록 API 는 없습니다(페이지는
|
||||
목록을 이루지 않습니다). 통합 검색 결과에 페이지가 섞이는 것은 `SearchPagesListener` 가 코어
|
||||
검색 훅에 응답을 얹기 때문입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 4. 확장점
|
||||
|
||||
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 확장점 | 수 | 상세 |
|
||||
|---|---|---|
|
||||
| 발행 훅 | 21개 | [발행 훅](docs/extension-points.md#발행-훅) |
|
||||
| 구독 훅 | 17개 | [구독 훅](docs/extension-points.md#구독-훅) |
|
||||
| 훅 리스너 | 5개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
|
||||
| 레이아웃 확장 | 0개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
|
||||
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
|
||||
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
|
||||
| 스케줄 | 1개 | [스케줄](docs/extension-points.md#스케줄) |
|
||||
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
|
||||
<!-- @generated:extension-points-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
발행 훅 21종은 두 도메인(`page` 14 · `attachment` 7)의 3단 패턴
|
||||
(`before_*` → `filter_*_data` → `after_*`)이 거의 전부입니다. 그 밖의 것 셋만 성격이 다릅니다:
|
||||
|
||||
| 훅 | 무엇을 열어 주는가 |
|
||||
|---|---|
|
||||
| `page.filter_content_thumbnail` | 본문에서 대표 이미지를 뽑는 규칙. 본문 형식이 특이한 사이트가 자기 방식으로 바꿀 수 있습니다 |
|
||||
| `search.page.index_should_update` | 어떤 변경에 검색 색인을 다시 태울지. 색인 비용이 큰 설치가 조건을 좁히는 자리입니다 |
|
||||
| `attachment.filter_upload_file` | 업로드 파일을 저장 전에 가공(리사이즈·변환) |
|
||||
|
||||
**구독 방향이 이 모듈의 성격을 더 잘 보여줍니다.** 17개 구독 중 12개는 자기 훅이고, 나머지
|
||||
5개가 바깥을 향합니다 — 코어 검색 3종(`core.search.results` · `build_response` ·
|
||||
`index_validation_rules`)에 페이지 결과를 얹고, 코어 활동 로그 1종에 설명 변수를 제공하며,
|
||||
`sirsoft-ckeditor5.image.filter_reference_sources` 로 편집기가 고를 수 있는 이미지 출처에
|
||||
페이지 첨부를 더합니다.
|
||||
|
||||
이 셋은 전부 **상대가 없으면 발화하지 않을 뿐**이라 manifest 의존에 없습니다. 대신 상대가 훅
|
||||
이름을 바꾸면 예외 없이 조용히 끊기므로, 코어 검색이나 ckeditor5 를 손댈 때는 이 구독이 함께
|
||||
확인 대상입니다.
|
||||
|
||||
미들웨어·브로드캐스트 채널·알림·레이아웃 확장은 0개입니다. 이 모듈은 다른 화면에 개입하지
|
||||
않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 5. 수정 시 동반 의무
|
||||
|
||||
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-page --force` 로 반영
|
||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
|
||||
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
|
||||
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
|
||||
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-page` 재실행 + `docs/api/**` 갱신
|
||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
|
||||
- [ ] 페이지 저장 경로를 추가·변경했다면 버전 스냅샷 적재와 `current_version` 증가가 같은 트랜잭션에 있는지 확인
|
||||
- [ ] 첨부를 내보내는 경로를 추가하면 발행 상태 게이트를 그 자리에서 재적용 (부모에서 한 번 판정하고 끝나지 않는다)
|
||||
- [ ] 코어 검색·SEO·ckeditor5 의 훅 이름이 바뀌면 이 모듈의 구독 5종이 조용히 끊기므로 함께 확인
|
||||
- [ ] 첨부 제한(`attachment.*`)은 `config/settings/defaults.json` 이 SSoT — 서비스에서 리터럴로 재클램프하지 않는다
|
||||
- [ ] 활동 로그 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨·description 과 번들 ja 팩까지 동반
|
||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan module:update sirsoft-page --force`
|
||||
|
||||
## 6. 금지 패턴
|
||||
|
||||
<!-- @intent START -->
|
||||
| 금지 | 올바른 사용 | 이유 |
|
||||
|---|---|---|
|
||||
| 페이지를 저장하면서 버전 스냅샷 적재를 건너뛰기 | `PageService` 의 저장 경로를 거친다 (스냅샷 + `current_version` 증가가 같은 트랜잭션) | 버전이 빠진 수정은 되돌릴 수 없다. 소프트 삭제를 걷어낸 뒤로 **되돌리기 수단이 버전 이력뿐**이다 |
|
||||
| 버전 복원을 현재 행 덮어쓰기로 구현 | 복원도 새 버전을 만든다 (`current_version` +1 후 스냅샷) | 되돌린 사실이 이력에서 사라지면 "누가 언제 무엇으로 되돌렸는가"를 추적할 수 없다 |
|
||||
| 첨부 공개 서빙(`download`)에만 발행 상태를 확인하고 `preview` 는 그대로 노출 | 두 경로 모두 같은 게이트를 재적용 | 한쪽만 막으면 같은 파일이 형제 엔드포인트로 새어나간다 |
|
||||
| 첨부 URL 을 순번 ID 로 조립 | 해시 경로(`/pages/attachment/{hash}`) | ID 노출은 다른 페이지의 첨부를 훑을 수 있는 열쇠가 된다 |
|
||||
| 소프트 삭제를 다시 도입 | 삭제는 실삭제, 되돌리기는 버전 이력 | 지운 페이지가 남아 있으면 같은 slug 를 다시 쓸 수 없고, slug 는 이 도메인에서 주소 그 자체다 |
|
||||
| 첨부 개수·용량 상한을 서비스에 리터럴로 재클램프 | 설정(`attachment.*`) 을 읽고 검증은 FormRequest 에 둔다 | 이중 클램프가 생기면 설정을 올려도 반영되지 않는다 |
|
||||
| 페이지 목록을 만들기 위해 공개 목록 API 를 추가 | 목록이 필요하면 게시판 모듈을 쓴다 | 페이지는 "주소 하나 = 문서 하나" 도메인이다. 목록을 들이면 게시판과 역할이 겹치면서 둘 다 애매해진다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 7. 테스트 실행
|
||||
|
||||
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 30개 | `modules/_bundled/sirsoft-page/tests` |
|
||||
| Vitest | 4개 | `vitest.config.ts` |
|
||||
| Playwright | 7개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 9개 | `tests/scenarios` |
|
||||
|
||||
기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
||||
|
||||
```bash
|
||||
# PHPUnit (변경 범위만) (Bash)
|
||||
php vendor/bin/phpunit modules/_bundled/sirsoft-page/tests --filter='<대상클래스>'
|
||||
|
||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||
cd modules/_bundled/sirsoft-page && powershell -Command "npm run test:run -- <대상>"
|
||||
|
||||
# Playwright E2E (Bash)
|
||||
npx playwright test modules/_bundled/sirsoft-page/tests/Playwright/specs/<대상>.spec.ts
|
||||
|
||||
```
|
||||
|
||||
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
|
||||
<!-- @generated:test-commands END -->
|
||||
|
||||
## 8. 문서 목차
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
@@ -4,6 +4,14 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.1.1] - 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||
|
||||
## [1.1.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# 페이지
|
||||
|
||||
**그누보드7 모듈 · sirsoft-page**
|
||||
정적 페이지(정보/정책/안내) 관리 모듈
|
||||
|
||||
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-1.1.1-0066FF?style=flat-square" alt="version 1.1.1">
|
||||
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.10-1F883D?style=flat-square" alt="그누보드7 >=7.0.10">
|
||||
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
|
||||
</p>
|
||||
<!-- @generated:badges END -->
|
||||
|
||||
---
|
||||
|
||||
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
|
||||
|
||||
---
|
||||
|
||||
## 소개
|
||||
|
||||
<!-- @intent START -->
|
||||
회사소개·이용약관·개인정보처리방침처럼 **주소가 고정된 문서 한 장**을 만들고 관리하는
|
||||
모듈입니다. 관리자 화면에서 주소(slug)와 내용을 정해 저장하면 `/{slug}` 로 공개됩니다.
|
||||
|
||||
게시판과 헷갈리기 쉬운데 역할이 다릅니다. 게시판은 여러 글이 목록을 이루고 댓글·검색·신고가
|
||||
따라오지만, 페이지는 **글 하나가 곧 주소 하나**입니다. 목록도 댓글도 없고, 대신 수정할 때마다
|
||||
이전 내용이 자동으로 보관되어 언제든 되돌릴 수 있습니다.
|
||||
|
||||
방문자가 보는 페이지 화면은 템플릿(`sirsoft-basic`)이 그립니다. 이 모듈은 내용을 관리하고
|
||||
넘겨주는 역할까지 맡습니다.
|
||||
|
||||
의도적으로 두지 않은 것: 관리자 환경설정 화면(첨부 제한은 설정 파일에서 조정합니다)·알림·
|
||||
페이지 목록 API. 여러 글을 목록으로 보여줘야 한다면 게시판 모듈이 맞는 선택입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 주요 기능
|
||||
|
||||
<!-- @intent START -->
|
||||
| 영역 | 설명 |
|
||||
|---|---|
|
||||
| 페이지 관리 | 주소(slug)·제목·본문 작성, 다국어 제목, 발행/미발행 전환, 여러 페이지 한 번에 발행 |
|
||||
| 버전 이력 | 저장할 때마다 자동 스냅샷, 이전 버전 내용 확인과 되돌리기 |
|
||||
| 첨부파일 | 파일 업로드·순서 변경·삭제, 개수/용량/형식 제한, 공개 내려받기와 미리보기 |
|
||||
| 미리보기 | 아직 발행하지 않은 페이지를 운영자만 실제 화면으로 확인 |
|
||||
| 검색 노출 | 사이트 통합 검색 결과에 페이지가 함께 나옴 |
|
||||
| SEO | 페이지별 메타 정보 설정, 내용이 바뀌면 검색엔진용 화면 캐시 자동 갱신 |
|
||||
| 본문 대표 이미지 | 본문에서 첫 이미지를 자동으로 뽑아 목록·공유 미리보기에 사용 |
|
||||
| 편집기 연동 | 편집기에서 이미지를 고를 때 페이지 첨부를 함께 제시 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 동작 방식
|
||||
|
||||
<!-- @intent START -->
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[운영자] -->|작성·수정| ADM[페이지 관리]
|
||||
ADM --> SAVE[저장]
|
||||
SAVE --> PAGE[(현재 내용)]
|
||||
SAVE --> VER[(버전 이력)]
|
||||
VER -.되돌리기.-> SAVE
|
||||
V[방문자] -->|/slug 접속| T[템플릿 화면]
|
||||
T --> PAGE
|
||||
```
|
||||
|
||||
저장할 때마다 현재 내용과 버전 이력이 함께 갱신됩니다. 되돌리기도 "예전으로 덮어쓰기"가 아니라
|
||||
**그 내용으로 다시 한 번 저장**하는 것이라, 되돌린 사실 자체가 이력에 남습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 요구 사항
|
||||
|
||||
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 그누보드7 코어 | `>=7.0.10` |
|
||||
| PHP | `^8.2` |
|
||||
<!-- @generated:requirements END -->
|
||||
|
||||
## 설치
|
||||
|
||||
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
```bash
|
||||
# 번들 설치 (코어에 동봉된 소스에서 설치)
|
||||
php artisan module:install sirsoft-page
|
||||
|
||||
# 활성화
|
||||
php artisan module:activate sirsoft-page
|
||||
|
||||
# 업데이트 (번들 소스 기준 강제 반영)
|
||||
php artisan module:update sirsoft-page --force
|
||||
```
|
||||
|
||||
저장소: https://github.com/gnuboard/g7-module-sirsoft-page
|
||||
<!-- @generated:install END -->
|
||||
|
||||
## 관리자 설정
|
||||
|
||||
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_별도의 관리자 설정 항목이 없습니다._
|
||||
<!-- @generated:settings-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
위 표가 비어 있는 것은 이 모듈에 **관리자 환경설정 화면이 없기** 때문입니다. 조정할 수 있는
|
||||
값은 첨부 제한 셋뿐이고, 설정 파일(`config/settings/defaults.json`)에 들어 있습니다.
|
||||
|
||||
| 항목 | 기본값 | 바꾸면 달라지는 것 |
|
||||
|---|---|---|
|
||||
| `attachment.max_count` | 5 | 페이지 하나에 붙일 수 있는 파일 개수 |
|
||||
| `attachment.max_size_mb` | 10 | 파일 하나의 최대 용량(MB) |
|
||||
| `attachment.allowed_types` | JPEG·PNG·GIF·WebP·PDF·ZIP | 업로드를 허용할 파일 형식 |
|
||||
|
||||
값을 바꾸려면 설치된 모듈의 설정 파일을 고친 뒤 모듈 캐시를 비웁니다. 화면 입력이 없는 이유는
|
||||
이 셋이 개점 후 거의 바뀌지 않는 값이라 판단했기 때문이며, 조정이 잦아지면 그때 설정 화면을
|
||||
추가하는 것이 맞습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 사용 방법
|
||||
|
||||
<!-- @intent START -->
|
||||
**페이지 만들기**: `/admin/pages` → "페이지 추가" → 주소(slug)와 제목·본문을 입력합니다. 주소는
|
||||
저장 전에 중복 여부를 확인해 주며, 한번 공개한 주소를 바꾸면 기존 링크가 끊기므로 신중히
|
||||
정합니다. 작성 중에는 "미발행" 으로 두고 미리보기로 확인한 뒤 발행합니다.
|
||||
|
||||
**예전 내용으로 되돌리기**: 페이지 상세의 버전 목록에서 원하는 시점을 골라 내용을 확인한 뒤
|
||||
복원합니다. 복원해도 그 사이의 버전이 지워지지 않고 **새 버전이 하나 더 생기므로**, 되돌린
|
||||
것을 다시 되돌릴 수 있습니다.
|
||||
|
||||
**약관 개정 공지처럼 여러 페이지를 동시에 여는 경우**: 각 페이지를 미발행 상태로 준비해 두고
|
||||
목록에서 대상을 체크한 뒤 일괄 발행합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 다른 확장과의 연동
|
||||
|
||||
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
없음 — 코어만으로 동작합니다.
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
| 확장 | 유형 | 요구 버전 |
|
||||
|---|---|---|
|
||||
| `sirsoft-marketing` | 플러그인 | `>=1.0.0` |
|
||||
| `sirsoft-basic` | 템플릿 | `>=1.1.0` |
|
||||
<!-- @generated:integrations END -->
|
||||
|
||||
## 문서
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
<!-- @intent START -->
|
||||
| 증상 | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
| 페이지 주소로 들어가면 404 | 아직 발행하지 않았거나 주소를 바꿈 | 관리자 화면에서 발행 상태와 현재 주소를 확인합니다. 운영자 계정으로는 미발행 페이지도 미리보기로 열립니다 |
|
||||
| 첨부 파일이 내려받아지지 않음 | 그 페이지가 미발행 상태 | 페이지를 발행하면 첨부도 함께 공개됩니다. 미발행 상태의 첨부는 권한 있는 운영자에게만 열립니다 |
|
||||
| 파일 업로드가 거부됨 | 개수·용량·형식 제한에 걸림 | 기본값은 5개·10MB·이미지/PDF/ZIP 입니다. 설정 파일에서 조정할 수 있습니다 |
|
||||
| 내용을 고쳤는데 검색 결과가 예전 그대로 | 검색 색인이 아직 갱신되지 않음 | 잠시 후 다시 확인하고, 계속 그렇다면 코어 검색 색인 점검을 실행합니다 |
|
||||
| 페이지를 지웠는데 되돌릴 수 없음 | 이 모듈은 삭제를 실제 삭제로 처리 | 삭제 전 되돌리기 수단은 버전 이력뿐입니다. 삭제 대신 "미발행" 으로 두면 언제든 되살릴 수 있습니다 |
|
||||
| 공유했을 때 미리보기 이미지가 나오지 않음 | 본문에 이미지가 없거나 대표 이미지를 뽑지 못함 | 본문 첫머리에 이미지를 넣거나 SEO 설정에서 직접 지정합니다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 변경 이력
|
||||
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-page",
|
||||
"description": "Page module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 페이지 개발자 문서
|
||||
|
||||
> modules/_bundled/sirsoft-page · 모듈
|
||||
|
||||
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**훅 수**: 21 · **구독 훅 수**: 17 · **라우트 수**: 17 · **모델 수**: 3 · **테이블 수**: 3 · **마이그레이션 수**: 8 · **레이아웃 수**: 3 · **핸들러 수**: 0
|
||||
<!-- @generated:stats END -->
|
||||
|
||||
## 문서 목차
|
||||
|
||||
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
|
||||
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
|
||||
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
|
||||
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
|
||||
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
|
||||
| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 |
|
||||
| [api/](api/README.md) | API 레퍼런스 |
|
||||
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
|
||||
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
|
||||
<!-- @generated:doc-toc END -->
|
||||
@@ -0,0 +1,86 @@
|
||||
# 페이지 — 아키텍처
|
||||
|
||||
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설계 의도
|
||||
|
||||
<!-- @intent START -->
|
||||
"문서 하나 = 주소 하나" 라는 전제 하나가 이 모듈의 모든 선택을 설명합니다.
|
||||
|
||||
- **목록이 없다.** 공개 API 는 `GET /pages/{slug}` 뿐이고 목록 엔드포인트가 없습니다. 여러 글을
|
||||
목록으로 다루는 것은 게시판 모듈의 역할이며, 두 모듈이 그 역할을 나눠 갖지 않으면 둘 다
|
||||
애매해집니다. 방문자가 페이지를 찾는 통로는 사이트 메뉴와 통합 검색입니다.
|
||||
- **삭제가 실삭제다.** 초기 스키마의 SoftDeletes 를 마이그레이션 두 개로 걷어냈습니다. slug 가
|
||||
주소이므로 지운 페이지가 보이지 않게 남아 있으면 같은 주소를 다시 쓸 수 없습니다. 되돌리기의
|
||||
책임은 삭제 플래그가 아니라 **버전 이력**이 집니다.
|
||||
- **모든 수정이 버전을 남긴다.** 그래서 되돌리기도 덮어쓰기가 아니라 새 버전 생성입니다 —
|
||||
되돌린 사실 자체가 이력에 남아야 하기 때문입니다.
|
||||
- **검색·SEO 를 스스로 만들지 않는다.** 코어 검색 훅에 결과를 얹고 코어 SEO 캐시에 무효화를
|
||||
통지할 뿐, 자기 검색 화면이나 자기 캐시를 두지 않습니다.
|
||||
- **관리자 설정 화면이 없다.** 조정 가능한 값은 첨부 제한 셋뿐이고 개점 후 거의 바뀌지 않아
|
||||
설정 파일에 두었습니다. 조정이 잦아지면 그때 화면을 더하는 것이 맞습니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: 알림·브로드캐스트·미들웨어·레이아웃 확장·프론트 액션 핸들러.
|
||||
이 모듈은 다른 확장의 화면이나 요청 흐름에 개입하지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 계층 지도
|
||||
|
||||
<!-- @intent START -->
|
||||
```
|
||||
Http/Controllers (Admin/ 관리자 CRUD, User/ 공개 조회·첨부 서빙)
|
||||
│
|
||||
▼
|
||||
FormRequest (slug 유일성 · 다국어 제목 · 첨부 용량/형식)
|
||||
│
|
||||
▼
|
||||
Services 3종
|
||||
│ ├─ PageService : CRUD + 발행 + 버전 스냅샷·복원
|
||||
│ ├─ PageAttachmentService : 업로드·순서·삭제 (개수 상한 판정)
|
||||
│ └─ PageSettingsService : 설정 파일 해석
|
||||
│ before_* → filter_*_data → 실행 → after_*
|
||||
▼
|
||||
Repositories 3종 (Interface 경유)
|
||||
│
|
||||
▼
|
||||
Models 3종 (Page ─1:N─ PageVersion / PageAttachment)
|
||||
```
|
||||
|
||||
`PageService` 안에 **정렬 이름 → 컬럼 선언**(`SEARCH_SORT_MAP`)이 상수로 있습니다. 코어
|
||||
`SearchPagePolicy` 가 이 선언을 읽어 커서 페이지네이션 적용 여부를 판정하므로, 정렬 이름을
|
||||
추가할 때는 이 상수부터 손댑니다 — 여기 없는 정렬(관련도순 등)은 계산값이라 커서 경계로 쓸 수
|
||||
없어 offset 을 유지합니다.
|
||||
|
||||
Listeners 5종은 별도 레인입니다 — 활동 로그·검색 결과 편입·SEO 캐시 무효화·편집기 이미지
|
||||
출처 제공. Service 는 이 부가효과를 알지 못하며, 그래서 새 부가효과는 리스너 추가만으로
|
||||
끝납니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 디렉토리
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
|
||||
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
|
||||
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
|
||||
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
|
||||
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
|
||||
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
|
||||
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-page --force` (빌드 불필요) |
|
||||
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan module:update sirsoft-page --force` |
|
||||
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-page --force` |
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-page --force` |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
@@ -0,0 +1,138 @@
|
||||
# 페이지 — 데이터 모델
|
||||
|
||||
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 모델
|
||||
|
||||
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 모델 | 테이블 | fillable | 관계 | 특성 |
|
||||
|---|---|---|---|---|
|
||||
| `Page` | `pages` | 11 | creator→User, updater→User, versions→PageVersion, attachments→PageAttachment | 검색 색인 |
|
||||
| `PageAttachment` | `page_attachments` | 13 | page→Page, creator→User | - |
|
||||
| `PageVersion` | `page_versions` | 8 | page→Page, creator→User | - |
|
||||
<!-- @generated:models END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
세 모델의 관계는 `Page` ─1:N─ `PageVersion` / `PageAttachment` 하나뿐입니다.
|
||||
|
||||
- **`Page`** — `slug` 가 사실상의 주소이고 `current_version` 이 이력의 현재 위치입니다.
|
||||
`title` 과 `content` 는 `AsUnicodeJson` 캐스팅이라 다국어 값을 담습니다(로케일별 문자열
|
||||
맵). `content_thumbnail_url` 은 본문에서 뽑은 대표 이미지를 **저장해 둔 것**이라, 본문을
|
||||
고치면 함께 갱신되어야 합니다.
|
||||
- **`PageVersion`** — 저장 시점의 `title`/`content`/`content_mode`/`seo_meta` 스냅샷입니다.
|
||||
복원은 이 값을 현재 페이지에 쓰고 `current_version` 을 **또 1 올린** 뒤 스냅샷을 한 번 더
|
||||
남깁니다.
|
||||
- **`PageAttachment`** — 공개 서빙은 순번 ID 가 아니라 **해시**로 합니다. 부모 페이지의 발행
|
||||
상태가 곧 첨부의 공개 여부이며, 내려받기와 미리보기 **두 경로가 각자** 그 판정을 합니다.
|
||||
|
||||
셋 다 **SoftDeletes 를 쓰지 않습니다.** `Page` 의 소프트 삭제는 마이그레이션
|
||||
`2026_06_29_000001` 이, `PageAttachment` 는 `2026_06_29_000002` 가 걷어냈습니다. 삭제된
|
||||
페이지가 보이지 않게 남아 있으면 같은 slug 를 다시 쓸 수 없기 때문이며, 되돌리기의 책임은
|
||||
버전 이력이 집니다.
|
||||
|
||||
`Page` 만 검색 색인 대상입니다. 색인에 실리는 컬럼과 가중치는 모델의 `searchableColumns()` ·
|
||||
`searchableWeights()` 가 선언하고, 다시 태울지 여부는 `searchIndexShouldBeUpdated()` 와
|
||||
`search.page.index_should_update` 필터가 정합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 소유 테이블
|
||||
|
||||
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 테이블 | 모델 |
|
||||
|---|---|
|
||||
| `page_attachments` | `PageAttachment` |
|
||||
| `page_versions` | `PageVersion` |
|
||||
| `pages` | `Page` |
|
||||
<!-- @generated:tables END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
세 테이블 모두 모델과 1:1 이며 피벗이 없습니다. 접두사가 `page_` 로 짧은 것은 이 모듈이
|
||||
코어에 가까운 기본 기능이라는 초기 판단 때문이며, 다른 확장이 같은 이름을 쓰지 않도록
|
||||
주의합니다.
|
||||
|
||||
`pages` 에는 FULLTEXT 인덱스(`2026_04_01_000004`)와 발행 정렬 인덱스(`2026_08_02_000001`)가
|
||||
따로 붙어 있습니다. 목록·검색 쿼리를 새로 만들 때 이 두 인덱스를 쓰는 형태인지 확인합니다 —
|
||||
컬럼에 함수를 씌우거나(`whereDate` 등) 정렬 컬럼을 바꾸면 인덱스가 쓰이지 않습니다.
|
||||
|
||||
삭제는 **DB CASCADE 에 맡기지 않습니다.** 페이지를 지울 때 `PageService` 가 첨부를 하나씩
|
||||
`PageAttachmentService::deleteAttachment()` 로 지웁니다 — 물리 파일 삭제와 훅 발행이 함께
|
||||
일어나야 하는데, CASCADE 로 지우면 그 둘이 통째로 건너뛰어지고 아무 오류도 남지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
마이그레이션 8개.
|
||||
|
||||
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|
||||
|---|---|---|---|
|
||||
| `2026_04_01_000001_create_pages_table.php` | `pages` | `pages` | ✅ |
|
||||
| `2026_04_01_000002_create_page_versions_table.php` | `page_versions` | `page_versions` | ✅ |
|
||||
| `2026_04_01_000003_create_page_attachments_table.php` | `page_attachments` | `page_attachments` | ✅ |
|
||||
| `2026_04_01_000004_add_fulltext_indexes_to_pages_table.php` | - | `pages` | ✅ |
|
||||
| `2026_06_29_000001_drop_soft_deletes_from_pages_table.php` | - | `pages` | ✅ |
|
||||
| `2026_06_29_000002_drop_soft_deletes_from_page_attachments_table.php` | - | `page_attachments` | ✅ |
|
||||
| `2026_08_02_000001_add_published_sort_index_to_pages_table.php` | - | - | ✅ |
|
||||
| `2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php` | - | `pages` | ✅ |
|
||||
<!-- @generated:migrations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
8개 중 3개가 초기 스키마이고 5개는 이후의 변경입니다. 그 5개가 이 모듈이 겪은 설계 변경을
|
||||
그대로 보여줍니다:
|
||||
|
||||
| 마이그레이션 | 무엇이 바뀌었나 |
|
||||
|---|---|
|
||||
| `add_fulltext_indexes_to_pages_table` | 통합 검색 편입을 위해 FULLTEXT 인덱스 추가 |
|
||||
| `drop_soft_deletes_from_pages_table` · `..._page_attachments_table` | 소프트 삭제 철회 — slug 재사용을 막기 때문 |
|
||||
| `add_published_sort_index_to_pages_table` | 발행 목록 정렬의 인덱스 확보 |
|
||||
| `add_content_thumbnail_url_to_pages_table` | 본문 대표 이미지를 조회 때마다 뽑지 않고 저장 |
|
||||
|
||||
새 컬럼을 더할 때 초기 `create_*` 파일을 고치지 않습니다 — 이미 설치된 사이트는 그 파일을
|
||||
다시 실행하지 않으므로 반영되지 않습니다. 컬럼 기본값·comment·데이터 형태를 바로잡는 변경은
|
||||
마이그레이션과 함께 `upgrades/` 의 업그레이드 스텝 백필이 필요합니다.
|
||||
|
||||
한국어 `comment` 와 `down()` 은 필수이고, FK 컬럼의 `->comment()` 는 `->constrained()` **앞**에
|
||||
둡니다(뒤에 두면 comment 가 FK 정의에 붙어 조용히 사라집니다).
|
||||
<!-- @intent END -->
|
||||
|
||||
## Enum
|
||||
|
||||
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_Enum 이 없습니다._
|
||||
<!-- @generated:enums END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 도메인의 상태는 `published` 불리언 하나뿐이라 분류 어휘가 생기지 않았습니다.
|
||||
|
||||
`content_mode` 는 문자열 컬럼입니다 — 편집기(위지윅/평문)가 무엇을 저장했는지를 나타내며,
|
||||
편집기 확보에 실패했을 때의 폴백 계약(`text`)과 짝을 이룹니다. 값의 가짓수가 늘어나
|
||||
분기가 생기기 시작하면 그때 Enum 으로 올리는 것이 맞습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## Repository
|
||||
|
||||
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 클래스 | 종류 | 설명 |
|
||||
|---|---|---|
|
||||
| `PageAttachmentRepository` | 구현 | 페이지 첨부파일 Repository |
|
||||
| `PageAttachmentRepositoryInterface` | 인터페이스 | 페이지 첨부파일 Repository 인터페이스 |
|
||||
| `PageRepository` | 구현 | 페이지 Repository |
|
||||
| `PageRepositoryInterface` | 인터페이스 | 페이지 Repository 인터페이스 |
|
||||
| `PageVersionRepository` | 구현 | 페이지 버전 Repository |
|
||||
| `PageVersionRepositoryInterface` | 인터페이스 | 페이지 버전 Repository 인터페이스 |
|
||||
<!-- @generated:repositories END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
세 Repository 모두 인터페이스와 1:1 이며 서비스는 **인터페이스만 주입**받습니다(구체 클래스
|
||||
타입힌트 금지).
|
||||
|
||||
이 모듈에서 특히 걸리는 것 둘:
|
||||
|
||||
- **버전 조회는 반드시 페이지 스코프로.** `PageVersionRepository::findForPage($pageId, $versionId)`
|
||||
처럼 상위 리소스 ID 를 where 절에 반영합니다. 버전 ID 만으로 찾으면 다른 페이지의 버전을
|
||||
현재 페이지에 복원할 수 있는 교차 접근 경로가 생기는데, 정상 응답이 나가므로 오류도 로그도
|
||||
남지 않습니다.
|
||||
- **목록 쿼리의 컬럼 프루닝과 정렬 화이트리스트.** 페이지 본문은 큰 컬럼이라 목록에 실으면
|
||||
오버플로 페이지 읽기가 발생합니다. 정렬은 `PageService::SEARCH_SORT_MAP` 이 닫힌 집합을
|
||||
정하며, 화면 정렬 옵션 ⊆ 검증 게이트 ⊆ 이 선언 순서로 포함 관계가 유지되어야 합니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,115 @@
|
||||
# 페이지 — 레이아웃 편집기 스펙
|
||||
|
||||
> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 선언 요약
|
||||
|
||||
<!-- @generated:editor-spec-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| manifest | `modules/_bundled/sirsoft-page/editor-spec.json` |
|
||||
| 형태 | 단일 파일 (인라인) |
|
||||
| 스펙 버전 | `1.0.0` |
|
||||
| 스타일 시스템 | - |
|
||||
| 다크 모드 전략 | - |
|
||||
|
||||
> 단일 파일 · 프리뷰 샘플 6 · 엔드포인트 샘플 3 · 페이지 상태 3
|
||||
<!-- @generated:editor-spec-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
페이지 모듈의 스펙은 세 블록뿐입니다. 화면이 "목록 · 편집 · 공개 보기" 로 단순하고,
|
||||
운영자가 편집기에서 손대는 대상이 페이지 **내용**이 아니라 그것을 감싸는 레이아웃이기
|
||||
때문입니다.
|
||||
|
||||
`sampleGlobal` 을 두지 않은 것은 누락이 아닙니다 — 페이지 도메인은 `_global` 키를
|
||||
자기 것으로 쓰지 않습니다. 필요 없는 블록을 빈 값으로라도 선언해 두면 다음 사람이 그
|
||||
빈 값을 채워야 할 자리로 오해합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 선언 블록
|
||||
|
||||
<!-- @generated:editor-spec-blocks START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 블록 | 역할 | 항목 수 | 출처 |
|
||||
|---|---|---|---|
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 6 | `editor-spec.json (인라인)` |
|
||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 3 | `editor-spec.json (인라인)` |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 3 | `editor-spec.json (인라인)` |
|
||||
<!-- @generated:editor-spec-blocks END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`byDataSourceId` 6종 중 `termsContent`·`privacyContent` 는 다른 넷과 성격이 다릅니다.
|
||||
약관·개인정보 페이지는 슬러그가 고정된 특수 페이지라 편집기에서 그 자리에 무엇이 들어갈지
|
||||
미리 보여 줘야 합니다. `byEndpointPattern` 3종도 같은 이유로 이 둘을 따로 덮습니다.
|
||||
|
||||
`states.groups` 3종은 공개 페이지와 관리자 편집·상세를 하나씩 맡습니다. 페이지는 상태
|
||||
변종이 적은 도메인이라 이 수가 늘어난다면 화면이 복잡해지고 있다는 신호입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 컴포넌트 팔레트
|
||||
|
||||
<!-- @generated:editor-spec-palette START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._
|
||||
<!-- @generated:editor-spec-palette END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이
|
||||
제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이
|
||||
확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고
|
||||
**도메인 데이터**(`sampleData`·`states`)만 담습니다.
|
||||
|
||||
팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿
|
||||
(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면
|
||||
템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 샘플 데이터와 페이지 상태
|
||||
|
||||
<!-- @generated:editor-spec-samples START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 자리 | 역할 | 개수 | ID |
|
||||
|---|---|---|---|
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 6 | `pages` · `page` · `pageData` · `versions` · `termsContent` · `privacyContent` |
|
||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 3 | `/api/modules/sirsoft-page/pages/terms` · `/api/modules/sirsoft-page/pages/privacy` · `/api/modules/sirsoft-page/pages/*` |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 3 | `/page/:slug` · `*/admin/pages/:id/edit` · `*/admin/pages/:id` |
|
||||
|
||||
_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._
|
||||
<!-- @generated:editor-spec-samples END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
페이지 모듈에서 주의할 것은 `/p/:slug` 처럼 **슬러그가 열려 있는 라우트**입니다.
|
||||
편집기는 특정 슬러그 하나를 골라 프리뷰를 그리므로, 그 샘플이 실제 운영 페이지 중
|
||||
가장 단순한 것을 닮아 있으면 복잡한 페이지에서 레이아웃이 깨지는 것을 편집기에서
|
||||
미리 볼 수 없습니다.
|
||||
|
||||
샘플을 고를 때는 가장 짧은 페이지가 아니라 **가장 많은 요소를 가진 페이지**를 기준으로
|
||||
삼습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 수정 시 동반 의무
|
||||
|
||||
<!-- @generated:editor-spec-obligations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 |
|
||||
|---|---|
|
||||
| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 |
|
||||
| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) |
|
||||
| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 |
|
||||
| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 |
|
||||
| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 |
|
||||
|
||||
편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다:
|
||||
|
||||
```bash
|
||||
php artisan module:update sirsoft-page --force
|
||||
```
|
||||
<!-- @generated:editor-spec-obligations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 —
|
||||
편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을
|
||||
고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은
|
||||
고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다.
|
||||
|
||||
또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는
|
||||
ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로
|
||||
나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한
|
||||
통로입니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,238 @@
|
||||
# 페이지 — 확장점
|
||||
|
||||
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 발행 훅
|
||||
|
||||
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
발행 훅 21종 / 호출 지점 23곳. 이 중 21종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
|
||||
|
||||
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|
||||
|---|---|---|---|
|
||||
| `sirsoft-page.attachment.after_delete` | action | — | `src/Services/PageAttachmentService.php:208` |
|
||||
| `sirsoft-page.attachment.after_reorder` | action | — | `src/Services/PageAttachmentService.php:335` |
|
||||
| `sirsoft-page.attachment.after_upload` | action | — | `src/Services/PageAttachmentService.php:139` |
|
||||
| `sirsoft-page.attachment.before_delete` | action | — | `src/Services/PageAttachmentService.php:200` |
|
||||
| `sirsoft-page.attachment.before_reorder` | action | — | `src/Services/PageAttachmentService.php:331` |
|
||||
| `sirsoft-page.attachment.before_upload` | action | — | `src/Services/PageAttachmentService.php:90` |
|
||||
| `sirsoft-page.attachment.filter_upload_file` | filter | — | `src/Services/PageAttachmentService.php:92` |
|
||||
| `sirsoft-page.page.after_create` | action | — | `src/Services/PageService.php:106` |
|
||||
| `sirsoft-page.page.after_delete` | action | — | `src/Services/PageService.php:190` |
|
||||
| `sirsoft-page.page.after_publish` | action | — | `src/Services/PageService.php:223` 외 1곳 |
|
||||
| `sirsoft-page.page.after_restore` | action | — | `src/Services/PageService.php:322` |
|
||||
| `sirsoft-page.page.after_update` | action | — | `src/Services/PageService.php:159` |
|
||||
| `sirsoft-page.page.before_create` | action | — | `src/Services/PageService.php:75` |
|
||||
| `sirsoft-page.page.before_delete` | action | — | `src/Services/PageService.php:179` |
|
||||
| `sirsoft-page.page.before_publish` | action | — | `src/Services/PageService.php:209` 외 1곳 |
|
||||
| `sirsoft-page.page.before_update` | action | — | `src/Services/PageService.php:127` |
|
||||
| `sirsoft-page.page.filter_content_thumbnail` | filter | — | `src/Models/Page.php:128` |
|
||||
| `sirsoft-page.page.filter_create_data` | filter | — | `src/Services/PageService.php:77` |
|
||||
| `sirsoft-page.page.filter_list_query` | filter | — | `src/Services/PageService.php:60` |
|
||||
| `sirsoft-page.page.filter_update_data` | filter | — | `src/Services/PageService.php:131` |
|
||||
| `sirsoft-page.search.page.index_should_update` | filter | — | `src/Models/Page.php:270` |
|
||||
<!-- @generated:hooks-published END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
21종은 두 도메인의 3단 패턴이 대부분입니다 — `page.*` 14종과 `attachment.*` 7종이
|
||||
`before_{동작}`(action) → `filter_{동작}_data`(filter) → `after_{동작}`(action) 을 반복합니다.
|
||||
CRUD 동작을 바꾸고 싶으면 이 셋 중 하나를 잡습니다.
|
||||
|
||||
패턴에서 벗어나는 셋이 이 모듈 고유의 확장점입니다:
|
||||
|
||||
| 훅 | 무엇을 열어 주는가 |
|
||||
|---|---|
|
||||
| `page.filter_content_thumbnail` | 본문에서 대표 이미지를 뽑는 규칙. 본문 형식이 특이한 사이트가 자기 방식으로 바꿉니다 (모델에서 발행되므로 조회 경로 전체에 걸립니다) |
|
||||
| `search.page.index_should_update` | 어떤 변경에 검색 색인을 다시 태울지. 색인 비용이 큰 설치가 조건을 좁히는 자리입니다 |
|
||||
| `attachment.filter_upload_file` | 저장 직전 파일 가공(리사이즈·형식 변환) |
|
||||
|
||||
`page.after_restore` 는 소프트 삭제 복원이 아니라 **버전 복원**입니다. 이 모듈은 소프트 삭제를
|
||||
쓰지 않으므로 "복원" 이라는 말이 나오면 언제나 버전 이력 쪽입니다.
|
||||
|
||||
발행 훅이 `getHooks()` 선언에 없어 소스에서 자동 감지된 상태입니다. 선언에 추가하면 유형과
|
||||
설명이 표에 함께 실리며, 이 모듈처럼 훅 수가 적은 확장은 선언을 채우는 비용이 낮습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 구독 훅
|
||||
|
||||
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|
||||
|---|---|---|---|---|
|
||||
| `core.activity_log.filter_description_params` | filter | `ActivityLogDescriptionResolver` | `resolveDescriptionParams` | 10 |
|
||||
| `core.search.build_response` | filter | `SearchPagesListener` | `buildPagesResponse` | 10 |
|
||||
| `core.search.index_validation_rules` | filter | `SearchPagesListener` | `addValidationRules` | 10 |
|
||||
| `core.search.results` | filter | `SearchPagesListener` | `searchPages` | 10 |
|
||||
| `sirsoft-ckeditor5.image.filter_reference_sources` | filter | `Ckeditor5ReferenceSourcesListener` | `addPageSources` | 10 |
|
||||
| `sirsoft-page.attachment.after_delete` | action (미선언) | `PageActivityLogListener` | `handleAttachmentAfterDelete` | 20 |
|
||||
| `sirsoft-page.attachment.after_upload` | action (미선언) | `PageActivityLogListener` | `handleAttachmentAfterUpload` | 20 |
|
||||
| `sirsoft-page.page.after_create` | action (미선언) | `PageActivityLogListener` | `handlePageAfterCreate` | 20 |
|
||||
| `sirsoft-page.page.after_create` | action (미선언) | `SeoPageCacheListener` | `onPageChange` | 20 |
|
||||
| `sirsoft-page.page.after_delete` | action (미선언) | `PageActivityLogListener` | `handlePageAfterDelete` | 20 |
|
||||
| `sirsoft-page.page.after_delete` | action (미선언) | `SeoPageCacheListener` | `onPageDelete` | 20 |
|
||||
| `sirsoft-page.page.after_publish` | action (미선언) | `PageActivityLogListener` | `handlePageAfterPublish` | 20 |
|
||||
| `sirsoft-page.page.after_publish` | action (미선언) | `SeoPageCacheListener` | `onPageChange` | 20 |
|
||||
| `sirsoft-page.page.after_restore` | action (미선언) | `PageActivityLogListener` | `handlePageAfterRestore` | 20 |
|
||||
| `sirsoft-page.page.after_restore` | action (미선언) | `SeoPageCacheListener` | `onPageChange` | 20 |
|
||||
| `sirsoft-page.page.after_update` | action (미선언) | `PageActivityLogListener` | `handlePageAfterUpdate` | 20 |
|
||||
| `sirsoft-page.page.after_update` | action (미선언) | `SeoPageCacheListener` | `onPageChange` | 20 |
|
||||
<!-- @generated:hooks-subscribed END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
17개 중 12개는 자기 훅입니다(활동 로그 7 + SEO 캐시 5). 바깥을 향한 5개가 이 모듈이 다른
|
||||
확장과 맞물리는 전부입니다:
|
||||
|
||||
| 상대 훅 | 리스너 | 무엇을 위해 |
|
||||
|---|---|---|
|
||||
| `core.search.results` · `core.search.build_response` · `core.search.index_validation_rules` | `SearchPagesListener` | 사이트 통합 검색 결과에 페이지를 섞고, 검색 요청의 검증 규칙에 페이지 축을 더합니다 |
|
||||
| `core.activity_log.filter_description_params` | `ActivityLogDescriptionResolver` | 활동 로그 문장의 치환 변수(페이지 제목 등)를 ID 에서 표시명으로 해석합니다 |
|
||||
| `sirsoft-ckeditor5.image.filter_reference_sources` | `Ckeditor5ReferenceSourcesListener` | 편집기가 이미지를 고를 때 페이지 첨부를 출처 목록에 더합니다 |
|
||||
|
||||
`sirsoft-ckeditor5` 는 **manifest 의존에 없습니다.** 훅 구독은 상대가 없으면 발화하지 않으므로
|
||||
편집기 플러그인이 없어도 이 모듈은 정상 동작하고 그 기능만 비어 있습니다. 대신 상대가 훅
|
||||
이름을 바꾸면 예외 없이 조용히 끊기므로, 코어 검색이나 ckeditor5 를 손댈 때 이 구독이 함께
|
||||
확인 대상입니다.
|
||||
|
||||
리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 를 직접 부르지 않습니다 — 데이터
|
||||
접근은 Repository 인터페이스 주입으로만 합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 훅 리스너
|
||||
|
||||
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|
||||
|---|---|---|---|---|
|
||||
| `ActivityLogDescriptionResolver` | 1개 | 명시 등록 | ✅ | `src/Listeners/ActivityLogDescriptionResolver.php` |
|
||||
| `Ckeditor5ReferenceSourcesListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/Ckeditor5ReferenceSourcesListener.php` |
|
||||
| `PageActivityLogListener` | 7개 | 명시 등록 | ✅ | `src/Listeners/PageActivityLogListener.php` |
|
||||
| `SearchPagesListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SearchPagesListener.php` |
|
||||
| `SeoPageCacheListener` | 5개 | 명시 등록 | ✅ | `src/Listeners/SeoPageCacheListener.php` |
|
||||
<!-- @generated:listeners END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
5개 전부 `HookListenerInterface` 를 구현하고 `getSubscribedHooks()` 로 자기 구독을 선언합니다.
|
||||
|
||||
| 리스너 | 역할 |
|
||||
|---|---|
|
||||
| `PageActivityLogListener` | 페이지·첨부 변경을 코어 `activity_logs` 에 기록 |
|
||||
| `ActivityLogDescriptionResolver` | 그 기록의 설명 변수(ID → 표시명) 해석 |
|
||||
| `SeoPageCacheListener` | 내용이 바뀌면 봇 화면 캐시 무효화 |
|
||||
| `SearchPagesListener` | 코어 통합 검색에 페이지 결과 편입 |
|
||||
| `Ckeditor5ReferenceSourcesListener` | 편집기 이미지 출처에 페이지 첨부 제공 |
|
||||
|
||||
새 활동 로그 항목을 더할 때는 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description
|
||||
본문이 함께 필요합니다 — **모듈 lang 파일에 넣으면 해석되지 않습니다.** 번들 일본어 팩도 같은
|
||||
작업 단위에서 동기화합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 레이아웃 확장
|
||||
|
||||
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_레이아웃 확장이 없습니다._
|
||||
<!-- @generated:layout-extensions END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 모듈은 다른 확장·템플릿의 화면에 조각을 주입하지 않습니다.
|
||||
|
||||
페이지 내용을 다른 화면에 노출하고 싶다면 그 화면을 소유한 쪽(템플릿 또는 그 모듈)이 이
|
||||
모듈의 공개 API 를 호출하는 것이 맞는 방향입니다. 여기에 조각을 더하면 대상 화면이 슬롯을
|
||||
없앨 때 오류 없이 사라지는 결합이 생깁니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 미들웨어
|
||||
|
||||
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 미들웨어가 없습니다._
|
||||
<!-- @generated:middleware END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 모듈의 라우트는 코어가 제공하는 인증 미들웨어(`auth:sanctum` ·
|
||||
`optional.sanctum`)와 요율 제한만 씁니다.
|
||||
|
||||
발행 상태·열람 권한 판정은 미들웨어가 아니라 **컨트롤러 안에서** 이루어집니다. 페이지 본문과
|
||||
첨부 두 종류의 응답에 서로 다른 판정이 필요하고(첨부 미리보기는 서명 링크도 인정), 그 차이를
|
||||
미들웨어 하나로 표현하면 어느 쪽이든 과하거나 모자라기 때문입니다. 그 대신 **경로마다 게이트를
|
||||
재적용해야 한다**는 의무가 생깁니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 브로드캐스트 채널
|
||||
|
||||
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 브로드캐스트 채널이 없습니다._
|
||||
<!-- @generated:channels END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 페이지는 실시간 갱신이 필요한 콘텐츠가 아닙니다 — 발행 시점이 운영자의 조작이고,
|
||||
방문자는 그 시점 이후의 접속에서 새 내용을 봅니다.
|
||||
|
||||
실시간이 필요한 화면이 생기면 이 모듈에 채널을 더하는 것이 아니라, `page.after_publish` 를
|
||||
구독하는 쪽에서 자기 채널로 내보냅니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 스케줄
|
||||
|
||||
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 스케줄 | 주기 | 설명 |
|
||||
|---|---|---|
|
||||
| `sirsoft-page:prune-temp-attachments` | `daily` | 미연결 임시 페이지 첨부 자동 삭제 |
|
||||
<!-- @generated:schedules END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
하나뿐입니다. `prune-temp-attachments` 는 **업로드했지만 페이지 저장까지 이어지지 않은 파일**을
|
||||
정리합니다 — 편집 중 창을 닫은 세션의 부산물이라 운영 데이터가 아니며, 그래서 설정 토글 없이
|
||||
상시 동작합니다(보존 기간은 커맨드 옵션).
|
||||
|
||||
이미 페이지에 연결된 첨부는 이 스케줄의 대상이 아닙니다. 페이지를 지우면 그 첨부는 삭제 흐름
|
||||
안에서 함께 정리됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 알림 정의
|
||||
|
||||
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 알림 정의가 없습니다._
|
||||
<!-- @generated:notifications END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 페이지 발행은 특정 수신자를 향한 사건이 아니라 사이트 전체에 대한 게시라, 누구에게
|
||||
보내야 할지가 정해지지 않습니다.
|
||||
|
||||
약관 개정 안내처럼 발행을 계기로 알림을 보내야 한다면 `page.after_publish` 를 구독해 코어
|
||||
`GenericNotification` 으로 발송하는 리스너를 **그 알림을 필요로 하는 확장 쪽에** 둡니다.
|
||||
수신자 범위가 사이트마다 다르므로 이 모듈이 정할 수 없습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 활동 로그 훅
|
||||
|
||||
> 이 확장이 코어 활동 로그(`activity_logs`)에 기록을 남기기 위해 구독하는 훅 7개입니다.
|
||||
> 코어 `docs/backend/activity-log-hooks.md` 에 있던 목록을 이 확장 소유로 옮긴 것입니다(#601) —
|
||||
> 확장이 훅을 더할 때 코어 문서를 고쳐야 하던 역방향 의존을 없애기 위해서입니다. 코어 문서에는
|
||||
> 총계와 이 문서로의 링크만 남습니다.
|
||||
|
||||
> 새 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문,
|
||||
> 그리고 번들 일본어 팩까지 함께 정의해야 합니다 — **모듈 lang 파일에 넣으면 해석되지
|
||||
> 않습니다.**
|
||||
|
||||
### 페이지 모듈 훅 (PageActivityLogListener)
|
||||
|
||||
**파일**: `modules/_bundled/sirsoft-page/src/Listeners/PageActivityLogListener.php`
|
||||
**총 7훅**
|
||||
|
||||
> 이 표에 `before_*` 훅이 없는 것은 누락이 아닙니다. 수정 전 스냅샷은 이 리스너가
|
||||
> `before_*` 훅으로 직접 잡지 않고 **Service 가 잡아 `after_*` 훅의 인자로 넘깁니다**
|
||||
> (`ChangeDetector::detect($model, $snapshot)`). `before_*` 훅 자체는 발행되며 그 목록은
|
||||
> 위 「발행 훅」 절에 있습니다.
|
||||
|
||||
#### Page (5훅)
|
||||
|
||||
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|
||||
|---------|----------------|-------------|---------|----------|
|
||||
| `sirsoft-page.page.after_create` | `handlePageAfterCreate` | `page.create` | Admin | Page |
|
||||
| `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 |
|
||||
@@ -0,0 +1,85 @@
|
||||
# 페이지 — 프론트엔드
|
||||
|
||||
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 레이아웃
|
||||
|
||||
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
레이아웃 3개 (루트: `resources/layouts`).
|
||||
|
||||
| 그룹 | 개수 |
|
||||
|---|---|
|
||||
| `admin` | 3개 |
|
||||
|
||||
| 레이아웃 | 그룹 | 종류 | extends |
|
||||
|---|---|---|---|
|
||||
| `admin_page_detail` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_page_form` | `admin` | 화면 | `_admin_base` |
|
||||
| `admin_page_list` | `admin` | 화면 | `_admin_base` |
|
||||
<!-- @generated:layouts END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
3개 전부 관리자 화면입니다 — 목록(`admin_page_list`) · 작성/수정(`admin_page_form`) ·
|
||||
상세(`admin_page_detail`). 부분 레이아웃이 없을 만큼 화면이 단순합니다.
|
||||
|
||||
방문자가 보는 페이지 화면은 여기에 없습니다. 템플릿(`sirsoft-basic`)이 `GET /pages/{slug}` 를
|
||||
호출해 그리므로, 페이지의 **보이는 모습**을 바꾸는 작업은 이 모듈이 아니라 그 템플릿 쪽입니다.
|
||||
|
||||
레이아웃 JSON 만 고쳤다면 빌드는 필요 없고 `php artisan module:update sirsoft-page --force`
|
||||
로 반영합니다. 새로 쓴 Tailwind 클래스가 빌드된 CSS 에 없으면 그 스타일만 조용히 빠지므로,
|
||||
기존 레이아웃에 없던 클래스를 도입할 때는 확인이 필요합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 액션 핸들러
|
||||
|
||||
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_등록하는 액션 핸들러가 없습니다._
|
||||
<!-- @generated:handlers END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 모듈의 관리자 화면은 코어 엔진의 기본 핸들러(`apiCall` · `navigate` · `setState`
|
||||
등)만으로 충분해서 자체 핸들러를 두지 않았습니다.
|
||||
|
||||
그래서 **전역 진입점(`initModule`)도 없고 빌드 산출물(`dist/`)도 없습니다.** 핸들러를 처음
|
||||
추가할 때는 셋이 함께 필요합니다 — 엔트리 파일, `window.__SirsoftPage.initModule()` 재등록
|
||||
진입점, 그리고 `module:build --production` 으로 구운 `dist/` 커밋. 진입점을 빠뜨리면 로케일
|
||||
전환 직후 그 핸들러들이 오류 없이 무반응이 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 전역 진입점
|
||||
|
||||
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_프론트 엔트리포인트가 없습니다._
|
||||
<!-- @generated:frontend-entry END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다 — 등록할 액션 핸들러가 없기 때문입니다.
|
||||
|
||||
핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록
|
||||
진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부
|
||||
무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅
|
||||
작업을 포함하지 않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 에셋
|
||||
|
||||
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 구분 |
|
||||
|---|---|
|
||||
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
|
||||
|
||||
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
|
||||
<!-- @generated:assets END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
JS·CSS 산출물이 없고 `editor-spec.json` 하나만 있습니다 — 레이아웃 편집기가 이 모듈의 화면을
|
||||
편집할 때 쓰는 팔레트·중첩 규칙 선언이며, 실행 코드가 아니라 manifest 입니다.
|
||||
|
||||
로딩 설정(`strategy: global`, `priority: 100`)은 골격 기본값이 그대로 남은 것입니다. 실을
|
||||
자산이 없으므로 현재는 아무 영향이 없지만, 나중에 JS 를 더하면 이 선언이 확장 번들 안에서의
|
||||
순서를 정하게 됩니다.
|
||||
|
||||
`editor-spec.json` 을 고친 뒤에는 빌드 없이 `php artisan module:update sirsoft-page --force`
|
||||
만 실행합니다. 편집기는 활성 디렉토리 기준으로 서빙하므로 `_bundled` 만 고치면 반영되지
|
||||
않습니다.
|
||||
<!-- @intent END -->
|
||||
@@ -0,0 +1,130 @@
|
||||
# 페이지 — 설정·권한·라우트
|
||||
|
||||
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설정 스키마
|
||||
|
||||
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_`getSettingsSchema()` 선언이 없습니다._
|
||||
|
||||
기본값 파일: `config/settings/defaults.json`
|
||||
<!-- @generated:settings-schema END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`getSettingsSchema()` 도 관리자 설정 화면도 없습니다. 조정 가능한 값은 첨부 제한 셋뿐이며
|
||||
`config/settings/defaults.json` 이 SSoT 입니다.
|
||||
|
||||
| 키 | 기본값 | 쓰이는 곳 |
|
||||
|---|---|---|
|
||||
| `attachment.max_count` | 5 | `PageAttachmentService` — 초과 시 `AttachmentLimitExceededException` |
|
||||
| `attachment.max_size_mb` | 10 | `UploadPageAttachmentRequest` 검증 규칙 |
|
||||
| `attachment.allowed_types` | 이미지 4종 + PDF + ZIP | 같은 FormRequest (미설정 시 클래스 상수 폴백) |
|
||||
|
||||
파일 형태가 다른 확장과 다릅니다 — 이커머스·게시판은 `{_meta, defaults, frontend_schema}` 3단
|
||||
구조지만 여기는 **평평한 값 트리**입니다. 관리자 화면이 없어 `frontend_schema` 가 필요 없고,
|
||||
그래서 `_meta` 도 두지 않았습니다. 읽기는 `g7_module_settings('sirsoft-page', 'attachment.…')`
|
||||
로 하고, `PageSettingsService` 가 설정이 아직 동기화되지 않은 환경을 위해 파일 직접 읽기를
|
||||
폴백으로 갖습니다.
|
||||
|
||||
**서비스에서 상한을 리터럴로 재클램프하지 않습니다.** 계산은 서비스가, 상한 검증은
|
||||
FormRequest 가 단일 책임으로 갖습니다 — 이중 클램프가 생기면 설정을 올려도 반영되지 않습니다.
|
||||
|
||||
설정 화면을 나중에 추가한다면 `_meta.categories` 와 `frontend_schema` 를 더하는 방식이며, 그때
|
||||
값의 위치(`attachment.*`)는 바꾸지 않아야 기존 설치의 값이 유지됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 권한
|
||||
|
||||
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 카테고리 | 이름 | 액션 | 라우트 키 |
|
||||
|---|---|---|---|
|
||||
| `pages` | 페이지 관리 | `read`, `create`, `update`, `delete` | `page` |
|
||||
<!-- @generated:permissions END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
`pages` 하나에 `read`/`create`/`update`/`delete` 네 액션이 전부입니다. 라우트 키 `page` 가
|
||||
선언되어 있어 관리자 라우트에 스코프 미들웨어가 걸립니다.
|
||||
|
||||
`read` 가 관장하는 범위에 주의가 필요합니다 — 관리자 목록·상세뿐 아니라 **미발행 페이지의
|
||||
공개 화면 미리보기**와 **미발행 페이지 첨부의 서빙**까지 이 권한이 판정합니다. 그래서 이
|
||||
권한을 넓게 주면 아직 공개하지 않은 문서가 그 계정에 열립니다.
|
||||
|
||||
역할(`getRoles()`)은 선언하지 않습니다. 게시판처럼 대상마다 담당자가 갈리는 도메인이 아니라
|
||||
페이지 전체를 한 사람이 관리하는 경우가 대부분이므로, 코어 역할에 이 권한을 부여하는 것으로
|
||||
충분하다고 보았습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 메뉴
|
||||
|
||||
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 구분 | slug | 이름 | URL | 하위 |
|
||||
|---|---|---|---|---|
|
||||
| 관리자 | `sirsoft-page` | 페이지 관리 | `/admin/pages` | - |
|
||||
<!-- @generated:menus END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
최상위 메뉴 하나(`/admin/pages`)뿐이고 하위 메뉴가 없습니다. 화면이 목록·작성/수정·상세 셋뿐이며
|
||||
셋 다 목록에서 이어지므로 별도 진입점이 필요 없습니다.
|
||||
|
||||
메뉴는 **권한과 짝을 이룰 때만 보입니다.** `pages.read` 가 없는 역할에는 이 메뉴가 렌더되지
|
||||
않습니다. 새 화면을 더한다면 권한·메뉴·라우트 이름 셋이 서로를 정확히 가리키는지 함께
|
||||
확인합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 라우트
|
||||
|
||||
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 파일 | URL prefix |
|
||||
|---|---|---|
|
||||
| `api` | `src/routes/api.php` | `/api/modules/sirsoft-page/...` |
|
||||
|
||||
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
|
||||
<!-- @generated:routes END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
17개가 파일 하나(`src/routes/api.php`)에 있고 세 무리로 갈립니다:
|
||||
|
||||
| 무리 | prefix | 인증 |
|
||||
|---|---|---|
|
||||
| 관리자 페이지 CRUD·버전 | `admin/pages` | `auth:sanctum` + 권한 스코프 |
|
||||
| 관리자 첨부 | `admin/attachments` | `auth:sanctum` |
|
||||
| 공개 조회·첨부 서빙 | `pages` | `optional.sanctum` (비로그인 접근, 발행 상태는 컨트롤러가 판정) |
|
||||
|
||||
화면용 라우트는 없습니다 — 관리자 화면은 레이아웃 JSON 이 이 API 를 호출해 그리고, 방문자
|
||||
화면은 템플릿이 `GET /pages/{slug}` 를 씁니다.
|
||||
|
||||
**공개 첨부 경로가 해시 기반**(`/pages/attachment/{hash}`, `.../preview`)인 것에 주의합니다.
|
||||
새 서빙 경로를 더하면 그 자리에서 발행 상태·권한 게이트를 **다시** 걸어야 합니다 — 한쪽만
|
||||
막으면 같은 파일이 형제 엔드포인트로 새어나가고, 정상 응답이라 오류도 로그도 남지 않습니다.
|
||||
|
||||
모든 라우트에 `name()` 이 필요하고, 라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 캐시에
|
||||
없는 라우트는 예외도 경고도 없이 404 가 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 의존 관계
|
||||
|
||||
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
없음 — 코어만으로 동작합니다.
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
| 확장 | 유형 | 요구 버전 |
|
||||
|---|---|---|
|
||||
| `sirsoft-marketing` | 플러그인 | `>=1.0.0` |
|
||||
| `sirsoft-basic` | 템플릿 | `>=1.1.0` |
|
||||
<!-- @generated:dependencies END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이 모듈은 아무 확장에도 의존하지 않습니다. 관계는 한 방향으로 들어옵니다 —
|
||||
`sirsoft-marketing` 플러그인과 `sirsoft-basic` 템플릿이 이 모듈을 요구합니다.
|
||||
|
||||
manifest 에는 없지만 **훅으로 맞물리는 확장이 하나 더** 있습니다: `sirsoft-ckeditor5` 가
|
||||
없으면 편집기 이미지 출처 제공만 비고 나머지는 정상 동작하므로, 의존으로 올리지 않는 것이
|
||||
맞습니다.
|
||||
|
||||
이 모듈의 공개 표면(Service·Repository·Contracts·라우트·발행 훅)을 바꿀 때는 위 확장들의
|
||||
`dependencies` 최소 버전 상향이 필요한지 검토합니다. 특히 공개 조회 API 의 응답 형태는
|
||||
템플릿이 그대로 화면에 그리므로, 필드를 빼면 그 템플릿의 페이지 화면이 빈 채로 렌더됩니다.
|
||||
<!-- @intent END -->
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"moduleId": "sirsoft-page",
|
||||
"version": "1.0.0",
|
||||
"description": "레이아웃 편집기 스펙 — 페이지 모듈 도메인 sampleData/states. 실제 admin 레이아웃 data_source ID 4종 전수(pages/page/pageData/versions) + 사용자 페이지(/p/:slug, 약관/개인정보) byEndpointPattern. sampleGlobal 은 페이지 도메인이 _global keyspace 를 두지 않아 미작성('필요 시' 조건부 — 정당).",
|
||||
"description": "페이지 모듈 레이아웃 편집기 스펙 — 관리자 화면 프리뷰 샘플과 페이지 화면(/p/:slug, 약관·개인정보) 엔드포인트 샘플. sampleGlobal 은 이 모듈이 _global 키를 두지 않아 선언하지 않는다.",
|
||||
"sampleData": {
|
||||
"comment": "페이지 모듈이 도메인 SSoT. 사용자 페이지 상세 + 약관/개인정보 모달 트리거 데이터.",
|
||||
"byDataSourceId": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "페이지",
|
||||
"en": "Page"
|
||||
},
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Hello 플러그인 — 에이전트 가이드
|
||||
|
||||
> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 유형: 플러그인 (gnuboard7-hello_plugin) — 학습용 최소 샘플. 학습용 모듈의 훅을 Action·Filter 두 방식으로 구독하는 것만 시연한다. 모델·테이블 없음, `hidden: true`
|
||||
2. 확장 방식: 발행 훅 1개(`log.written`) — 구독한 플러그인이 다시 발행해 연쇄를 잇는 형태를 보인다
|
||||
3. 건드리면 안 되는 것: Filter 구독의 `'type' => 'filter'` 누락(반환값이 버려진다), 대상 모듈 직접 수정, 설정 토글 없는 무조건 동작, 완전한 페이지 레이아웃 등록
|
||||
4. 작업 위치: `plugins/_bundled/gnuboard7-hello_plugin` — 활성 디렉토리 직접 수정 금지
|
||||
5. 반영: `php artisan plugin:update gnuboard7-hello_plugin --force`
|
||||
```
|
||||
|
||||
## 1. 이 확장은 무엇인가
|
||||
|
||||
<!-- @intent START -->
|
||||
**학습용 최소 샘플 플러그인**입니다. 플러그인의 핵심 역할인 **훅 구독**을 두 종류로 시연하는
|
||||
것이 유일한 목적입니다 — 부가 작업을 수행하는 Action 리스너 하나와, 흐름 중간에서 값을 가공하는
|
||||
Filter 리스너 하나.
|
||||
|
||||
대상은 학습용 모듈(`gnuboard7-hello_module`)의 메모입니다. 메모가 생성되면 로그를 남기고(Action),
|
||||
메모 제목이 화면에 나가기 전에 접두사를 붙입니다(Filter). **모듈 코드는 한 줄도 고치지
|
||||
않습니다** — 그것이 훅 시스템이 존재하는 이유입니다.
|
||||
|
||||
**모듈과 플러그인의 경계**도 함께 보여줍니다. 플러그인은 완전한 페이지 레이아웃을 등록할 수
|
||||
없고, 설정 화면(`plugin_settings.json`)과 `layout_extensions`(다른 화면에 끼워 넣는 조각)만
|
||||
허용됩니다. 이 샘플에는 설정 화면 하나가 있습니다.
|
||||
|
||||
`manifest.hidden = true` 라 관리자 UI 의 플러그인 목록에 나타나지 않습니다. artisan CLI 로는
|
||||
정상 설치·활성화됩니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: 모델·테이블·마이그레이션·API 라우트. 플러그인이 자기 데이터를 가질
|
||||
수는 있지만(다른 플러그인들이 그렇습니다), 이 샘플은 **훅만** 보이면 되므로 두지 않았습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 2. 디렉토리 지도
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update gnuboard7-hello_plugin --force` (빌드 불필요) |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
|
||||
## 3. 핵심 흐름
|
||||
|
||||
<!-- @intent START -->
|
||||
**Action 구독** — 부가 작업: 학습용 모듈의 `MemoService::create()` 가
|
||||
`gnuboard7-hello_module.memo.created` 를 발행 → `LogMemoCreatedListener::onMemoCreated()`
|
||||
가 그것을 받아 로그를 기록 → 기록 직후 자기 훅
|
||||
`gnuboard7-hello_plugin.log.written` 을 발행합니다. **구독한 플러그인이 다시 발행하는** 이
|
||||
연쇄가 훅 시스템의 확장 방식입니다 — 또 다른 확장이 이 플러그인의 동작에 반응할 수 있습니다.
|
||||
|
||||
로그 기록 여부는 설정(`log_enabled`)이 정합니다. **설정으로 끌 수 있게 만드는 것**이 부가
|
||||
동작의 규약입니다 — 리스너가 무조건 동작하면 그 확장을 설치한 사이트는 끌 방법이 없습니다.
|
||||
|
||||
**Filter 구독** — 값 가공: `gnuboard7-hello_module.memo.title.filter` 가 발행되면
|
||||
`FilterMemoTitleListener::prependHelloPrefix()` 가 그 값을 받아 접두사를 붙여 **반환**합니다.
|
||||
Action 과 달리 Filter 는 **반환값이 흐름에 다시 들어갑니다.**
|
||||
|
||||
이 훅은 **학습용 모듈이 실제로 발행하지 않습니다.** 리스너 docblock 이 "발행한다고 가정하고"
|
||||
라고 밝히고 있으며, 그 자체가 학습 포인트입니다 — **훅이 발행되지 않아도 리스너 등록은
|
||||
유효하고**, 나중에 발행 지점이 생기면 그때부터 자동으로 호출됩니다. 구독은 발행자에게 아무런
|
||||
부담을 주지 않으므로 확장이 서로를 몰라도 됩니다.
|
||||
|
||||
Filter 구독에는 `'type' => 'filter'` 선언이 반드시 필요합니다. 빠뜨리면 코어가 그것을 Action
|
||||
으로 취급해 **반환값을 버립니다** — 리스너는 정상 실행되고 오류도 없는데 가공만 반영되지
|
||||
않습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 4. 확장점
|
||||
|
||||
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 확장점 | 수 | 상세 |
|
||||
|---|---|---|
|
||||
| 발행 훅 | 1개 | [발행 훅](docs/extension-points.md#발행-훅) |
|
||||
| 구독 훅 | 2개 | [구독 훅](docs/extension-points.md#구독-훅) |
|
||||
| 훅 리스너 | 2개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
|
||||
| 레이아웃 확장 | 0개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
|
||||
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
|
||||
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
|
||||
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
|
||||
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
|
||||
<!-- @generated:extension-points-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
이 샘플이 보여주는 것은 **구독 쪽**이지만, 발행도 하나 있습니다.
|
||||
|
||||
| 방향 | 훅 | 무엇을 보여주는가 |
|
||||
|---|---|---|
|
||||
| 구독 (Action) | `gnuboard7-hello_module.memo.created` | 다른 확장의 흐름에 부가 작업을 붙이는 법 |
|
||||
| 구독 (Filter) | `gnuboard7-hello_module.memo.title.filter` | 흐름 중간의 값을 가공하는 법 (`'type' => 'filter'` 필수). **모듈이 실제로 발행하지는 않는 가상의 훅** — 미발행 훅 구독도 유효함을 함께 보인다 |
|
||||
| 발행 (Action) | `gnuboard7-hello_plugin.log.written` | 구독한 확장이 **다시 발행**해 연쇄를 잇는 법 |
|
||||
|
||||
발행 훅에는 `getHooks()` 선언이 있어 표에 유형과 설명이 함께 실립니다 — 발행 훅을 선언하면
|
||||
구독하려는 쪽에 계약이 드러납니다.
|
||||
|
||||
**의존 방향에 주의합니다.** 이 플러그인은 `gnuboard7-hello_module` 에 manifest 의존을
|
||||
선언합니다. 구독 대상이 없으면 훅이 발화하지 않을 뿐이지만, 이 샘플은 **그 모듈의 훅을 보는
|
||||
것 자체가 목적**이라 모듈 없이는 존재 이유가 없습니다. 실제 플러그인에서는 "없으면 그 기능만
|
||||
비는" 관계인지 "없으면 성립하지 않는" 관계인지를 보고 의존 선언 여부를 정합니다.
|
||||
|
||||
레이아웃 확장·미들웨어·브로드캐스트·스케줄·알림·권한·메뉴는 없습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 5. 수정 시 동반 의무
|
||||
|
||||
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update gnuboard7-hello_plugin --force` 로 반영
|
||||
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
|
||||
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
|
||||
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:gnuboard7-hello_plugin` 재실행 + `docs/api/**` 갱신
|
||||
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
|
||||
- [ ] Filter 훅을 구독한다면 `'type' => 'filter'` 를 선언했는지 확인 — 누락 시 반환값이 조용히 버려진다
|
||||
- [ ] 부가 동작은 설정 토글 뒤에 둔다 (`log_enabled` 가 그 본보기)
|
||||
- [ ] `manifest.hidden = true` 를 유지 (복제본에서만 제거)
|
||||
- [ ] 구독 대상 모듈의 훅 이름이 바뀌면 이 플러그인이 조용히 아무 일도 하지 않게 된다
|
||||
- [ ] `docs/extension/sample-extensions.md` 의 계층 표와 어긋나지 않는지 확인 (파일을 추가·삭제했다면 그 표도 갱신)
|
||||
- [ ] 플러그인은 완전한 페이지 레이아웃을 등록할 수 없다 — 설정 화면과 `layout_extensions` 만
|
||||
- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어도 되는 상태(공용 ID 만 사용)다. 이 확장만 쓰는 `data_source` 를 새로 붙이는 순간 `editor-spec.json` 신설이 필요해진다
|
||||
|
||||
## 6. 금지 패턴
|
||||
|
||||
<!-- @intent START -->
|
||||
| 금지 | 올바른 사용 | 이유 |
|
||||
|---|---|---|
|
||||
| Filter 훅을 구독하면서 `'type' => 'filter'` 를 빠뜨리기 | 선언 필수 | 코어가 Action 으로 취급해 **반환값을 버린다** — 리스너는 실행되고 오류도 없는데 가공만 반영되지 않는다 |
|
||||
| 대상 모듈의 코드를 직접 고쳐 부가 동작을 넣기 | 훅 구독 | 모듈이 업그레이드될 때마다 충돌하고, 플러그인을 꺼도 그 동작이 남는다 |
|
||||
| 부가 동작을 설정 없이 무조건 수행 | 설정 토글(`log_enabled`) 뒤에 둔다 | 설치한 사이트가 끌 방법이 없다 |
|
||||
| 리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 직접 호출 | Repository 인터페이스 주입 | 리스너가 데이터 접근 규약의 예외가 되면 그 예외가 번진다 |
|
||||
| 플러그인에 완전한 페이지 레이아웃을 등록 | 설정 화면(`plugin_settings.json`)과 `layout_extensions` 만 | 페이지 소유권은 모듈·템플릿에 있다 — 경로를 다투면 설치 순서에 따라 화면이 바뀐다 |
|
||||
| 이 샘플에 기능을 더해 "쓸모 있게" 만들기 | 짧게 유지하고, 필요한 기능은 별도 확장으로 | 샘플의 가치는 한눈에 읽히는 것이다 |
|
||||
| `manifest.hidden` 을 제거 | 그대로 둔다 (복제본에서만 제거) | 학습용 플러그인이 운영 사이트의 목록에 섞인다 |
|
||||
| 금전이 오가는 훅을 기본 설정(큐)으로 구독 | `'sync' => true` | 커밋 뒤 실행이라 예외를 던져도 롤백되지 않는다 (이 샘플에는 해당 없으나 실제 플러그인에서 자주 걸린다) |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 7. 테스트 실행
|
||||
|
||||
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 2개 | `plugins/_bundled/gnuboard7-hello_plugin/tests` |
|
||||
| Vitest | 0개 | — |
|
||||
| Playwright | 0개 | — |
|
||||
| 시나리오 매니페스트 | 0개 | — |
|
||||
|
||||
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
||||
|
||||
```bash
|
||||
# PHPUnit (변경 범위만) (Bash)
|
||||
php vendor/bin/phpunit plugins/_bundled/gnuboard7-hello_plugin/tests --filter='<대상클래스>'
|
||||
|
||||
```
|
||||
|
||||
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
|
||||
<!-- @generated:test-commands END -->
|
||||
|
||||
## 8. 문서 목차
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
@@ -4,6 +4,14 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [0.1.2] - 2026-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
|
||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||
|
||||
## [0.1.1] - 2026-08-17
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Hello 플러그인
|
||||
|
||||
**그누보드7 플러그인 · gnuboard7-hello_plugin**
|
||||
학습용 최소 샘플 플러그인 (Hello 모듈 훅 소비)
|
||||
|
||||
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.1.2-0066FF?style=flat-square" alt="version 0.1.2">
|
||||
<img src="https://img.shields.io/badge/type-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-555555?style=flat-square" alt="type 플러그인">
|
||||
<img src="https://img.shields.io/badge/%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7-%3E%3D7.0.0-1F883D?style=flat-square" alt="그누보드7 >=7.0.0">
|
||||
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
|
||||
<img src="https://img.shields.io/badge/requires-gnuboard7--hello__module-BF8700?style=flat-square" alt="requires gnuboard7-hello_module">
|
||||
</p>
|
||||
<!-- @generated:badges END -->
|
||||
|
||||
---
|
||||
|
||||
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
|
||||
|
||||
---
|
||||
|
||||
## 소개
|
||||
|
||||
<!-- @intent START -->
|
||||
그누보드7 **플러그인이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 업무에 쓰는 기능은
|
||||
없습니다.
|
||||
|
||||
플러그인의 핵심 역할은 **다른 확장의 코드를 고치지 않고 그 동작에 끼어드는 것**입니다. 이
|
||||
샘플은 그 두 가지 방식을 하나씩 보여줍니다 — 학습용 모듈에 메모가 등록되면 기록을 남기고,
|
||||
메모 제목이 화면에 나가기 전에 앞에 표시를 붙이는 것입니다.
|
||||
|
||||
관리자 화면의 플러그인 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로
|
||||
설치·활성화할 수 있으며, 학습용 모듈이 함께 설치되어 있어야 동작을 확인할 수 있습니다.
|
||||
|
||||
플러그인은 자기 페이지를 가질 수 없습니다 — 설정 화면과 "다른 화면에 끼워 넣는 조각" 만
|
||||
허용됩니다. 이 샘플에는 설정 화면 하나가 있습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 주요 기능
|
||||
|
||||
<!-- @intent START -->
|
||||
| 영역 | 설명 |
|
||||
|---|---|
|
||||
| 기록 남기기 | 학습용 모듈에 메모가 등록되면 로그 파일에 기록 (설정으로 끌 수 있음) |
|
||||
| 제목 가공 | 메모 제목 앞에 표시를 붙이는 예시 |
|
||||
| 설정 화면 | 기록 사용 여부를 켜고 끄는 관리자 설정 |
|
||||
| 연결점 제공 | 기록을 남긴 직후 다른 확장이 반응할 수 있는 연결점 |
|
||||
| 다국어 | 한국어·영어 화면 문구 |
|
||||
| 테스트 | 모듈이 신호를 보내고 이 플러그인이 받는지 확인하는 예시 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 동작 방식
|
||||
|
||||
<!-- @intent START -->
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M[학습용 모듈<br/>메모 등록] -->|생성 신호| P[이 플러그인]
|
||||
P -->|설정이 켜져 있으면| LOG[(로그 기록)]
|
||||
LOG -->|기록 완료 신호| X[다른 확장]
|
||||
M -.제목 가공 요청.-> P2[제목 앞에 표시 붙이기]
|
||||
```
|
||||
|
||||
모듈은 이 플러그인의 존재를 모릅니다. 모듈이 "메모가 등록되었다" 는 신호를 보내면, 그 신호를
|
||||
듣고 있던 이 플러그인이 자기 일을 합니다. 그래서 플러그인을 꺼도 모듈은 그대로 동작합니다.
|
||||
|
||||
기록을 남긴 뒤에는 이 플러그인도 신호를 보냅니다 — 신호를 받은 확장이 다시 신호를 보내며
|
||||
이어지는 것이 확장 시스템의 기본 구조입니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 요구 사항
|
||||
|
||||
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 그누보드7 코어 | `>=7.0.0` |
|
||||
| PHP | `^8.2` |
|
||||
| 의존 모듈 | `gnuboard7-hello_module` `>=0.1.0` |
|
||||
<!-- @generated:requirements END -->
|
||||
|
||||
## 설치
|
||||
|
||||
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
```bash
|
||||
# 번들 설치 (코어에 동봉된 소스에서 설치)
|
||||
php artisan plugin:install gnuboard7-hello_plugin
|
||||
|
||||
# 활성화
|
||||
php artisan plugin:activate gnuboard7-hello_plugin
|
||||
|
||||
# 업데이트 (번들 소스 기준 강제 반영)
|
||||
php artisan plugin:update gnuboard7-hello_plugin --force
|
||||
```
|
||||
<!-- @generated:install END -->
|
||||
|
||||
## 관리자 설정
|
||||
|
||||
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 키 | 의미 | 기본값 |
|
||||
|---|---|---|
|
||||
| `log_enabled` | 로그 기록 사용 | `true` |
|
||||
|
||||
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
|
||||
<!-- @generated:settings-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
설정 항목은 하나뿐입니다.
|
||||
|
||||
| 항목 | 기본값 | 바꾸면 달라지는 것 |
|
||||
|---|---|---|
|
||||
| 로그 기록 사용 | 켜짐 | 끄면 메모가 등록되어도 기록을 남기지 않습니다 (모듈 동작에는 영향 없음) |
|
||||
|
||||
부가 동작을 **설정으로 끌 수 있게 만드는 것**이 이 항목의 학습 포인트입니다. 설정 없이 무조건
|
||||
동작하면 그 플러그인을 설치한 사이트는 동작을 멈출 방법이 없습니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 사용 방법
|
||||
|
||||
<!-- @intent START -->
|
||||
**설치해 보기**: 학습용 모듈을 먼저 설치한 뒤 이 플러그인을 설치합니다.
|
||||
|
||||
```bash
|
||||
php artisan module:install gnuboard7-hello_module
|
||||
php artisan module:activate gnuboard7-hello_module
|
||||
php artisan plugin:install gnuboard7-hello_plugin
|
||||
php artisan plugin:activate gnuboard7-hello_plugin
|
||||
```
|
||||
|
||||
관리자에서 "Hello 메모" 를 등록하면 로그에 기록이 남습니다. 플러그인 설정에서 기록을 끈 뒤
|
||||
다시 등록해 보면 기록이 남지 않는 것을 확인할 수 있습니다 — 모듈 동작 자체는 그대로입니다.
|
||||
|
||||
**새 플러그인의 출발점으로 쓰기**: 이 디렉토리를 복제한 뒤 식별자·네임스페이스를 모두 바꾸고
|
||||
학습용 표시를 지우면 새 플러그인이 됩니다. 자세한 절차는 확장 시스템 문서의 "학습용 샘플 확장"
|
||||
항목을 참고합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 다른 확장과의 연동
|
||||
|
||||
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**이 확장이 의존하는 확장**
|
||||
|
||||
| 확장 | 유형 | 버전 제약 | 번들 |
|
||||
|---|---|---|---|
|
||||
| `gnuboard7-hello_module` | 모듈 | `>=0.1.0` | ✅ |
|
||||
|
||||
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|
||||
|
||||
없음.
|
||||
<!-- @generated:integrations END -->
|
||||
|
||||
## 문서
|
||||
|
||||
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 | 상태 |
|
||||
|---|---|---|
|
||||
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
|
||||
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
|
||||
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
|
||||
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
|
||||
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
|
||||
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
|
||||
| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
|
||||
<!-- @generated:docs-index END -->
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
<!-- @intent START -->
|
||||
| 증상 | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
| 관리자 플러그인 목록에 이 플러그인이 없음 | 학습용이라 목록에서 제외됨 | 정상입니다. 명령줄로 설치·활성화합니다 |
|
||||
| 메모를 등록해도 기록이 남지 않음 | 설정에서 기록이 꺼져 있거나 학습용 모듈이 비활성 | 플러그인 설정과 모듈 활성화 상태를 확인합니다 |
|
||||
| 제목 가공이 반영되지 않음 | 이 예시가 기대하는 가공 요청 지점이 모듈에 없음 | 정상입니다. 연결점이 없어도 등록 자체는 유효하며, 그 지점이 생기면 자동으로 동작합니다 |
|
||||
| 복제해서 만든 플러그인이 관리자 목록에 안 보임 | 복제본에 학습용 표시가 남아 있음 | 복제본의 `hidden` 표시를 지웁니다 |
|
||||
| 복제한 플러그인에서 값 가공이 무시됨 | 가공용 구독에 종류 표시가 빠짐 | 가공(Filter) 구독에는 종류를 명시해야 반환값이 반영됩니다 |
|
||||
<!-- @intent END -->
|
||||
|
||||
## 변경 이력
|
||||
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "plugins/gnuboard7-hello_plugin",
|
||||
"description": "Learning minimal sample plugin for Gnuboard7 platform (consumes Hello module hooks)",
|
||||
"type": "library",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Plugins\\Gnuboard7\\HelloPlugin\\": ["src/", "./"]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Hello 플러그인 개발자 문서
|
||||
|
||||
> plugins/_bundled/gnuboard7-hello_plugin · 플러그인
|
||||
|
||||
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**훅 수**: 1 · **구독 훅 수**: 2 · **라우트 수**: 0 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 0
|
||||
<!-- @generated:stats END -->
|
||||
|
||||
## 문서 목차
|
||||
|
||||
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
|
||||
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
|
||||
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
|
||||
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
|
||||
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
|
||||
| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 |
|
||||
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
|
||||
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
|
||||
<!-- @generated:doc-toc END -->
|
||||
@@ -0,0 +1,71 @@
|
||||
# Hello 플러그인 — 아키텍처
|
||||
|
||||
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 설계 의도
|
||||
|
||||
<!-- @intent START -->
|
||||
"플러그인은 무엇을 하는가" 에 대한 답을 **가장 짧게** 보이는 것이 목표입니다. 플러그인의 핵심은
|
||||
**다른 확장의 코드를 고치지 않고 그 동작에 끼어드는 것**이며, 그 방식이 둘(Action·Filter)
|
||||
이므로 리스너도 둘입니다.
|
||||
|
||||
거기에 두 가지를 덧붙였습니다:
|
||||
|
||||
- **부가 동작은 설정으로 끌 수 있어야 한다** — `log_enabled` 가 그 본보기입니다. 리스너가
|
||||
무조건 동작하면 그 확장을 설치한 사이트는 멈출 방법이 없습니다.
|
||||
- **구독한 확장이 다시 발행할 수 있다** — `log.written` 이 그 예입니다. 훅은 한 번 받고 끝나는
|
||||
것이 아니라 연쇄를 이룹니다.
|
||||
|
||||
**플러그인의 경계**도 구조로 드러납니다. 완전한 페이지 레이아웃을 등록할 수 없고, 설정 화면
|
||||
(`plugin_settings.json`)과 `layout_extensions`(다른 화면에 끼워 넣는 조각)만 허용됩니다. 이
|
||||
샘플에는 설정 화면 하나가 있고 `layout_extensions` 는 없습니다.
|
||||
|
||||
**의도적으로 하지 않는 것**: 모델·테이블·마이그레이션·API 라우트·권한·메뉴. 플러그인이 자기
|
||||
데이터를 가질 수는 있지만(실제 플러그인들이 그렇습니다), 이 샘플은 훅만 보이면 되므로 두지
|
||||
않았습니다. `manifest.hidden = true` 로 관리자 UI 목록에서 제외되며 CLI 로는 정상 동작합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 계층 지도
|
||||
|
||||
<!-- @intent START -->
|
||||
```
|
||||
plugin.php 진입 클래스 — 설정 스키마 · 발행 훅 선언 · 리스너 등록
|
||||
│
|
||||
├─ Listeners/LogMemoCreatedListener Action 구독
|
||||
│ gnuboard7-hello_module.memo.created 를 받아
|
||||
│ 설정(log_enabled) 확인 → 로그 기록 → log.written 발행
|
||||
│
|
||||
└─ Listeners/FilterMemoTitleListener Filter 구독 ('type' => 'filter')
|
||||
gnuboard7-hello_module.memo.title.filter 의 값을 가공해 반환
|
||||
|
||||
config/settings/defaults.json 설정 기본값
|
||||
resources/layouts/admin/plugin_settings.json 설정 화면 (파일 이름이 계약)
|
||||
src/routes/web.php web 라우트 — 플러그인도 라우트를 가질 수 있음을 보이는 예시
|
||||
resources/lang/{ko,en}.json 프론트 다국어 (백엔드 PHP 다국어는 없음)
|
||||
```
|
||||
|
||||
**계층이 얕은 것이 정상**입니다. 플러그인은 자기 도메인을 갖지 않고 남의 흐름에 붙으므로,
|
||||
Controller → Service → Repository → Model 사슬이 필요 없습니다. 실제 플러그인 중 자기 데이터를
|
||||
갖는 것들(결제·GDPR 등)은 그 사슬을 갖지만, 그것은 플러그인의 필수 구조가 아니라 그 도메인의
|
||||
필요입니다.
|
||||
|
||||
`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를
|
||||
찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 디렉토리
|
||||
|
||||
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 경로 | 역할 | 수정 시 필요한 절차 |
|
||||
|---|---|---|
|
||||
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
|
||||
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
|
||||
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
|
||||
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
|
||||
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
|
||||
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update gnuboard7-hello_plugin --force` (빌드 불필요) |
|
||||
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
|
||||
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
|
||||
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
|
||||
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
|
||||
<!-- @generated:directory-map END -->
|
||||
@@ -0,0 +1,76 @@
|
||||
# Hello 플러그인 — 데이터 모델
|
||||
|
||||
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
|
||||
|
||||
## 모델
|
||||
|
||||
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_소유 모델이 없습니다._
|
||||
<!-- @generated:models END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 샘플은 자기 데이터를 갖지 않습니다.
|
||||
|
||||
플러그인이 모델과 테이블을 가질 수는 있고 실제로 그런 플러그인이 많습니다(결제 이력·동의 기록·
|
||||
메시지 발송 기록 등). 다만 이 샘플의 목적은 **훅 구독**을 보이는 것이라, 데이터 계층을 두면
|
||||
읽어야 할 코드만 늘어납니다.
|
||||
|
||||
모델·Repository·마이그레이션이 있는 플러그인 예시가 필요하면 실제 도메인 플러그인의 문서를
|
||||
참고합니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 소유 테이블
|
||||
|
||||
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_소유 테이블이 없습니다._
|
||||
<!-- @generated:tables END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 저장하는 데이터가 없습니다.
|
||||
|
||||
플러그인이 테이블을 가질 때는 **확장 식별자를 접두사로** 붙입니다 — 확장은 같은 데이터베이스를
|
||||
공유하므로 짧은 이름을 쓰면 다른 확장과 충돌합니다. 그리고 플러그인 제거 시 정리 대상임을
|
||||
`getDynamicTables()` 로 코어에 알립니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## 마이그레이션
|
||||
|
||||
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_마이그레이션이 없습니다._
|
||||
<!-- @generated:migrations END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 스키마가 없으므로 마이그레이션도 없습니다.
|
||||
|
||||
플러그인이 마이그레이션을 가질 때의 규약은 모듈과 같습니다 — 한국어 `comment` 와 `down()`
|
||||
필수, 초기 `create_*` 파일을 나중에 고치지 않기, 기존 행을 손봐야 하는 변경에는 `upgrades/`
|
||||
업그레이드 스텝 백필 동반.
|
||||
<!-- @intent END -->
|
||||
|
||||
## Enum
|
||||
|
||||
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_Enum 이 없습니다._
|
||||
<!-- @generated:enums END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 이 샘플에는 상태도 분류도 없습니다.
|
||||
|
||||
실제 확장에서 상태·타입·분류를 다룰 때는 문자열 리터럴이 아니라 Enum 을 단일 출처로 둡니다 —
|
||||
화면 필터 옵션·검증 게이트·실제 기록 값 셋이 같은 Enum 에서 파생되지 않으면, 빠진 값으로
|
||||
기록된 행이 어떤 필터로도 도달할 수 없게 됩니다.
|
||||
<!-- @intent END -->
|
||||
|
||||
## Repository
|
||||
|
||||
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
_Repository 가 없습니다._
|
||||
<!-- @generated:repositories END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
없습니다. 데이터 접근 자체가 없습니다.
|
||||
|
||||
리스너에서 데이터에 접근해야 한다면 `Model::query()` · `DB::table()` · `$row->save()` 를 직접
|
||||
부르지 않고 **Repository 인터페이스를 주입**받습니다. 리스너가 데이터 접근 규약의 예외가 되면
|
||||
그 예외가 다른 리스너로 번집니다.
|
||||
<!-- @intent END -->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user