Merge pull request from gnuboard:HeuJung/issue601

HeuJung/issue601
This commit is contained in:
정정홍
2026-09-01 13:46:28 +09:00
committed by GitHub
304 changed files with 35630 additions and 2650 deletions
+67 -9
View File
@@ -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) |
+7
View File
@@ -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
View File
@@ -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>
+3 -7
View File
@@ -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);
}
}
+527
View File
@@ -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
View File
@@ -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) |
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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/파라미터/응답 필드 +... |
+42 -343
View File
@@ -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 클래스 생성
+7
View File
@@ -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 기재 의무 |
---
## 확장 타입별 네이밍 규칙
+83
View File
@@ -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`
+293
View File
@@ -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 레퍼런스 문서 규정
-7
View File
@@ -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) |
### 컴포넌트 개발
| 문서 | 설명 |
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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:` 다국어
+3 -3
View File
@@ -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)
+5 -8
View File
@@ -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) - 전역/로컬 상태 관리 및 동기화 패턴
+1 -1
View File
@@ -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)
---
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
---
+5 -6
View File
@@ -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)
+13
View File
@@ -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 &gt;=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)",
+193
View File
@@ -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
+172
View File
@@ -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 &gt;=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
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
+2 -2
View File
@@ -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 -1
View File
@@ -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 &gt;=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 분리 원칙과 동일.
| 데이터 종류 | 위치 |
|---|---|
+209
View File
@@ -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
+183
View File
@@ -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 &gt;=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
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "페이지",
"en": "Page"
},
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"description": {
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
+2 -2
View File
@@ -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 -1
View File
@@ -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 &gt;=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