diff --git a/AGENTS.md b/AGENTS.md index faf244d8..0f6ce26e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | + + --- @@ -461,6 +484,40 @@ Icon 은 `` 글리프라 박스 크기가 곧 `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) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ba5408..4b1c73a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,18 @@ - 프록시 뒤에서 구동 중인데 신뢰 프록시가 지정되지 않았으면 관리자 대시보드가 그 사실을 알립니다. 환경설정 > 고급 에서 사이트가 인식한 접속 방식과 방문자 IP 를 확인할 수 있고, 서버에서 `php artisan trusted-proxy:status` 로도 확인할 수 있으며, 설치 마법사도 설치 단계에서 함께 안내합니다. HTTPS 를 쓰지 않는 사이트도 대상입니다 — 이 경우 화면은 정상이지만 방문자 IP 기록과 결제 통보 수신이 어긋나 있어도 드러나지 않기 때문입니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.) - 사이트 설정 문제로 화면 구성 파일이 브라우저에 차단된 경우, 네트워크 오류와 구분되는 안내를 표시합니다. 새로고침해도 낫지 않는 상황이므로 [새로고침] 버튼을 두지 않으며, 원인과 조치 방법은 운영자가 확인할 수 있도록 브라우저 콘솔에 남깁니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.) - 사이트가 쓰는 외부 라이브러리의 알려진 취약점을 한 번에 점검하는 명령이 추가되었습니다. `php artisan security:audit-dependencies` 로 코어와 설치된 모든 확장을 함께 확인할 수 있고, 개발 대시보드에서도 실행할 수 있습니다. 점검 도구가 원리상 볼 수 없는 동봉 라이브러리는 버전 목록으로 함께 표시해 운영자가 직접 확인할 수 있게 했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.) +- 확장(모듈·플러그인·템플릿)이 개발자 문서를 갖추기 위한 체계가 마련되었습니다. 확장마다 `AGENTS.md`(확장을 고치는 사람용 — 설계 의도·확장점·수정 시 동반 의무·금지 패턴)와 `README.md`(도입 검토·운영자용 — 기능·설치·사용 방법·트러블슈팅), `docs/` 상세 문서를 두는 형식을 정의했으며, **동봉된 확장 20개 전부에 문서가 채워졌습니다.** 새 확장을 만들면 스캐폴딩 단계에서 이 문서 골격이 함께 생성됩니다. +- 확장 문서에서 코드로 확인되는 부분(발행·구독 훅, 라우트, 권한, 메뉴, 설정 항목, 모델과 테이블, 레이아웃, 액션 핸들러, 테스트 실행 경로, 다른 확장과의 의존 관계)을 `php artisan ext:docgen` 이 자동으로 채우고 유지합니다. 사람이 쓴 서술은 손대지 않고 자동 생성 표만 교체하며, `php artisan ext:docgen --check` 로 문서가 코드와 어긋났는지 확인할 수 있습니다. 개발 대시보드에서도 실행할 수 있습니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목이 추가되었습니다. 그 확장이 레이아웃 편집기에 무엇을 선언했는지(추가 가능한 화면 요소, 스타일 조절 항목, 미리보기용 샘플 데이터, 화면 상태)와 화면 요소·데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담으며, 편집기 스펙을 두지 않은 확장에는 그것이 정상인지 아닌지를 적습니다. +- 확장 문서 검사가 「설명 자리를 비워 둔 상태」도 미작성으로 셉니다. 종전에는 채워 넣으라는 표시만 지우고 내용을 쓰지 않으면 검사를 통과해, 빈 문서가 완비된 것으로 집계되었습니다. +- 확장의 화면에 데이터를 붙였는데 레이아웃 편집기 미리보기에서 그 자리가 비는 경우를 문서가 실측해 알려 줍니다. 이 어긋남은 실제 화면이 정상 동작해 아무 오류도 남지 않으므로 종전에는 편집기를 열어 보기 전까지 드러나지 않았습니다. ### Changed - 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.) - 레이아웃 편집기를 여는 중 네트워크가 잠시 끊겨도 자동으로 다시 시도합니다. 끝내 실패하면 내부 파일 경로 대신 다음에 무엇을 하면 되는지를 안내합니다. +- 확장 문서에서 제품을 가리키는 이름이 「그누보드7」로 통일되었습니다. 종전에는 같은 문서 안에서도 약칭과 정식 명칭이 섞여, 확장만 내려받은 사람에게 별개 제품처럼 보였습니다. +- 번들 템플릿의 컴포넌트·핸들러·레이아웃 상세 문서와 확장이 사용하는 활동 로그 항목 목록이 각 확장의 문서로 옮겨졌습니다. 확장이 기능을 늘릴 때 코어 문서를 함께 고쳐야 하던 의존이 사라졌으며, 코어 문서에는 총계와 각 확장 문서로의 링크만 남습니다. ### Fixed diff --git a/README.ko.md b/README.ko.md index 70098fc3..28e085cf 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,13 +1,9 @@

English | 한국어

-

- 그누보드7 (Gnuboard7) -

+# 그누보드7 -

- 모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS
- A modern, extensible CMS platform built with Laravel + React -

+**모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS** +A modern, extensible CMS platform built with Laravel + React

Version diff --git a/README.md b/README.md index 907e300d..e6ded2ed 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,9 @@

English | 한국어

-

- Gnuboard7 (그누보드7) -

+# Gnuboard7 -

- A modern, extensible CMS platform built with Laravel + React
- The next generation of Gnuboard — Korea's most widely used open-source CMS -

+**A modern, extensible CMS platform built with Laravel + React** +The next generation of Gnuboard — Korea's most widely used open-source CMS

Version diff --git a/app/Console/Commands/Extension/ExtDocgenCommand.php b/app/Console/Commands/Extension/ExtDocgenCommand.php new file mode 100644 index 00000000..34667562 --- /dev/null +++ b/app/Console/Commands/Extension/ExtDocgenCommand.php @@ -0,0 +1,453 @@ + 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 $ctx 수집 컨텍스트 + * @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더 + * @return array 처리 결과 + */ + 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 $ctx 수집 컨텍스트 + * @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더 + * @param array $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'; + $endPattern = '//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> $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'; + } +} diff --git a/app/Support/ExtensionDoc/DataModelCollector.php b/app/Support/ExtensionDoc/DataModelCollector.php new file mode 100644 index 00000000..923adee9 --- /dev/null +++ b/app/Support/ExtensionDoc/DataModelCollector.php @@ -0,0 +1,332 @@ + + */ + private const RELATION_METHODS = [ + 'hasOne', 'hasMany', 'belongsTo', 'belongsToMany', + 'hasOneThrough', 'hasManyThrough', + 'morphOne', 'morphMany', 'morphTo', 'morphToMany', 'morphedByMany', + ]; + + /** + * 확장의 데이터 모델 표면을 수집합니다. + * + * @param array $record ExtensionInventory 레코드 + * @return array{models: array>, enums: array>, migrations: array>, tables: array, repositories: array>} + */ + 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 $record 확장 레코드 + * @return array> 모델 목록 + */ + 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 관계 목록 + */ + 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 $record 확장 레코드 + * @return array> 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 $record 확장 레코드 + * @return array> 마이그레이션 목록 (파일명 정렬) + */ + 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 $record 확장 레코드 + * @return array> 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 $record 확장 레코드 + * @param string $sub 확장 루트 기준 하위 경로 + * @return array 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 $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); + } +} diff --git a/app/Support/ExtensionDoc/DeclarativeSurfaceCollector.php b/app/Support/ExtensionDoc/DeclarativeSurfaceCollector.php new file mode 100644 index 00000000..0bdb0d23 --- /dev/null +++ b/app/Support/ExtensionDoc/DeclarativeSurfaceCollector.php @@ -0,0 +1,364 @@ + + */ + 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 $record ExtensionInventory 레코드 + * @return array{available: bool, reason: string|null, values: array, errors: array, 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 $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 $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 $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); + }; + } +} diff --git a/app/Support/ExtensionDoc/DependencyGraphCollector.php b/app/Support/ExtensionDoc/DependencyGraphCollector.php new file mode 100644 index 00000000..0c9c5d19 --- /dev/null +++ b/app/Support/ExtensionDoc/DependencyGraphCollector.php @@ -0,0 +1,194 @@ +>|null 전수 인벤토리 캐시 + */ + private ?array $universe = null; + + /** + * @param ExtensionInventory $inventory 번들 확장 인벤토리 + */ + public function __construct(private readonly ExtensionInventory $inventory) {} + + /** + * 확장의 의존 관계를 수집합니다. + * + * @param array $record ExtensionInventory 레코드 + * @return array{requires: array, requiredBy: array, coreVersion: string|null} + */ + public function collect(array $record): array + { + return [ + 'requires' => $this->requires($record), + 'requiredBy' => $this->requiredBy($record), + 'coreVersion' => $this->coreConstraint($record), + ]; + } + + /** + * 이 확장이 의존하는 확장 목록을 반환합니다. + * + * @param array $record 확장 레코드 + * @return array + */ + 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 $record 확장 레코드 + * @return array + */ + 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 $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 $record 확장 레코드 + * @return array{modules: array, plugins: array} + */ + 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> 확장 레코드 목록 + */ + private function allExtensions(): array + { + if ($this->universe === null) { + $this->universe = $this->inventory->collect('all'); + } + + return $this->universe; + } +} diff --git a/app/Support/ExtensionDoc/EditorSpecCollector.php b/app/Support/ExtensionDoc/EditorSpecCollector.php new file mode 100644 index 00000000..0a31bbeb --- /dev/null +++ b/app/Support/ExtensionDoc/EditorSpecCollector.php @@ -0,0 +1,566 @@ + false`). 미보유는 정상 상태일 수 있고, 문서는 그 + * 정상 여부를 서술할 자리를 가져야 합니다. + * + * 합본은 런타임 서빙과 같은 경로(`EditorSpecAssembler`)를 씁니다. 수집기가 별도 병합 + * 규칙을 갖게 되면 문서가 말하는 스펙과 편집기가 읽는 스펙이 갈라집니다. + */ +class EditorSpecCollector +{ + /** + * 블록별 **항목이 실제로 담긴 자리**. + * + * 블록 최상위 키를 그대로 세면 안 됩니다 — 블록들은 자기 항목을 `entries` / `groups` / + * `byDataSourceId` 같은 하위 자리에 담고, 최상위에는 `comment` 같은 메타 키를 함께 + * 둡니다. 최상위를 세면 팔레트 79개가 3(comment·groups·entries)으로 집계되는데, 그 + * 숫자는 오류 없이 문서에 실려 "이 확장은 팔레트 항목이 3개" 라는 사실 주장이 됩니다. + * + * 값이 빈 배열인 블록은 최상위(메타 키 제외)가 곧 항목입니다. + * + * 선언 순서가 곧 문서 표의 행 순서입니다. + * + * @var array> 블록 키 → 항목이 담긴 하위 키 목록 + */ + 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 설명 키 목록 + */ + private const META_KEYS = ['comment', '$comment', '$schema', '_propControlsComment']; + + /** + * @var array|null 번들 템플릿이 커버하는 샘플 ID (프로세스 단위 메모) + */ + private static ?array $fallbackSampleIds = null; + + /** + * 확장의 편집기 스펙 표면을 수집합니다. + * + * @param array $record ExtensionInventory 레코드 + * @param array $layoutRelFiles 이 확장의 레이아웃 파일(확장 루트 기준 상대 경로) + * @return array 편집기 스펙 인벤토리 + */ + 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 $record 확장 레코드 + * @param array $layoutRelFiles 레이아웃 파일 목록 + * @param bool $malformed manifest 는 있으나 디코드에 실패했는지 여부 + * @return array 인벤토리 + */ + 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 블록 키 → 상대 경로 + */ + 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 $spec 합본 spec + * @param array $includes 블록 키 → 분할 파일 상대 경로 + * @return array 블록 요약 + */ + 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 $map 대상 맵 + * @return array 메타 키를 뺀 맵 + */ + 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 그룹 요약 + */ + 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 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 $record 확장 레코드 + * @param array $layoutRelFiles 레이아웃 파일(확장 루트 기준 상대 경로) + * @param array $spec 이 확장의 합본 spec (없으면 빈 배열) + * @return array 샘플이 없는 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 $spec 합본 spec + * @return array ID 목록 + */ + private function sampleIdsOf(array $spec): array + { + return $this->idsAt($spec['sampleData'] ?? null, 'byDataSourceId'); + } + + /** + * 번들 템플릿 스펙이 채우는 샘플 ID 집합을 돌려줍니다. + * + * 결과를 프로세스 단위로 기억합니다. 확장 20개를 도는 동안 매번 다시 합본하면 템플릿 + * 스펙(팔레트·컨트롤·역량을 담아 수만 줄에 이른다)을 스무 번 메모리에 올리게 되고, + * 메모리 한도가 낮은 실행 환경(테스트 프로세스)에서는 그대로 OOM 이 됩니다. 남기는 + * 것은 스펙 전체가 아니라 **ID 문자열 목록**이라 유지 비용도 작습니다. + * + * @return array 번들 템플릿이 커버하는 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 $node 레이아웃 노드 + * @return array 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|null 디코드 결과 + */ + private function langRoot(string $root): ?array + { + return $this->decodeJson($root.DIRECTORY_SEPARATOR.'ko.json'); + } + + /** + * JSON 파일을 배열로 읽습니다. + * + * @param string $path 절대 경로 + * @return array|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; + } +} diff --git a/app/Support/ExtensionDoc/ExtensionDocContext.php b/app/Support/ExtensionDoc/ExtensionDocContext.php new file mode 100644 index 00000000..8fc6ec11 --- /dev/null +++ b/app/Support/ExtensionDoc/ExtensionDocContext.php @@ -0,0 +1,72 @@ + $record ExtensionInventory 레코드 + * @return array 수집 컨텍스트 + */ + 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), + ]; + } +} diff --git a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php new file mode 100644 index 00000000..28ac9ae3 --- /dev/null +++ b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php @@ -0,0 +1,3147 @@ +, sections: array, blocks: array}> + */ + public const DOCUMENTS = [ + 'AGENTS.md' => [ + 'types' => ['module', 'plugin', 'template'], + 'sections' => ['TL;DR (5초 요약)', '1. 이 확장은 무엇인가', '2. 디렉토리 지도', '3. 핵심 흐름', '4. 확장점', '5. 수정 시 동반 의무', '6. 금지 패턴', '7. 테스트 실행', '8. 문서 목차'], + 'blocks' => ['directory-map', 'extension-points-summary', 'test-commands', 'docs-index'], + ], + 'README.md' => [ + 'types' => ['module', 'plugin', 'template'], + 'sections' => ['소개', '주요 기능', '동작 방식', '요구 사항', '설치', '관리자 설정', '사용 방법', '다른 확장과의 연동', '문서', '트러블슈팅', '변경 이력', '라이선스'], + // 템플릿은 관리자 설정 화면을 갖지 않는다 — 같은 자리에 제공 컴포넌트 요약을 둔다. + 'sectionOverrides' => [ + 'template' => ['관리자 설정' => '제공 컴포넌트'], + ], + 'blocks' => ['badges', 'requirements', 'install', 'settings-summary', 'integrations', 'docs-index'], + ], + 'docs/README.md' => [ + 'types' => ['module', 'plugin', 'template'], + 'sections' => ['문서 목차'], + 'blocks' => ['stats', 'doc-toc'], + ], + 'docs/architecture.md' => [ + 'types' => ['module', 'plugin', 'template'], + 'sections' => ['설계 의도', '계층 지도', '디렉토리'], + 'blocks' => ['directory-map'], + ], + 'docs/extension-points.md' => [ + 'types' => ['module', 'plugin'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '발행 훅' => 'hooks-published', + '구독 훅' => 'hooks-subscribed', + '훅 리스너' => 'listeners', + '레이아웃 확장' => 'layout-extensions', + '미들웨어' => 'middleware', + '브로드캐스트 채널' => 'channels', + '스케줄' => 'schedules', + '알림 정의' => 'notifications', + ], + ], + 'docs/data-model.md' => [ + 'types' => ['module', 'plugin'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '모델' => 'models', + '소유 테이블' => 'tables', + '마이그레이션' => 'migrations', + 'Enum' => 'enums', + 'Repository' => 'repositories', + ], + ], + 'docs/settings.md' => [ + 'types' => ['module', 'plugin'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '설정 스키마' => 'settings-schema', + '권한' => 'permissions', + '메뉴' => 'menus', + '라우트' => 'routes', + '의존 관계' => 'dependencies', + ], + ], + 'docs/frontend.md' => [ + 'types' => ['module', 'plugin'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '레이아웃' => 'layouts', + '액션 핸들러' => 'handlers', + '전역 진입점' => 'frontend-entry', + '에셋' => 'assets', + ], + ], + 'docs/components.md' => [ + 'types' => ['template'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '제공 컴포넌트' => 'components', + ], + ], + 'docs/layouts.md' => [ + 'types' => ['template'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '레이아웃 목록' => 'layouts', + '라우트 매핑' => 'layout-map', + // 템플릿의 `extensions/{module-identifier}/*.json` 은 그 모듈/플러그인이 + // 발행한 레이아웃 확장 조각을 이 템플릿이 오버라이드한 것이다(모듈/플러그인 + // 쪽 `resources/extensions/` 와는 반대 방향 — 발행이 아니라 대체). 모듈/플러그인 + // 문서의 `docs/extension-points.md` 는 템플릿에 존재하지 않아 자리가 없었다. + '확장 오버라이드' => 'template-overrides', + ], + ], + 'docs/handlers.md' => [ + 'types' => ['template'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '템플릿 전용 핸들러' => 'handlers', + '부트스트랩' => 'frontend-entry', + ], + ], + // 편집기 스펙은 **세 유형 모두** 가진다. 스펙을 두지 않은 확장에도 문서를 두는 것은 + // 미보유가 곧 정상일 수 있기 때문이다 — "이 확장은 왜 편집기 스펙이 없어도 되는가 / + // 언제 필요해지는가" 를 적을 자리가 없으면, 다음 사람이 그 부재를 누락으로 오해하거나 + // 반대로 필요한 시점을 놓친다. 미보유 확장의 블록은 그 사실을 명시하고 사람 서술로 + // 이어진다. + 'docs/editor-spec.md' => [ + 'types' => ['module', 'plugin', 'template'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '선언 요약' => 'editor-spec-summary', + '선언 블록' => 'editor-spec-blocks', + '컴포넌트 팔레트' => 'editor-spec-palette', + '샘플 데이터와 페이지 상태' => 'editor-spec-samples', + '수정 시 동반 의무' => 'editor-spec-obligations', + ], + ], + ]; + + /** + * 확장 유형에 해당하는 문서 목록을 반환합니다. + * + * @param string $type 확장 유형 + * @return array 문서 상대 경로 목록 + */ + public static function documentsForType(string $type): array + { + $docs = []; + + foreach (self::DOCUMENTS as $rel => $meta) { + if (in_array($type, $meta['types'], true)) { + $docs[] = $rel; + } + } + + return $docs; + } + + /** + * 문서의 필수 섹션 목록을 확장 유형에 맞춰 반환합니다. + * + * 같은 문서라도 유형에 따라 한 절의 정체가 달라집니다 — 템플릿 README 의 `관리자 설정` + * 자리는 설정 화면이 없으므로 `제공 컴포넌트` 가 됩니다. 검사 스크립트·계약 테스트가 + * 같은 판정을 쓰도록 이 메서드를 단일 출처로 둡니다. + * + * @param string $doc 문서 상대 경로 + * @param string $type 확장 유형 + * @return array 필수 섹션 목록 + */ + /** + * 선언형 표면(`AbstractModule`/`AbstractPlugin` 의 getter)을 읽어야 렌더되는 블록 키. + * + * 이 목록의 블록은 표면 수집이 실패했을 때 "없음" 이 아니라 "확인하지 못함" 으로 + * 렌더된다. 판정은 `renderBlock()` 한 곳에서만 하며, 개별 렌더러는 가드를 갖지 않는다 + * — 가드를 흩어 놓으면 새 렌더러가 조용히 빠지고 그 누락이 "0개" 로 보인다. + * + * `stats` 는 수치를 남기고 경고를 덧붙이는 형태라 별도 취급한다(`renderStats`). + * `hooks-published` / `hooks-subscribed` 는 선언 외에 소스 스캔 결과도 실려 있어 + * 통째로 대체하면 읽어낸 사실까지 버리므로, 빈 결과일 때만 사유를 갈라 적는다. + */ + /** + * 표면을 읽지 못했음을 알리는 두 통지가 공유하는 판정 어구. + * + * 통지는 둘이다 — 표면 전체를 못 읽은 경우(`surfaceUnavailable`)와 개별 getter 만 + * 던진 경우(`surfaceErrorsNotice`). 코어 인덱스 스캐너는 집계 블록에서 이 어구를 + * 찾아 수치를 "점검 불가" 로 가르는데, 두 문장이 서로 다른 어구로 시작하면 한쪽만 + * 잡힌다. 실제로 후자가 스캐너에 잡히지 않아 `라우트 0` 이 확장의 사실로 인덱스에 + * 실릴 수 있었다 — 두 문장이 이 상수를 함께 쓰게 해서 갈라질 자리를 없앤다. + * + * 프로세스 경계를 넘는 리터럴이므로 스캐너와의 일치는 계약 테스트가 잠근다. + */ + public const SURFACE_NOTICE_MARKER = '**읽지 못했다**는 뜻입니다'; + + /** + * 세지 못한 지표의 표시 문자열. + * + * 수집기는 셀 수 없는 지표를 `null` 로 올립니다. 그 자리에 0 을 넣으면 "없다" 는 + * 사실 주장이 되고, 코어 문서 인덱스는 그 0 을 실측으로 옮깁니다 — 배지와 콘솔이 + * 같은 문자열을 쓰도록 여기 한 곳에 둡니다. + */ + public const STAT_UNMEASURED = '확인 못함'; + + private const SURFACE_DEPENDENT_BLOCKS = [ + 'requirements', + 'extension-points-summary', + 'listeners', + 'assets', + 'layout-extensions', + 'middleware', + 'channels', + 'schedules', + 'notifications', + 'settings-schema', + 'settings-summary', + 'permissions', + 'menus', + 'routes', + ]; + + /** + * 표면에 의존하지만 본문을 통째로 대체하지는 않는 블록 키. + * + * `stats` 는 수치를, `hooks-*` 는 소스 스캔 결과를 함께 실으므로 표면 실패에도 + * 읽어낸 사실을 남긴다. 다만 **사유는 붙어야 한다** — 붙지 않으면 `getHooks()` 가 + * 던졌을 때 선언 훅 전량이 표에서 빠진 채 "발행 훅 N종" 이 경고 없이 실린다. + * 세 키를 여기 모아 두어 통지 대상이 `renderBlock()` 한 곳에서만 정해지게 한다. + */ + private const SURFACE_NOTICE_EXTRA_BLOCKS = [ + 'stats', + 'hooks-published', + 'hooks-subscribed', + ]; + + public static function sectionsFor(string $doc, string $type): array + { + $meta = self::DOCUMENTS[$doc] ?? null; + if ($meta === null) { + return []; + } + + $overrides = $meta['sectionOverrides'][$type] ?? []; + + // 절 ↔ 블록을 키로 묶은 문서는 `sections` 를 따로 두지 않는다 — 두 벌을 두면 + // 한쪽만 고쳐도 게이트가 초록인 채로 골격이 어긋난다. + $sections = $meta['sections'] ?? array_keys($meta['blocks']); + + return array_map( + static fn (string $section): string => $overrides[$section] ?? $section, + $sections, + ); + } + + /** + * 문서의 절과 자동 생성 블록이 키로 짝지어져 있는지 판정합니다. + * + * @param string $doc 문서 상대 경로 + * @return bool 연관 배열이면 true + */ + public static function pairsSectionsWithBlocks(string $doc): bool + { + $blocks = self::DOCUMENTS[$doc]['blocks'] ?? []; + + return $blocks !== [] && ! array_is_list($blocks); + } + + /** + * 문서가 담아야 하는 자동 생성 블록 키 목록을 반환합니다. + * + * `DOCUMENTS[$doc]['blocks']` 는 문서에 따라 목록이거나 `절 => 블록` 연관 배열입니다. + * 소비자가 그 형태를 알 필요가 없도록 이 접근자가 항상 목록으로 돌려줍니다. + * + * @param string $doc 문서 상대 경로 + * @return array 블록 키 목록 + */ + public static function blocksFor(string $doc): array + { + return array_values(self::DOCUMENTS[$doc]['blocks'] ?? []); + } + + /** + * 문서에 그 절의 **헤딩**이 있는지 판정합니다. + * + * 절 이름을 단순 부분문자열로 찾으면 이 축은 사실상 실패할 수 없습니다 — 절 이름이 + * `모델` · `테이블` · `Enum` · `구독 훅` · `미들웨어` · `스케줄` · `레이아웃` · `문서` + * 처럼 같은 문서의 자동 생성 표 헤더에 필연적으로 등장하는 낱말이고, README 는 자동 + * 생성 인라인 목차가 12개 절 이름을 전부 담기 때문입니다. 헤딩을 통째로 지워도 + * 통과하므로 "검사했다" 와 "검사하지 못했다" 가 구분되지 않습니다. + * + * @param string $content 문서 본문 + * @param string $section 절 이름 (헤딩 접두 `#` 없이) + * @return bool 헤딩 존재 여부 + */ + public static function hasSection(string $content, string $section): bool + { + // PCRE 의 \h 는 CR 을 포함하지 않는다 — CRLF 문서는 줄 끝이 CR 이라 + // 줄끝 앵커가 매치되지 않아 **모든 헤딩을 놓친다**(있는 절을 없다고 보고). + // 같은 결함군을 generate-docs-index.cjs 에서 이미 겪었으므로 여기서도 줄 끝 CR 을 허용한다. + $pattern = '/^[^\S\r\n]*#{1,6}[^\S\r\n]+'.preg_quote($section, '/').'[^\S\r\n]*\r?$/mu'; + + return preg_match($pattern, $content) === 1; + } + + /** + * 미채움 마커 5종을 반환합니다. + * + * @return array 마커 리터럴 + */ + public static function todoMarkers(): array + { + return [ + self::TODO_INTENT, + self::TODO_FLOW, + self::TODO_FORBIDDEN, + self::TODO_USAGE, + self::TODO_TROUBLESHOOTING, + ]; + } + + /** + * 본문이 빈 사람 영역(`@intent`) 블록 수를 셉니다. + * + * 미채움은 `TODO:` 마커로만 드러나지 않습니다. 스캐폴딩 직후 마커를 **지우기만 하고** + * 서술을 쓰지 않으면 마커 검사는 0 을 돌려주고 골격·블록 검사도 전부 통과합니다 — + * 결과가 "다 채웠다" 와 구분되지 않아, 비어 있는 문서가 완비로 집계됩니다. + * + * 그래서 마커 잔량과 빈 본문을 **같은 축**(미채움)으로 함께 봅니다. + * + * @param string $content 문서 전문 + * @return int 빈 `@intent` 블록 수 + */ + public static function emptyIntentBlocks(string $content): int + { + if (preg_match_all('/(.*?)/s', $content, $m) === false) { + return 0; + } + + $empty = 0; + + foreach ($m[1] as $body) { + if (trim($body) === '') { + $empty++; + } + } + + return $empty; + } + + /** + * 자동 생성 블록을 마커로 감쌉니다. + * + * @param string $key 블록 키 + * @param string $body 블록 본문 + * @return string 마커를 포함한 블록 + */ + public static function wrap(string $key, string $body): string + { + return self::startMarker($key)."\n".trim($body)."\n".self::endMarker($key); + } + + /** + * 블록 시작 마커를 만듭니다. + * + * @param string $key 블록 키 + * @return string 시작 마커 + */ + public static function startMarker(string $key): string + { + return self::GEN_PREFIX.$key.' START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->'; + } + + /** + * 블록 종료 마커를 만듭니다. + * + * @param string $key 블록 키 + * @return string 종료 마커 + */ + public static function endMarker(string $key): string + { + return self::GEN_PREFIX.$key.' END -->'; + } + + /** + * 문서에 존재하는 자동 생성 블록 키를 찾습니다. + * + * @param string $content 문서 내용 + * @return array 블록 키 목록 + */ + public static function presentBlockKeys(string $content): array + { + if (! preg_match_all('//s'; + $endPattern = '//s'; + + if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) { + $missing[] = $key; + + continue; + } + + $startPos = (int) $sm[0][1]; + $searchFrom = $startPos + strlen($sm[0][0]); + + if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $searchFrom)) { + $missing[] = $key; + + continue; + } + + $endPos = (int) $em[0][1]; + $endLen = strlen($em[0][0]); + + $content = substr($content, 0, $startPos) + .$start."\n".trim($body)."\n".self::endMarker($key) + .substr($content, $endPos + $endLen); + + $replaced[] = $key; + } + + return [ + 'content' => $content, + 'replaced' => $replaced, + 'missing' => $missing, + 'unchanged' => $content === $original, + ]; + } + + /** + * 확장의 모든 자동 생성 블록 본문을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return array 블록 키 => 본문 + */ + public function renderBlocks(array $ctx): array + { + $keys = []; + foreach (self::documentsForType($ctx['record']['type']) as $doc) { + foreach (self::blocksFor($doc) as $key) { + $keys[$key] = true; + } + } + + $bodies = []; + foreach (array_keys($keys) as $key) { + $bodies[$key] = $this->renderBlock($key, $ctx); + } + + return $bodies; + } + + /** + * 단일 자동 생성 블록 본문을 렌더합니다. + * + * @param string $key 블록 키 + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 본문 + */ + public function renderBlock(string $key, array $ctx): string + { + // 선언형 표면에 의존하는 블록은 여기 한 곳에서 갈린다. + // + // 가드를 렌더러마다 손으로 달면 새 렌더러가 추가될 때마다 새는데, 결과가 "0개" · + // "없습니다" 라 이상으로 보이지 않는다. 실제로 확장점 요약 · 요구 사항 · 리스너 · + // 에셋 4개가 그렇게 빠져 있었고, 같은 실행이 만든 상세 문서는 "확인하지 못했습니다" + // 라 한 산출물 안에서 두 문서가 반대되는 사실을 말하고 있었다. + $surfaceDependent = in_array($key, self::SURFACE_DEPENDENT_BLOCKS, true); + + if ($surfaceDependent && ($unavailable = $this->surfaceUnavailable($ctx)) !== null) { + return $unavailable; + } + + $body = $this->renderBlockBody($key, $ctx); + + // 본문을 통째로 대체하지 않는 블록(수치·훅 표)에도 사유는 붙어야 한다. + // + // 표면 실패는 두 갈래다 — 진입 클래스를 통째로 읽지 못한 경우(`surfaceUnavailable`)와 + // 클래스는 읽고 개별 getter 가 던진 경우(`surfaceErrorsNotice`). 뒤쪽만 배선하면 + // 앞쪽에서 `stats` 는 수치를, 훅 표는 "선언에 없어 소스에서 자동 감지" 를 경고 없이 + // 싣고, 코어 인덱스 스캐너는 그 수치를 실측으로 옮긴다. 두 갈래를 같은 자리에서 고른다. + $noticeEligible = $surfaceDependent + || in_array($key, self::SURFACE_NOTICE_EXTRA_BLOCKS, true); + + // 렌더러가 자체 인라인 가드로 이미 사유를 실었으면 겹쳐 붙이지 않는다. + $notice = $noticeEligible && ! str_contains($body, self::SURFACE_NOTICE_MARKER) + ? ($this->surfaceUnavailable($ctx) ?? $this->surfaceErrorsNotice($ctx)) + : null; + + if ($notice !== null) { + $body .= ' + +'.$notice; + } + + return $body; + } + + /** + * 블록 본문을 렌더합니다 (표면 가용성 판정은 호출자가 담당). + * + * @param string $key 블록 키 + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderBlockBody(string $key, array $ctx): string + { + return match ($key) { + 'badges' => $this->renderBadges($ctx), + 'requirements' => $this->renderRequirements($ctx), + 'install' => $this->renderInstall($ctx), + 'integrations' => $this->renderIntegrations($ctx), + 'docs-index' => $this->renderDocsIndex($ctx), + 'directory-map' => $this->renderDirectoryMap($ctx), + 'extension-points-summary' => $this->renderExtensionPointsSummary($ctx), + 'test-commands' => $this->renderTestCommands($ctx), + 'stats' => $this->renderStats($ctx), + 'doc-toc' => $this->renderDocToc($ctx), + 'hooks-published' => $this->renderHooksPublished($ctx), + 'hooks-subscribed' => $this->renderHooksSubscribed($ctx), + 'listeners' => $this->renderListeners($ctx), + 'layout-extensions' => $this->renderLayoutExtensions($ctx), + 'template-overrides' => $this->renderLayoutExtensions($ctx), + 'middleware' => $this->renderMiddleware($ctx), + 'channels' => $this->renderChannels($ctx), + 'schedules' => $this->renderSchedules($ctx), + 'notifications' => $this->renderNotifications($ctx), + 'models' => $this->renderModels($ctx), + 'tables' => $this->renderTables($ctx), + 'migrations' => $this->renderMigrations($ctx), + 'enums' => $this->renderEnums($ctx), + 'repositories' => $this->renderRepositories($ctx), + 'settings-schema' => $this->renderSettingsSchema($ctx), + 'settings-summary' => $this->renderSettingsSummary($ctx), + 'permissions' => $this->renderPermissions($ctx), + 'menus' => $this->renderMenus($ctx), + 'routes' => $this->renderRoutes($ctx), + 'dependencies' => $this->renderDependencies($ctx), + 'layouts' => $this->renderLayouts($ctx), + 'handlers' => $this->renderHandlers($ctx), + 'frontend-entry' => $this->renderFrontendEntry($ctx), + 'assets' => $this->renderAssets($ctx), + 'components' => $this->renderComponents($ctx), + 'layout-map' => $this->renderLayoutMap($ctx), + 'editor-spec-summary' => $this->renderEditorSpecSummary($ctx), + 'editor-spec-blocks' => $this->renderEditorSpecBlocks($ctx), + 'editor-spec-palette' => $this->renderEditorSpecPalette($ctx), + 'editor-spec-samples' => $this->renderEditorSpecSamples($ctx), + 'editor-spec-obligations' => $this->renderEditorSpecObligations($ctx), + default => $this->none('알 수 없는 블록 키: '.$key), + }; + } + + // ----------------------------------------------------------------------- + // 블록 렌더러 + // ----------------------------------------------------------------------- + + /** + * README 히어로 배지를 렌더합니다. + * + * 값은 전부 manifest 에서 옵니다. 정적 이미지 배지라 브라우저가 화면을 그리기 위해 + * 도달해야 하는 구동 자산이 아닙니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderBadges(array $ctx): string + { + $record = $ctx['record']; + $manifest = $record['manifest']; + + $badges = []; + $badges[] = $this->badge('version', (string) ($manifest['version'] ?? '-'), '0066FF'); + $badges[] = $this->badge('type', ExtensionInventory::typeLabel($record['type']), '555555'); + + $core = $ctx['deps']['coreVersion'] ?? null; + if ($core !== null) { + $badges[] = $this->badge('그누보드7', $core, '1F883D'); + } + + $license = $manifest['license'] ?? null; + if (is_string($license) && $license !== '') { + $badges[] = $this->badge('license', $license, '8250DF'); + } + + foreach ($ctx['deps']['requires'] as $dep) { + $badges[] = $this->badge('requires', $dep['id'], 'BF8700'); + } + + return '

'."\n ".implode("\n ", $badges)."\n".'

'; + } + + /** + * shields.io 정적 배지 마크다운을 만듭니다. + * + * @param string $label 라벨 + * @param string $message 값 + * @param string $color 색상 (hex, `#` 없음) + * @return string 이미지 마크다운 + */ + private function badge(string $label, string $message, string $color): string + { + $encode = static fn (string $s): string => rawurlencode(str_replace(['-', '_'], ['--', '__'], $s)); + + return sprintf( + '%s %s', + $encode($label), + $encode($message), + $color, + $this->escape($label), + $this->escape($message), + ); + } + + /** + * 요구 사항 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderRequirements(array $ctx): string + { + $rows = []; + $rows[] = ['그누보드7 코어', $this->code($ctx['deps']['coreVersion'] ?? '(제약 없음)')]; + $rows[] = ['PHP', $this->code($this->composerPhp($ctx) ?? '^8.2')]; + + foreach ($ctx['deps']['requires'] as $dep) { + $label = ExtensionInventory::typeLabel(rtrim($dep['type'], 's')); + $rows[] = ["의존 {$label}", $this->code($dep['id']).' '.$this->code($dep['constraint'])]; + } + + $hosts = $ctx['surface']['values']['getTrustedScriptHosts'] ?? []; + if (is_array($hosts) && $hosts !== []) { + $rows[] = ['외부 스크립트 호스트', implode(', ', array_map(fn ($h) => $this->code((string) $h), $hosts))]; + } + + return $this->table(['항목', '값'], $rows); + } + + /** + * 설치 절차를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderInstall(array $ctx): string + { + $record = $ctx['record']; + $type = $record['type']; + $id = $record['id']; + + $lines = []; + $lines[] = '```bash'; + $lines[] = '# 번들 설치 (코어에 동봉된 소스에서 설치)'; + $lines[] = "php artisan {$type}:install {$id}"; + $lines[] = ''; + $lines[] = '# 활성화'; + $lines[] = "php artisan {$type}:activate {$id}"; + $lines[] = ''; + $lines[] = '# 업데이트 (번들 소스 기준 강제 반영)'; + $lines[] = "php artisan {$type}:update {$id} --force"; + $lines[] = '```'; + + $github = $record['manifest']['github_url'] ?? null; + if (is_string($github) && $github !== '') { + $lines[] = ''; + $lines[] = '저장소: '.$github; + } + + return implode("\n", $lines); + } + + /** + * 다른 확장과의 연동(정방향·역방향)을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderIntegrations(array $ctx): string + { + $sections = []; + + $requires = $ctx['deps']['requires']; + $sections[] = '**이 확장이 의존하는 확장**'; + $sections[] = ''; + $sections[] = $requires === [] + ? '없음 — 코어만으로 동작합니다.' + : $this->table( + ['확장', '유형', '버전 제약', '번들'], + array_map(fn (array $d): array => [ + $this->code($d['id']), + ExtensionInventory::typeLabel(rtrim($d['type'], 's')), + $this->code($d['constraint']), + $d['bundled'] ? '✅' : '—', + ], $requires), + ); + + $requiredBy = $ctx['deps']['requiredBy']; + $sections[] = ''; + $sections[] = '**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)'; + $sections[] = ''; + $sections[] = $requiredBy === [] + ? '없음.' + : $this->table( + ['확장', '유형', '요구 버전'], + array_map(fn (array $d): array => [ + $this->code($d['id']), + ExtensionInventory::typeLabel($d['type']), + $this->code($d['constraint']), + ], $requiredBy), + ); + + return implode("\n", $sections); + } + + /** + * 문서 목차를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderDocsIndex(array $ctx): string + { + $record = $ctx['record']; + $rows = []; + + foreach (self::documentsForType($record['type']) as $doc) { + if ($doc === 'AGENTS.md' || $doc === 'README.md') { + continue; + } + + $exists = is_file($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc)); + $rows[] = [ + $exists ? "[{$doc}]({$doc})" : $this->code($doc), + $this->documentPurpose($doc), + $exists ? '✅' : '미작성', + ]; + } + + if (is_dir($record['docsPath'].DIRECTORY_SEPARATOR.'api')) { + $rows[] = ['[docs/api/](docs/api/README.md)', 'API 레퍼런스 (엔드포인트별 파라미터·응답 필드)', '✅']; + } + + // 다른 행은 전부 `is_file()` 로 판정하는데 이 행만 무조건 ✅ 였다 — CHANGELOG 가 + // 없는 확장(21번째 시나리오)에서 문서가 "있음" 이라고 거짓말하고 링크가 404 가 된다. + $hasChangelog = is_file($record['path'].DIRECTORY_SEPARATOR.'CHANGELOG.md'); + $rows[] = [ + $hasChangelog ? '[CHANGELOG.md](CHANGELOG.md)' : $this->code('CHANGELOG.md'), + '변경 이력', + $hasChangelog ? '✅' : '미작성', + ]; + + return $this->table(['문서', '내용', '상태'], $rows); + } + + /** + * `docs/README.md` 의 목차를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderDocToc(array $ctx): string + { + $record = $ctx['record']; + $rows = []; + + foreach (self::documentsForType($record['type']) as $doc) { + if (! str_starts_with($doc, 'docs/') || $doc === 'docs/README.md') { + continue; + } + + $name = basename($doc); + $exists = is_file($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc)); + $rows[] = [ + $exists ? "[{$name}]({$name})" : $this->code($name), + $this->documentPurpose($doc), + ]; + } + + if (is_dir($record['docsPath'].DIRECTORY_SEPARATOR.'api')) { + $rows[] = ['[api/](api/README.md)', 'API 레퍼런스']; + } + + // 진입점 링크도 존재 확인 후 건다 — 없는 파일로 링크하면 404 가 된다. + foreach ([['AGENTS.md', '에이전트·확장개발자 진입점'], ['README.md', '사람(도입검토자·운영자) 진입점']] as [$name, $purpose]) { + $exists = is_file($record['path'].DIRECTORY_SEPARATOR.$name); + $rows[] = [$exists ? "[../{$name}](../{$name})" : $this->code("../{$name}"), $purpose]; + } + + return $this->table(['문서', '내용'], $rows); + } + + /** + * 문서의 용도 설명을 반환합니다. + * + * @param string $doc 문서 상대 경로 + * @return string 용도 + */ + private function documentPurpose(string $doc): string + { + return match ($doc) { + 'docs/README.md' => '문서 통합 목차와 실측 집계', + 'docs/architecture.md' => '설계 의도·계층 지도·디렉토리 맵', + 'docs/extension-points.md' => '발행/구독 훅·미들웨어·채널·스케줄', + 'docs/data-model.md' => '모델·소유 테이블·마이그레이션·Enum', + 'docs/settings.md' => '설정 스키마·권한·메뉴·라우트·의존 관계', + 'docs/frontend.md' => '레이아웃·액션 핸들러·전역 진입점·에셋', + 'docs/components.md' => '템플릿이 제공하는 컴포넌트', + 'docs/layouts.md' => '레이아웃 목록과 라우트 매핑', + 'docs/handlers.md' => '템플릿 전용 핸들러와 부트스트랩', + 'docs/editor-spec.md' => '레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터', + default => '-', + }; + } + + /** + * 디렉토리 지도를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderDirectoryMap(array $ctx): string + { + $record = $ctx['record']; + $rows = []; + + foreach ($this->directoryCandidates($record['type'], $record['id']) as $path => [$role, $procedure]) { + $abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, rtrim($path, '/')); + if (! file_exists($abs)) { + continue; + } + + $rows[] = [$this->code($path), $role, $procedure]; + } + + return $this->table(['경로', '역할', '수정 시 필요한 절차'], $rows); + } + + /** + * 유형별 디렉토리 후보와 설명을 반환합니다. + * + * @param string $type 확장 유형 + * @param string $id 확장 식별자 + * @return array 경로 => [역할, 절차] + */ + private function directoryCandidates(string $type, string $id): array + { + $updateCmd = "`php artisan {$type}:update {$id} --force`"; + $buildCmd = "`php artisan {$type}:build` → {$updateCmd}"; + + $common = [ + 'CHANGELOG.md' => ['변경 이력', '버전 상향 시 항목 추가 (미기재 시 버전 상향 불가)'], + // 편집기 컴포넌트 선언은 레이아웃 저작자가 읽는 props 계약이다(실측 15파일). + // 자리가 없으면 그 선언을 고쳐도 `docs/components.md` 갱신 요구가 걸리지 않는다. + 'components.json' => ['편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약)', $updateCmd], + // 확장이 셸로 호출하는 외부 실행파일·인증서·WSDL 자리다(실측 6파일, 결제 플러그인). + // 비면 그 기능이 죽는데 코드에는 "bin/ 에 복사하세요" 안내만 있고 문서에는 자리가 없었다. + 'bin/' => ['확장이 실행하는 외부 바이너리·인증서', '교체 시 OS별 파일과 권한을 함께 확인 (비면 해당 기능 정지)'], + 'docs/' => ['개발자 문서', '표면 변경 시 `php artisan ext:docgen` 재실행'], + 'lang/' => ['다국어', '키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화'], + 'custom/' => ['운영자 추가 에셋 자리', '저작자는 두지 않는다 (운영자 소유 — 보존 계층이 덮지 않음)'], + ]; + + if ($type === ExtensionInventory::TYPE_TEMPLATE) { + return [ + 'template.json' => ['manifest (버전 SSoT)', 'version 변경 시 package.json·package-lock.json 동기화'], + 'routes.json' => ['라우트 → 레이아웃 매핑', $updateCmd], + 'layouts/' => ['레이아웃 JSON', $updateCmd.' (빌드 불필요)'], + // 모듈·플러그인의 `resources/extensions/` 와 같은 개념인데 템플릿은 + // 확장 루트 직속에 둔다. 유형 분기를 놓치면 다른 확장 화면에 끼워 넣는 + // 조각을 고쳐도 문서에 반영할 자리가 없다. + 'extensions/' => ['다른 확장 화면에 주입하는 레이아웃 조각', $updateCmd.' (빌드 불필요)'], + 'seo-config.json' => ['SEO 렌더 설정', $updateCmd], + 'src/components/' => ['React 컴포넌트', $buildCmd], + 'src/handlers/' => ['템플릿 전용 액션 핸들러', $buildCmd], + 'dist/' => ['커밋되는 빌드 산출물', '`--production` 으로 재빌드 (sourceMappingURL 잔존 금지)'], + 'editor-spec.json' => ['레이아웃 편집기 스펙', $updateCmd], + 'editor-spec/' => ['분할 편집기 스펙', $updateCmd], + 'tests/' => ['테스트', '변경 범위만 필터 실행'], + ...$common, + ]; + } + + $entry = $type === ExtensionInventory::TYPE_MODULE ? 'module' : 'plugin'; + + return [ + "{$entry}.json" => ['manifest (버전 SSoT)', 'version 변경 시 package.json·package-lock.json·composer.json 동기화'], + "{$entry}.php" => ['진입 클래스 (선언형 표면 SSoT)', '표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토'], + // 컨트롤러 자리는 두 갈래다. 번들 플러그인 5개(pay_kginicis · pay_nhnkcp · + // pay_nicepayments · message_bizppurio · tosspayments, 49파일)는 `src/Controllers/` + // 를 쓴다. 한 갈래만 적으면 그 5개 문서에서 컨트롤러 행이 통째로 사라져 + // "API 표면 변경 시 `api:docgen` 재실행" 절차가 어디에도 남지 않는다. + // 렌더러가 `file_exists` 로 거르므로 두 행을 함께 두어도 실재하는 쪽만 나온다. + 'src/Http/Controllers/' => ['컨트롤러', 'API 표면 변경 시 `api:docgen` 재실행'], + 'src/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', $updateCmd.' (빌드 불필요)'], + // 모듈·플러그인의 라우트 → 레이아웃 매핑은 `resources/routes.json`(단일) 또는 + // `resources/routes/*.json`(분할)에 있다(실측 7파일). 여기 자리가 없으면 + // "라우트를 고쳤는데 문서에 반영할 곳이 없다" 가 된다 — editor-spec 과 같은 형태다. + 'resources/routes.json' => ['라우트 → 레이아웃 매핑', $updateCmd], + 'resources/routes/' => ['라우트 → 레이아웃 매핑 (분할)', $updateCmd], + 'resources/js/' => ['프론트 엔트리·핸들러', $buildCmd], + 'resources/extensions/' => ['다른 확장 레이아웃에 주입하는 조각', $updateCmd], + // 모듈·플러그인도 편집기 스펙을 소유한다(실측 10개). 여기 자리가 없으면 + // "editor-spec 을 고쳤는데 문서에 반영할 곳이 없다" 가 된다. + 'editor-spec.json' => ['레이아웃 편집기 스펙', $updateCmd], + 'editor-spec/' => ['분할 편집기 스펙', $updateCmd], + 'dist/' => ['커밋되는 빌드 산출물', '`--production` 으로 재빌드 (sourceMappingURL 잔존 금지)'], + 'config/' => ['확장 config', '설정 기본값은 settings 스키마와 어긋나지 않게'], + 'tests/' => ['테스트', '변경 범위만 필터 실행'], + ...$common, + ]; + } + + /** + * AGENTS.md 확장점 요약을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderExtensionPointsSummary(array $ctx): string + { + $hooks = $ctx['hooks']; + $surface = $ctx['surface']['values']; + $frontend = $ctx['frontend']; + + $rows = [ + ['발행 훅', (string) count($hooks['published']).'개', $this->docLink($ctx, 'docs/extension-points.md', '발행 훅')], + ['구독 훅', (string) count($hooks['subscribed']).'개', $this->docLink($ctx, 'docs/extension-points.md', '구독 훅')], + ['훅 리스너', (string) count($hooks['listeners']).'개', $this->docLink($ctx, 'docs/extension-points.md', '훅 리스너')], + ['레이아웃 확장', (string) count($frontend['layoutExtensions']).'개', $this->docLink($ctx, 'docs/extension-points.md', '레이아웃 확장')], + ['미들웨어', (string) $this->countOf($surface['getMiddleware'] ?? []).'개', $this->docLink($ctx, 'docs/extension-points.md', '미들웨어')], + ['브로드캐스트 채널', (string) $this->countOf($surface['getChannels'] ?? []).'개', $this->docLink($ctx, 'docs/extension-points.md', '브로드캐스트 채널')], + ['스케줄', (string) $this->countOf($surface['getSchedules'] ?? []).'개', $this->docLink($ctx, 'docs/extension-points.md', '스케줄')], + ['알림 정의', (string) $this->countOf($surface['getNotificationDefinitions'] ?? []).'개', $this->docLink($ctx, 'docs/extension-points.md', '알림 정의')], + ]; + + if ($ctx['record']['type'] === ExtensionInventory::TYPE_TEMPLATE) { + $rows = [ + ['제공 컴포넌트', (string) $frontend['components']['total'].'개', $this->docLink($ctx, 'docs/components.md', '제공 컴포넌트')], + ['레이아웃', (string) count($frontend['layouts']).'개', $this->docLink($ctx, 'docs/layouts.md', '레이아웃 목록')], + ['전용 핸들러', (string) count($frontend['handlers']['names']).'개', $this->docLink($ctx, 'docs/handlers.md', '템플릿 전용 핸들러')], + ['확장 오버라이드', (string) count($frontend['layoutExtensions']).'개', $this->docLink($ctx, 'docs/layouts.md', '확장 오버라이드')], + ]; + } + + return $this->table(['확장점', '수', '상세'], $rows); + } + + /** + * 테스트 실행 명령을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderTestCommands(array $ctx): string + { + $tests = $ctx['tests']; + $lines = []; + + $rows = [ + ['PHPUnit', (string) $tests['phpunit']['count'].'개', $tests['phpunit']['count'] > 0 ? $this->code($ctx['record']['relPath'].'/tests') : '—'], + ['Vitest', (string) $tests['vitest']['files'].'개', $tests['vitest']['config'] !== null ? $this->code($tests['vitest']['config']) : '—'], + ['Playwright', (string) $tests['playwright']['count'].'개', $tests['playwright']['count'] > 0 ? $this->code('tests/Playwright') : '—'], + ['시나리오 매니페스트', (string) count($tests['scenarios']).'개', $tests['scenarios'] === [] ? '—' : $this->code('tests/scenarios')], + ]; + + $lines[] = $this->table(['종류', '개수', '위치'], $rows); + + if ($tests['testCaseBase'] !== null) { + $lines[] = ''; + $lines[] = '기저 TestCase: '.$this->code($tests['testCaseBase']).' — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).'; + } + + if ($tests['commands'] !== []) { + $lines[] = ''; + $lines[] = '```bash'; + foreach ($tests['commands'] as $command) { + $lines[] = '# '.$command['label'].' ('.$command['shell'].')'; + $lines[] = $command['command']; + $lines[] = ''; + } + $lines[] = '```'; + $lines[] = ''; + $lines[] = '무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.'; + } + + return implode("\n", $lines); + } + + /** + * `docs/README.md` 집계 배지 라인을 렌더합니다. + * + * 코어 문서 인덱스 생성기가 이 라인을 읽어 확장 문서 표를 채우므로, 형식이 계약입니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderStats(array $ctx): string + { + $stats = self::statsOf($ctx); + + $parts = []; + $unmeasured = []; + + foreach ($stats as $label => $value) { + if ($value === null) { + $unmeasured[] = $label; + $parts[] = "**{$label}**: ".self::STAT_UNMEASURED; + + continue; + } + + $parts[] = "**{$label}**: {$value}"; + } + + $line = implode(' · ', $parts); + + // 선언형 표면을 못 읽었으면 0 은 실측이 아니다 — 수치만 내보내면 "없음" 으로 읽힌다. + if (($unavailable = $this->surfaceUnavailable($ctx)) !== null) { + return $line."\n\n".$unavailable; + } + + // 표면과 무관하게 개별 지표를 세지 못하는 경우가 있다 — 템플릿은 선언형 표면을 + // 갖지 않아 위 안내의 대상이 아니고(`surfaceUnavailable` 이 null 을 돌려준다), + // 그 대신 `routes.json` 을 읽지 못하면 여기서 단서를 남겨야 한다. 남기지 않으면 + // 코어 인덱스 스캐너가 이 블록을 실측으로 읽어 세지 못한 수치를 사실로 옮긴다. + if ($unmeasured !== []) { + $names = array_map(static fn (string $l): string => '`'.$l.'`', $unmeasured); + + $line .= "\n\n".$this->none(sprintf( + '아래 수치를 세지 못했습니다 (%s). 항목이 없다는 뜻이 아니라 %s.', + implode(' · ', $names), + self::SURFACE_NOTICE_MARKER, + )); + } + + return $line; + } + + /** + * 집계 배지에 쓰이는 실측 수치를 반환합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return array 라벨 => 수치 (세지 못한 지표는 null) + */ + public static function statsOf(array $ctx): array + { + return [ + '훅 수' => count($ctx['hooks']['published']), + '구독 훅 수' => count($ctx['hooks']['subscribed']), + // 주소를 어디에 선언하는지가 유형마다 다르다. 모듈·플러그인은 `getRoutes()` 가 + // 가리키는 라우트 파일이고, 템플릿은 `routes.json` 이다. 선언형 표면만 보면 + // 템플릿은 구조적으로 항상 0 이 되는데(실측 40·29) 템플릿에는 "확인하지 못함" + // 안내도 붙지 않아 그 0 이 단서 없이 사실로 읽힌다. + // 템플릿은 셀 수 없으면 `null` 이 온다 — `?? 0` 으로 받으면 수집기가 구분해 + // 올린 "읽지 못함" 이 이 자리에서 "주소 0개" 라는 사실 주장으로 바뀐다. + '라우트 수' => $ctx['record']['type'] === 'template' + ? ($ctx['frontend']['routeCount'] === null + ? null + : (int) $ctx['frontend']['routeCount']) + : (int) ($ctx['surface']['endpoints'] ?? 0), + '모델 수' => count($ctx['data']['models']), + '테이블 수' => count($ctx['data']['tables']), + '마이그레이션 수' => count($ctx['data']['migrations']), + '레이아웃 수' => count($ctx['frontend']['layouts']), + '핸들러 수' => count($ctx['frontend']['handlers']['names']), + ]; + } + + /** + * 발행 훅 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderHooksPublished(array $ctx): string + { + $hooks = $ctx['hooks']; + + // 표가 비어 있어도 호출 지점이 있으면 "발행하지 않는다" 가 아니다 — 이름을 정적으로 + // 읽지 못했을 뿐이다. 이 두 상태를 같은 문장으로 보고하면 훅을 발행하는 확장이 + // 발행하지 않는다고 문서화된다. + if ($hooks['published'] === []) { + if ($hooks['publishedSites'] > 0) { + return $this->none(sprintf( + '훅 발행 호출이 %d곳 있으나 훅 이름을 확인하지 못했습니다 — 이름이 상수·변수로 조립되어 ' + .'정적으로 읽을 수 없습니다. 이 확장의 진입 클래스에 `getHooks()` 로 발행 훅을 선언하면 ' + .'이 표가 채워집니다.', + $hooks['publishedSites'], + )); + } + + // 발행 훅의 1차 출처는 진입 클래스의 `getHooks()` 선언이다. 그 클래스를 읽지 + // 못했으면 남는 것은 리터럴 스캔 결과뿐이고, 스캔은 이름 조립형 발행을 원리상 + // 읽지 못한다 — 그 조합에서 "발행하지 않습니다" 는 거짓이 된다. + if (($unavailable = $this->surfaceUnavailable($ctx)) !== null) { + return $unavailable; + } + + return $this->none('이 확장은 훅을 발행하지 않습니다.'); + } + + $rows = []; + foreach ($hooks['published'] as $hook) { + $sites = $hook['sites']; + + if ($sites === []) { + // 선언에는 있으나 리터럴 호출이 잡히지 않은 훅 — 이름 조립형 발행이다. + $where = '선언 (호출 위치 미확인)'; + } else { + $extra = count($sites) > 1 ? ' 외 '.(count($sites) - 1).'곳' : ''; + $where = $this->code($sites[0]['file'].':'.$sites[0]['line']).$extra; + } + + $rows[] = [ + $this->code($hook['name']), + $hook['type'], + // 자동 생성 블록 안이라 `TODO:` 마커를 쓰지 않는다 — 손으로 채워도 다음 + // 재생성에서 지워지고, 채울 수 없는 자리가 미채움 잔량만 부풀린다. + $hook['description'] ?? '—', + $where, + ]; + } + + $notes = [sprintf( + '발행 훅 %d종 / 호출 지점 %d곳.', + count($hooks['published']), + $hooks['publishedSites'], + )]; + + if (($hooks['publishedUndeclared'] ?? 0) > 0) { + $notes[] = sprintf( + '이 중 %d종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.', + $hooks['publishedUndeclared'], + ); + } + + if ($hooks['publishedDynamic'] > 0) { + $notes[] = sprintf( + '훅 이름이 상수·변수로 조립된 호출이 %d곳 있어 호출 위치가 표에 다 실리지 않을 수 있습니다.', + $hooks['publishedDynamic'], + ); + } + + return implode(' ', $notes)."\n\n".$this->table(['훅 이름', '유형', '설명', '발행 위치'], $rows); + } + + /** + * 구독 훅 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderHooksSubscribed(array $ctx): string + { + $subscribed = $ctx['hooks']['subscribed']; + + if ($subscribed === []) { + // 구독 훅은 `getHookListeners()` 선언과 리스너 스캔을 합쳐 만든다 — 진입 클래스를 + // 읽지 못한 상태에서 "구독하지 않습니다" 는 확인 결과가 아니라 미확인이다. + if (($unavailable = $this->surfaceUnavailable($ctx)) !== null) { + return $unavailable; + } + + return $this->none('이 확장은 훅을 구독하지 않습니다.'); + } + + $rows = []; + foreach ($subscribed as $hook) { + $rows[] = [ + $this->code($hook['name']), + $hook['typeDeclared'] ? $hook['type'] : $hook['type'].' (미선언)', + $this->code($this->shortName($hook['listener'])), + $hook['method'] !== null ? $this->code($hook['method']) : '-', + $hook['priority'] !== null ? (string) $hook['priority'] : '-', + ]; + } + + return $this->table(['훅 이름', '유형', '리스너', '메서드', '우선순위'], $rows); + } + + /** + * 훅 리스너 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderListeners(array $ctx): string + { + $listeners = $ctx['hooks']['listeners']; + + if ($listeners === []) { + return $this->none('훅 리스너가 없습니다.'); + } + + $registered = $ctx['surface']['values']['getHookListeners'] ?? []; + $registeredShort = []; + if (is_array($registered)) { + foreach ($registered as $class) { + if (is_string($class)) { + $registeredShort[$this->shortName($class)] = true; + } + } + } + + $rows = []; + foreach ($listeners as $listener) { + $rows[] = [ + $this->code($listener['shortClass']), + (string) count($listener['hooks']).'개', + isset($registeredShort[$listener['shortClass']]) ? '명시 등록' : '자동 발견', + $listener['implementsContract'] ? '✅' : '❌', + $this->code($listener['relFile']), + ]; + } + + return $this->table(['리스너', '구독 훅', '등록 방식', 'HookListenerInterface', '파일'], $rows); + } + + /** + * 레이아웃 확장 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderLayoutExtensions(array $ctx): string + { + $files = $ctx['frontend']['layoutExtensions']; + $declared = $ctx['surface']['values']['getLayoutExtensions'] ?? []; + // 템플릿의 `extensions/{module-identifier}/*.json` 은 그 모듈/플러그인이 발행한 + // 조각을 이 템플릿이 대체하는 것이지, 모듈/플러그인처럼 밖으로 발행하는 것이 아니다. + $isTemplate = ($ctx['record']['type'] ?? null) === ExtensionInventory::TYPE_TEMPLATE; + + if ($files === [] && ! is_array($declared)) { + return $this->none($isTemplate ? '오버라이드하는 레이아웃 확장 조각이 없습니다.' : '레이아웃 확장이 없습니다.'); + } + + if ($files === [] && $declared === []) { + return $this->none($isTemplate ? '오버라이드하는 레이아웃 확장 조각이 없습니다.' : '레이아웃 확장이 없습니다.'); + } + + $known = array_flip($files); + + $rows = []; + foreach ($files as $file) { + $rows[] = [$this->code($file), $isTemplate ? '모듈/플러그인 확장 조각을 대체하는 오버라이드' : '다른 확장/템플릿 레이아웃에 주입되는 조각']; + } + + // `getLayoutExtensions()` 기본 구현(AbstractModule/AbstractPlugin)은 위 파일 목록과 + // 같은 디렉토리를 glob() 한 절대경로라 100% 중복이다. 확장-상대 경로로 정규화한 뒤 + // 이미 실린 파일은 건너뛰고, 확장이 오버라이드해 다른 대상을 선언한 경우만 싣는다. + if (is_array($declared)) { + foreach ($declared as $key => $value) { + $raw = is_string($key) ? $key : (is_string($value) ? $value : (string) $value); + $rel = $this->relativeToExtension($ctx, $raw); + if (isset($known[$rel])) { + continue; + } + $known[$rel] = true; + $rows[] = [$this->code($rel), '`getLayoutExtensions()` 선언']; + } + } + + return $this->table(['대상', '설명'], $rows); + } + + /** + * 미들웨어 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderMiddleware(array $ctx): string + { + $middleware = $ctx['surface']['values']['getMiddleware'] ?? []; + + if (! is_array($middleware) || $middleware === []) { + return $this->none('등록하는 미들웨어가 없습니다.'); + } + + $rows = []; + foreach ($middleware as $key => $entry) { + $class = is_array($entry) ? ($entry['class'] ?? $entry['middleware'] ?? null) : $entry; + $targets = is_array($entry) ? ($entry['targets'] ?? null) : null; + + $rows[] = [ + $this->code(is_string($class) ? $this->shortName($class) : (is_string($key) ? $key : '-')), + is_array($targets) ? implode(', ', array_map(fn ($t) => $this->code((string) $t), $targets)) : '-', + is_array($entry) && isset($entry['priority']) ? (string) $entry['priority'] : '-', + ]; + } + + return $this->table(['미들웨어', '부착 대상(targets)', '우선순위'], $rows); + } + + /** + * 브로드캐스트 채널 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderChannels(array $ctx): string + { + $channels = $ctx['surface']['values']['getChannels'] ?? []; + + if (! is_array($channels) || $channels === []) { + return $this->none('등록하는 브로드캐스트 채널이 없습니다.'); + } + + $rows = []; + foreach ($channels as $name => $value) { + $rows[] = [$this->code(is_string($name) ? $name : (string) $value), is_string($name) ? '인가 콜백 등록' : '채널 선언']; + } + + return $this->table(['채널', '비고'], $rows); + } + + /** + * 스케줄 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderSchedules(array $ctx): string + { + $schedules = $ctx['surface']['values']['getSchedules'] ?? []; + + if (! is_array($schedules) || $schedules === []) { + return $this->none('등록하는 스케줄이 없습니다.'); + } + + $rows = []; + foreach ($schedules as $key => $schedule) { + if (! is_array($schedule)) { + $rows[] = [$this->code((string) $key), $this->code((string) $schedule), '-']; + + continue; + } + + $rows[] = [ + $this->code((string) ($schedule['name'] ?? $schedule['command'] ?? $key)), + // 표준 계약 키는 `schedule` 이다 (AbstractModule/AbstractPlugin 의 getSchedules() + // 주석과 routes/console.php 소비부가 SSoT). 이 키를 빼면 주기 열이 모든 확장에서 + // 영구히 '-' 가 되는데, "주기를 선언하지 않았다" 와 구분되지 않는다. + $this->code((string) ($schedule['schedule'] ?? $schedule['expression'] ?? $schedule['cron'] ?? $schedule['frequency'] ?? '-')), + (string) ($schedule['description'] ?? '-'), + ]; + } + + return $this->table(['스케줄', '주기', '설명'], $rows); + } + + /** + * 알림 정의 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderNotifications(array $ctx): string + { + $definitions = $ctx['surface']['values']['getNotificationDefinitions'] ?? []; + + if (! is_array($definitions) || $definitions === []) { + return $this->none('등록하는 알림 정의가 없습니다.'); + } + + $rows = []; + foreach ($definitions as $key => $definition) { + // getNotificationDefinitions() 의 표준 계약(AbstractModule/AbstractPlugin 소비처인 + // NotificationSyncHelper 기준)은 리스트 배열 + 각 원소의 'type' 키다. 'key'/'event' + // 는 어떤 확장도 쓰지 않아, 문자열 키가 아닌 한(list 배열이면 전부 정수 키) 이 표는 + // 항상 '-' 만 찍고 있었다. + $name = is_string($key) ? $key : (is_array($definition) ? ($definition['type'] ?? $definition['key'] ?? $definition['event'] ?? '-') : (string) $definition); + $channels = is_array($definition) ? ($definition['channels'] ?? null) : null; + + $rows[] = [ + $this->code((string) $name), + is_array($channels) ? implode(', ', array_map(fn ($c) => $this->code((string) $c), $channels)) : '-', + ]; + } + + return $this->table(['알림 키', '채널'], $rows); + } + + /** + * 모델 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderModels(array $ctx): string + { + $models = $ctx['data']['models']; + + if ($models === []) { + return $this->none('소유 모델이 없습니다.'); + } + + $rows = []; + foreach ($models as $model) { + $flags = []; + if ($model['softDeletes']) { + $flags[] = 'SoftDeletes'; + } + if ($model['userOverrides']) { + $flags[] = 'HasUserOverrides'; + } + if ($model['searchable']) { + $flags[] = '검색 색인'; + } + + $relations = array_map( + fn (array $r): string => $r['method'].'→'.($r['target'] ?? '?'), + array_slice($model['relations'], 0, 6), + ); + if (count($model['relations']) > 6) { + $relations[] = '외 '.(count($model['relations']) - 6).'개'; + } + + $rows[] = [ + $this->code($model['class']), + $model['table'] !== null ? $this->code($model['table']) : '(규약)', + $model['fillable'] !== null ? (string) $model['fillable'] : '-', + $relations === [] ? '-' : implode(', ', $relations), + $flags === [] ? '-' : implode(', ', $flags), + ]; + } + + return $this->table(['모델', '테이블', 'fillable', '관계', '특성'], $rows); + } + + /** + * 소유 테이블 목록을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderTables(array $ctx): string + { + $tables = $ctx['data']['tables']; + + if ($tables === []) { + return $this->none('소유 테이블이 없습니다.'); + } + + $byTable = []; + foreach ($ctx['data']['models'] as $model) { + if ($model['table'] !== null) { + $byTable[$model['table']][] = $model['class']; + } + } + + $rows = []; + foreach ($tables as $table) { + $rows[] = [ + $this->code($table), + isset($byTable[$table]) ? implode(', ', array_map(fn ($c) => $this->code($c), $byTable[$table])) : '-', + ]; + } + + return $this->table(['테이블', '모델'], $rows); + } + + /** + * 마이그레이션 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderMigrations(array $ctx): string + { + $migrations = $ctx['data']['migrations']; + + if ($migrations === []) { + return $this->none('마이그레이션이 없습니다.'); + } + + $rows = []; + foreach ($migrations as $migration) { + $rows[] = [ + $this->code($migration['file']), + $migration['creates'] === [] ? '-' : implode(', ', array_map(fn ($t) => $this->code($t), $migration['creates'])), + $migration['alters'] === [] ? '-' : implode(', ', array_map(fn ($t) => $this->code($t), $migration['alters'])), + $migration['hasDown'] ? '✅' : '❌', + ]; + } + + return sprintf('마이그레이션 %d개.', count($migrations))."\n\n" + .$this->table(['파일', '생성 테이블', '변경 테이블', 'down()'], $rows); + } + + /** + * Enum 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderEnums(array $ctx): string + { + $enums = $ctx['data']['enums']; + + if ($enums === []) { + return $this->none('Enum 이 없습니다.'); + } + + $rows = []; + foreach ($enums as $enum) { + $cases = array_map(fn (array $c): string => $c['value'] ?? $c['name'], array_slice($enum['cases'], 0, 8)); + if (count($enum['cases']) > 8) { + $cases[] = '외 '.(count($enum['cases']) - 8).'개'; + } + + $rows[] = [ + $this->code($enum['class']), + $enum['backing'] !== null ? $this->code($enum['backing']) : '(pure)', + (string) count($enum['cases']), + $cases === [] ? '-' : implode(', ', array_map(fn ($c) => $this->code((string) $c), $cases)), + ]; + } + + return $this->table(['Enum', 'backing', 'case 수', 'case'], $rows); + } + + /** + * Repository 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderRepositories(array $ctx): string + { + $repositories = $ctx['data']['repositories']; + + if ($repositories === []) { + return $this->none('Repository 가 없습니다.'); + } + + $rows = []; + foreach ($repositories as $repository) { + $rows[] = [ + $this->code($repository['class']), + $repository['isInterface'] ? '인터페이스' : '구현', + $repository['summary'] ?? '-', + ]; + } + + return $this->table(['클래스', '종류', '설명'], $rows); + } + + /** + * 설정 스키마 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderSettingsSchema(array $ctx): string + { + $schema = $ctx['surface']['values']['getSettingsSchema'] ?? []; + $defaultsPath = $ctx['surface']['values']['getSettingsDefaultsPath'] ?? null; + $layout = $ctx['surface']['values']['getSettingsLayout'] ?? null; + + $lines = []; + + if (is_array($schema) && $schema !== []) { + $rows = []; + foreach ($this->flattenSchema($schema) as $key => $meta) { + // label/description 은 다국어 배열일 수 있다 — 문자열 캐스팅하면 경고가 예외로 + // 승격되어 생성 전체가 중단된다. + $label = ExtensionInventory::localized($meta['label'] ?? ''); + if ($label === '') { + $label = ExtensionInventory::localized($meta['description'] ?? ''); + } + + $rows[] = [ + $this->code($key), + $this->scalarLabel($meta['type'] ?? null), + $this->scalar($meta['default'] ?? null), + $label !== '' ? $label : '-', + ]; + } + $lines[] = $this->table(['키', '타입', '기본값', '설명'], $rows); + } else { + $lines[] = $this->none('`getSettingsSchema()` 선언이 없습니다.'); + } + + $extra = []; + if (is_string($defaultsPath) && $defaultsPath !== '') { + $extra[] = '기본값 파일: '.$this->code($this->relativeToExtension($ctx, $defaultsPath)); + } + if (is_string($layout) && $layout !== '') { + $extra[] = '설정 화면 레이아웃: '.$this->code($this->relativeToExtension($ctx, $layout)); + } + + if ($extra !== []) { + $lines[] = ''; + $lines[] = implode(' · ', $extra); + } + + return implode("\n", $lines); + } + + /** + * README 의 관리자 설정 요약을 렌더합니다. + * + * 운영자가 읽는 자리이므로 `docs/settings.md` 의 개발자용 스키마 표보다 얕게 — 키·의미· + * 기본값만 둡니다. 템플릿은 관리자 설정 화면을 갖지 않으므로 같은 자리에 제공 컴포넌트 + * 요약을 놓습니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderSettingsSummary(array $ctx): string + { + if ($ctx['record']['type'] === ExtensionInventory::TYPE_TEMPLATE) { + return $this->renderComponents($ctx); + } + + $schema = $ctx['surface']['values']['getSettingsSchema'] ?? []; + + if (! is_array($schema) || $schema === []) { + $route = $ctx['surface']['values']['getSettingsRoute'] ?? null; + $layout = $ctx['surface']['values']['getSettingsLayout'] ?? null; + + if (is_string($layout) && $layout !== '') { + return '관리자 설정 화면이 있습니다 (레이아웃: '.$this->code($this->relativeToExtension($ctx, $layout)).'). 설정 항목은 화면에서 확인하세요.' + .(is_string($route) && $route !== '' ? ' 경로: '.$this->code($route) : ''); + } + + return $this->none('별도의 관리자 설정 항목이 없습니다.'); + } + + $rows = []; + foreach ($this->flattenSchema($schema) as $key => $meta) { + $label = ExtensionInventory::localized($meta['label'] ?? ''); + if ($label === '') { + $label = ExtensionInventory::localized($meta['description'] ?? ''); + } + + $rows[] = [ + $this->code($key), + $label !== '' ? $label : '-', + $this->scalar($meta['default'] ?? null), + ]; + } + + return $this->table(['키', '의미', '기본값'], $rows) + ."\n\n".'개발자용 상세(타입·검증·저장 위치)는 '.$this->docLink($ctx, 'docs/settings.md', '설정 스키마').' 를 보세요.'; + } + + /** + * 권한 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderPermissions(array $ctx): string + { + $permissions = $ctx['surface']['values']['getPermissions'] ?? []; + $categories = is_array($permissions) ? ($permissions['categories'] ?? []) : []; + + if (! is_array($categories) || $categories === []) { + return $this->none('선언된 권한이 없습니다.'); + } + + $rows = []; + foreach ($categories as $category) { + if (! is_array($category)) { + continue; + } + + $actions = []; + foreach ($category['permissions'] ?? [] as $permission) { + if (is_array($permission) && isset($permission['action'])) { + $actions[] = (string) $permission['action']; + } + } + + $rows[] = [ + $this->code((string) ($category['identifier'] ?? '-')), + ExtensionInventory::localized($category['name'] ?? ''), + $actions === [] ? '-' : implode(', ', array_map(fn ($a) => $this->code($a), $actions)), + isset($category['resource_route_key']) ? $this->code((string) $category['resource_route_key']) : '-', + ]; + } + + return $this->table(['카테고리', '이름', '액션', '라우트 키'], $rows); + } + + /** + * 메뉴 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderMenus(array $ctx): string + { + $rows = []; + + foreach ([['getAdminMenus', '관리자'], ['getCustomMenus', '사용자']] as [$getter, $label]) { + $menus = $ctx['surface']['values'][$getter] ?? []; + if (! is_array($menus)) { + continue; + } + + foreach ($menus as $menu) { + if (! is_array($menu)) { + continue; + } + + $children = is_array($menu['children'] ?? null) ? count($menu['children']) : 0; + + $rows[] = [ + $label, + $this->code((string) ($menu['slug'] ?? '-')), + ExtensionInventory::localized($menu['name'] ?? ''), + isset($menu['url']) && is_string($menu['url']) ? $this->code($menu['url']) : '-', + $children > 0 ? (string) $children.'개' : '-', + ]; + } + } + + if ($rows === []) { + return $this->none('등록하는 메뉴가 없습니다.'); + } + + return $this->table(['구분', 'slug', '이름', 'URL', '하위'], $rows); + } + + /** + * 라우트 파일 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderRoutes(array $ctx): string + { + $routes = $ctx['surface']['values']['getRoutes'] ?? []; + + if (! is_array($routes) || $routes === []) { + return $this->none('라우트 파일이 없습니다.'); + } + + $type = $ctx['record']['type'] === ExtensionInventory::TYPE_MODULE ? 'modules' : 'plugins'; + $id = $ctx['record']['id']; + + $rows = []; + foreach ($routes as $kind => $path) { + $prefix = $kind === 'api' ? "/api/{$type}/{$id}/..." : "/{$type}/{$id}/..."; + + $rows[] = [ + $this->code((string) $kind), + $this->code($this->relativeToExtension($ctx, (string) $path)), + $this->code($prefix), + ]; + } + + $note = '확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.'; + + return $this->table(['종류', '파일', 'URL prefix'], $rows)."\n\n".$note; + } + + /** + * 의존 관계 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderDependencies(array $ctx): string + { + return $this->renderIntegrations($ctx); + } + + /** + * 레이아웃 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderLayouts(array $ctx): string + { + $frontend = $ctx['frontend']; + $layouts = $frontend['layouts']; + + if ($layouts === []) { + return $this->none('레이아웃 JSON 이 없습니다.'); + } + + $groupRows = []; + foreach ($frontend['layoutGroups'] as $group => $count) { + $groupRows[] = [$this->code($group), (string) $count.'개']; + } + + $rows = []; + foreach ($layouts as $layout) { + $rows[] = [ + $this->code($layout['name']), + $this->code($layout['group']), + $layout['partial'] ? 'partial' : '화면', + $layout['extends'] !== null ? $this->code($layout['extends']) : '-', + ]; + } + + return sprintf('레이아웃 %d개 (루트: `%s`).', count($layouts), $frontend['layoutRoot'])."\n\n" + .$this->table(['그룹', '개수'], $groupRows)."\n\n" + .$this->table(['레이아웃', '그룹', '종류', 'extends'], $rows); + } + + /** + * 템플릿 라우트 → 레이아웃 매핑을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderLayoutMap(array $ctx): string + { + $record = $ctx['record']; + $routesJson = $record['path'].DIRECTORY_SEPARATOR.'routes.json'; + + if (! is_file($routesJson)) { + return $this->none('`routes.json` 이 없습니다.'); + } + + $data = json_decode((string) file_get_contents($routesJson), true); + $routes = is_array($data) ? ($data['routes'] ?? $data) : []; + + if (! is_array($routes) || $routes === []) { + return $this->none('`routes.json` 에 라우트 선언이 없습니다.'); + } + + $rows = []; + foreach ($routes as $key => $route) { + if (is_string($route)) { + $rows[] = [$this->code((string) $key), $this->code($route), '-']; + + continue; + } + + if (! is_array($route)) { + continue; + } + + $rows[] = [ + $this->code((string) ($route['path'] ?? $key)), + $this->code((string) ($route['layout'] ?? '-')), + (string) ($route['name'] ?? '-'), + ]; + } + + return $this->table(['경로', '레이아웃', '이름'], $rows); + } + + /** + * 액션 핸들러 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderHandlers(array $ctx): string + { + $handlers = $ctx['frontend']['handlers']; + + if ($handlers['names'] === []) { + return $this->none('등록하는 액션 핸들러가 없습니다.'); + } + + $namespace = $handlers['namespace']; + $rows = []; + + foreach ($handlers['names'] as $name) { + // 등록 키가 이미 네임스페이스를 포함하면(따옴표 키) 그 값 자체가 호출 이름이다. + // 템플릿은 namespace 가 null 이지만 네임스페이스를 붙여 등록하는 핸들러를 함께 + // 가질 수 있어, 그 경우 "네임스페이스 없음" 으로 적으면 사실과 반대가 된다. + $dot = strrpos($name, '.'); + $qualified = $dot !== false + ? $name + : ($namespace !== null ? "{$namespace}.{$name}" : null); + + $rows[] = [ + $this->code($dot !== false ? substr($name, $dot + 1) : $name), + $qualified !== null ? $this->code($qualified) : '(템플릿 전용 — 네임스페이스 없음)', + ]; + } + + return sprintf('핸들러 %d개 (정의: `%s`).', count($handlers['names']), (string) $handlers['source'])."\n\n" + .$this->table(['핸들러', '레이아웃에서 부르는 이름'], $rows); + } + + /** + * 프론트 전역 진입점을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderFrontendEntry(array $ctx): string + { + $entry = $ctx['frontend']['entryPoints']; + + if ($entry['source'] === null) { + return $this->none('프론트 엔트리포인트가 없습니다.'); + } + + $rows = [ + ['엔트리 파일', $this->code((string) $entry['source'])], + ['전역 객체', $entry['global'] !== null ? $this->code('window.'.$entry['global']) : '**미노출**'], + ['재등록 진입점', $entry['initFunction'] !== null ? $this->code($entry['initFunction'].'()') : '**미노출**'], + ]; + + $note = $entry['global'] === null || $entry['initFunction'] === null + ? '재등록 진입점이 전역에 고정 이름으로 노출되지 않으면 로케일 전환 후 이 확장의 액션이 전부 무반응이 됩니다 (오류·토스트 없음).' + : '로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.'; + + return $this->table(['항목', '값'], $rows)."\n\n".$note; + } + + /** + * 에셋 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderAssets(array $ctx): string + { + $frontend = $ctx['frontend']; + $loading = $ctx['surface']['values']['getAssetLoadingConfig'] ?? []; + + $rows = []; + foreach ($frontend['builtAssets'] as $asset) { + $rows[] = [$this->code($asset), '빌드 산출물 (커밋 대상)']; + } + foreach ($frontend['vendoredAssets'] as $asset) { + $rows[] = [$this->code('dist/vendor/'.$asset), '동봉 제3자 자산 (자체 제공)']; + } + if ($frontend['customDir']) { + $rows[] = [$this->code('custom/'), '운영자 추가 에셋 (확장 교체 시 보존)']; + } + + // 편집기 스펙은 수집만 하고 렌더하지 않으면 죽은 수집이 된다. 모듈·플러그인도 + // 소유하며(실측 10개), 검사 룰이 그 파일 변경에 문서 동반을 요구한다. + $editorSpec = $frontend['editorSpec'] ?? ['manifest' => false, 'split' => 0]; + if (! empty($editorSpec['manifest'])) { + $rows[] = [$this->code('editor-spec.json'), '레이아웃 편집기 스펙 (manifest)']; + } + if (($editorSpec['split'] ?? 0) > 0) { + $rows[] = [ + $this->code('editor-spec/'), + sprintf('분할 편집기 스펙 %d개', (int) $editorSpec['split']), + ]; + } + + // manifest 가 선언했는데 디스크에 없는 산출물은 그 자체가 신호다 — 브라우저에서는 + // 404 가 되고 서버 로그에는 흔적이 없다. 위 스캔은 `dist/` 관례만 보므로, 관례를 + // 벗어난 경로를 선언한 확장은 여기서만 드러난다. + $declared = $ctx['surface']['values']['getBuiltAssetPaths'] ?? []; + $missing = []; + + if (is_array($declared)) { + foreach ($declared as $declaredPath) { + if (! is_string($declaredPath) || $declaredPath === '') { + continue; + } + + $rel = ltrim(str_replace('\\', '/', $declaredPath), '/'); + if (in_array($rel, $frontend['builtAssets'], true)) { + continue; + } + + $abs = $ctx['record']['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel); + if (! is_file($abs)) { + $missing[] = $rel; + + continue; + } + + $rows[] = [$this->code($rel), '빌드 산출물 (manifest 선언)']; + } + } + + if ($rows === [] && $missing === []) { + return $this->none('프론트 에셋이 없습니다.'); + } + + $lines = $rows === [] ? [] : [$this->table(['경로', '구분'], $rows)]; + + if ($missing !== []) { + $lines[] = ''; + $lines[] = $this->none(sprintf( + 'manifest 가 선언했으나 디스크에 없는 산출물: %s — 빌드하지 않았거나 경로가 어긋났습니다.', + implode(', ', array_map(fn (string $p): string => $this->code($p), $missing)), + )); + } + + if (is_array($loading) && $loading !== []) { + $lines[] = ''; + $lines[] = '로딩 설정: '.$this->code(json_encode($loading, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '-'); + } + + return implode("\n", $lines); + } + + /** + * 템플릿 제공 컴포넌트 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderComponents(array $ctx): string + { + $components = $ctx['frontend']['components']; + + if ($components['total'] === 0) { + return $this->none('제공 컴포넌트가 없습니다.'); + } + + $rows = []; + foreach ($components['byCategory'] as $category => $count) { + $rows[] = [$this->code($category), (string) $count.'개']; + } + + return sprintf('컴포넌트 %d개 (루트: `%s`).', $components['total'], (string) $components['root'])."\n\n" + .$this->table(['분류', '개수'], $rows); + } + + // ----------------------------------------------------------------------- + // 골격 생성 + // ----------------------------------------------------------------------- + + /** + * 문서 골격 전문을 만듭니다 (신규 파일 전용). + * + * @param string $doc 문서 상대 경로 + * @param array $ctx 수집 컨텍스트 + * @return string 문서 전문 + */ + public function skeleton(string $doc, array $ctx): string + { + return match ($doc) { + 'AGENTS.md' => $this->skeletonAgents($ctx), + 'README.md' => $this->skeletonReadme($ctx), + 'docs/README.md' => $this->skeletonDocsReadme($ctx), + 'docs/architecture.md' => $this->skeletonSectioned($doc, $ctx, '설계 의도와 계층 구조'), + default => $this->skeletonSectioned($doc, $ctx, $this->documentPurpose($doc)), + }; + } + + /** + * AGENTS.md 골격을 만듭니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 문서 전문 + */ + private function skeletonAgents(array $ctx): string + { + $record = $ctx['record']; + $name = $record['name']; + $label = ExtensionInventory::typeLabel($record['type']); + + $lines = []; + $lines[] = "# {$name} — 에이전트 가이드"; + $lines[] = ''; + $lines[] = "> 이 문서는 이 {$label}을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요."; + $lines[] = ''; + $lines[] = '## TL;DR (5초 요약)'; + $lines[] = ''; + $lines[] = '```text'; + $lines[] = "1. 유형: {$label} ({$record['id']}) — ".self::TODO_INTENT.' (소유 도메인 한 줄)'; + $lines[] = '2. 확장 방식: '.self::TODO_INTENT.' (이 확장을 건드리지 않고 붙이는 방법)'; + $lines[] = '3. 건드리면 안 되는 것: '.self::TODO_FORBIDDEN; + $lines[] = '4. 작업 위치: `'.$record['relPath'].'` — 활성 디렉토리 직접 수정 금지'; + $lines[] = "5. 반영: `php artisan {$record['type']}:update {$record['id']} --force`"; + $lines[] = '```'; + $lines[] = ''; + $lines[] = '## 1. 이 확장은 무엇인가'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 개발 의도, 해결하는 문제, 설계 원칙, 의도적으로 하지 않는 것을 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 2. 디렉토리 지도'; + $lines[] = ''; + $lines[] = self::wrap('directory-map', $this->renderBlock('directory-map', $ctx)); + $lines[] = ''; + $lines[] = '## 3. 핵심 흐름'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_FLOW.' — 대표 시나리오 2~3개를 Controller → FormRequest → Service → Repository → Model 경로로 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 4. 확장점'; + $lines[] = ''; + $lines[] = self::wrap('extension-points-summary', $this->renderBlock('extension-points-summary', $ctx)); + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 이 확장을 수정하지 않고 동작을 바꾸는 방법(어느 훅을 잡는가)을 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 5. 수정 시 동반 의무'; + $lines[] = ''; + $lines[] = $this->obligationChecklist($ctx); + $lines[] = ''; + $lines[] = '## 6. 금지 패턴'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_FORBIDDEN.' — 이 확장에서 실제로 발생했거나 발생할 수 있는 오용을 표로 적습니다.', + '', + '| 금지 | 올바른 사용 | 이유 |', + '|---|---|---|', + '| - | - | - |', + ]); + $lines[] = ''; + $lines[] = '## 7. 테스트 실행'; + $lines[] = ''; + $lines[] = self::wrap('test-commands', $this->renderBlock('test-commands', $ctx)); + $lines[] = ''; + $lines[] = '## 8. 문서 목차'; + $lines[] = ''; + $lines[] = self::wrap('docs-index', $this->renderBlock('docs-index', $ctx)); + $lines[] = ''; + + return implode("\n", $lines); + } + + /** + * 수정 시 동반 의무 체크리스트 초안을 만듭니다. + * + * 확장이 실제로 보유한 표면만 항목으로 남깁니다 — 걸리지 않는 의무를 나열하면 + * 체크리스트 전체가 형식적으로 읽힙니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function obligationChecklist(array $ctx): string + { + $record = $ctx['record']; + $items = []; + + $items[] = "`_bundled` 에서만 수정하고 `php artisan {$record['type']}:update {$record['id']} --force` 로 반영"; + + // 동기화 대상은 유형마다 다르다. 템플릿은 PHP 패키지가 아니라 `composer.json` 을 + // 갖지 않는데(번들 템플릿 4개 전부 0건), 3유형 공통 문구를 내면 **없는 파일**을 + // 체크 항목으로 요구하게 된다. 이 절은 골격에 한 번만 쓰이고 자동 생성 블록이 + // 아니라 재생성으로 고쳐지지도 않으므로, 틀린 채로 20세트에 굳는다. + $items[] = $record['type'] === 'template' + ? 'manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재' + : 'manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재'; + + if ($ctx['data']['migrations'] !== []) { + $items[] = '스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝'; + } + + if ($ctx['hooks']['published'] !== []) { + $items[] = '발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)'; + } + + // 표면을 못 읽었으면 "라우트가 없다" 가 아니라 "모른다" 다. 여기서 조건만 보고 + // 항목을 빼면 그 누락에 아무 신호가 남지 않는데, 이 절은 골격에 한 번만 쓰이고 + // 자동 생성 블록이 아니라 재생성으로 복구되지도 않는다. + $errors = $ctx['surface']['errors'] ?? []; + $routesKnown = ($ctx['surface']['available'] ?? false) === true + && ! array_key_exists('getRoutes', $errors) + && ! array_key_exists('__path_injection', $errors); + + if (! $routesKnown && $record['type'] !== 'template') { + $items[] = '라우트 선언을 읽지 못했습니다 — API 표면이 있다면 `php artisan api:docgen --scope='.$record['type'].':'.$record['id'].'` 동반 여부를 직접 확인하세요.'; + } elseif (($ctx['surface']['values']['getRoutes'] ?? []) !== []) { + $items[] = 'API 표면 변경 시 `php artisan api:docgen --scope='.$record['type'].':'.$record['id'].'` 재실행 + `docs/api/**` 갱신'; + } + + if ($ctx['frontend']['layouts'] !== []) { + $items[] = '레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인'; + } + + if ($ctx['frontend']['builtAssets'] !== []) { + $items[] = 'TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)'; + } + + if (is_dir($record['path'].DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'lang') + || is_dir($record['path'].DIRECTORY_SEPARATOR.'lang')) { + $items[] = '다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화'; + } + + $items[] = self::TODO_INTENT.' — 이 확장에만 걸리는 코어 횡단 규정을 추려 추가합니다.'; + + $lines = []; + foreach ($items as $item) { + $lines[] = '- [ ] '.$item; + } + + return implode("\n", $lines); + } + + /** + * README.md 골격을 만듭니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 문서 전문 + */ + private function skeletonReadme(array $ctx): string + { + $record = $ctx['record']; + $name = $record['name']; + $label = ExtensionInventory::typeLabel($record['type']); + $description = $record['description'] !== '' ? $record['description'] : self::TODO_INTENT; + + // 인라인 TOC 는 필수 섹션 목록에서 만든다 — 유형별 절 이름 차이(관리자 설정 ↔ 제공 + // 컴포넌트)를 두 곳에 적으면 한쪽만 고쳐 링크가 끊긴다. + $sections = self::sectionsFor('README.md', $record['type']); + // 절 이름은 override 를 거쳐 나오므로 위치가 아니라 **원래 절 이름**으로 되짚는다. + // 인덱스로 집으면 README 절을 하나 끼워 넣는 순간 엉뚱한 절이 설정 자리가 되는데, + // 헤딩은 그 자리에서 만들어지므로 필수 섹션 검사도 함께 통과해 드러나지 않는다. + $settingsSection = self::sectionsFor('README.md', $record['type'])[ + array_search('관리자 설정', self::DOCUMENTS['README.md']['sections'], true) + ]; + $toc = array_map( + fn (string $s): string => '['.$s.'](#'.str_replace(' ', '-', $s).')', + $sections, + ); + + // 확장명은 히어로 이미지가 아니라 평범한 H1 이다 (PO 결정 2026-08-31). 확장 20개는 + // 대등하게 병렬로 존재하는 구성요소이고, 각자가 코어와 같은 히어로 브랜딩을 받으면 + // "이 확장이 곧 독립 프로젝트" 라는 착시를 준다. `@generated:badges` 블록의 + // flat-square 정보 배지는 manifest 에서 오는 것이라 그대로 둔다. + $lines = []; + $lines[] = '# '.$name; + $lines[] = ''; + $lines[] = "**그누보드7 {$label} · {$record['id']}**"; + $lines[] = $this->escape($description); + $lines[] = ''; + $lines[] = self::wrap('badges', $this->renderBlock('badges', $ctx)); + $lines[] = ''; + $lines[] = '---'; + $lines[] = ''; + $lines[] = implode(' · ', $toc); + $lines[] = ''; + $lines[] = '---'; + $lines[] = ''; + $lines[] = '## 소개'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 무엇을 해결하는가 · 어떤 상황에 쓰는가 · 의도적으로 하지 않는 것.', + ]); + $lines[] = ''; + $lines[] = '## 주요 기능'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 영역별 기능을 표로 적습니다.', + '', + '| 영역 | 설명 |', + '|---|---|', + '| - | - |', + ]); + $lines[] = ''; + $lines[] = '## 동작 방식'; + $lines[] = ''; + // 템플릿은 요청 흐름 대신 레이아웃 상속 구조가 이 자리의 답이다. + $lines[] = $record['type'] === ExtensionInventory::TYPE_TEMPLATE + ? $this->intentBlock([ + self::TODO_FLOW.' — 레이아웃 상속도를 둡니다 (베이스 레이아웃 → 자식 레이아웃).', + '', + '```mermaid', + 'flowchart TD', + ' base[_base] --> child1[목록 화면]', + ' base --> child2[상세 화면]', + '```', + ]) + : $this->intentBlock([ + self::TODO_FLOW.' — 운영자 눈높이의 mermaid 흐름도를 1~2개 둡니다 (요청 흐름 / 상태 전이 / 확장 간 관계).', + '', + '```mermaid', + 'flowchart LR', + ' A[운영자] --> B[화면]', + ' B --> C[처리]', + '```', + ]); + $lines[] = ''; + $lines[] = '## 요구 사항'; + $lines[] = ''; + $lines[] = self::wrap('requirements', $this->renderBlock('requirements', $ctx)); + $lines[] = ''; + $lines[] = '## 설치'; + $lines[] = ''; + $lines[] = self::wrap('install', $this->renderBlock('install', $ctx)); + $lines[] = ''; + $lines[] = '## '.$settingsSection; + $lines[] = ''; + $lines[] = self::wrap('settings-summary', $this->renderBlock('settings-summary', $ctx)); + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_USAGE.' — 위 표가 답하지 않는 것(각 항목을 언제 바꾸는가 · 바꾸면 무엇이 달라지는가)을 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 사용 방법'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_USAGE.' — 대표 시나리오 2~3개를 운영자 관점 단계로 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 다른 확장과의 연동'; + $lines[] = ''; + $lines[] = self::wrap('integrations', $this->renderBlock('integrations', $ctx)); + $lines[] = ''; + $lines[] = '## 문서'; + $lines[] = ''; + $lines[] = self::wrap('docs-index', $this->renderBlock('docs-index', $ctx)); + $lines[] = ''; + $lines[] = '## 트러블슈팅'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_TROUBLESHOOTING.' — 운영 중 자주 만나는 증상 → 원인 → 조치를 표로 적습니다.', + '', + '| 증상 | 원인 | 조치 |', + '|---|---|---|', + '| - | - | - |', + ]); + $lines[] = ''; + $lines[] = '## 변경 이력'; + $lines[] = ''; + $lines[] = '[CHANGELOG.md](CHANGELOG.md)'; + $lines[] = ''; + $lines[] = '## 라이선스'; + $lines[] = ''; + $lines[] = (string) ($record['manifest']['license'] ?? 'MIT'); + $lines[] = ''; + + return implode("\n", $lines); + } + + /** + * `docs/README.md` 골격을 만듭니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 문서 전문 + */ + private function skeletonDocsReadme(array $ctx): string + { + $record = $ctx['record']; + + $lines = []; + $lines[] = "# {$record['name']} 개발자 문서"; + $lines[] = ''; + $lines[] = "> {$record['relPath']} · ".ExtensionInventory::typeLabel($record['type']); + $lines[] = ''; + $lines[] = self::wrap('stats', $this->renderBlock('stats', $ctx)); + $lines[] = ''; + $lines[] = '## 문서 목차'; + $lines[] = ''; + $lines[] = self::wrap('doc-toc', $this->renderBlock('doc-toc', $ctx)); + $lines[] = ''; + + return implode("\n", $lines); + } + + /** + * 섹션 기반 문서 골격을 만듭니다. + * + * @param string $doc 문서 상대 경로 + * @param array $ctx 수집 컨텍스트 + * @param string $purpose 문서 용도 한 줄 + * @return string 문서 전문 + */ + private function skeletonSectioned(string $doc, array $ctx, string $purpose): string + { + $meta = self::DOCUMENTS[$doc]; + $record = $ctx['record']; + $overrides = $meta['sectionOverrides'][$record['type']] ?? []; + + $lines = []; + $lines[] = '# '.$record['name'].' — '.$this->documentTitle($doc); + $lines[] = ''; + $lines[] = '> '.$purpose.' · 진입점: [AGENTS.md](../AGENTS.md)'; + $lines[] = ''; + + // `docs/architecture.md` 만 절 수와 블록 수가 다르다(서술 2 + 블록 1). + if (! self::pairsSectionsWithBlocks($doc)) { + foreach (self::sectionsFor($doc, $record['type']) as $section) { + $lines[] = '## '.$section; + $lines[] = ''; + + if ($section === '디렉토리') { + $lines[] = self::wrap('directory-map', $this->renderBlock('directory-map', $ctx)); + } else { + $lines[] = $this->intentBlock([ + ($section === '설계 의도' ? self::TODO_INTENT : self::TODO_FLOW).' — '.$section.' 를 서술합니다.', + ]); + } + + $lines[] = ''; + } + + return implode(' +', $lines); + } + + // 절 ↔ 블록은 배열의 **키**가 짝을 정한다. 순번(`$blocks[$i]`)으로 짝지으면 절을 + // 하나 끼우는 순간 그 뒤 블록이 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재와 + // 블록 존재를 각각만 보는 게이트는 그 어긋남을 잡지 못한다. + foreach ($meta['blocks'] as $section => $blockKey) { + $lines[] = '## '.($overrides[$section] ?? $section); + $lines[] = ''; + $lines[] = self::wrap($blockKey, $this->renderBlock($blockKey, $ctx)); + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 위 표가 답하지 않는 것(왜 이렇게 설계했는가 · 어느 것을 잡아야 하는가)을 적습니다.', + ]); + $lines[] = ''; + } + + return implode(' +', $lines); + } + + /** + * 문서 제목을 반환합니다. + * + * @param string $doc 문서 상대 경로 + * @return string 제목 + */ + private function documentTitle(string $doc): string + { + return match ($doc) { + 'docs/architecture.md' => '아키텍처', + 'docs/extension-points.md' => '확장점', + 'docs/data-model.md' => '데이터 모델', + 'docs/settings.md' => '설정·권한·라우트', + 'docs/frontend.md' => '프론트엔드', + 'docs/components.md' => '컴포넌트', + 'docs/layouts.md' => '레이아웃', + 'docs/handlers.md' => '핸들러', + 'docs/editor-spec.md' => '레이아웃 편집기 스펙', + default => basename($doc, '.md'), + }; + } + + // ----------------------------------------------------------------------- + // 편집기 스펙 블록 렌더러 + // ----------------------------------------------------------------------- + + /** + * 편집기 스펙 보유 여부와 형태를 렌더합니다. + * + * 미보유는 결함이 아니라 정상일 수 있으므로 "없음" 을 사실로 적고 사람 서술에 넘깁니다. + * 다만 manifest 가 있는데 읽지 못한 경우(malformed)는 구분해 적습니다 — 뭉뚱그리면 + * 깨진 JSON 이 "이 확장은 편집기 스펙을 두지 않는다" 로 굳습니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderEditorSpecSummary(array $ctx): string + { + $spec = $ctx['editorSpec'] ?? null; + + if (! is_array($spec)) { + return $this->none('편집기 스펙을 수집하지 못했습니다.'); + } + + if (($spec['malformed'] ?? false) === true) { + return $this->none('`editor-spec.json` 이 존재하지만 JSON 으로 읽지 못했습니다 — 편집기가 이 확장의 선언을 무시하고 있습니다.'); + } + + if (($spec['present'] ?? false) !== true) { + return $this->none('이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다.'); + } + + $rows = []; + $rows[] = ['manifest', $this->code((string) $spec['manifest'])]; + $rows[] = ['형태', $spec['split'] === true + ? '분할 — manifest + `editor-spec/*.json` '.count($spec['includes']).'개 블록' + : '단일 파일 (인라인)']; + $rows[] = ['스펙 버전', $this->code((string) ($spec['version'] ?? ''))]; + $rows[] = ['스타일 시스템', $this->code((string) ($spec['styleSystem'] ?? ''))]; + $rows[] = ['다크 모드 전략', $this->code((string) ($spec['darkMode'] ?? ''))]; + + $table = $this->table(['항목', '값'], $rows); + + // 한 줄 요약은 스펙 파일의 `description` 을 옮기지 않고 **실측에서 만든다.** + // + // 그 필드는 코드가 아니라 사람이 쓴 메모라 실측과 대조되지 않는다. 옮겨 싣던 동안 + // 12건 중 5건이 낡았고, 그중 둘은 바로 아래 표와 정반대를 말했다("controls 는 + // 나중에 추가" — 이미 303개가 있는데). 오류도 경고도 나지 않는다: 문서가 자기 + // 자신을 반박한 채로 읽힐 뿐이다. + // + // 수치에서 파생하면 그 어긋남이 생길 자리가 없어진다. 도메인 의미("무엇을 위한 + // 샘플인가")는 이 블록 바로 아래 사람 서술 칸이 담당하므로 잃는 것도 없다. + $summary = $this->editorSpecHeadline($spec); + + return $summary === null ? $table : $table."\n\n> ".$summary; + } + + /** + * 실측값으로 편집기 스펙 한 줄 요약을 만듭니다. + * + * 두 표(선언 요약·선언 블록)에 흩어진 값을 한 줄로 압축해, 문서를 연 사람이 첫 줄만 + * 읽고도 이 스펙의 규모와 성격을 알 수 있게 합니다. 값이 없는 축은 넣지 않습니다 — + * `0` 을 나열하면 요약이 길어지기만 하고 읽히지 않습니다. + * + * @param array $spec 편집기 스펙 인벤토리 + * @return string|null 요약 문장 (실을 값이 없으면 null) + */ + private function editorSpecHeadline(array $spec): ?string + { + $counts = []; + + foreach ($spec['blocks'] as $block) { + $counts[$block['key']] = $block['count']; + } + + $parts = []; + + $parts[] = $spec['split'] === true + ? '분할 '.count($spec['includes']).'블록' + : '단일 파일'; + + // 표시 순서는 편집기에서 마주치는 순서다 — 무엇을 놓을 수 있고(팔레트), 어떻게 + // 꾸미고(컨트롤·역량), 어디에 담고(중첩), 무엇으로 그려 보는가(샘플·상태). + foreach ([ + ['componentPalette.entries', '팔레트'], + ['controls', '스타일 컨트롤'], + ['componentCapabilities', '편집 역량'], + ['nesting.containers', '중첩 컨테이너'], + ['sampleData.byDataSourceId', '프리뷰 샘플'], + ['sampleData.byEndpointPattern', '엔드포인트 샘플'], + ['states.groups', '페이지 상태'], + ['actionRecipes', '액션 레시피'], + ] as [$key, $label]) { + $n = $counts[$key] ?? null; + + if (is_int($n) && $n > 0) { + $parts[] = $label.' '.$n; + } + } + + return $parts === [] ? null : implode(' · ', $parts); + } + + /** + * 스펙이 선언한 블록별 항목 수를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderEditorSpecBlocks(array $ctx): string + { + $spec = $ctx['editorSpec'] ?? null; + + if (! is_array($spec) || ($spec['present'] ?? false) !== true) { + return $this->none('선언된 편집기 스펙 블록이 없습니다.'); + } + + $rows = []; + + foreach ($spec['blocks'] as $block) { + $rows[] = [ + $this->code($block['key']), + $this->editorSpecBlockRole($block['key']), + $block['count'] === null ? '-' : (string) $block['count'], + $this->code($block['source']), + ]; + } + + return $this->table(['블록', '역할', '항목 수', '출처'], $rows); + } + + /** + * 편집기 스펙 블록의 역할을 한 줄로 설명합니다. + * + * 블록 키는 코어가 정한 어휘이므로 확장마다 다시 설명할 필요가 없고, 반대로 설명이 + * 없으면 확장 문서만 읽는 쪽이 키 이름만으로 의미를 추측하게 됩니다. + * + * @param string $key 블록 키 + * @return string 역할 설명 + */ + private function editorSpecBlockRole(string $key): string + { + return match ($key) { + 'componentPalette', 'componentPalette.entries' => '편집기 "요소 추가" 팔레트에 나타나는 항목', + 'componentPalette.groups' => '팔레트 좌측 목록의 묶음', + 'nesting.draggable' => '캔버스에서 끌어 옮길 수 있는 컴포넌트', + 'nesting.containers' => '자식을 담을 수 있는 컴포넌트와 그 허용 규칙', + 'sampleData.byDataSourceId' => '레이아웃 `data_sources` ID 로 붙는 프리뷰 응답', + 'sampleData.byEndpointPattern' => '엔드포인트 패턴으로 붙는 프리뷰 응답', + 'states.groups' => '상태 변종을 적용할 범위(라우트·베이스 레이아웃)', + 'conditionRecipes.operators' => '조건 표현식에 쓸 수 있는 연산자', + 'controls' => '재사용 스타일 컨트롤 정의', + 'componentCapabilities' => '컴포넌트별 편집 역량(어떤 속성을 편집기가 다루는가)', + 'nesting' => '어떤 컴포넌트 안에 무엇을 넣을 수 있는가', + 'sampleData' => '캔버스 프리뷰용 샘플 응답', + 'sampleGlobal' => '`_global.*` 프리뷰 baseline 시드', + 'states' => '페이지 상태 변종(빈 목록·오류 등)', + 'stateLabels' => '상태값 친화 명칭 카탈로그', + 'actionRecipes' => '친화 명칭 → 액션 JSON 레시피', + 'conditionRecipes' => '친화 조건 → `if` 표현식 레시피', + 'computedRecipes' => '계산값 레시피', + 'errorRecipes' => '오류 처리 레시피', + 'loadingComponents' => '로딩 표시 컴포넌트 후보', + 'actionChipCandidates' => '동작 데이터 칩 컨텍스트 후보', + default => '-', + }; + } + + /** + * ID 목록을 표 한 칸에 담을 수 있게 줄입니다. + * + * 70개를 한 줄로 늘어놓으면 표가 읽히지 않고, 그렇다고 개수만 남기면 어떤 ID 인지 + * 확인할 길이 사라집니다. 앞쪽을 보이고 나머지는 수로 줄입니다. + * + * @param array $ids ID 목록 + * @param int $limit 나열할 최대 개수 + * @return string 마크다운 셀 내용 + */ + private function idListCell(array $ids, int $limit = 12): string + { + if ($ids === []) { + return '-'; + } + + $shown = array_slice($ids, 0, $limit); + $cell = implode(' · ', array_map(fn (string $id): string => $this->code($id), $shown)); + + $rest = count($ids) - count($shown); + + return $rest > 0 ? $cell." … 외 {$rest}개" : $cell; + } + + /** + * 팔레트 그룹별 항목 수를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderEditorSpecPalette(array $ctx): string + { + $spec = $ctx['editorSpec'] ?? null; + + if (! is_array($spec) || ($spec['present'] ?? false) !== true) { + return $this->none('이 확장은 편집기 팔레트에 항목을 추가하지 않습니다.'); + } + + $rows = []; + + foreach ($spec['paletteGroups'] as $group) { + $rows[] = [ + $this->escape($group['group']), + $this->code($group['kind']), + (string) $group['count'], + ]; + } + + if ($rows === []) { + return $this->none('이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다.'); + } + + return $this->table(['그룹', '종류', '컴포넌트 수'], $rows); + } + + /** + * 샘플 데이터 ID 와 페이지 상태 변종을 렌더합니다. + * + * 이 두 축은 편집기 캔버스가 실제 API 없이 화면을 그릴 때 쓰는 값입니다. 레이아웃의 + * `data_sources` ID 와 어긋나면 편집기 프리뷰만 빈 화면이 되는데, 실제 화면은 정상이라 + * 어긋남이 드러나지 않습니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderEditorSpecSamples(array $ctx): string + { + $spec = $ctx['editorSpec'] ?? null; + + if (! is_array($spec)) { + return $this->none('편집기 스펙을 수집하지 못했습니다.'); + } + + // 스펙이 없어도 **미커버 목록은 낸다.** 스펙이 없는 확장이야말로 프리뷰가 비는 + // 자리를 가질 가능성이 크고, 여기서 조기 반환하면 정작 필요한 확장에서 그 목록이 + // 통째로 사라진다 — 그런데 결과는 "빈 자리가 없다" 와 구분되지 않는다. + $sections = []; + + if (($spec['present'] ?? false) === true) { + // 세 자리를 한 행으로 합치지 않는다 — `data_sources` ID 로 붙는 샘플과 엔드포인트 + // 패턴으로 붙는 샘플은 어긋났을 때 고칠 자리가 다르고, 페이지 상태는 ID 가 아니라 + // 적용 범위(라우트/베이스 레이아웃)로 식별된다. + $rows = []; + + foreach ([ + ['sampleData.byDataSourceId', $spec['sampleDataIds']], + ['sampleData.byEndpointPattern', $spec['sampleEndpointPatterns']], + ['states.groups', $spec['stateScopes']], + ] as [$label, $ids]) { + $declared = ($spec['declaredPaths'][$label] ?? false) === true; + + $rows[] = [ + $this->code($label), + $this->editorSpecBlockRole($label), + $declared ? (string) count($ids) : '미선언', + $declared ? $this->idListCell($ids) : '-', + ]; + } + + $sections[] = $this->table(['자리', '역할', '개수', 'ID'], $rows); + } else { + $sections[] = $this->none('이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다.'); + } + + $uncovered = $spec['uncovered'] ?? []; + + if ($uncovered === []) { + $sections[] = $this->none('이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버).'); + } else { + $sections[] = '**프리뷰 샘플이 없는 `data_source` '.count($uncovered).'개** — 편집기 캔버스에서 이 자리만 빈 화면이 됩니다. 실제 화면은 정상이라 오류도 경고도 남지 않습니다.' + ."\n\n".$this->idListCell($uncovered, 20); + } + + return implode("\n\n", $sections); + } + + /** + * 편집기 스펙을 함께 고쳐야 하는 변경과 그 절차를 렌더합니다. + * + * 이 표가 없으면 확장에 화면 요소를 추가해도 편집기 팔레트에 나타나지 않는 상태가 + * 오류도 경고도 없이 남습니다 — 편집기는 선언되지 않은 컴포넌트를 "없는 것" 으로 + * 다룰 뿐 실패를 보고하지 않습니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderEditorSpecObligations(array $ctx): string + { + $record = $ctx['record']; + $spec = $ctx['editorSpec'] ?? null; + $hasSpec = is_array($spec) && ($spec['present'] ?? false) === true; + + $update = "php artisan {$record['type']}:update {$record['id']} --force"; + + $rows = [ + ['컴포넌트를 새로 만들었다', '`componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정'], + ['레이아웃에 `data_sources` 를 추가했다', '`sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면)'], + ['`_global.*` 을 새로 읽는다', '`sampleGlobal` 에 baseline 값 추가'], + ['빈 목록·오류 같은 화면 변종을 추가했다', '`states` 에 변종 추가 · `stateLabels` 에 친화 명칭'], + ['새 액션·조건 패턴을 도입했다', '`actionRecipes` / `conditionRecipes` 에 친화 명칭 등록'], + ]; + + $table = $this->table(['이런 변경을 했다면', '편집기 스펙에서 함께 할 일'], $rows); + + if (! $hasSpec) { + // 스펙이 없는 확장에도 이 표를 남긴다. 지금은 해당 없음이지만, 위 사건 중 하나가 + // 일어나는 순간 스펙을 **신설**해야 한다는 것이 이 문서가 전할 내용이다. + return $this->none('이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다.') + ."\n\n".$table; + } + + // `_bundled` 편집분은 update 커맨드로 활성 디렉토리에 반영된 뒤에만 편집기에 보인다 + // (`EditorSpecAssembler` 는 활성 디렉토리만 합본한다 — `_bundled` 폴백이 없다). + return $table."\n\n" + .'편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다:' + ."\n\n```bash\n".$update."\n```"; + } + + // ----------------------------------------------------------------------- + // 마크다운 유틸 + // ----------------------------------------------------------------------- + + /** + * 사람 영역(`@intent`) 블록을 만듭니다. + * + * @param array $lines 본문 줄 + * @return string 마크다운 + */ + private function intentBlock(array $lines): string + { + return "\n".implode("\n", $lines)."\n"; + } + + /** + * 마크다운 표를 만듭니다. + * + * @param array $headers 헤더 + * @param array> $rows 행 + * @return string 마크다운 표 (행이 없으면 안내 문구) + */ + private function table(array $headers, array $rows): string + { + if ($rows === []) { + return $this->none('해당 항목이 없습니다.'); + } + + $lines = []; + $lines[] = '| '.implode(' | ', $headers).' |'; + $lines[] = '|'.str_repeat('---|', count($headers)); + + foreach ($rows as $row) { + $cells = []; + for ($i = 0; $i < count($headers); $i++) { + $cells[] = $this->cell($row[$i] ?? '-'); + } + $lines[] = '| '.implode(' | ', $cells).' |'; + } + + return implode("\n", $lines); + } + + /** + * 표 셀 값을 안전하게 만듭니다 (파이프·개행 이스케이프). + * + * @param string $value 값 + * @return string 셀 문자열 + */ + private function cell(string $value): string + { + $value = str_replace(["\r\n", "\r", "\n"], ' ', $value); + $value = str_replace('|', '\\|', $value); + + return trim($value) === '' ? '-' : trim($value); + } + + /** + * 인라인 코드로 감쌉니다. + * + * @param string $value 값 + * @return string 마크다운 + */ + private function code(string $value): string + { + return $value === '' ? '-' : '`'.$value.'`'; + } + + /** + * 항목 없음 안내를 만듭니다. + * + * @param string $message 안내 문구 + * @return string 마크다운 + */ + private function none(string $message): string + { + return '_'.$message.'_'; + } + + /** + * 개별 getter 수집 실패를 알리는 문장을 만듭니다. + * + * `available` 은 **진입 클래스**를 읽었는지만 말합니다. 클래스를 읽고도 개별 getter 가 + * 던지면(`errors`) 그 항목만 빈 값이 되는데, 렌더러가 그 빈 값을 그대로 "선언된 권한이 + * 없습니다" · "훅을 발행하지 않습니다" 로 서술하면 **읽지 못한 것이 없는 것으로 굳는다**. + * 경로 주입 실패(`__path_injection`)는 경로 기반 getter 전부를 동시에 비우므로 특히 그렇다. + * + * 표면을 통째로 못 읽은 경우(`surfaceUnavailable`)와 달리 여기서는 본문을 **대체하지 + * 않고 덧붙인다** — 나머지 getter 가 돌려준 실측을 버릴 이유가 없다. + * + * @param array $ctx 수집 컨텍스트 + * @return string|null 통지 문장, 실패가 없으면 null + */ + private function surfaceErrorsNotice(array $ctx): ?string + { + if (($ctx['record']['type'] ?? null) === 'template') { + return null; + } + + $errors = $ctx['surface']['errors'] ?? []; + if (! is_array($errors) || $errors === []) { + return null; + } + + $names = array_map(static fn (string $g): string => '`'.$g.'`', array_keys($errors)); + + return $this->none(sprintf( + '아래 표면을 읽지 못했습니다 (%s). 이 절의 "없음" 은 사실이 아닐 수 있습니다 — 항목이 없다는 뜻이 아니라 %s.', + implode(' · ', $names), + self::SURFACE_NOTICE_MARKER, + )); + } + + /** + * 선언형 표면을 읽지 못한 경우의 안내를 만듭니다 (읽었으면 null). + * + * 수집기는 진입 클래스 로드·인스턴스화 실패를 `available=false` + `reason` 으로 올립니다. + * 그 신호를 읽지 않으면 "권한이 없습니다" · "라우트 파일이 없습니다" 처럼 **사실이 아닌 + * 문장**이 문서에 박힙니다 — 콘솔 경고는 사라지고 커밋된 문서만 남으므로, 읽는 사람에게는 + * 그것이 확장의 사실로 보입니다. "없음" 과 "확인하지 못함" 은 구분해서 보고합니다. + * + * 템플릿은 선언형 표면 자체를 갖지 않으므로(`available=false` 가 정상) 대상이 아닙니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string|null 확인 불가 안내, 정상 수집이면 null + */ + private function surfaceUnavailable(array $ctx): ?string + { + if (($ctx['surface']['available'] ?? false) === true) { + return null; + } + + if (($ctx['record']['type'] ?? null) === 'template') { + return null; + } + + $reason = $ctx['surface']['reason'] ?? null; + + return $this->none(sprintf( + '이 항목을 확인하지 못했습니다 (%s). 항목이 없다는 뜻이 아니라 %s.', + is_string($reason) && $reason !== '' ? $reason : '사유 미상', + self::SURFACE_NOTICE_MARKER, + )); + } + + /** + * 스칼라 값을 표시용 문자열로 만듭니다. + * + * @param mixed $value 값 + * @return string 표시 문자열 + */ + private function scalar(mixed $value): string + { + if ($value === null) { + return '-'; + } + if (is_bool($value)) { + return $this->code($value ? 'true' : 'false'); + } + if (is_scalar($value)) { + return $this->code((string) $value); + } + + return $this->code((string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + /** + * 값을 인라인 코드 라벨로 만듭니다 (배열/객체도 안전하게 처리). + * + * @param mixed $value 값 + * @return string 마크다운 + */ + private function scalarLabel(mixed $value): string + { + if ($value === null || $value === '') { + return '-'; + } + if (is_scalar($value)) { + return $this->code((string) $value); + } + + return $this->code((string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + /** + * HTML 속성용으로 문자열을 이스케이프합니다. + * + * @param string $value 값 + * @return string 이스케이프된 값 + */ + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * 배열/문자열의 항목 수를 셉니다. + * + * @param mixed $value 값 + * @return int 항목 수 + */ + private function countOf(mixed $value): int + { + return is_array($value) ? count($value) : 0; + } + + /** + * FQCN 의 짧은 이름을 반환합니다. + * + * @param string $fqcn 클래스명 + * @return string 짧은 이름 + */ + private function shortName(string $fqcn): string + { + $parts = explode('\\', trim($fqcn, '\\')); + + return end($parts) ?: $fqcn; + } + + /** + * 절대 경로를 확장 루트 기준 상대 경로로 바꿉니다. + * + * @param array $ctx 수집 컨텍스트 + * @param string $path 경로 + * @return string 상대 경로 + */ + private function relativeToExtension(array $ctx, string $path): string + { + $base = rtrim((string) $ctx['record']['path'], '/\\').DIRECTORY_SEPARATOR; + $normalized = str_replace('\\', '/', $path); + $normalizedBase = str_replace('\\', '/', $base); + + return str_starts_with($normalized, $normalizedBase) + ? substr($normalized, strlen($normalizedBase)) + : $normalized; + } + + /** + * 문서 링크를 만듭니다 (AGENTS.md 기준 상대 경로). + * + * @param array $ctx 수집 컨텍스트 + * @param string $doc 문서 상대 경로 + * @param string $anchor 섹션 제목 + * @return string 마크다운 링크 + */ + private function docLink(array $ctx, string $doc, string $anchor): string + { + $slug = strtolower(str_replace([' ', '.', '(', ')'], ['-', '', '', ''], $anchor)); + + return "[{$anchor}]({$doc}#{$slug})"; + } + + /** + * composer.json 의 PHP 제약을 읽습니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string|null PHP 제약 (없으면 null) + */ + private function composerPhp(array $ctx): ?string + { + $path = $ctx['record']['path'].DIRECTORY_SEPARATOR.'composer.json'; + if (! is_file($path)) { + return null; + } + + $composer = json_decode((string) file_get_contents($path), true); + $php = $composer['require']['php'] ?? null; + + return is_string($php) ? $php : null; + } + + /** + * 설정 스키마를 점 표기 키로 평탄화합니다. + * + * @param array $schema 스키마 + * @param string $prefix 키 접두 + * @return array> 평탄화된 키 => 메타 + */ + private function flattenSchema(array $schema, string $prefix = ''): array + { + $flat = []; + + foreach ($schema as $key => $value) { + $path = $prefix === '' ? (string) $key : $prefix.'.'.$key; + + if (is_array($value) && isset($value['type'])) { + $flat[$path] = $value; + + continue; + } + + if (is_array($value) && $value !== [] && array_keys($value) !== range(0, count($value) - 1)) { + $flat += $this->flattenSchema($value, $path); + + continue; + } + + $flat[$path] = ['type' => gettype($value), 'default' => $value]; + } + + return $flat; + } +} diff --git a/app/Support/ExtensionDoc/ExtensionInventory.php b/app/Support/ExtensionDoc/ExtensionInventory.php new file mode 100644 index 00000000..731669d9 --- /dev/null +++ b/app/Support/ExtensionDoc/ExtensionInventory.php @@ -0,0 +1,322 @@ + + */ + 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 유형 목록 + */ + 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> 확장 레코드 목록 (유형 → 식별자 정렬) + */ + 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|null 확장 레코드 (없으면 null) + */ + public function find(string $type, string $id): ?array + { + $records = $this->collect("{$type}:{$id}"); + + return $records[0] ?? null; + } + + /** + * @var array manifest 를 읽지 못한 디렉토리 + */ + private array $malformed = []; + + /** + * 직전 `collect()` 에서 manifest 파싱에 실패한 디렉토리 목록을 반환합니다. + * + * 호출자가 이 목록을 보고해야 "확장이 없다" 와 "읽지 못했다" 가 구분됩니다. + * + * @return array 실패 목록 + */ + public function malformed(): array + { + return $this->malformed; + } + + /** + * 확장 레코드를 조립합니다. + * + * manifest 가 없거나 JSON 파싱에 실패한 디렉토리는 확장이 아니므로 제외합니다 + * (`_backup_*` · 업데이트 중 임시 디렉토리 등). + * + * @param string $type 확장 유형 + * @param string $id 확장 식별자 + * @param string $dirPath 확장 절대 경로 + * @return array|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 $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 ''; + } +} diff --git a/app/Support/ExtensionDoc/FrontendInventory.php b/app/Support/ExtensionDoc/FrontendInventory.php new file mode 100644 index 00000000..1ab32a7c --- /dev/null +++ b/app/Support/ExtensionDoc/FrontendInventory.php @@ -0,0 +1,540 @@ + $record ExtensionInventory 레코드 + * @return array 프론트 인벤토리 + */ + 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 $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 $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 $record 확장 레코드 + * @return string 레이아웃 확장 루트 + */ + private function layoutExtensionRoot(array $record): string + { + return $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'extensions' : 'resources/extensions'; + } + + /** + * 레이아웃 JSON 을 수집합니다. + * + * @param array $record 확장 레코드 + * @return array> 레이아웃 목록 + */ + 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> $layouts 레이아웃 목록 + * @return array 그룹 => 개수 + */ + 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 $record 확장 레코드 + * @return array{namespace: string|null, names: array, 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 $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 $record 확장 레코드 + * @return array{total: int, byCategory: array, 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 $record 확장 레코드 + * @return array 산출물 상대 경로 + */ + 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 $record 확장 레코드 + * @return array `{라이브러리}/{버전}` 목록 + */ + 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 $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 $record 확장 레코드 + * @param string $sub 확장 루트 기준 하위 경로 + * @return array 상대 경로 목록 (정렬) + */ + 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 키 목록 + */ + private function objectKeys(string $content, string $name): array + { + // 타입 주석이 붙은 선언(`handlerMap: Record 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 핸들러 이름 목록 + */ + 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 $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); + } +} diff --git a/app/Support/ExtensionDoc/HookInventory.php b/app/Support/ExtensionDoc/HookInventory.php new file mode 100644 index 00000000..9e81679d --- /dev/null +++ b/app/Support/ExtensionDoc/HookInventory.php @@ -0,0 +1,527 @@ + + */ + 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 $record ExtensionInventory 레코드 + * @param array $declared 확장이 `getHooks()` 로 선언한 발행 훅 목록 + * @return array{published: array>, publishedSites: int, publishedDynamic: int, publishedUndeclared: int, subscribed: array>, listeners: array>} + */ + 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 $record 확장 레코드 + * @param array $declared `getHooks()` 선언 목록 + * @return array{hooks: array>, 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 $declared 선언 목록 + * @return array> 훅 이름 → 항목 + */ + 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 $record 확장 레코드 + * @return array> 리스너 목록 + */ + 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> 구독 훅 목록 + */ + 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 항목 목록 (빈 항목 제외) + */ + 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 $record 확장 레코드 + * @return \Generator 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 $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); + } +} diff --git a/app/Support/ExtensionDoc/TestPathCollector.php b/app/Support/ExtensionDoc/TestPathCollector.php new file mode 100644 index 00000000..23aeb672 --- /dev/null +++ b/app/Support/ExtensionDoc/TestPathCollector.php @@ -0,0 +1,241 @@ + $record ExtensionInventory 레코드 + * @return array 테스트 인벤토리 + */ + 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 $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 $record 확장 레코드 + * @return array{config: string|null, dirs: array, 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 $record 확장 레코드 + * @return array 매니페스트 상대 경로 + */ + 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 $record 확장 레코드 + * @param array{count: int, dirs: array} $phpunit PHPUnit 집계 + * @param array{config: string|null, dirs: array, files: int} $vitest Vitest 집계 + * @param array{count: int, dirs: array} $playwright Playwright 집계 + * @return array 실행 명령 목록 + */ + 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 $record 확장 레코드 + * @param string $sub 확장 루트 기준 하위 경로 + * @param string $extension 대상 확장자 + * @param array $excludeDirs 제외할 1단계 하위 디렉토리명 + * @return array{count: int, dirs: array, 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]; + } +} diff --git a/docs/README.md b/docs/README.md index 46c7cc68..d98847eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | diff --git a/docs/ai-tools/agents/src/prompts/template.ts b/docs/ai-tools/agents/src/prompts/template.ts index 0907cb93..bd44fe4f 100644 --- a/docs/ai-tools/agents/src/prompts/template.ts +++ b/docs/ai-tools/agents/src/prompts/template.ts @@ -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 diff --git a/docs/backend/README.md b/docs/backend/README.md index 728af961..2a3b7c06 100644 --- a/docs/backend/README.md +++ b/docs/backend/README.md @@ -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/파라미터/응답 필드 +... | diff --git a/docs/backend/activity-log-hooks.md b/docs/backend/activity-log-hooks.md index 18a6be98..6e18543d 100644 --- a/docs/backend/activity-log-hooks.md +++ b/docs/backend/activity-log-hooks.md @@ -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 클래스 생성 diff --git a/docs/extension/README.md b/docs/extension/README.md index 14851a48..f59b169d 100644 --- a/docs/extension/README.md +++ b/docs/extension/README.md @@ -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 기재 의무 | + --- ## 확장 타입별 네이밍 규칙 diff --git a/docs/extension/editor-spec.md b/docs/extension/editor-spec.md index aef85104..4e4a5b3a 100644 --- a/docs/extension/editor-spec.md +++ b/docs/extension/editor-spec.md @@ -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` diff --git a/docs/extension/extension-documentation.md b/docs/extension/extension-documentation.md new file mode 100644 index 00000000..fa6f66a0 --- /dev/null +++ b/docs/extension/extension-documentation.md @@ -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 + +| 훅 이름 | 유형 | 발행 위치 | +| ... | + + + +이 훅은 ... (사람이 쓰는 영역 — 생성기가 건드리지 않는다) + +``` + +블록 밖 전부가 사람 영역이다. 계약은 세 가지다. + +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 레퍼런스 문서 규정 diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 75f4fba0..1f9defe0 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -36,13 +36,6 @@ --- -### 템플릿별 레퍼런스 - -| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 | -|--------------|---------|--------|--------| -| `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) | - ### 컴포넌트 개발 | 문서 | 설명 | diff --git a/docs/frontend/component-props-composite.md b/docs/frontend/component-props-composite.md index 888e855a..c77d3056 100644 --- a/docs/frontend/component-props-composite.md +++ b/docs/frontend/component-props-composite.md @@ -1,6 +1,6 @@ # 컴포넌트 Props 레퍼런스 - Composite -> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) +> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) --- @@ -671,7 +671,7 @@ G7에서는 콘텐츠의 렌더링 모드를 DB의 `*_mode` 컬럼(`'text'` / `' - [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) - [컴포넌트 개발 규칙](components.md) - [컴포넌트 고급 기능](components-advanced.md) -- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) +- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - [액션 핸들러 - 커스텀 콜백](actions.md#커스텀-이벤트-event-필드) - [보안 가이드 - HTML 렌더링](security.md#htmlcontent--htmleditor-html-렌더링이-필요한-경우) - [에디터 컴포넌트](editors.md) diff --git a/docs/frontend/component-props.md b/docs/frontend/component-props.md index ab9c010d..13113dea 100644 --- a/docs/frontend/component-props.md +++ b/docs/frontend/component-props.md @@ -1144,7 +1144,7 @@ id prop 사용: scrollIntoView 등 DOM selector로 접근해야 할 때 필수 - [컴포넌트 개발 규칙](components.md) - basic, composite, layout 컴포넌트 - [레이아웃 JSON 스키마](layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법 -- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 (111개) -- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개) +- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록 +- [sirsoft-basic 컴포넌트](../../templates/_bundled/sirsoft-basic/docs/components.md) - User 컴포넌트 목록 (확장 소유) - [에디터 컴포넌트](editors.md) - HtmlEditor, CodeEditor 상세 가이드 - [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어 diff --git a/docs/frontend/components-types.md b/docs/frontend/components-types.md index 961daf02..c93a34c5 100644 --- a/docs/frontend/components-types.md +++ b/docs/frontend/components-types.md @@ -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 = ({ 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) diff --git a/docs/frontend/components.md b/docs/frontend/components.md index ad643981..b6a88f08 100644 --- a/docs/frontend/components.md +++ b/docs/frontend/components.md @@ -25,15 +25,12 @@ | [components-advanced.md](components-advanced.md) | componentEvent, 아이콘, 체크리스트 | 이벤트 통신, 아이콘 규칙, 개발 체크리스트 | -### 템플릿별 레퍼런스 - -| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 | -|--------------|---------|--------|--------| -| `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) | +> `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) - 전역/로컬 상태 관리 및 동기화 패턴 diff --git a/docs/frontend/dark-mode.md b/docs/frontend/dark-mode.md index 6b6b3cf0..f4930bf1 100644 --- a/docs/frontend/dark-mode.md +++ b/docs/frontend/dark-mode.md @@ -25,7 +25,7 @@ | `sirsoft-admin_basic` | 미선언 | ThemeToggle 존재, dark: variant 동작 | | `sirsoft-basic` | `true` | template.json에 선언, 완전 지원 | -> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md) +> 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](../../templates/_bundled/sirsoft-basic/docs/components.md) --- diff --git a/docs/frontend/editors.md b/docs/frontend/editors.md index 82eaea03..d31a14a9 100644 --- a/docs/frontend/editors.md +++ b/docs/frontend/editors.md @@ -366,7 +366,7 @@ HtmlEditor는 내부적으로 **DOMPurify**를 사용하여 HTML을 정화합니 ## 관련 문서 - [컴포넌트 Props 레퍼런스](component-props.md) - Select, Input, Button Props -- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 +- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록 - [레이아웃 JSON](layout-json.md) - 레이아웃 JSON 스키마 - [데이터 바인딩](data-binding.md) - {{}} 표현식, $t: 다국어 - [상태 관리](state-management.md) - _local, setState diff --git a/docs/frontend/responsive-layout.md b/docs/frontend/responsive-layout.md index bbccfe3c..4ffc9ea2 100644 --- a/docs/frontend/responsive-layout.md +++ b/docs/frontend/responsive-layout.md @@ -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) --- diff --git a/docs/frontend/template-handlers.md b/docs/frontend/template-handlers.md index f3a0c278..4eae544c 100644 --- a/docs/frontend/template-handlers.md +++ b/docs/frontend/template-handlers.md @@ -20,13 +20,12 @@ ## 템플릿별 상세 문서 -| 템플릿 식별자 | 핸들러 문서 | 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과 동일 키 공유) | +> `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) diff --git a/docs/frontend/templates/README.md b/docs/frontend/templates/README.md new file mode 100644 index 00000000..ca328706 --- /dev/null +++ b/docs/frontend/templates/README.md @@ -0,0 +1,13 @@ +# 템플릿별 컴포넌트·핸들러·레이아웃 문서 + +번들 템플릿의 컴포넌트/핸들러/레이아웃 상세 문서는 그 템플릿이 소유합니다(#601). 어느 +템플릿이 어디에 문서를 갖는지는 아래 표를 따릅니다 — 이관이 끝난 템플릿은 코어에 사본을 +남기지 않습니다. + +| 템플릿 | 상태 | 문서 위치 | +|---|---|---| +| `sirsoft-admin_basic` | 이관 완료 | [templates/_bundled/sirsoft-admin_basic/docs/](../../../templates/_bundled/sirsoft-admin_basic/docs/README.md) | +| `sirsoft-basic` | 이관 완료 | [templates/_bundled/sirsoft-basic/docs/](../../../templates/_bundled/sirsoft-basic/docs/README.md) | + +이관 배경·문서 체계 전반은 [extension-documentation.md](../../extension/extension-documentation.md) +를 참고하세요. diff --git a/docs/frontend/templates/sirsoft-admin_basic/handlers.md b/docs/frontend/templates/sirsoft-admin_basic/handlers.md deleted file mode 100644 index a9aa7d89..00000000 --- a/docs/frontend/templates/sirsoft-admin_basic/handlers.md +++ /dev/null @@ -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) diff --git a/docs/frontend/templates/sirsoft-admin_basic/layouts.md b/docs/frontend/templates/sirsoft-admin_basic/layouts.md deleted file mode 100644 index 46a49173..00000000 --- a/docs/frontend/templates/sirsoft-admin_basic/layouts.md +++ /dev/null @@ -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) diff --git a/docs/frontend/templates/sirsoft-basic/handlers.md b/docs/frontend/templates/sirsoft-basic/handlers.md deleted file mode 100644 index 78269e8d..00000000 --- a/docs/frontend/templates/sirsoft-basic/handlers.md +++ /dev/null @@ -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) diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md index 580bd675..68e73ecb 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.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 diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php index 1f01be16..ab8d69cf 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php @@ -203,6 +203,7 @@ return [ 'register' => '会員登録', 'mypage' => 'マイページ', 'mypage_renew_all' => 'マイページ一括再同意', + 'withdraw' => '会員退会', ], 'col' => [ 'created_at' => '時点', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json index 917a2e3f..ee0b4268 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json @@ -238,7 +238,8 @@ "preference_center": "環境設定", "mypage": "マイページ", "register": "会員登録", - "mypage_renew_all": "マイページ一括再同意" + "mypage_renew_all": "マイページ一括再同意", + "withdraw": "会員退会" }, "col": { "created_at": "時点", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json index 799fe5b4..393526fc 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json @@ -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", diff --git a/modules/_bundled/gnuboard7-hello_module/AGENTS.md b/modules/_bundled/gnuboard7-hello_module/AGENTS.md new file mode 100644 index 00000000..28b13e5a --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/AGENTS.md @@ -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. 이 확장은 무엇인가 + + +**학습용 최소 샘플 모듈**입니다. 실제 업무 기능을 제공하지 않으며, 모듈이 필요로 하는 +계층을 **하나씩만** 담아 "모듈은 이런 모양이다" 를 보여주는 것이 유일한 목적입니다. + +도메인은 메모(Memo) 하나이고 필드는 셋뿐입니다. 그 위에 Model · Migration · Factory · +Seeder · Repository(인터페이스 + 구현) · Service · FormRequest · Resource · Controller · +Listener · Layout · Test · 다국어(백엔드 PHP + 프론트 JSON)가 각 1개씩 있습니다. 실제 +모듈은 이 계층을 엔티티 수만큼 늘린 것입니다. + +**설계 원칙: 짧게 유지한다.** 샘플의 가치는 완결성이 아니라 **한눈에 읽히는 것**입니다. +여기에 기능을 더하면 계층 구조를 보러 온 사람이 도메인 로직을 읽게 되므로, 새 기능이 +필요하면 이 샘플이 아니라 별도 확장을 만듭니다. + +`manifest.hidden = true` 라 관리자 UI 의 모듈 목록에 나타나지 않습니다. artisan CLI 로는 +정상 설치·활성화됩니다 — 학습용이 운영 화면에 섞이지 않게 하면서도 실제로 동작해 봐야 +학습이 되기 때문입니다. + +**의도적으로 하지 않는 것**: 검색 색인·SEO·알림·스케줄·미들웨어·브로드캐스트. 각 축의 사용법은 +그것을 실제로 쓰는 확장(게시판·이커머스)의 문서가 다룹니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + + +## 3. 핵심 흐름 + + +계층 하나씩을 지나는 **가장 짧은 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` 을 함께 설치하면 **바깥에서 구독하는** 모습도 볼 수 있습니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 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#알림-정의) | + + + +발행 훅 하나(`memo.created`)가 전부입니다. 실제 모듈이라면 도메인마다 +`before_*` → `filter_*_data` → `after_*` 3단을 두지만, 샘플에서는 **훅이 무엇인지**만 +보이면 되므로 하나로 줄였습니다. + +`gnuboard7-hello_plugin` 이 이 훅을 구독합니다. 두 샘플을 함께 설치하면 "모듈이 발행하고 +플러그인이 받는" 확장 시스템의 기본 관계를 실제로 확인할 수 있습니다 — 플러그인이 그 +모듈에 `dependencies` 로 묶여 있는 것도 그 관계의 표현입니다. + +`gnuboard7-hello_user_template` 도 이 모듈에 의존합니다. 그쪽은 훅이 아니라 **공개 API 를 +`data_sources` 로 소비**하는 관계이며, 모듈이 데이터를, 템플릿이 화면을 담당하는 경계를 +보여줍니다. + +미들웨어·브로드캐스트 채널·스케줄·알림은 없습니다. 샘플에 넣으면 계층 구조를 보러 온 사람이 +읽어야 할 코드가 늘어납니다. + + +## 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. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 이 샘플에 기능을 더해 "쓸모 있게" 만들기 | 짧게 유지하고, 필요한 기능은 별도 확장으로 | 샘플의 가치는 한눈에 읽히는 것이다. 계층을 보러 온 사람이 도메인 로직을 읽게 되면 목적이 사라진다 | +| `manifest.hidden` 을 제거 | 그대로 둔다 (복제본에서만 제거) | 학습용 모듈이 운영 사이트의 모듈 목록에 섞인다 | +| 복제해 새 모듈을 만들면서 `hidden` 을 남겨 두기 | 복제본에서는 제거하거나 `false` | 새 모듈이 관리자 UI 에 나타나지 않는다 | +| 복제 후 식별자·네임스페이스를 부분만 치환 | `gnuboard7-hello_module` · `Gnuboard7\HelloModule` · `hello_module` · `Memo` 계열을 **전부** 치환 | 남은 옛 이름이 오토로드 실패나 테이블 이름 충돌로 나타난다 | +| 검증 로직을 `MemoService` 에 넣기 | `MemoRequest` (FormRequest) | 샘플이 잘못된 본을 보이면 그것을 따라 한 모듈이 전부 같은 형태가 된다 | +| `MemoService` 가 `MemoRepository` 구체 클래스를 타입힌트 | `MemoRepositoryInterface` | 위와 같은 이유 — 샘플은 규약의 본보기다 | +| 훅 발행 없이 Service 안에서 로그·알림을 직접 수행 | 훅 발행 + 리스너 | 부가 작업이 Service 에 쌓이면 그 Service 를 재사용할 수 없다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| 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='<대상클래스>' + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/modules/_bundled/gnuboard7-hello_module/CHANGELOG.md b/modules/_bundled/gnuboard7-hello_module/CHANGELOG.md index d01e7760..684f86df 100644 --- a/modules/_bundled/gnuboard7-hello_module/CHANGELOG.md +++ b/modules/_bundled/gnuboard7-hello_module/CHANGELOG.md @@ -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 diff --git a/modules/_bundled/gnuboard7-hello_module/README.md b/modules/_bundled/gnuboard7-hello_module/README.md new file mode 100644 index 00000000..950102b9 --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/README.md @@ -0,0 +1,177 @@ +# Hello 모듈 + +**그누보드7 모듈 · gnuboard7-hello_module** +학습용 최소 샘플 모듈 (Memo CRUD) + + +

+ version 0.1.2 + type 모듈 + 그누보드7 >=7.0.0 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +그누보드7 **모듈이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 업무에 쓰는 기능은 +없고, 메모를 등록·수정·삭제하는 가장 단순한 화면 하나가 전부입니다. + +모듈을 처음 만들어 보는 개발자가 "무엇을 어디에 두어야 하는가" 를 파악하는 데 쓰거나, 새 +모듈을 시작할 때 **복제해서 이름만 바꾸는 출발점**으로 씁니다. + +관리자 화면의 모듈 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로는 +정상적으로 설치·활성화할 수 있으며, 설치하면 관리자에 "Hello 메모" 메뉴가 생깁니다. + +이 샘플은 짧게 유지하는 것이 원칙입니다 — 기능이 늘어나면 구조를 보러 온 사람이 읽어야 할 +코드가 함께 늘어나기 때문입니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 메모 관리 | 제목·내용으로 메모를 등록·수정·삭제하는 관리자 화면 | +| 메모 목록 | 방문자 화면용 목록 레이아웃 1개 (사용자 템플릿 연동 예시) | +| 권한 | 메모 관리 권한 4종(읽기·생성·수정·삭제) | +| 다국어 | 한국어·영어 (관리자 문구와 화면 문구 각각) | +| 확장 지점 | 메모 생성 시점 알림용 연결점 1개 | +| 테스트 | 기능 테스트와 단위 테스트 예시 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[운영자] -->|메모 등록| ADM[관리자 화면] + ADM --> SVC[메모 처리] + SVC --> DB[(메모 데이터)] + SVC -->|생성 알림| L[연결된 확장] + T[사용자 템플릿] -->|목록 조회| SVC +``` + +운영자가 메모를 등록하면 저장과 함께 "메모가 생성되었다" 는 신호가 나갑니다. 다른 확장은 그 +신호를 받아 자기 일을 할 수 있습니다 — 같이 제공되는 학습용 플러그인이 그 예입니다. + +실제 모듈도 구조는 같고, 다루는 대상과 규모만 다릅니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.0` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan module:install gnuboard7-hello_module + +# 활성화 +php artisan module:activate gnuboard7-hello_module + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan module:update gnuboard7-hello_module --force +``` + + +## 관리자 설정 + + +_별도의 관리자 설정 항목이 없습니다._ + + + +설정 항목이 없습니다. 이 샘플은 설정 화면 없이도 모듈의 계층 구조를 보여줄 수 있어 일부러 +두지 않았습니다. + +설정 화면이 있는 모듈의 예를 보려면 함께 제공되는 학습용 플러그인 +(`gnuboard7-hello_plugin`)을 참고합니다 — 그쪽에 설정 스키마와 설정 화면 예시가 있습니다. + + +## 사용 방법 + + +**설치해 보기**: 관리자 화면에는 나타나지 않으므로 명령줄로 설치합니다. + +```bash +php artisan module:install gnuboard7-hello_module +php artisan module:activate gnuboard7-hello_module +``` + +활성화하면 관리자에 "Hello 메모" 메뉴가 생깁니다. 메모를 몇 건 등록해 보면 목록·작성 화면과 +권한이 어떻게 맞물리는지 확인할 수 있습니다. + +**새 모듈의 출발점으로 쓰기**: 이 디렉토리를 복제한 뒤 식별자·네임스페이스·도메인 이름을 모두 +바꾸고, `hidden` 표시를 지우면 새 모듈이 됩니다. 자세한 절차는 확장 시스템 문서의 "학습용 샘플 +확장" 항목을 참고합니다. + +**함께 보면 좋은 것**: 학습용 플러그인·관리자 템플릿·사용자 템플릿 샘플이 함께 제공됩니다. 넷을 +모두 설치하면 모듈이 데이터를, 템플릿이 화면을, 플러그인이 부가 동작을 담당하는 구조를 한 번에 +볼 수 있습니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `gnuboard7-hello_plugin` | 플러그인 | `>=0.1.0` | +| `gnuboard7-hello_user_template` | 템플릿 | `>=0.1.0` | + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 관리자 모듈 목록에 이 모듈이 없음 | 학습용이라 목록에서 제외됨 | 정상입니다. 명령줄로 설치·활성화합니다 | +| 복제해서 만든 모듈이 관리자 목록에 안 보임 | 복제본에 학습용 표시가 남아 있음 | 복제본의 `hidden` 표시를 지웁니다 | +| 복제 후 설치하면 오류가 남 | 식별자·네임스페이스 치환이 일부만 이루어짐 | 옛 이름이 남아 있는지 전체 검색으로 확인하고 오토로드를 갱신합니다 | +| "Hello 메모" 메뉴가 보이지 않음 | 그 계정 역할에 메모 관리 권한이 없음 | 역할에 메모 관리 권한을 부여합니다 | +| 메모를 등록해도 아무 일도 일어나지 않음 | 학습용 플러그인이 설치되지 않음 | 생성 신호를 받아 동작하는 예시는 그 플러그인에 있습니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/modules/_bundled/gnuboard7-hello_module/composer.json b/modules/_bundled/gnuboard7-hello_module/composer.json index 4ce20a7f..d6e3127a 100644 --- a/modules/_bundled/gnuboard7-hello_module/composer.json +++ b/modules/_bundled/gnuboard7-hello_module/composer.json @@ -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": { diff --git a/modules/_bundled/gnuboard7-hello_module/docs/README.md b/modules/_bundled/gnuboard7-hello_module/docs/README.md new file mode 100644 index 00000000..9f2a0337 --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/README.md @@ -0,0 +1,23 @@ +# Hello 모듈 개발자 문서 + +> modules/_bundled/gnuboard7-hello_module · 모듈 + + +**훅 수**: 1 · **구독 훅 수**: 1 · **라우트 수**: 7 · **모델 수**: 1 · **테이블 수**: 1 · **마이그레이션 수**: 1 · **레이아웃 수**: 3 · **핸들러 수**: 0 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/modules/_bundled/gnuboard7-hello_module/docs/architecture.md b/modules/_bundled/gnuboard7-hello_module/docs/architecture.md new file mode 100644 index 00000000..f8fa045c --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/architecture.md @@ -0,0 +1,79 @@ +# Hello 모듈 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +"모듈이 필요로 하는 계층을 **하나씩만** 담는다" 가 이 확장의 유일한 설계 목표입니다. 도메인은 +메모 하나, 필드는 셋뿐이고, 그 위에 Model · Migration · Factory · Seeder · Repository(인터페이스 ++ 구현) · Service · FormRequest · Resource · Controller · Listener · Layout · Test · 다국어가 +각 1개씩 있습니다. 실제 모듈은 이 계층을 엔티티 수만큼 늘린 것입니다. + +**짧게 유지하는 것이 기능보다 우선입니다.** 샘플의 가치는 완결성이 아니라 한눈에 읽히는 +것이므로, 기능을 더하면 계층 구조를 보러 온 사람이 도메인 로직을 읽게 됩니다. + +`manifest.hidden = true` 는 학습용이 운영 사이트의 모듈 목록에 섞이지 않게 하면서도 CLI 로는 +실제로 설치·동작하게 하는 장치입니다 — 읽기만 해서는 학습이 되지 않기 때문입니다. + +**의도적으로 하지 않는 것**: 검색 색인·SEO·알림·스케줄·미들웨어·브로드캐스트·설정 화면. 각 +축의 사용법은 그것을 실제로 쓰는 확장의 문서가 다룹니다. 설정 화면 예시는 함께 제공되는 +`gnuboard7-hello_plugin` 에 있습니다. + + +## 계층 지도 + + +``` +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` 그룹인 것에 주의합니다. 실제 도메인 모듈(게시판· +이커머스)은 방문자 화면을 소유하지 않고 템플릿에 맡기지만, 이 샘플은 **모듈도 사용자 레이아웃을 +가질 수 있다**는 사실을 보이기 위해 하나를 둡니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + diff --git a/modules/_bundled/gnuboard7-hello_module/docs/data-model.md b/modules/_bundled/gnuboard7-hello_module/docs/data-model.md new file mode 100644 index 00000000..5e2b35d8 --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/data-model.md @@ -0,0 +1,87 @@ +# Hello 모듈 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `Memo` | `gnuboard7_hello_module_memos` | 3 | - | - | + + + +`Memo` 하나이며 fillable 이 셋뿐입니다. 관계도 특성(SoftDeletes·검색 색인 등)도 없습니다 — +"모델은 이런 모양이다" 를 보이는 데 그 이상이 필요하지 않기 때문입니다. + +실제 모듈이 모델에 붙이는 것들(관계·캐스팅·스코프·SoftDeletes·검색 색인·`HasUserOverrides`)은 +그것을 실제로 쓰는 확장의 문서를 참고합니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `gnuboard7_hello_module_memos` | `Memo` | + + + +`gnuboard7_hello_module_memos` 하나입니다. 테이블 이름에 **확장 식별자 전체가 접두사로** +들어가는 것에 주의합니다 — 확장은 같은 데이터베이스를 공유하므로, 짧은 이름(`memos`)을 쓰면 +다른 확장과 충돌합니다. + +복제해서 새 모듈을 만들 때 이 접두사도 함께 바꿔야 합니다. 마이그레이션 파일명·클래스 안의 +테이블 이름·모델의 `$table` 이 모두 대상입니다. + + +## 마이그레이션 + + +마이그레이션 1개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_04_21_000001_create_gnuboard7_hello_module_memos_table.php` | `gnuboard7_hello_module_memos` | `gnuboard7_hello_module_memos` | ✅ | + + + +하나이며 테이블 생성뿐입니다. 한국어 `comment` 와 `down()` 이 붙어 있는 것이 규약의 +본보기입니다. + +실제 모듈에서 새 컬럼을 더할 때는 이 `create_*` 파일을 고치지 않습니다 — 이미 설치된 사이트는 +그 파일을 다시 실행하지 않으므로 반영되지 않습니다. 새 `add_*` 파일을 더하고, 기존 행을 +손봐야 하면 `upgrades/` 의 업그레이드 스텝 백필을 함께 씁니다. + + +## Enum + + +_Enum 이 없습니다._ + + + +없습니다. 메모에는 상태도 분류도 없어 닫힌 어휘가 생기지 않았습니다. + +실제 모듈에서 상태·타입·분류를 다룰 때는 문자열 리터럴이 아니라 Enum 을 단일 출처로 둡니다 — +화면 필터 옵션·검증 게이트·실제 기록 값 셋이 같은 Enum 에서 파생되지 않으면, 빠진 값으로 +기록된 행이 어떤 필터로도 도달할 수 없게 됩니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `MemoRepository` | 구현 | 메모 Repository 구현체 | +| `MemoRepositoryInterface` | 인터페이스 | 메모 Repository 인터페이스 | + + + +인터페이스와 구현이 1:1 로 짝을 이룹니다. **`MemoService` 는 인터페이스만 주입받습니다** — +구체 클래스를 타입힌트하면 그 Service 를 다른 구현으로 바꿀 수 없고, 테스트에서 대역을 끼울 +수도 없습니다. + +바인딩은 모듈 서비스 프로바이더가 담당합니다. 새 Repository 를 더할 때는 인터페이스·구현· +바인딩 셋을 함께 만듭니다 — 바인딩을 빠뜨리면 주입 시점에 해결 실패로 드러납니다. + diff --git a/modules/_bundled/gnuboard7-hello_module/docs/editor-spec.md b/modules/_bundled/gnuboard7-hello_module/docs/editor-spec.md new file mode 100644 index 00000000..f3ba70a7 --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/editor-spec.md @@ -0,0 +1,86 @@ +# Hello 모듈 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._ + + + +학습용 샘플 모듈이라 편집기 스펙을 일부러 두지 않았습니다. 이 모듈의 목적은 모듈의 +최소 구조(라우트 → 컨트롤러 → 서비스 → 저장소)를 보여 주는 것이고, 편집기 스펙은 그 +구조와 무관한 별개 축입니다. + +다만 아래 "샘플 데이터와 페이지 상태" 절이 보여 주듯, 이 모듈에는 프리뷰가 비는 자리가 +실제로 있습니다. 스펙이 없어도 되는 상태와 스펙이 필요한데 없는 상태는 다릅니다. + + +## 선언 블록 + + +_선언된 편집기 스펙 블록이 없습니다._ + + + +선언한 블록이 없으므로 표가 비어 있습니다. 이것은 "편집기가 이 모듈을 다루지 않는다" +가 아니라 "이 모듈이 편집기에 아무것도 알려 주지 않는다" 는 뜻입니다 — 편집기는 여전히 +이 모듈의 레이아웃을 열 수 있고, 다만 데이터가 붙지 않은 채로 엽니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다. +편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도 +`componentPalette` 는 여전히 비어 있을 것입니다. + + +## 샘플 데이터와 페이지 상태 + + +_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._ + +**프리뷰 샘플이 없는 `data_source` 2개** — 편집기 캔버스에서 이 자리만 빈 화면이 됩니다. 실제 화면은 정상이라 오류도 경고도 남지 않습니다. + +`memoData` · `memos` + + + +`memoData` · `memos` 두 ID 가 미커버입니다. 메모 목록과 폼이 편집기 캔버스에서 빈 +채로 보인다는 뜻입니다. + +샘플 모듈이므로 이 상태를 그대로 두는 것도 선택입니다 — 다만 그것은 "편집기 스펙이 +없으면 어떤 화면이 되는가" 를 보여 주는 교보재로서 의도적으로 남긴 것이지, 문제가 +없다는 뜻이 아닙니다. 스펙을 하나 만들어 보는 것이 이 모듈로 할 수 있는 좋은 연습입니다. + + +## 수정 시 동반 의무 + + +_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._ + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + + + +편집기 스펙을 신설해야 하는 시점은 하나입니다 — **이 확장이 소유한 레이아웃에 +이 확장만 쓰는 `data_source` 가 생겼을 때**. 그 순간부터 편집기 캔버스의 그 영역은 +빈 화면이 되고, 실제 화면은 정상 동작하므로 오류도 경고도 남지 않습니다. + +신설 절차는 확장 루트에 `editor-spec.json` 을 만들고 `sampleData.byDataSourceId` 에 +그 ID 를 넣는 것으로 시작합니다. 팔레트·컨트롤은 템플릿의 일이므로 넣지 않습니다. +파일을 만든 뒤 update 커맨드로 활성 디렉토리에 반영해야 편집기가 읽습니다 — +`_bundled` 폴백이 없습니다. + diff --git a/modules/_bundled/gnuboard7-hello_module/docs/extension-points.md b/modules/_bundled/gnuboard7-hello_module/docs/extension-points.md new file mode 100644 index 00000000..e4772ab9 --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/extension-points.md @@ -0,0 +1,125 @@ +# Hello 모듈 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 1종 / 호출 지점 1곳. 이 중 1종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `gnuboard7-hello_module.memo.created` | action | — | `src/Services/MemoService.php:62` | + + + +하나뿐입니다. 실제 모듈이라면 도메인마다 `before_*` → `filter_*_data` → `after_*` 3단을 +두지만, 샘플에서는 **훅이 무엇이고 어떻게 발행하는가**만 보이면 되므로 하나로 줄였습니다. + +`MemoService::create()` 가 저장 직후 이 액션을 발행합니다. 발행 지점이 컨트롤러가 아니라 +Service 인 것이 규약입니다 — 컨트롤러에서 발행하면 같은 로직을 다른 경로(커맨드·시더·다른 +서비스)에서 부를 때 훅이 발화하지 않습니다. + +`getHooks()` 선언에 없어 소스에서 자동 감지된 상태입니다. 선언에 추가하면 유형과 설명이 표에 +함께 실리며, 실제 모듈에서는 발행 훅을 선언하는 편이 구독하는 쪽에 계약을 드러냅니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `gnuboard7-hello_module.memo.created` | action (미선언) | `LogMemoCreatedListener` | `onMemoCreated` | 10 | + + + +자기가 발행한 훅 하나를 자기가 구독합니다. 실제로는 다른 확장이 구독하는 것이 정상이지만, +**샘플 하나만 설치해도 훅 흐름이 눈에 보이도록** 리스너를 같이 넣었습니다. + +`gnuboard7-hello_plugin` 을 함께 설치하면 같은 훅을 **바깥에서 구독하는** 모습을 볼 수 +있습니다 — 그쪽이 확장 시스템의 실제 사용 형태입니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `LogMemoCreatedListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/LogMemoCreatedListener.php` | + + + +`LogMemoCreatedListener` 하나이며 `HookListenerInterface` 를 구현하고 +`getSubscribedHooks()` 로 자기 구독을 선언합니다(명시 등록). + +하는 일은 로그 한 줄이지만, 그 자리가 중요합니다 — **부가 작업은 Service 안이 아니라 리스너로 +뺀다**는 규약의 본보기입니다. Service 에 로그·알림을 쌓으면 그 Service 를 다른 맥락에서 +재사용할 수 없습니다. + +리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 를 직접 부르지 않습니다 — 데이터 +접근이 필요하면 Repository 인터페이스를 주입받습니다. + + +## 레이아웃 확장 + + +_레이아웃 확장이 없습니다._ + + + +없습니다. 이 샘플은 다른 확장의 화면에 조각을 주입하지 않습니다. + +주입 예시가 필요하면 실제로 그렇게 하는 확장(이커머스의 관리자 대시보드 위젯, 마케팅의 회원가입 +동의 항목)의 문서를 참고합니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +없습니다. 코어가 제공하는 인증 미들웨어만 씁니다. + +샘플에 미들웨어를 넣으면 계층 구조를 보러 온 사람이 읽어야 할 코드가 늘어납니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +없습니다. 같은 이유로 두지 않았습니다. + +실시간이 필요하면 이 모듈이 발행하는 `memo.created` 를 구독해 소비하는 쪽에서 +`HookManager::broadcast()` 로 자기 채널에 내보내는 것이 방향입니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +없습니다. 샘플에는 시간 축 동작이 없습니다. + +스케줄 선언 형태(`command` · `schedule` · `description` · `enabled_config`)는 실제로 스케줄을 +쓰는 확장의 문서를 참고합니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +없습니다. 알림은 수신자 해석·채널 게이트·템플릿까지 함께 필요해 "하나씩만" 원칙으로 담기 +어렵습니다. + +알림이 필요한 예시는 실제로 알림을 발송하는 확장의 문서를 참고합니다. + diff --git a/modules/_bundled/gnuboard7-hello_module/docs/frontend.md b/modules/_bundled/gnuboard7-hello_module/docs/frontend.md new file mode 100644 index 00000000..d7584e57 --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/frontend.md @@ -0,0 +1,86 @@ +# Hello 모듈 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 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` | + + + +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` 로 반영합니다. + + +## 액션 핸들러 + + +_등록하는 액션 핸들러가 없습니다._ + + + +없습니다. 이 샘플의 화면은 코어 엔진의 기본 핸들러(`apiCall` · `navigate` · `setState` 등) +만으로 충분합니다. + +핸들러를 처음 추가할 때는 셋이 함께 필요합니다 — 엔트리 파일, `window.__[Name].initModule()` +재등록 진입점, 그리고 `--production` 으로 구운 `dist/` 커밋. 진입점을 빠뜨리면 로케일 전환 +직후 그 핸들러들이 오류 없이 무반응이 됩니다. + + +## 전역 진입점 + + +_프론트 엔트리포인트가 없습니다._ + + + +없습니다 — 등록할 액션 핸들러가 없기 때문입니다. + +핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록 +진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부 +무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 +작업을 포함하지 않습니다. + + +## 에셋 + + +_프론트 에셋이 없습니다._ + + + +없습니다. 이 샘플의 프론트엔드는 레이아웃 JSON 3개뿐이라 빌드할 실행 코드가 없습니다. + +그래서 반영이 `php artisan module:update gnuboard7-hello_module --force` 하나로 끝납니다. +JS 를 더하면 그때 빌드(`module:build --production`)·`dist/` 커밋·전역 진입점 셋이 함께 +필요해집니다. + +구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도 +서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다. + diff --git a/modules/_bundled/gnuboard7-hello_module/docs/settings.md b/modules/_bundled/gnuboard7-hello_module/docs/settings.md new file mode 100644 index 00000000..c6597deb --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/settings.md @@ -0,0 +1,116 @@ +# Hello 모듈 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +_`getSettingsSchema()` 선언이 없습니다._ + +기본값 파일: `config/settings/defaults.json` + + + +설정이 없습니다. 이 샘플은 설정 화면 없이도 모듈의 계층 구조를 보여줄 수 있어 일부러 두지 +않았습니다. + +설정 스키마와 설정 화면 레이아웃의 예시는 함께 제공되는 `gnuboard7-hello_plugin` 에 있습니다 — +`getSettingsSchema()` 선언과 `resources/layouts/admin/plugin_settings.json` 이 짝을 이루는 +형태입니다. + + +## 권한 + + +| 카테고리 | 이름 | 액션 | 라우트 키 | +|---|---|---|---| +| `memos` | 메모 관리 | `read`, `create`, `update`, `delete` | `memo` | + + + +`memos` 하나에 `read`/`create`/`update`/`delete` 네 액션입니다. 라우트 키 `memo` 가 선언되어 +있어 관리자 라우트에 스코프 미들웨어가 걸립니다. + +권한 이름은 코어가 `{확장식별자}.{카테고리}.{액션}` 으로 조립합니다 +(`gnuboard7-hello_module.memos.read`). 확장 식별자가 앞에 붙으므로 다른 확장과 이름이 겹칠 +걱정이 없습니다. + +**권한만 추가하고 메뉴를 빠뜨리면 화면에 도달할 길이 없고, 반대면 눌러도 403 입니다.** 새 +화면을 더할 때는 권한·메뉴·라우트 이름 셋이 서로를 정확히 가리키는지 함께 확인합니다. + + +## 메뉴 + + +| 구분 | slug | 이름 | URL | 하위 | +|---|---|---|---|---| +| 관리자 | `gnuboard7-hello_module` | Hello 메모 | `/admin/memos` | - | + + + +관리자 메뉴 하나(`/admin/memos`)입니다. 하위 메뉴가 없어 최상위 항목이 바로 목록 화면으로 +갑니다. + +메뉴는 **권한과 짝을 이룰 때만 보입니다** — 그 역할에 `memos.read` 가 없으면 렌더되지 +않습니다. 설치 직후 메뉴가 보이지 않는다면 대부분 권한 부여가 빠진 것입니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/modules/gnuboard7-hello_module/...` | +| `web` | `src/routes/web.php` | `/modules/gnuboard7-hello_module/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +파일이 둘(`api.php` · `web.php`)인 것이 이 샘플의 학습 포인트입니다. 실제 도메인 모듈은 +대개 `api.php` 만 두지만, **모듈이 web 라우트도 가질 수 있다**는 사실을 보이기 위해 둘 다 +둡니다. + +두 파일의 URL prefix 가 다릅니다 — API 는 `/api/modules/{id}/`, web 은 `/modules/{id}/`. +확장이 다른 확장의 경로를 침범하지 않도록 코어가 강제하는 규칙입니다. + +모든 라우트에 `name()` 이 필요합니다. 이름이 없으면 미들웨어 self-gate 의 `targets` 패턴과 +IDV 정책의 라우트명 인덱스가 그 라우트를 찾지 못해, 보호가 걸린 것처럼 보이지만 실제로는 +통과합니다. + +라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 확장 라우트는 활성 상태인 확장의 것만 +등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 404 가 됩니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `gnuboard7-hello_plugin` | 플러그인 | `>=0.1.0` | +| `gnuboard7-hello_user_template` | 템플릿 | `>=0.1.0` | + + + +이 모듈은 아무 확장에도 의존하지 않습니다. 관계는 한 방향으로 들어옵니다 — 학습용 플러그인과 +학습용 사용자 템플릿이 이 모듈을 요구합니다. + +**두 의존의 성격이 다른 것이 학습 포인트**입니다: + +| 확장 | 어떻게 묶이는가 | +|---|---| +| `gnuboard7-hello_plugin` | 이 모듈이 발행하는 훅(`memo.created`)을 구독 — 확장이 다른 확장의 흐름에 끼어드는 형태 | +| `gnuboard7-hello_user_template` | 이 모듈의 공개 API 를 `data_sources` 로 소비 — 모듈이 데이터를, 템플릿이 화면을 담당하는 경계 | + +넷을 모두 설치하면 이 세 역할(데이터·화면·부가 동작)이 어떻게 나뉘는지 실제로 확인할 수 +있습니다. + +발행 훅 이름이나 공개 API 응답 형태를 바꾸면 두 확장이 조용히 끊깁니다 — 샘플에서도 그 규율은 +같습니다. + diff --git a/modules/_bundled/gnuboard7-hello_module/module.json b/modules/_bundled/gnuboard7-hello_module/module.json index 70c982db..4f0ee319 100644 --- a/modules/_bundled/gnuboard7-hello_module/module.json +++ b/modules/_bundled/gnuboard7-hello_module/module.json @@ -5,7 +5,7 @@ "ko": "Hello 모듈", "en": "Hello Module" }, - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "description": { "ko": "학습용 최소 샘플 모듈 (Memo CRUD)", diff --git a/modules/_bundled/sirsoft-board/AGENTS.md b/modules/_bundled/sirsoft-board/AGENTS.md new file mode 100644 index 00000000..8e3c4d71 --- /dev/null +++ b/modules/_bundled/sirsoft-board/AGENTS.md @@ -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. 이 확장은 무엇인가 + + +게시판·게시글·댓글·신고·게시판별 알림설정을 소유하는 콘텐츠 도메인 모듈입니다. 운영자가 +`/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) — 클라이언트가 비밀글 여부를 +판단해 화면만 가리는 방식은 쓰지 않습니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + + +## 3. 핵심 흐름 + + +**게시판 생성**: `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`)이 걸려 있습니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 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#알림-정의) | + + + +발행 훅 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 흐름 자체를 **자기 도메인으로 대체**하는 가장 무거운 형태의 확장 사례입니다 — 새로운 +"게시판을 흉내 낸 도메인"을 만들 때 참고할 선례입니다. + + +## 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. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 비밀글 상세만 `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 코드가 알지 못하는 도메인이 늘어날수록 분기가 무한 증식한다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| 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 + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 4f3bcb54..516582cb 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -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 diff --git a/modules/_bundled/sirsoft-board/README.md b/modules/_bundled/sirsoft-board/README.md new file mode 100644 index 00000000..2221fc8d --- /dev/null +++ b/modules/_bundled/sirsoft-board/README.md @@ -0,0 +1,172 @@ +# 게시판 + +**그누보드7 모듈 · sirsoft-board** +게시판 관리를 위한 모듈 + + +

+ version 1.1.1 + type 모듈 + 그누보드7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +게시판·게시글·댓글·신고를 관리하는 콘텐츠 모듈입니다. 운영자가 관리자 화면에서 자유형(가로형/ +갤러리형/카드형) 게시판을 원하는 개수만큼 만들고, 게시판마다 비밀글·답변형·본인인증·자동 알림 +같은 세부 정책을 독립적으로 설정할 수 있습니다. + +이 모듈은 관리자 화면과 공개 API 만 제공합니다. 방문자가 보는 목록·상세·글쓰기 화면은 +템플릿(`sirsoft-basic`)이 이 모듈의 API 를 호출해 그립니다 — 운영자 입장에서는 "게시판 콘텐츠는 +여기서 관리하고, 화면 디자인은 템플릿이 담당한다"로 이해하면 됩니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 게시판 관리 | 게시판 생성/수정/삭제, 게시판 유형(기본/갤러리/카드) 선택, 게시판별 세부 설정 일괄 적용 | +| 게시글·댓글 | 작성/수정/삭제/블라인드/복원, 답변형 게시판(원글-답변 트리), 대댓글, 비밀글 | +| 신고 처리 | 사용자 신고 접수 → 관리자 검토 → 블라인드/삭제/복원 처리, 처리 결과 알림 | +| 첨부파일 | 업로드/다운로드/순서 변경, 게시판별 허용 확장자·용량 제한 | +| 대시보드 | 게시판별 게시글·댓글·신고 현황과 추세, 미처리 신고 요약 | +| 알림 | 새 댓글/대댓글/답변글/신고 접수/처리 결과를 메일·앱 내 알림으로 발송, 회원별 수신 여부 설정 | +| 본인인증 연동 | 게시글/댓글 삭제, 신고 작성, 첫 글 작성 등 민감 작업에 코어 IDV 정책 적용(기본은 비활성) | + + +## 동작 방식 + + +```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 계층을 거치므로 비밀글 게이팅·카운트 +동기화·훅 발행은 어느 쪽에서 들어오든 동일하게 적용됩니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | + + +## 설치 + + +```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 + + +## 관리자 설정 + + +_별도의 관리자 설정 항목이 없습니다._ + + + +위 표가 비어 있는 이유는 이 모듈에 전역 환경설정(`getSettingsSchema()`) 이 없기 때문입니다 — +설정은 전역이 아니라 **게시판 하나하나**에 딸려 있습니다(`/admin/boards/{slug}/settings`). +게시판을 만들 때 기본/갤러리/카드 유형을 고르면 그 유형의 기본값이 채워지고, 이후 기본 +정보·목록 표시·게시글 정책·댓글 정책·첨부 정책·본인인증·알림·SEO 탭에서 게시판별로 따로 +조정합니다. 여러 게시판에 같은 값을 한 번에 반영하려면 게시판 목록 화면의 "설정 일괄 적용"을 +씁니다(`settings.before_bulk_apply`/`after_bulk_apply` 훅으로 계측 가능). + + +## 사용 방법 + + +**게시판 신설**: `/admin/boards` → "게시판 추가" → 이름·slug·유형 지정 → 저장. 저장 즉시 +관리자 메뉴·동적 권한(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`)이 자동 생성되므로, +바로 이어서 "권한" 탭에서 그 게시판을 담당할 운영자에게 `{slug}.manager` 역할을 부여합니다. + +**신고 처리**: 방문자가 게시글/댓글을 신고하면 `/admin/boards/reports` 에 접수되고 담당자에게 +메일이 갑니다. 신고 상세에서 신고 사유·신고 이력을 확인한 뒤 블라인드/삭제/복원 중 하나로 +처리하면, 그 결과가 원 작성자에게 자동으로 통지됩니다 — 별도로 작성자에게 안내 메일을 보낼 +필요가 없습니다. + +**여러 게시판 설정 일괄 변경**: 예를 들어 전체 게시판의 첨부 용량 상한을 한 번에 올리고 싶으면, +게시판 목록에서 대상 게시판을 체크한 뒤 "설정 일괄 적용" 모달에서 첨부 탭 값만 바꿔 적용합니다. +다른 탭 값은 그대로 유지되고, 체크한 게시판에만 반영됩니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `sirsoft-basic` | 템플릿 | `>=1.0.0` | + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 게시판 삭제 후에도 관리자 화면에 그 게시판 권한/역할이 남아 있음 | 정리 배치가 아직 실행되지 않았거나 삭제 트랜잭션이 중간에 실패 | `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()` 는 현재 `boards` 테이블 기준으로 계산되므로, 확장 정리 커맨드를 다시 실행하면 stale 항목이 잡힙니다 | +| 검색어에 `+`, `-`, `"` 를 넣으면 결과가 0건으로 나옴 | 코어 검색 정제기가 FULLTEXT 연산자를 제거한 뒤 검색 — 연산자만 입력하면 빈 결과가 정상 동작 | 오류가 아닙니다. 실제 키워드를 함께 입력하면 정상 매칭됩니다 | +| 비밀글의 댓글 개수가 0으로 보이는데 실제로는 댓글이 있음 | 열람 권한이 없는 요청에는 댓글 목록이 빈 배열(200)로 마스킹됨(KVE-2026-1914) | 정상 동작입니다. 작성자 본인 또는 `posts.read-secret`/관리 권한으로 조회하면 보입니다 | +| 게시판 설정 일괄 적용 후 일부 게시판만 반영됨 | 대상 게시판 중 일부가 적용 도중 실패(예: 유효성 위반) | `settings.after_bulk_apply_aborted` 훅 시점의 로그로 실패한 게시판을 특정한 뒤 개별 재적용 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json index b426f515..f2c4f06a 100644 --- a/modules/_bundled/sirsoft-board/composer.json +++ b/modules/_bundled/sirsoft-board/composer.json @@ -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": { diff --git a/modules/_bundled/sirsoft-board/docs/README.md b/modules/_bundled/sirsoft-board/docs/README.md new file mode 100644 index 00000000..37c0db83 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/README.md @@ -0,0 +1,23 @@ +# 게시판 개발자 문서 + +> modules/_bundled/sirsoft-board · 모듈 + + +**훅 수**: 90 · **구독 훅 수**: 77 · **라우트 수**: 80 · **모델 수**: 9 · **테이블 수**: 10 · **마이그레이션 수**: 30 · **레이아웃 수**: 46 · **핸들러 수**: 0 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/modules/_bundled/sirsoft-board/docs/architecture.md b/modules/_bundled/sirsoft-board/docs/architecture.md new file mode 100644 index 00000000..659266c2 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/architecture.md @@ -0,0 +1,75 @@ +# 게시판 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +게시판마다 완결된 권한·역할 체계를 갖게 하면서도, 새 게시판을 만드는 데 코드 변경이 필요 없게 +하는 것이 이 모듈의 핵심 설계 목표입니다. `boards` 테이블 한 행이 하나의 확장처럼 동작하도록, +권한·역할·메뉴는 모두 **런타임에 게시판 데이터로부터 파생**됩니다(`getDynamicPermissionIdentifiers()` +등 3개 메서드가 코드가 아니라 DB 를 읽어 계산). 그 대가로 이 세 메서드는 게시판이 하나 +추가/삭제될 때마다 정확해야 하고, 어긋나면 stale 권한이 남거나 존재하는 게시판의 권한이 +정리 대상으로 오판됩니다. + +또한 "관리자 백엔드 모듈 + 방문자 화면은 템플릿" 분리를 의도적으로 유지합니다. 방문자 화면을 +이 모듈 안에 두면 템플릿마다 디자인이 다른 게시판 UI 를 이 모듈이 전부 알아야 하는데, +API 로만 노출하면 템플릿 쪽에서 자유롭게 화면을 구성할 수 있습니다. 이 경계 때문에 +"레이아웃 확장"·"레이아웃"에는 오직 관리자 화면만 나타나며, 그것이 정상입니다. + + +## 계층 지도 + + +``` +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 를 건드리지 않고 리스너 추가만으로 끝납니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + diff --git a/modules/_bundled/sirsoft-board/docs/data-model.md b/modules/_bundled/sirsoft-board/docs/data-model.md new file mode 100644 index 00000000..e052712e --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/data-model.md @@ -0,0 +1,161 @@ +# 게시판 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | 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 | - | + + + +`Post`·`Comment` 모두 `parent`/`replies` 자기참조 관계를 갖습니다 — `Post` 의 자기참조는 +"답변형 게시판"(원글에 대한 관리자 답변)을, `Comment` 의 자기참조는 대댓글을 표현합니다. 둘은 +서로 다른 기능이라 관계 이름은 같아도 코드에서 섞어 쓰지 않습니다. `Board` 는 `HasUserOverrides` +를 쓰지 **않습니다** — 게시판 자체는 운영자가 직접 소유·수정하는 리소스라 "모듈 재설치 시 +운영자 수정 보존"이 필요 없는 반면, `BoardType`(게시판 유형 프리셋)은 모듈이 시딩한 기본값을 +운영자가 손댈 수 있어야 하므로 그 트레이트를 씁니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `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` | + + + +`board_mail_templates` 는 모델이 없는 채로 남아 있습니다 — 아래 마이그레이션 표의 +`drop_board_mail_templates_table`(2026-04-13)이 보여주듯, 메일 템플릿을 자체 테이블로 +관리하던 초기 설계를 코어 `GenericNotification` 알림 정의(§알림 정의)로 이관하며 테이블만 +드롭하고 이름은 이력상 남아 있는 상태입니다. 신규 코드에서 이 이름을 참조하지 않습니다. +테이블 접두어가 `board_*`와 `boards_*` 두 가지로 섞여 있는 것은 설계 의도가 아니라 이력입니다 +— 새 테이블을 추가할 때는 `board_*`(단수)를 따릅니다. + + +## 마이그레이션 + + +마이그레이션 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` | ✅ | + + + +목록 성능 마이그레이션이 지속적으로 추가되는 것(인덱스 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()` 은 "파티션 복원은 데이터 재배치가 필요해 자동 롤백 불가"라고 명시하므로, 파티셔닝을 +다시 도입할 때는 이 파일을 그대로 재실행하는 방식이 아니라 새 마이그레이션으로 설계해야 합니다. + + +## Enum + + +| 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` | + + + +`TriggerType`(6 case: report/admin/system/auto_hide/user/cascade)은 "이 콘텐츠가 왜 지금 +상태가 됐는가"를 기록하는 감사(audit) 축입니다 — 예를 들어 게시글 블라인드가 `report`(신고 +처리 결과)인지 `admin`(관리자 직접 조치)인지에 따라 `post_action`/`report_action` 두 알림이 +갈라집니다(§확장점 "알림 정의" 참고). `cascade` 는 부모(게시글)가 지워질 때 자식(댓글)이 +함께 지워진 경우이며, `ReplyDeletePolicy`(`block`/`cascade`)가 게시판별로 부모 삭제 시 +자식을 막을지 함께 지울지를 결정합니다 — 이 정책과 `TriggerType::Cascade` 는 같은 흐름의 +서로 다른 절반(정책 설정 vs 결과 기록)입니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `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 인터페이스 | + + + +`BoardStatRepository`(일별 집계)는 별도 Repository 로 분리돼 있습니다 — `sirsoft-board:aggregate-stats` +스케줄이 매시간 `board_stats` 를 갱신하는데, 이 집계 쿼리를 `PostRepository`/`CommentRepository` +에 섞으면 대시보드 조회 경로와 실시간 CRUD 경로가 같은 클래스 안에서 뒤엉킵니다. 새 Repository +를 추가할 때는 반드시 인터페이스를 함께 만들고 `CoreServiceProvider`(또는 이 모듈의 +서비스 프로바이더)에서 바인딩합니다 — Service 가 구체 클래스를 직접 타입힌트하면 안 됩니다. + diff --git a/modules/_bundled/sirsoft-board/docs/editor-spec.md b/modules/_bundled/sirsoft-board/docs/editor-spec.md new file mode 100644 index 00000000..a1302bb5 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/editor-spec.md @@ -0,0 +1,123 @@ +# 게시판 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `modules/_bundled/sirsoft-board/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 23 · 엔드포인트 샘플 4 · 페이지 상태 8 + + + +단일 파일로 둔 것은 분량 때문입니다. 게시판 스펙은 도메인 데이터 4블록뿐이라 분할할 +이유가 없습니다 — 분할은 템플릿 스펙처럼 한 파일이 만 줄 단위로 커질 때의 장치입니다. + +`스타일 시스템`·`다크 모드 전략` 이 비어 있는 것도 의도입니다. 그 둘은 화면을 **그리는** +쪽의 결정이라 템플릿 스펙이 소유합니다. 게시판이 여기에 값을 넣으면 어떤 템플릿을 깔든 +게시판이 스타일 체계를 강제하는 셈이 됩니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `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 (인라인)` | + + + +이 네 블록은 "편집기가 게시판 화면을 실제 API 없이 그리려면 무엇이 필요한가" 에서 +그대로 나옵니다. `byDataSourceId` 23종은 admin 레이아웃의 `data_source` ID 를 전수 +스캔해 맞춘 것이고, `byEndpointPattern` 4종은 사용자 게시판 페이지처럼 ID 가 아니라 +호출 주소로 붙는 자리를 덮습니다. + +여기에 없는 것이 무엇인지가 더 중요합니다 — `roles`·`availableChannels`· +`identityProviders` 같은 공용 인프라 ID 는 게시판이 쓰지만 게시판이 선언하지 않습니다. +그것들은 admin 템플릿 스펙과 코어 프리셋이 채웁니다. 여기에 같이 넣으면 같은 ID 의 +샘플이 두 곳에 생기고, 둘이 갈라져도 아무 오류가 나지 않습니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | 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` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +`states.groups` 8종은 게시판 화면 중 **상태에 따라 다르게 보이는 것**만 골랐습니다. +비밀글 잠금(`/board/:slug/:id`), 목록의 빈 상태(`/board/:slug`), 작성 폼 등입니다. 상태 +변종이 없는 화면은 기본 샘플 하나로 충분하므로 등록하지 않습니다. + +게시판 레이아웃에 `data_source` 를 새로 붙일 때는 그 ID 가 공용 인프라인지 게시판 +도메인인지 먼저 가릅니다. 도메인이면 이 스펙의 `byDataSourceId` 에, 공용이면 템플릿 +스펙에 갑니다. 잘못 판단해도 편집기 화면만 비므로 실행 중에는 드러나지 않습니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan module:update sirsoft-board --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +게시판은 관리자 화면과 사용자 화면을 모두 갖습니다. 관리자 쪽 `data_source` 는 +`byDataSourceId` 로 붙지만 사용자 게시판 페이지는 템플릿이 렌더하므로 ID 가 아니라 +`byEndpointPattern` 으로 붙습니다 — 사용자 화면을 건드렸는데 관리자 쪽 자리만 고치면 +그 화면은 편집기에서 계속 빈 채로 남습니다. + diff --git a/modules/_bundled/sirsoft-board/docs/extension-points.md b/modules/_bundled/sirsoft-board/docs/extension-points.md new file mode 100644 index 00000000..b71ea23d --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/extension-points.md @@ -0,0 +1,404 @@ +# 게시판 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 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` | + + + +`{도메인}.{동사}` 이름 규칙 안에서 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 이 직접 훅을 겁니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `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 | + + + +`core.user.*` 4종을 구독하는 이유는 회원가입/수정 화면에 "댓글 알림 수신 여부" 필드를 끼워 +넣기 위해서입니다 — 이 필드는 `UserNotificationSetting` 모델(board 소유)에 저장되지만, 입력 +자체는 코어 회원 폼에서 받습니다. `sirsoft-ckeditor5.image.filter_reference_sources` 구독은 +board 글 본문(HTML 에디터)에 삽입된 이미지가 삭제 시 함께 정리되도록 참조 소스 목록에 게시글을 +등록하는 자리입니다. `sirsoft-ecommerce.inquiry.*` 8개는 이커머스 "상품 문의"가 board 의 +Post/Comment CRUD 를 그대로 재사용하되 저장 로직만 이커머스가 대신 처리하는 위임 지점입니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | 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` | + + + +`BoardActivityLogListener` 하나가 30개 훅을 구독하는 것이 의도된 형태입니다 — 활동 로그는 +"무엇이 언제 왜 바뀌었는가"를 도메인 전체에서 일관된 형식으로 남겨야 하므로, 도메인별로 +리스너를 쪼개면 로그 스키마가 갈라질 위험이 커집니다. 반대로 카운트 동기화(`*CountSyncListener`) +는 목적이 하나씩이라 도메인별로 쪼개져 있습니다 — 첨부 개수와 댓글 개수는 서로 독립적으로 +실패해도 되므로, 한쪽이 예외를 던져도 다른 쪽 동기화는 영향받지 않습니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `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` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +5개 조각 중 `admin-ecommerce-inquiry-settings.json`·`admin_dashboard_community.json`· +`admin_dashboard_quick_menu.json` 은 board 자신의 화면이 아니라 **다른 확장(이커머스 문의 +설정 화면, 관리자 대시보드)에** 게시판 관련 UI 를 끼워 넣는 조각입니다. 이 모듈이 다른 확장의 +레이아웃을 코드로 알지 못한 채(레이아웃 확장 시스템을 통해서만) UI 를 주입한다는 뜻입니다. +나머지 2개(`user-notification-*`)는 코어 회원 알림 설정 화면에 board 알림 수신 옵션을 +끼워 넣는 자리입니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +공개 API 라우트(`optional.sanctum`)와 관리자 API 라우트(코어 `auth`+권한 미들웨어)는 전부 +코어가 이미 등록한 미들웨어로 충분합니다. board 만의 요청 전처리(예: 게시판별 rate limit)가 +필요해지면 이 자리에 선언형으로 추가하되, 대상(targets)을 명시해 자기 라우트에만 부착합니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +실시간 갱신(새 댓글이 열려 있는 화면에 즉시 반영되는 등)은 이 모듈의 범위 밖입니다(§1 참고). +필요해지면 `sirsoft-board.{slug}.*` 채널을 신설하되, 게시판별로 채널을 분리해야 방문자가 +관심 없는 다른 게시판의 이벤트까지 구독하지 않습니다. + + +## 스케줄 + + +| 스케줄 | 주기 | 설명 | +|---|---|---| +| `sirsoft-board:aggregate-stats` | `hourly` | 대시보드 게시물 현황 집계 | +| `sirsoft-board:prune-attachments --scheduled` | `daily` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 | + + + +`prune-attachments` 는 두 가지 서로 다른 작업을 한 스케줄에 묶습니다 — "방치된 임시 첨부 +정리"(업로드했지만 게시글 저장까지 이어지지 않은 파일)는 사용자 파일을 지우지 않으므로 항상 +실행되고, "보존기간 경과 삭제 첨부 영구 정리"(이미 삭제 처리된 첨부의 실제 파일 파기)는 +`attachment_settings.purge_enabled` 로 게이트됩니다 — module.php 의 `enabled_config: null` 은 +스케줄 자체는 끌 수 없다는 뜻이고, 실제 파기 여부만 설정으로 조정됩니다. + + +## 알림 정의 + + +| 알림 키 | 채널 | +|---|---| +| `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` | + + + +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개를 구독하는 것은 +중복이 아니라 **관점의 차이**입니다 — 관리자가 직접 블라인드했는지, 신고 처리 결과로 +블라인드됐는지에 따라 원 작성자에게 보이는 문구(원인 설명)가 갈라져야 하기 때문입니다. + + +## 활동 로그 훅 + +> 이 확장이 코어 활동 로그(`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 | diff --git a/modules/_bundled/sirsoft-board/docs/frontend.md b/modules/_bundled/sirsoft-board/docs/frontend.md new file mode 100644 index 00000000..6efd4025 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/frontend.md @@ -0,0 +1,121 @@ +# 게시판 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 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 | - | + + + +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 파일이므로 한쪽만 고치면 다른 화면은 그대로입니다 — 같은 항목을 여러 화면에 +반영해야 한다면 파일을 전부 찾아 고쳐야 합니다. + + +## 액션 핸들러 + + +_등록하는 액션 핸들러가 없습니다._ + + + +이 모듈의 관리자 레이아웃은 코어 빌트인 핸들러(`apiCall`/`navigate`/`setState` 등)만으로 +전부 구성됩니다 — 게시판 CRUD·신고 처리·설정 저장은 결국 REST 호출 + 표준 폼 상태 관리라 +전용 핸들러를 등록할 필요가 없었습니다. 새 관리자 화면을 추가할 때도 먼저 빌트인 핸들러 +조합으로 가능한지 확인하고, 그래도 부족할 때만(예: 파일 업로드 진행률 같은 복잡한 클라이언트 +상태) `resources/js/` 에 전용 핸들러를 신설합니다. + + +## 전역 진입점 + + +_프론트 엔트리포인트가 없습니다._ + + + +액션 핸들러가 없는 것과 같은 이유로 `window.__[Name]` 재등록 진입점도 없습니다 — 로케일 +전환 후 재등록해야 할 자체 핸들러가 이 모듈에는 없기 때문입니다. 프론트 전용 코드 +(`resources/js/`)를 신설하면 그 순간부터 이 자리에 진입점을 만들어야 합니다(§CLAUDE.md +"확장 미들웨어는..." 항목 인근의 재등록 진입점 규정 참고) — 없으면 로케일 전환 후 그 확장의 +액션이 전부 무반응이 됩니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +`editor-spec.json` 이 이 모듈의 유일한 프론트 자산인 것도 위와 같은 이유입니다 — 레이아웃 +편집기가 게시판 관리 화면의 컴포넌트를 인식하려면 이 선언이 필요하지만, 실행 시점에 로드할 +JS/CSS 번들은 없습니다. `priority: 100` 은 다른 확장의 에셋 우선순위와 충돌하지 않는 기본값이며, +`dependencies: []` 는 이 확장의 에디터 스펙이 다른 확장의 스펙 로드를 전제하지 않는다는 뜻입니다. + diff --git a/modules/_bundled/sirsoft-board/docs/settings.md b/modules/_bundled/sirsoft-board/docs/settings.md new file mode 100644 index 00000000..da7c4889 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/settings.md @@ -0,0 +1,98 @@ +# 게시판 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +_`getSettingsSchema()` 선언이 없습니다._ + +기본값 파일: `config/settings/defaults.json` + + + +`getSettingsSchema()` 가 없는 것은 누락이 아니라 설계입니다 — 이 모듈에는 "전역 설정"이라 +부를 만한 것이 없습니다. 운영자가 조정하는 값은 전부 **게시판 하나**에 속한 설정 +(`BoardSettingsService`, `/admin/boards/{slug}/settings`)이라 코어의 전역 설정 스키마 +메커니즘과 맞지 않습니다. `config/board.php` 는 운영자가 바꾸는 자리가 아니라 개발자가 +정의하는 상수(첨부 저장 디스크, 게시판별 동적 권한 정의 템플릿)이며, 이 값은 `.env` 로만 +바꿉니다. + + +## 권한 + + +| 카테고리 | 이름 | 액션 | 라우트 키 | +|---|---|---|---| +| `boards` | 게시판 관리 | `read`, `create`, `update`, `delete` | `board` | +| `settings` | 환경설정 | `read`, `update` | - | +| `identity.policies` | 게시판 본인인증 정책 | `read`, `update` | - | +| `dashboard` | 게시판 대시보드 | `view` | - | +| `reports` | 게시판 신고 관리 | `view`, `manage` | `report` | + + + +위 표는 **모듈 레벨** 권한(게시판 관리 자체를 다루는 관리자 권한)만 보여줍니다. 게시판 하나를 +만들면 그 게시판 전용 권한이 `config/board.php` 의 `board_permission_definitions` 템플릿을 +기반으로 추가 생성됩니다(admin.posts.read/write, posts.read-secret 등 — 게시판마다 독립적인 +권한 묶음). 그 동적 권한은 이 표에 나타나지 않으며 `getDynamicPermissionIdentifiers()` 로만 +전수를 확인할 수 있습니다. `boards`/`reports` 카테고리에 `resource_route_key`/`owner_key` 가 +붙어 있는 것은 소유자 기반 스코프 판정(자기 글만 관리 가능한 `manager` 이하 역할 등)이 걸려 +있다는 뜻입니다. + + +## 메뉴 + + +| 구분 | slug | 이름 | URL | 하위 | +|---|---|---|---|---| +| 관리자 | `sirsoft-board` | 게시판 관리 | - | 3개 | + + + +정적 관리자 메뉴는 "게시판 관리" 3개 하위 메뉴(환경설정/목록/신고현황)뿐입니다. 게시판을 +만들 때마다 생기는 `board-{slug}` 메뉴는 동적 메뉴라 이 표에 없으며 +`getDynamicMenuSlugs()` 로 전수를 확인합니다. 방문자용 메뉴(사이트 상단 게시판 링크 등)는 +이 모듈이 등록하지 않습니다 — 템플릿이 공개 API(`boards.board-menu`)를 호출해 직접 구성합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/modules/sirsoft-board/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +같은 `src/routes/api.php` 파일 안에 관리자 전용 그룹(`/admin/board/{slug}/...`, 권한 미들웨어)과 +공개 그룹(`/boards/...`, `optional.sanctum`)이 함께 있습니다 — 파일을 분리하지 않은 것은 +board 의 라우트가 20개 안팎으로 한 파일에서 관리 가능한 규모이기 때문입니다. 새 공개 +엔드포인트를 추가할 때는 반드시 `optional.sanctum`(비회원도 접근 가능, 회원이면 컨텍스트 +주입)을 쓰고 `auth:sanctum` 을 쓰지 않습니다 — 게시판 열람은 비회원에게도 열려 있어야 합니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `sirsoft-basic` | 템플릿 | `>=1.0.0` | + + + +이 모듈이 의존하는 확장이 "없음"인 것은 board 가 코어 훅·API 만으로 완결되도록 설계됐다는 +뜻입니다. 반대로 이 모듈에 의존하는 쪽은 하나(템플릿)뿐이지만, 그보다 결합이 느슨한 +**필터 훅 위임** 소비자(이커머스 문의)는 `dependencies` 로 선언되지 않습니다 — 이커머스는 +board 를 자기 도메인으로 대체할 뿐 board API 계약에 실제로 묶여 있지 않기 때문입니다. +이 모듈의 공개 표면(라우트·API 응답 구조)을 바꿀 때는 `sirsoft-basic` 의 최소 버전 상향을 +검토해야 합니다(§CLAUDE.md "확장 → 확장 동기화"). + diff --git a/modules/_bundled/sirsoft-board/editor-spec.json b/modules/_bundled/sirsoft-board/editor-spec.json index d79c29f4..8a116072 100644 --- a/modules/_bundled/sirsoft-board/editor-spec.json +++ b/modules/_bundled/sirsoft-board/editor-spec.json @@ -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": { diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index a894148e..2385af7c 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -5,7 +5,7 @@ "ko": "게시판", "en": "Board" }, - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "description": { "ko": "게시판 관리를 위한 모듈", diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json index 4b0d6142..fa177ce9 100644 --- a/modules/_bundled/sirsoft-board/package-lock.json +++ b/modules/_bundled/sirsoft-board/package-lock.json @@ -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", diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json index ebf99bd7..3ac608c6 100644 --- a/modules/_bundled/sirsoft-board/package.json +++ b/modules/_bundled/sirsoft-board/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-board", - "version": "1.1.0", + "version": "1.1.1", "description": "그누보드7 게시판 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/AGENTS.md b/modules/_bundled/sirsoft-ecommerce/AGENTS.md new file mode 100644 index 00000000..1a7cab89 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/AGENTS.md @@ -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. 이 확장은 무엇인가 + + +상품·주문·결제·배송·취소/환불·쿠폰·마일리지·리뷰·문의를 소유하는 커머스 도메인 모듈입니다. +모델 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 +의존에는 게시판이 없습니다 — 연결이 코드 결합이 아니라 훅 구독이라 게시판이 없으면 문의 기능만 +비고 나머지는 그대로 동작합니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + + +## 3. 핵심 흐름 + + +**주문 생성 (장바구니 → 결제 완료)**: `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 는 +이 부가효과를 알지 못합니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 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#알림-정의) | + + + +발행 훅 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개입니다. 실시간 반영이 필요한 화면은 이 모듈이 아니라 소비하는 +템플릿·모듈 쪽에서 폴링·재조회로 해결합니다. + + +## 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. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 화면·서비스마다 금액을 다시 계산 (`합계 = 단가 × 수량` 재구현) | `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`) | 부분 취소가 이 도메인의 기본이며, 주문 단위로 처리하면 남은 옵션의 안분 금액이 계산되지 않는다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| 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 + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index f8e77e81..c8bace6e 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -6,6 +6,12 @@ ## [1.2.1] - 2026-08-28 +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ### Fixed - 상세설명을 편집기(HTML)로 작성한 상품을 등록하거나 수정할 때 저장이 실패하던 문제를 수정했습니다. 상품 설명의 보안 정화에 쓰는 구성요소가 모듈 설치 폴더 안에 자기 캐시 파일을 만들려 했기 때문에, 보안상 모듈 폴더에 쓰기를 막아 둔 서버에서는 저장이 항상 오류로 끝났고 다시 시도해도 같은 결과였습니다. 이제 이 캐시는 `storage` 폴더 아래에 만들어지며, 그 위치마저 쓸 수 없는 경우에는 캐시 없이 정화만 수행해 저장이 실패하지 않습니다(설명은 종전과 똑같이 정화됩니다). (#125 @lyg-kaban 님께서 제보해주셨습니다.) diff --git a/modules/_bundled/sirsoft-ecommerce/README.md b/modules/_bundled/sirsoft-ecommerce/README.md new file mode 100644 index 00000000..abc31368 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/README.md @@ -0,0 +1,226 @@ +# 이커머스 + +**그누보드7 모듈 · sirsoft-ecommerce** +그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리 + + +

+ version 1.2.1 + type 모듈 + 그누보드7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +온라인 상점 운영에 필요한 상품·주문·결제·배송·취소/환불·쿠폰·마일리지·리뷰·문의를 한곳에서 +관리하는 모듈입니다. 관리자 화면에서 상품을 등록하고 주문을 처리하면, 방문자가 보는 상점 +화면은 템플릿(`sirsoft-basic`)이 이 모듈의 데이터를 받아 그립니다. + +여러 나라·여러 통화를 동시에 다루도록 설계되어 있습니다. 상품 가격을 저장하는 **기본 통화**, +구매자가 화면에서 고르는 **표시 통화**, 결제사에 청구되는 **결제 통화**를 각각 따로 설정할 수 +있고, 주문이 만들어지는 순간의 통화 정보가 그 주문에 그대로 남습니다 — 나중에 통화 설정을 +바꿔도 지난 주문의 금액 표기는 변하지 않습니다. + +결제사(PG) 연동은 이 모듈에 들어 있지 않습니다. KG이니시스·NHN KCP·나이스페이먼츠·토스페이먼츠는 +각각 별도 플러그인이며, 쓰려는 결제사의 플러그인을 설치·활성화한 뒤 환경설정에서 고르면 됩니다. +상품 문의 게시판도 마찬가지로 게시판 모듈이 글을 보관하고, 이 모듈은 "어떤 상품의 문의인가"만 +연결합니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 상품 | 상품·옵션·추가옵션·이미지 등록, 카테고리/브랜드/라벨 분류, 상품정보제공고시와 공통정보 템플릿, 진열·판매 상태 관리 | +| 주문 | 주문 목록·상세, 상태 변경(입금대기 → 결제완료 → 배송준비 → 배송중 → 배송완료 → 구매확정), 관리자 수기 결제, 엑셀 내려받기 | +| 결제 | 카드·가상계좌·계좌이체·무통장·휴대폰·마일리지 등 결제수단 관리, 현금영수증·세금계산서 발행 이력, 입금 확인 | +| 배송 | 배송정책(국가별 요금·무료배송 기준·구간 요금 14종)·배송사·배송유형·추가배송비 템플릿, 송장 등록과 배송 추적 | +| 취소·환불 | 주문 전체/부분 취소, 환불 수단(PG·계좌·마일리지) 선택, 클레임 사유 관리, 이미 적용된 쿠폰·마일리지 자동 되돌림 | +| 쿠폰 | 상품/카테고리/주문금액/배송비 대상 쿠폰, 정액·정률 할인, 발급 방식(직접·다운로드·자동), 가입·첫구매·생일 자동 발급 | +| 마일리지 | 적립률·적립 시점(배송완료/구매확정)·지연 적립·자동 소멸과 소멸 예정 알림, 통화별 적립 규칙, 관리자 수동 지급·차감 | +| 리뷰·문의 | 구매자 리뷰(이미지 첨부·작성 기한·노출 관리), 상품 1:1 문의(게시판 모듈에 글로 보관) | +| 회원 | 회원별 배송지, 결제 통화·배송 국가 지정, 장바구니(비로그인 → 로그인 시 자동 병합), 찜 목록 | +| 대시보드·통계 | 매출·주문 현황 집계, 미처리 주문 요약, 관리자 대시보드에 커머스 위젯 주입 | +| 다국어·다통화 | 기본/표시/결제 통화 분리, 통화별 소수 자릿수·절사 규칙, 주문 시점 통화 정보 보존 | +| SEO | 상품·카테고리·검색·상점 첫 화면의 메타 정보와 구조화 데이터 자동 생성 | + + +## 동작 방식 + + +```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 +``` + +주문 상태는 위 순서로 진행하며, 결제 완료 이후 배송 시작 전까지는 취소가 가능합니다(어느 +상태까지 취소를 허용할지는 환경설정에서 조정합니다). 취소하면 그 주문에 쓰인 쿠폰과 마일리지가 +자동으로 되돌아갑니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | + + +## 설치 + + +```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 + + +## 관리자 설정 + + +_별도의 관리자 설정 항목이 없습니다._ + + + +위 표가 비어 있는 이유는 이 모듈의 환경설정이 코드 선언이 아니라 설정 파일 +(`config/settings/defaults.json`)에서 오기 때문입니다. 실제 설정은 `/admin/ecommerce/settings` +한 화면에 9개 탭으로 모여 있습니다. + +| 탭 | 언제 바꾸는가 | 바꾸면 달라지는 것 | +|---|---|---| +| 기본 정보 | 개점 준비 시 1회 | 상점명·사업자 정보·상점 주소 경로가 상점 화면과 주문서에 반영됩니다 | +| 언어·통화 | 판매 국가를 늘릴 때 | 기본 통화와 취급 통화 목록. 기본 통화를 바꿔도 **이미 만들어진 주문의 표기는 그대로**입니다 | +| 주문 설정 | 결제사를 도입·교체할 때 | 기본 PG·현금영수증 발행처·결제수단 노출·무통장 계좌·미입금 자동취소 기한·취소 허용 상태 | +| 배송 | 해외 배송을 시작할 때 | 기본 배송 국가·취급 국가·무료배송 기준·주소 검증 사용 여부 | +| SEO | 검색 노출을 조정할 때 | 상품·카테고리·검색·상점 첫 화면의 제목/설명 서식과 구조화 데이터 사용 여부 | +| 리뷰 | 리뷰 정책을 바꿀 때 | 작성 가능 기한(구매 후 N일)·이미지 개수와 용량 제한 | +| 문의 | 문의 게시판을 지정할 때 | 상품 문의가 저장될 게시판. **게시판 모듈이 설치·활성화되어 있어야 합니다** | +| 알림 | 알림 채널을 조정할 때 | 주문·배송·문의 알림을 메일/앱 내 알림 중 어디로 보낼지 | +| 마일리지 | 적립 제도를 운영할 때 | 사용 여부·적립률·적립 시점(배송완료/구매확정)·지연 적립일·통화별 규칙·소멸 기한과 사전 알림 | + +결제수단 목록은 **설치된 결제사 플러그인이 스스로 등록**합니다. 그래서 플러그인을 삭제하거나 +비활성화하면 그 결제수단은 구매자 화면에서 자동으로 사라집니다 — 설정에서 따로 지울 필요가 +없습니다. + + +## 사용 방법 + + +**개점 준비**: `/admin/ecommerce/settings` 에서 기본 정보와 기본 통화를 정하고, 쓰려는 결제사 +플러그인을 설치·활성화한 뒤 "주문 설정" 탭에서 기본 PG 와 노출할 결제수단을 고릅니다. 그다음 +"배송" 탭에서 기본 배송 국가를 정하고 `/admin/ecommerce/shipping-policies` 에서 배송정책을 +하나 이상 만듭니다 — 배송정책이 없으면 상품을 등록해도 배송비가 계산되지 않습니다. 마지막으로 +`/admin/ecommerce/categories` 에서 카테고리를 만든 뒤 상품을 등록합니다. + +**주문 처리**: 새 주문이 들어오면 `/admin/ecommerce/orders` 에 뜨고 담당자에게 알림이 갑니다. +무통장 입금 주문은 입금을 확인해 "결제완료"로 바꾸고(현금영수증 발행 설정이 켜져 있으면 이때 +자동 발행됩니다), 상품을 준비한 뒤 송장 번호를 등록하면 상태가 "배송중"으로 넘어가면서 구매자 +알림이 나갑니다. 배송완료 후 구매확정되면 마일리지 적립 시점 설정에 따라 적립이 이루어집니다. + +**부분 취소·환불**: 주문 상세에서 취소할 **옵션(품목)을 골라** 취소를 진행합니다. 주문 전체가 +아니라 품목 단위라서, 세 개 중 하나만 취소하면 나머지 두 개에 걸린 할인과 배송비가 자동으로 +다시 안분됩니다. 환불 수단은 PG 취소·계좌 입금·마일리지 반환 중에서 고르며, 그 주문에 쓰인 +쿠폰과 마일리지는 취소 처리와 같은 시점에 되돌아갑니다. + +**쿠폰 발행**: `/admin/ecommerce/promotion-coupons` 에서 대상(전체/특정 상품/특정 카테고리)과 +할인 방식(정액/정률), 적용 대상(상품금액/주문금액/배송비)을 정합니다. 발급 방식을 "자동"으로 +두고 조건을 가입·첫구매·생일 중에서 고르면 해당 시점에 자동 발급됩니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `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` | + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 결제 단계에서 결제수단이 하나도 보이지 않음 | 결제사 플러그인이 설치·활성화되지 않았거나, 환경설정 "주문 설정" 탭에서 노출이 꺼져 있음 | 플러그인을 활성화한 뒤 주문 설정 탭에서 해당 결제수단을 켭니다. 플러그인을 지웠다면 그 결제수단은 자동으로 목록에서 빠집니다 | +| 상품을 등록했는데 장바구니에서 배송비가 0원 | 그 상품에 배송정책이 지정되지 않았거나, 정책에 현재 배송 국가 설정이 없음 | 배송정책을 만들고 상품 편집 화면에서 지정한 뒤, 정책의 국가별 설정에 해당 국가를 추가합니다 | +| 상품 문의 메뉴가 동작하지 않음 | 게시판 모듈이 없거나, 환경설정 "문의" 탭의 게시판이 지정되지 않음 | 게시판 모듈을 활성화하고 문의용 게시판을 만든 뒤 문의 탭에서 그 게시판을 고릅니다 | +| 통화 설정을 바꿨는데 지난 주문의 금액 표기가 그대로 | 주문 시점의 통화 정보가 그 주문에 보존됨 | 정상 동작입니다. 지난 거래의 표기가 나중 설정 변경으로 달라지면 정산 근거가 바뀌므로 의도적으로 고정합니다 | +| 마일리지 잔액이 내역 합계와 어긋나 보임 | 표시용 잔액이 아직 재계산되지 않음 | 정합 교정 스케줄이 주기적으로 맞춥니다. 즉시 맞추려면 `php artisan sirsoft-ecommerce:reconcile-mileage-balance` 를 실행합니다 | +| 소멸 예정 마일리지 알림이 오지 않음 | 스케줄러가 동작하지 않거나 알림 채널이 꺼져 있음 | 서버의 스케줄러 등록을 확인하고, 환경설정 "알림" 탭에서 해당 알림의 채널을 켭니다 | +| 미입금 주문이 계속 남아 있음 | 자동 취소가 꺼져 있거나 기한이 길게 설정됨 | 주문 설정 탭의 "미입금 자동취소" 사용 여부와 기한(일)을 확인합니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/modules/_bundled/sirsoft-ecommerce/docs/README.md b/modules/_bundled/sirsoft-ecommerce/docs/README.md new file mode 100644 index 00000000..4394904c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/README.md @@ -0,0 +1,23 @@ +# 이커머스 개발자 문서 + +> modules/_bundled/sirsoft-ecommerce · 모듈 + + +**훅 수**: 508 · **구독 훅 수**: 142 · **라우트 수**: 239 · **모델 수**: 47 · **테이블 수**: 51 · **마이그레이션 수**: 102 · **레이아웃 수**: 206 · **핸들러 수**: 160 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/architecture.md b/modules/_bundled/sirsoft-ecommerce/docs/architecture.md new file mode 100644 index 00000000..738bafed --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/architecture.md @@ -0,0 +1,106 @@ +# 이커머스 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +이 모듈의 설계는 "커머스 도메인에서 **변하는 것**과 **변하지 않는 것**을 갈라 두는 것"에 +집중되어 있습니다. 변하지 않는 것은 금액 계산 규칙·주문 상태 전이·원장 기록이고, 변하는 것은 +결제사·화면 디자인·나라별 배송 규칙·프로모션 정책입니다. 변하는 축은 전부 이 모듈 **밖**으로 +빼거나 데이터로 내려서, 새 결제사·새 나라·새 화면이 추가될 때 이 모듈의 소스를 고치지 않아도 +되게 했습니다. + +- **결제사**: 플러그인이 이 모듈에 의존하지, 이 모듈이 플러그인을 알지 않습니다. 코어 + `PaymentMethodEnum` 도 확장 결제수단 ID 를 모르므로, 능력(`needs_pg`/`pg_locked`/ + `pg_provider`)은 등록하는 플러그인이 카탈로그에 선언하고 화면과 서버는 그 선언만 읽습니다. +- **화면**: 레이아웃 206개가 전부 관리자 화면입니다. 방문자 상점 화면은 템플릿이 공개 API 를 + 소비해 그리므로, 상점 디자인이 여러 벌 필요해도 이 모듈은 하나로 유지됩니다. +- **나라·통화·배송**: `ShippingPolicy` + `ShippingPolicyCountrySetting` 조합으로 국가별 요금 + 규칙을 데이터로 표현하고(`ChargePolicyEnum` 14종), 통화는 설정에서 읽습니다. 코드에 통화 + 코드나 국가 코드를 박지 않는 것이 규칙입니다. +- **프로모션**: 쿠폰의 대상 범위(`CouponTargetScope`)·대상 금액(`CouponTargetType`)·할인 방식 + (`CouponDiscountType`)·발급 방식(`CouponIssueMethod`)이 전부 Enum + 데이터 조합이라, 새 + 프로모션 유형 대부분은 코드 없이 관리자 화면에서 만들어집니다. + +그 대가로 **계산기 하나가 무거워집니다.** `OrderCalculationService` 는 9단계(옵션 금액 → 상품· +카테고리 쿠폰 → 배송비 → 배송비 쿠폰 → 주문금액 쿠폰 → 적립 마일리지 → 결제금액 → 마일리지 +사용 → 최종 지불금액)를 한 번에 수행하며, 상품 상세·장바구니·체크아웃·주문 생성·결제 완료 +검증·부분 취소 여섯 지점이 모두 이 하나를 부릅니다. 이 집중은 의도된 것입니다 — 계산이 흩어지면 +화면 금액과 청구 금액이 갈라지고, 그 어긋남은 결제가 끝난 뒤에야 예외로 드러납니다. + +**의도적으로 하지 않는 것**: 실시간 브로드캐스트(채널 0개)·PG 통신·방문자 화면 소유·문의 본문 +저장. 문의는 게시판 모듈이 글로 보관하고 이 모듈은 상품↔글 피벗만 갖는데, manifest 의존에는 +게시판이 없습니다. 연결이 코드 결합이 아니라 훅 구독이라 게시판이 없으면 문의 기능만 비고 +나머지는 그대로 동작합니다. + + +## 계층 지도 + + +``` +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` 로 구독해야 호출자 트랜잭션과 함께 롤백됩니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/data-model.md b/modules/_bundled/sirsoft-ecommerce/docs/data-model.md new file mode 100644 index 00000000..09a5c71c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/data-model.md @@ -0,0 +1,468 @@ +# 이커머스 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | 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 | - | + + + +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` 가 훅으로 받아 처리합니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `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` | + + + +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()` 를 습관적으로 붙이면 취소·삭제된 행이 매출 +집계에 섞이므로 주의합니다. + + +## 마이그레이션 + + +마이그레이션 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` | ✅ | + + + +102개는 초기 스키마 한 벌이 아니라 **누적된 변경 이력**입니다. 새 컬럼을 추가할 때 초기 +`create_*` 파일을 고치는 것이 아니라 새 `add_*`/`change_*` 파일을 더합니다 — 이미 설치된 +사이트는 초기 마이그레이션을 다시 실행하지 않기 때문입니다. + +같은 이유로 **소스만 고쳐서는 기설치본이 낫지 않습니다.** 컬럼 기본값·comment·데이터 형태를 +바로잡는 변경은 마이그레이션과 함께 `upgrades/` 의 업그레이드 스텝에 백필을 써야 이미 운영 +중인 사이트에 반영됩니다. + +작성 규칙 셋(코어 공통이지만 이 모듈에서 특히 자주 걸립니다): + +- 모든 컬럼에 한국어 `comment` 와 `down()` 구현 +- FK 컬럼의 `->comment()` 는 `->constrained()` **앞**에 둡니다 (뒤에 두면 comment 가 컬럼이 + 아니라 FK 정의에 붙어 조용히 사라집니다) +- 데이터를 순회하며 그 행을 갱신·삭제하는 백필은 `chunkById()` — `chunk()` 계열은 OFFSET + 기반이라 처리된 행이 필터에서 이탈한 만큼 커서가 밀려 미처리 행을 조용히 건너뜁니다 + + +## Enum + + +| 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` | + + + +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 을 고치는 일이 아닙니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `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 인터페이스 | + + + +Repository 는 인터페이스와 구현이 1:1 로 짝을 이루며, 서비스는 **인터페이스만 주입**받습니다 +(구체 클래스 타입힌트 금지). 바인딩은 모듈 서비스 프로바이더가 담당합니다. + +이 모듈에서 Repository 를 손댈 때 특히 걸리는 것 셋: + +- **목록 쿼리의 컬럼 프루닝** — `paginate()` 에 컬럼 목록을 주고, 목록이 실제로 그리는 것만 + 싣습니다. 상품 목록에 옵션 전체를 실으면 상품 100건 × 옵션 20건이 한 응답에 나갑니다. +- **정렬 컬럼 화이트리스트** — 요청에서 온 정렬 컬럼을 그대로 `orderBy` 에 넘기지 않습니다. + 화면의 정렬 옵션 ⊆ FormRequest 게이트 ⊆ Repository 화이트리스트 순서로 포함 관계가 + 유지되어야 하며, 어긋나면 422 뒤에 직전 목록이 남아 **정렬된 것처럼 보입니다.** +- **마일리지 두 Repository 의 역할 차이** — `MileageTransactionRepository` 는 원장이고 + `MileageBalanceRepository` 는 파생 캐시입니다. 차감 가능 여부 판정은 반드시 원장 + `FOR UPDATE` 로 하고, 캐시는 같은 트랜잭션 마지막에 재계산합니다. 캐시를 근거로 차감하면 + 동시 요청에서 잔액이 음수가 됩니다. + +`EcommerceStatRepository` 만 성격이 다릅니다 — 대시보드용 일별 집계 테이블을 읽고 쓰며, 원본 +주문에서 매번 집계하지 않기 위한 자리입니다. 집계를 채우는 것은 `aggregate-stats` 스케줄입니다. + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/editor-spec.md b/modules/_bundled/sirsoft-ecommerce/docs/editor-spec.md new file mode 100644 index 00000000..64fd2725 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/editor-spec.md @@ -0,0 +1,125 @@ +# 이커머스 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `modules/_bundled/sirsoft-ecommerce/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 51 · 엔드포인트 샘플 7 · 페이지 상태 17 · 액션 레시피 1 + + + +이커머스는 저장소에서 가장 큰 모듈이지만 편집기 스펙은 여전히 단일 파일입니다. 스펙 +분량을 키우는 것은 팔레트·컨트롤·컴포넌트 역량인데 그 셋은 템플릿이 소유하기 때문입니다. +모듈 쪽에 남는 것은 도메인 데이터라 라우트 239개·레이아웃 206개 규모에도 한 파일에 +들어갑니다. + +이 사실이 곧 설계 원칙입니다 — 확장이 커진다고 편집기 스펙이 따라 커지지 않습니다. +커진다면 그 확장이 템플릿의 일을 하고 있다는 신호입니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `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 (인라인)` | + + + +`actionRecipes` 와 `actionChipCandidates` 를 각 1건씩 둔 것이 다른 모듈과 다른 +지점입니다. 이커머스에는 운영자가 편집기에서 직접 조립하기 어려운 동작(장바구니·주문 +흐름에 얽힌 것)이 있어, 친화 명칭으로 미리 만들어 둔 레시피가 필요합니다. + +나머지 네 블록은 게시판과 같은 원리입니다 — admin 레이아웃 `data_source` ID 51종을 +전수로 덮고, 사용자 페이지 7종은 호출 주소로 덮습니다. `sampleGlobal` 12종은 통화·로케일 +같은 값이 `_global` 에 없으면 상품 카드가 통째로 깨지기 때문에 baseline 으로 박아 +둔 것입니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | 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` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +`states.groups` 17종은 이커머스에서 상태가 실제로 화면을 가르는 자리입니다 — 품절 +상품, 빈 장바구니, 비회원 주문 조회, 재주문 등입니다. 상태를 늘리는 기준은 "그 상태에서 +운영자가 화면을 따로 손봐야 하는가" 입니다. 값만 다르고 구조가 같은 경우는 변종을 +만들지 않습니다. + +`sampleGlobal` 에 통화 관련 값을 둘 때는 특정 통화를 정답으로 박지 않도록 주의합니다. +기본 통화는 설정이 정하므로, 샘플이 특정 통화를 전제하면 편집기 프리뷰만 그 통화로 +고정되어 다른 통화 상점의 운영자에게 잘못된 화면을 보여 줍니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan module:update sirsoft-ecommerce --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +이커머스는 `sampleGlobal` 이 12종으로 가장 많습니다. 레이아웃이 `_global.*` 을 새로 +읽기 시작했는데 baseline 을 안 넣으면 그 값이 `undefined` 가 되어, 표현식이 통째로 +falsy 로 떨어지며 **영역 전체가 사라집니다.** 값 하나가 비는 것보다 알아채기 어렵습니다. + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/extension-points.md b/modules/_bundled/sirsoft-ecommerce/docs/extension-points.md new file mode 100644 index 00000000..db0c7224 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/extension-points.md @@ -0,0 +1,1189 @@ +# 이커머스 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 508종 / 호출 지점 548곳. 이 중 508종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다. 훅 이름이 상수·변수로 조립된 호출이 5곳 있어 호출 위치가 표에 다 실리지 않을 수 있습니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `core.module_settings.after_save` | action | — | `src/Http/Controllers/Admin/EcommerceSettingsController.php:178` 외 2곳 | +| `sirsoft-ecommerce.adjustment.filter_restore_promotions` | filter | — | `src/Services/OrderAdjustmentService.php:375` | +| `sirsoft-ecommerce.admin.user_currency.changed` | action | — | `src/Services/UserCurrencyService.php:72` | +| `sirsoft-ecommerce.admin.user_shipping_country.changed` | action | — | `src/Services/UserShippingCountryService.php:74` | +| `sirsoft-ecommerce.brand.after_create` | action | — | `src/Services/BrandService.php:100` | +| `sirsoft-ecommerce.brand.after_delete` | action | — | `src/Services/BrandService.php:216` | +| `sirsoft-ecommerce.brand.after_list` | action | — | `src/Services/BrandService.php:45` | +| `sirsoft-ecommerce.brand.after_show` | action | — | `src/Services/BrandService.php:68` | +| `sirsoft-ecommerce.brand.after_toggle_status` | action | — | `src/Services/BrandService.php:175` | +| `sirsoft-ecommerce.brand.after_update` | action | — | `src/Services/BrandService.php:142` | +| `sirsoft-ecommerce.brand.before_create` | action | — | `src/Services/BrandService.php:83` | +| `sirsoft-ecommerce.brand.before_delete` | action | — | `src/Services/BrandService.php:209` | +| `sirsoft-ecommerce.brand.before_list` | action | — | `src/Services/BrandService.php:34` | +| `sirsoft-ecommerce.brand.before_show` | action | — | `src/Services/BrandService.php:59` | +| `sirsoft-ecommerce.brand.before_toggle_status` | action | — | `src/Services/BrandService.php:166` | +| `sirsoft-ecommerce.brand.before_update` | action | — | `src/Services/BrandService.php:123` | +| `sirsoft-ecommerce.brand.create_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreBrandRequest.php:42` | +| `sirsoft-ecommerce.brand.filter_create_data` | filter | — | `src/Services/BrandService.php:86` | +| `sirsoft-ecommerce.brand.filter_list_query` | filter | — | `src/Services/BrandService.php:37` | +| `sirsoft-ecommerce.brand.filter_list_result` | filter | — | `src/Services/BrandService.php:42` | +| `sirsoft-ecommerce.brand.filter_show_result` | filter | — | `src/Services/BrandService.php:65` | +| `sirsoft-ecommerce.brand.filter_update_data` | filter | — | `src/Services/BrandService.php:129` | +| `sirsoft-ecommerce.brand.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateBrandRequest.php:40` | +| `sirsoft-ecommerce.calculation.after_final_result` | filter | — | `src/Services/OrderCalculationService.php:371` | +| `sirsoft-ecommerce.calculation.after_item_subtotals` | filter | — | `src/Services/OrderCalculationService.php:169` | +| `sirsoft-ecommerce.calculation.after_order_discount` | filter | — | `src/Services/OrderCalculationService.php:272` | +| `sirsoft-ecommerce.calculation.after_payment_amount` | filter | — | `src/Services/OrderCalculationService.php:295` | +| `sirsoft-ecommerce.calculation.after_points_earning` | filter | — | `src/Services/OrderCalculationService.php:324` | +| `sirsoft-ecommerce.calculation.after_points_usage` | filter | — | `src/Services/OrderCalculationService.php:309` | +| `sirsoft-ecommerce.calculation.after_product_discount` | filter | — | `src/Services/OrderCalculationService.php:193` | +| `sirsoft-ecommerce.calculation.after_shipping_discount` | filter | — | `src/Services/OrderCalculationService.php:249` | +| `sirsoft-ecommerce.calculation.after_shipping_fee` | filter | — | `src/Services/OrderCalculationService.php:228` | +| `sirsoft-ecommerce.calculation.after_tax_classification` | filter | — | `src/Services/OrderCalculationService.php:203` | +| `sirsoft-ecommerce.calculation.before_final_result` | filter | — | `src/Services/OrderCalculationService.php:335` | +| `sirsoft-ecommerce.calculation.before_item_subtotals` | filter | — | `src/Services/OrderCalculationService.php:162` | +| `sirsoft-ecommerce.calculation.before_order_discount` | filter | — | `src/Services/OrderCalculationService.php:259` | +| `sirsoft-ecommerce.calculation.before_payment_amount` | filter | — | `src/Services/OrderCalculationService.php:282` | +| `sirsoft-ecommerce.calculation.before_product_discount` | filter | — | `src/Services/OrderCalculationService.php:178` | +| `sirsoft-ecommerce.calculation.before_shipping_discount` | filter | — | `src/Services/OrderCalculationService.php:238` | +| `sirsoft-ecommerce.calculation.before_shipping_fee` | filter | — | `src/Services/OrderCalculationService.php:212` | +| `sirsoft-ecommerce.calculation.filter_promotions_snapshot` | filter | — | `src/Services/OrderAdjustmentService.php:778` 외 1곳 | +| `sirsoft-ecommerce.cart.after_add` | action | — | `src/Services/CartService.php:257` | +| `sirsoft-ecommerce.cart.after_change_option` | action | — | `src/Services/CartService.php:470` | +| `sirsoft-ecommerce.cart.after_delete` | action | — | `src/Services/CartService.php:503` | +| `sirsoft-ecommerce.cart.after_delete_all` | action | — | `src/Services/CartService.php:788` | +| `sirsoft-ecommerce.cart.after_delete_multiple` | action | — | `src/Services/CartService.php:535` | +| `sirsoft-ecommerce.cart.after_list` | action | — | `src/Services/CartService.php:76` | +| `sirsoft-ecommerce.cart.after_merge` | action | — | `src/Services/CartService.php:667` | +| `sirsoft-ecommerce.cart.after_reorder` | action | — | `src/Services/CartService.php:746` | +| `sirsoft-ecommerce.cart.after_update_quantity` | action | — | `src/Services/CartService.php:377` | +| `sirsoft-ecommerce.cart.before_add` | action | — | `src/Services/CartService.php:188` | +| `sirsoft-ecommerce.cart.before_change_option` | action | — | `src/Services/CartService.php:449` | +| `sirsoft-ecommerce.cart.before_delete` | action | — | `src/Services/CartService.php:497` | +| `sirsoft-ecommerce.cart.before_delete_all` | action | — | `src/Services/CartService.php:776` | +| `sirsoft-ecommerce.cart.before_delete_multiple` | action | — | `src/Services/CartService.php:518` | +| `sirsoft-ecommerce.cart.before_list` | action | — | `src/Services/CartService.php:64` | +| `sirsoft-ecommerce.cart.before_merge` | action | — | `src/Services/CartService.php:549` | +| `sirsoft-ecommerce.cart.before_reorder` | action | — | `src/Services/CartService.php:700` | +| `sirsoft-ecommerce.cart.before_update_quantity` | action | — | `src/Services/CartService.php:371` | +| `sirsoft-ecommerce.cart.bulk_add_validation_rules` | filter | — | `src/Http/Requests/Public/BulkAddToCartRequest.php:51` | +| `sirsoft-ecommerce.cart.change_option_validation_rules` | filter | — | `src/Http/Requests/Public/ChangeCartOptionRequest.php:46` | +| `sirsoft-ecommerce.cart.delete_items_validation_rules` | filter | — | `src/Http/Requests/Public/DeleteCartItemsRequest.php:35` | +| `sirsoft-ecommerce.cart.filter_add_data` | filter | — | `src/Services/CartService.php:190` | +| `sirsoft-ecommerce.cart.filter_list_result` | filter | — | `src/Services/CartService.php:74` | +| `sirsoft-ecommerce.cart.get_validation_rules` | filter | — | `src/Http/Requests/Public/GetCartRequest.php:35` | +| `sirsoft-ecommerce.cart.update_quantity_validation_rules` | filter | — | `src/Http/Requests/Public/UpdateCartQuantityRequest.php:37` | +| `sirsoft-ecommerce.cash_receipt.cancel` | filter | — | `src/Services/CashReceiptService.php:194` | +| `sirsoft-ecommerce.cash_receipt.issue` | filter | — | `src/Services/CashReceiptService.php:126` | +| `sirsoft-ecommerce.cash_receipt.registered_providers` | filter | — | `src/Services/EcommerceSettingsService.php:878` | +| `sirsoft-ecommerce.category-image.after_delete` | action | — | `src/Services/CategoryImageService.php:246` 외 1곳 | +| `sirsoft-ecommerce.category-image.after_reorder` | action | — | `src/Services/CategoryImageService.php:308` | +| `sirsoft-ecommerce.category-image.after_update` | action | — | `src/Services/CategoryImageService.php:382` | +| `sirsoft-ecommerce.category-image.after_upload` | action | — | `src/Services/CategoryImageService.php:126` | +| `sirsoft-ecommerce.category-image.before_delete` | action | — | `src/Services/CategoryImageService.php:222` 외 1곳 | +| `sirsoft-ecommerce.category-image.before_reorder` | action | — | `src/Services/CategoryImageService.php:303` | +| `sirsoft-ecommerce.category-image.before_update` | action | — | `src/Services/CategoryImageService.php:369` | +| `sirsoft-ecommerce.category-image.before_upload` | action | — | `src/Services/CategoryImageService.php:64` | +| `sirsoft-ecommerce.category-image.filter_reorder_validation_rules` | filter | — | `src/Http/Requests/Admin/ReorderCategoryImagesRequest.php:36` | +| `sirsoft-ecommerce.category-image.filter_update_data` | filter | — | `src/Services/CategoryImageService.php:372` | +| `sirsoft-ecommerce.category-image.filter_upload_file` | filter | — | `src/Services/CategoryImageService.php:67` | +| `sirsoft-ecommerce.category-image.filter_upload_validation_rules` | filter | — | `src/Http/Requests/Admin/UploadCategoryImageRequest.php:64` | +| `sirsoft-ecommerce.category.after_create` | action | — | `src/Services/CategoryService.php:176` | +| `sirsoft-ecommerce.category.after_delete` | action | — | `src/Services/CategoryService.php:287` | +| `sirsoft-ecommerce.category.after_list` | action | — | `src/Services/CategoryService.php:53` | +| `sirsoft-ecommerce.category.after_public_list` | action | — | `src/Services/CategoryService.php:73` | +| `sirsoft-ecommerce.category.after_public_show` | action | — | `src/Services/CategoryService.php:101` | +| `sirsoft-ecommerce.category.after_reorder` | action | — | `src/Services/CategoryService.php:438` | +| `sirsoft-ecommerce.category.after_show` | action | — | `src/Services/CategoryService.php:129` | +| `sirsoft-ecommerce.category.after_toggle_status` | action | — | `src/Services/CategoryService.php:386` | +| `sirsoft-ecommerce.category.after_update` | action | — | `src/Services/CategoryService.php:235` | +| `sirsoft-ecommerce.category.before_create` | action | — | `src/Services/CategoryService.php:144` | +| `sirsoft-ecommerce.category.before_delete` | action | — | `src/Services/CategoryService.php:274` | +| `sirsoft-ecommerce.category.before_list` | action | — | `src/Services/CategoryService.php:34` | +| `sirsoft-ecommerce.category.before_public_list` | action | — | `src/Services/CategoryService.php:67` | +| `sirsoft-ecommerce.category.before_public_show` | action | — | `src/Services/CategoryService.php:86` | +| `sirsoft-ecommerce.category.before_reorder` | action | — | `src/Services/CategoryService.php:399` | +| `sirsoft-ecommerce.category.before_show` | action | — | `src/Services/CategoryService.php:116` | +| `sirsoft-ecommerce.category.before_toggle_status` | action | — | `src/Services/CategoryService.php:379` | +| `sirsoft-ecommerce.category.before_update` | action | — | `src/Services/CategoryService.php:198` | +| `sirsoft-ecommerce.category.create_validation_rules` | filter | — | `src/Http/Requests/Admin/CreateCategoryRequest.php:73` | +| `sirsoft-ecommerce.category.filter_create_data` | filter | — | `src/Services/CategoryService.php:147` | +| `sirsoft-ecommerce.category.filter_list_query` | filter | — | `src/Services/CategoryService.php:37` | +| `sirsoft-ecommerce.category.filter_list_result` | filter | — | `src/Services/CategoryService.php:50` | +| `sirsoft-ecommerce.category.filter_public_list_result` | filter | — | `src/Services/CategoryService.php:71` | +| `sirsoft-ecommerce.category.filter_public_show_result` | filter | — | `src/Services/CategoryService.php:100` | +| `sirsoft-ecommerce.category.filter_show_result` | filter | — | `src/Services/CategoryService.php:126` | +| `sirsoft-ecommerce.category.filter_update_data` | filter | — | `src/Services/CategoryService.php:204` | +| `sirsoft-ecommerce.category.reorder_validation_rules` | filter | — | `src/Http/Requests/Admin/ReorderCategoriesRequest.php:50` | +| `sirsoft-ecommerce.category.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateCategoryRequest.php:77` | +| `sirsoft-ecommerce.checkout.before_payment` | action | — | `src/Http/Controllers/Traits/HandlesOrderCreation.php:84` | +| `sirsoft-ecommerce.checkout.filter_response_data` | filter | — | `src/Services/CheckoutDataService.php:96` | +| `sirsoft-ecommerce.checkout.update_validation_rules` | filter | — | `src/Http/Requests/Public/UpdateCheckoutRequest.php:55` | +| `sirsoft-ecommerce.checkout.validation_rules` | filter | — | `src/Http/Requests/Public/CheckoutRequest.php:56` | +| `sirsoft-ecommerce.claim_reason.after_create` | action | — | `src/Services/ClaimReasonService.php:135` | +| `sirsoft-ecommerce.claim_reason.after_delete` | action | — | `src/Services/ClaimReasonService.php:288` | +| `sirsoft-ecommerce.claim_reason.after_list` | action | — | `src/Services/ClaimReasonService.php:44` | +| `sirsoft-ecommerce.claim_reason.after_show` | action | — | `src/Services/ClaimReasonService.php:63` | +| `sirsoft-ecommerce.claim_reason.after_toggle_status` | action | — | `src/Services/ClaimReasonService.php:198` | +| `sirsoft-ecommerce.claim_reason.after_update` | action | — | `src/Services/ClaimReasonService.php:169` | +| `sirsoft-ecommerce.claim_reason.before_create` | action | — | `src/Services/ClaimReasonService.php:122` | +| `sirsoft-ecommerce.claim_reason.before_delete` | action | — | `src/Services/ClaimReasonService.php:282` | +| `sirsoft-ecommerce.claim_reason.before_list` | action | — | `src/Services/ClaimReasonService.php:36` | +| `sirsoft-ecommerce.claim_reason.before_show` | action | — | `src/Services/ClaimReasonService.php:57` | +| `sirsoft-ecommerce.claim_reason.before_toggle_status` | action | — | `src/Services/ClaimReasonService.php:190` | +| `sirsoft-ecommerce.claim_reason.before_update` | action | — | `src/Services/ClaimReasonService.php:157` | +| `sirsoft-ecommerce.claim_reason.filter_create_data` | filter | — | `src/Services/ClaimReasonService.php:124` | +| `sirsoft-ecommerce.claim_reason.filter_list_query` | filter | — | `src/Services/ClaimReasonService.php:38` | +| `sirsoft-ecommerce.claim_reason.filter_list_result` | filter | — | `src/Services/ClaimReasonService.php:42` | +| `sirsoft-ecommerce.claim_reason.filter_show_result` | filter | — | `src/Services/ClaimReasonService.php:62` | +| `sirsoft-ecommerce.claim_reason.filter_update_data` | filter | — | `src/Services/ClaimReasonService.php:159` | +| `sirsoft-ecommerce.coupon.after_bulk_status` | action | — | `src/Services/CouponService.php:258` | +| `sirsoft-ecommerce.coupon.after_create` | action | — | `src/Services/CouponService.php:132` | +| `sirsoft-ecommerce.coupon.after_delete` | action | — | `src/Services/CouponService.php:226` | +| `sirsoft-ecommerce.coupon.after_direct_issue` | action | — | `src/Services/CouponService.php:308` | +| `sirsoft-ecommerce.coupon.after_direct_issue_batch` | action | — | `src/Services/CouponService.php:317` | +| `sirsoft-ecommerce.coupon.after_issue_cancel` | action | — | `src/Services/CouponService.php:360` | +| `sirsoft-ecommerce.coupon.after_issues_list` | action | — | `src/Services/CouponService.php:388` | +| `sirsoft-ecommerce.coupon.after_list` | action | — | `src/Services/CouponService.php:51` | +| `sirsoft-ecommerce.coupon.after_show` | action | — | `src/Services/CouponService.php:80` | +| `sirsoft-ecommerce.coupon.after_update` | action | — | `src/Services/CouponService.php:192` | +| `sirsoft-ecommerce.coupon.before_bulk_status` | action | — | `src/Services/CouponService.php:248` | +| `sirsoft-ecommerce.coupon.before_create` | action | — | `src/Services/CouponService.php:95` | +| `sirsoft-ecommerce.coupon.before_delete` | action | — | `src/Services/CouponService.php:214` | +| `sirsoft-ecommerce.coupon.before_direct_issue` | action | — | `src/Services/CouponService.php:285` | +| `sirsoft-ecommerce.coupon.before_issue_cancel` | action | — | `src/Services/CouponService.php:348` | +| `sirsoft-ecommerce.coupon.before_issues_list` | action | — | `src/Services/CouponService.php:383` | +| `sirsoft-ecommerce.coupon.before_list` | action | — | `src/Services/CouponService.php:40` | +| `sirsoft-ecommerce.coupon.before_show` | action | — | `src/Services/CouponService.php:65` | +| `sirsoft-ecommerce.coupon.before_update` | action | — | `src/Services/CouponService.php:155` | +| `sirsoft-ecommerce.coupon.create_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreCouponRequest.php:142` | +| `sirsoft-ecommerce.coupon.filter_create_data` | filter | — | `src/Services/CouponService.php:98` | +| `sirsoft-ecommerce.coupon.filter_list_query` | filter | — | `src/Services/CouponService.php:43` | +| `sirsoft-ecommerce.coupon.filter_list_result` | filter | — | `src/Services/CouponService.php:48` | +| `sirsoft-ecommerce.coupon.filter_show_result` | filter | — | `src/Services/CouponService.php:77` | +| `sirsoft-ecommerce.coupon.filter_update_data` | filter | — | `src/Services/CouponService.php:161` | +| `sirsoft-ecommerce.coupon.issues_list_validation_rules` | filter | — | `src/Http/Requests/Admin/CouponIssuesListRequest.php:37` | +| `sirsoft-ecommerce.coupon.restore` | action | — | `src/Services/OrderCancellationService.php:1072` | +| `sirsoft-ecommerce.coupon.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateCouponRequest.php:144` | +| `sirsoft-ecommerce.coupon.use` | action | — | `src/Services/OrderProcessingService.php:199` | +| `sirsoft-ecommerce.coupon.user_available_validation_rules` | filter | — | `src/Http/Requests/User/UserCouponAvailableRequest.php:35` | +| `sirsoft-ecommerce.coupon.user_downloadable_validation_rules` | filter | — | `src/Http/Requests/User/UserCouponDownloadableRequest.php:34` | +| `sirsoft-ecommerce.coupon.user_list_validation_rules` | filter | — | `src/Http/Requests/User/UserCouponListRequest.php:35` | +| `sirsoft-ecommerce.extra_fee_template.after_bulk_create` | action | — | `src/Services/ExtraFeeTemplateService.php:273` | +| `sirsoft-ecommerce.extra_fee_template.after_bulk_delete` | action | — | `src/Services/ExtraFeeTemplateService.php:199` | +| `sirsoft-ecommerce.extra_fee_template.after_bulk_toggle_active` | action | — | `src/Services/ExtraFeeTemplateService.php:227` | +| `sirsoft-ecommerce.extra_fee_template.after_create` | action | — | `src/Services/ExtraFeeTemplateService.php:99` | +| `sirsoft-ecommerce.extra_fee_template.after_delete` | action | — | `src/Services/ExtraFeeTemplateService.php:151` | +| `sirsoft-ecommerce.extra_fee_template.after_read` | action | — | `src/Services/ExtraFeeTemplateService.php:61` | +| `sirsoft-ecommerce.extra_fee_template.after_toggle_active` | action | — | `src/Services/ExtraFeeTemplateService.php:172` | +| `sirsoft-ecommerce.extra_fee_template.after_update` | action | — | `src/Services/ExtraFeeTemplateService.php:130` | +| `sirsoft-ecommerce.extra_fee_template.before_bulk_create` | action | — | `src/Services/ExtraFeeTemplateService.php:261` | +| `sirsoft-ecommerce.extra_fee_template.before_bulk_delete` | action | — | `src/Services/ExtraFeeTemplateService.php:194` | +| `sirsoft-ecommerce.extra_fee_template.before_bulk_toggle_active` | action | — | `src/Services/ExtraFeeTemplateService.php:222` | +| `sirsoft-ecommerce.extra_fee_template.before_create` | action | — | `src/Services/ExtraFeeTemplateService.php:87` | +| `sirsoft-ecommerce.extra_fee_template.before_delete` | action | — | `src/Services/ExtraFeeTemplateService.php:146` | +| `sirsoft-ecommerce.extra_fee_template.before_toggle_active` | action | — | `src/Services/ExtraFeeTemplateService.php:167` | +| `sirsoft-ecommerce.extra_fee_template.before_update` | action | — | `src/Services/ExtraFeeTemplateService.php:116` | +| `sirsoft-ecommerce.extra_fee_template.filter_bulk_create_data` | filter | — | `src/Services/ExtraFeeTemplateService.php:264` | +| `sirsoft-ecommerce.extra_fee_template.filter_create_data` | filter | — | `src/Services/ExtraFeeTemplateService.php:90` | +| `sirsoft-ecommerce.extra_fee_template.filter_list_params` | filter | — | `src/Services/ExtraFeeTemplateService.php:33` | +| `sirsoft-ecommerce.extra_fee_template.filter_update_data` | filter | — | `src/Services/ExtraFeeTemplateService.php:122` | +| `sirsoft-ecommerce.inquiry.count_replies` | filter | — | `src/Listeners/ProductInquiryBoardListener.php:120` 외 1곳 | +| `sirsoft-ecommerce.inquiry.create` | filter | — | `src/Services/ProductInquiryService.php:277` 외 1곳 | +| `sirsoft-ecommerce.inquiry.delete` | filter | — | `src/Services/ProductInquiryService.php:477` 외 1곳 | +| `sirsoft-ecommerce.inquiry.delete_reply` | filter | — | `src/Services/ProductInquiryService.php:559` | +| `sirsoft-ecommerce.inquiry.get_by_ids` | filter | — | `src/Services/ProductInquiryService.php:199` 외 1곳 | +| `sirsoft-ecommerce.inquiry.get_settings` | filter | — | `src/Listeners/ProductInquiryBoardListener.php:270` 외 2곳 | +| `sirsoft-ecommerce.inquiry.store_validation_messages` | filter | — | `src/Http/Requests/Public/StoreInquiryRequest.php:57` | +| `sirsoft-ecommerce.inquiry.store_validation_rules` | filter | — | `src/Http/Requests/Public/StoreInquiryRequest.php:41` | +| `sirsoft-ecommerce.inquiry.update` | filter | — | `src/Services/ProductInquiryService.php:435` | +| `sirsoft-ecommerce.inquiry.update_reply` | filter | — | `src/Services/ProductInquiryService.php:518` | +| `sirsoft-ecommerce.inquiry.update_validation_rules` | filter | — | `src/Http/Requests/User/UpdateInquiryRequest.php:40` | +| `sirsoft-ecommerce.label.after_create` | action | — | `src/Services/ProductLabelService.php:103` | +| `sirsoft-ecommerce.label.after_delete` | action | — | `src/Services/ProductLabelService.php:212` | +| `sirsoft-ecommerce.label.after_list` | action | — | `src/Services/ProductLabelService.php:42` | +| `sirsoft-ecommerce.label.after_show` | action | — | `src/Services/ProductLabelService.php:65` | +| `sirsoft-ecommerce.label.after_toggle_status` | action | — | `src/Services/ProductLabelService.php:173` | +| `sirsoft-ecommerce.label.after_update` | action | — | `src/Services/ProductLabelService.php:142` | +| `sirsoft-ecommerce.label.before_create` | action | — | `src/Services/ProductLabelService.php:90` | +| `sirsoft-ecommerce.label.before_delete` | action | — | `src/Services/ProductLabelService.php:205` | +| `sirsoft-ecommerce.label.before_list` | action | — | `src/Services/ProductLabelService.php:31` | +| `sirsoft-ecommerce.label.before_show` | action | — | `src/Services/ProductLabelService.php:56` | +| `sirsoft-ecommerce.label.before_toggle_status` | action | — | `src/Services/ProductLabelService.php:164` | +| `sirsoft-ecommerce.label.before_update` | action | — | `src/Services/ProductLabelService.php:126` | +| `sirsoft-ecommerce.label.filter_create_data` | filter | — | `src/Services/ProductLabelService.php:93` | +| `sirsoft-ecommerce.label.filter_list_query` | filter | — | `src/Services/ProductLabelService.php:34` | +| `sirsoft-ecommerce.label.filter_list_result` | filter | — | `src/Services/ProductLabelService.php:39` | +| `sirsoft-ecommerce.label.filter_show_result` | filter | — | `src/Services/ProductLabelService.php:62` | +| `sirsoft-ecommerce.label.filter_update_data` | filter | — | `src/Services/ProductLabelService.php:132` | +| `sirsoft-ecommerce.mileage.earn` | action | — | `src/Services/OrderProcessingService.php:310` 외 1곳 | +| `sirsoft-ecommerce.mileage.max_usable_validation_rules` | filter | — | `src/Http/Requests/User/UserMileageMaxUsableRequest.php:34` | +| `sirsoft-ecommerce.mileage.notify_expiring` | action | — | `src/Console/Commands/NotifyExpiringMileageCommand.php:88` | +| `sirsoft-ecommerce.mileage.restore` | action | — | `src/Services/OrderCancellationService.php:1105` | +| `sirsoft-ecommerce.mileage.use` | action | — | `src/Services/OrderProcessingService.php:2020` | +| `sirsoft-ecommerce.notification.channels` | filter | — | `src/Http/Controllers/Admin/EcommerceSettingsController.php:525` | +| `sirsoft-ecommerce.option.after_bulk_update` | action | — | `src/Services/ProductOptionService.php:297` | +| `sirsoft-ecommerce.option.before_bulk_update` | action | — | `src/Services/ProductOptionService.php:197` | +| `sirsoft-ecommerce.option.bulk_update_validation_rules` | filter | — | `src/Http/Requests/Admin/BulkUpdateOptionsRequest.php:72` | +| `sirsoft-ecommerce.option.filter_bulk_update_data` | filter | — | `src/Services/ProductOptionService.php:200` | +| `sirsoft-ecommerce.order-option.after_confirm` | action | — | `src/Services/OrderOptionService.php:258` 외 1곳 | +| `sirsoft-ecommerce.order-option.before_confirm` | action | — | `src/Services/OrderService.php:786` | +| `sirsoft-ecommerce.order.after_admin_notify` | action | — | `src/Services/OrderProcessingService.php:240` 외 2곳 | +| `sirsoft-ecommerce.order.after_bulk_shipping_update` | action | — | `src/Services/OrderService.php:577` | +| `sirsoft-ecommerce.order.after_bulk_status_update` | action | — | `src/Services/OrderService.php:542` | +| `sirsoft-ecommerce.order.after_bulk_update` | action | — | `src/Services/OrderService.php:466` | +| `sirsoft-ecommerce.order.after_confirm` | action | — | `src/Services/OrderProcessingService.php:314` 외 1곳 | +| `sirsoft-ecommerce.order.after_create` | action | — | `src/Services/OrderProcessingService.php:232` | +| `sirsoft-ecommerce.order.after_delete` | action | — | `src/Services/OrderService.php:376` | +| `sirsoft-ecommerce.order.after_deposit_recorded` | action | — | `src/Services/OrderProcessingService.php:1878` | +| `sirsoft-ecommerce.order.after_payment_complete` | action | — | `src/Services/OrderProcessingService.php:313` 외 1곳 | +| `sirsoft-ecommerce.order.after_purchase_confirmed` | action | — | `src/Services/OrderOptionService.php:776` 외 1곳 | +| `sirsoft-ecommerce.order.after_read` | action | — | `src/Services/OrderService.php:136` 외 1곳 | +| `sirsoft-ecommerce.order.after_reset_guest_password` | action | — | `src/Services/OrderService.php:859` | +| `sirsoft-ecommerce.order.after_send_email` | action | — | `src/Services/OrderService.php:607` | +| `sirsoft-ecommerce.order.after_status_change` | action | — | `src/Services/OrderOptionService.php:770` 외 3곳 | +| `sirsoft-ecommerce.order.after_update` | action | — | `src/Services/OrderService.php:336` | +| `sirsoft-ecommerce.order.after_update_shipping_address` | action | — | `src/Services/OrderService.php:748` | +| `sirsoft-ecommerce.order.before_bulk_shipping_update` | action | — | `src/Services/OrderService.php:569` | +| `sirsoft-ecommerce.order.before_bulk_status_update` | action | — | `src/Services/OrderService.php:525` | +| `sirsoft-ecommerce.order.before_bulk_update` | action | — | `src/Services/OrderService.php:412` | +| `sirsoft-ecommerce.order.before_cancel` | action | — | `src/Services/OrderCancellationService.php:295` | +| `sirsoft-ecommerce.order.before_create` | action | — | `src/Services/OrderProcessingService.php:112` | +| `sirsoft-ecommerce.order.before_delete` | action | — | `src/Services/OrderService.php:362` | +| `sirsoft-ecommerce.order.before_payment_complete` | action | — | `src/Services/OrderProcessingService.php:306` 외 1곳 | +| `sirsoft-ecommerce.order.before_reset_guest_password` | action | — | `src/Services/OrderService.php:853` | +| `sirsoft-ecommerce.order.before_update` | action | — | `src/Services/OrderService.php:173` | +| `sirsoft-ecommerce.order.before_update_shipping_address` | action | — | `src/Services/OrderService.php:687` | +| `sirsoft-ecommerce.order.create_validation_rules` | filter | — | `src/Http/Requests/Public/CreateOrderRequest.php:133` | +| `sirsoft-ecommerce.order.filter_create_data` | filter | — | `src/Services/OrderProcessingService.php:619` | +| `sirsoft-ecommerce.order.filter_export_params` | filter | — | `src/Services/OrderService.php:629` | +| `sirsoft-ecommerce.order.filter_list_params` | filter | — | `src/Services/OrderService.php:97` | +| `sirsoft-ecommerce.order.filter_update_data` | filter | — | `src/Services/OrderService.php:179` | +| `sirsoft-ecommerce.order.list_validation_messages` | filter | — | `src/Http/Requests/Admin/OrderListRequest.php:182` | +| `sirsoft-ecommerce.order.list_validation_rules` | filter | — | `src/Http/Requests/Admin/OrderListRequest.php:108` | +| `sirsoft-ecommerce.order.payment_failed` | action | — | `src/Services/OrderProcessingService.php:2003` | +| `sirsoft-ecommerce.order.shipping_address_validation_rules` | filter | — | `src/Http/Requests/User/UpdateOrderShippingAddressRequest.php:71` | +| `sirsoft-ecommerce.order_option.after_bulk_status_change` | action | — | `src/Services/OrderOptionService.php:389` | +| `sirsoft-ecommerce.order_option.after_status_change` | action | — | `src/Services/OrderOptionService.php:251` | +| `sirsoft-ecommerce.order_option.before_bulk_status_change` | action | — | `src/Services/OrderOptionService.php:339` | +| `sirsoft-ecommerce.order_option.before_status_change` | action | — | `src/Services/OrderOptionService.php:112` | +| `sirsoft-ecommerce.payment.before_approve` | action | — | `src/Services/OrderService.php:418` 외 1곳 | +| `sirsoft-ecommerce.payment.before_cancel` | action | — | `src/Services/OrderCancellationService.php:292` | +| `sirsoft-ecommerce.payment.before_confirm_deposit` | action | — | `src/Services/OrderProcessingService.php:1815` | +| `sirsoft-ecommerce.payment.get_client_config` | filter | — | `src/Http/Controllers/Shop/PaymentConfigController.php:26` | +| `sirsoft-ecommerce.payment.refund` | filter | — | `src/Services/OrderCancellationService.php:1027` | +| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | — | `src/Services/EcommerceSettingsService.php:862` | +| `sirsoft-ecommerce.preset.after_create` | action | — | `src/Services/SearchPresetService.php:60` | +| `sirsoft-ecommerce.preset.after_delete` | action | — | `src/Services/SearchPresetService.php:118` | +| `sirsoft-ecommerce.preset.after_update` | action | — | `src/Services/SearchPresetService.php:93` | +| `sirsoft-ecommerce.preset.before_create` | action | — | `src/Services/SearchPresetService.php:52` | +| `sirsoft-ecommerce.preset.before_delete` | action | — | `src/Services/SearchPresetService.php:113` | +| `sirsoft-ecommerce.preset.before_update` | action | — | `src/Services/SearchPresetService.php:85` | +| `sirsoft-ecommerce.preset.filter_create_data` | filter | — | `src/Services/SearchPresetService.php:55` | +| `sirsoft-ecommerce.preset.filter_update_data` | filter | — | `src/Services/SearchPresetService.php:88` | +| `sirsoft-ecommerce.preset.store_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreSearchPresetRequest.php:51` | +| `sirsoft-ecommerce.preset.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateSearchPresetRequest.php:54` | +| `sirsoft-ecommerce.product-common-info.after_create` | action | — | `src/Services/ProductCommonInfoService.php:125` | +| `sirsoft-ecommerce.product-common-info.after_delete` | action | — | `src/Services/ProductCommonInfoService.php:204` | +| `sirsoft-ecommerce.product-common-info.after_list` | action | — | `src/Services/ProductCommonInfoService.php:42` | +| `sirsoft-ecommerce.product-common-info.after_list_paginated` | action | — | `src/Services/ProductCommonInfoService.php:65` | +| `sirsoft-ecommerce.product-common-info.after_show` | action | — | `src/Services/ProductCommonInfoService.php:88` | +| `sirsoft-ecommerce.product-common-info.after_update` | action | — | `src/Services/ProductCommonInfoService.php:168` | +| `sirsoft-ecommerce.product-common-info.before_create` | action | — | `src/Services/ProductCommonInfoService.php:103` | +| `sirsoft-ecommerce.product-common-info.before_delete` | action | — | `src/Services/ProductCommonInfoService.php:193` | +| `sirsoft-ecommerce.product-common-info.before_list` | action | — | `src/Services/ProductCommonInfoService.php:31` 외 1곳 | +| `sirsoft-ecommerce.product-common-info.before_show` | action | — | `src/Services/ProductCommonInfoService.php:79` | +| `sirsoft-ecommerce.product-common-info.before_update` | action | — | `src/Services/ProductCommonInfoService.php:148` | +| `sirsoft-ecommerce.product-common-info.create_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreProductCommonInfoRequest.php:42` | +| `sirsoft-ecommerce.product-common-info.filter_create_data` | filter | — | `src/Services/ProductCommonInfoService.php:106` | +| `sirsoft-ecommerce.product-common-info.filter_list_query` | filter | — | `src/Services/ProductCommonInfoService.php:34` 외 1곳 | +| `sirsoft-ecommerce.product-common-info.filter_list_result` | filter | — | `src/Services/ProductCommonInfoService.php:39` | +| `sirsoft-ecommerce.product-common-info.filter_show_result` | filter | — | `src/Services/ProductCommonInfoService.php:85` | +| `sirsoft-ecommerce.product-common-info.filter_update_data` | filter | — | `src/Services/ProductCommonInfoService.php:154` | +| `sirsoft-ecommerce.product-common-info.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateProductCommonInfoRequest.php:42` | +| `sirsoft-ecommerce.product-image.after_delete` | action | — | `src/Services/ProductImageService.php:293` | +| `sirsoft-ecommerce.product-image.after_reorder` | action | — | `src/Services/ProductImageService.php:312` | +| `sirsoft-ecommerce.product-image.after_upload` | action | — | `src/Services/ProductImageService.php:144` | +| `sirsoft-ecommerce.product-image.before_delete` | action | — | `src/Services/ProductImageService.php:271` | +| `sirsoft-ecommerce.product-image.before_reorder` | action | — | `src/Services/ProductImageService.php:307` | +| `sirsoft-ecommerce.product-image.before_upload` | action | — | `src/Services/ProductImageService.php:76` | +| `sirsoft-ecommerce.product-image.filter_reorder_validation_rules` | filter | — | `src/Http/Requests/Admin/ReorderProductImagesRequest.php:36` | +| `sirsoft-ecommerce.product-image.filter_upload_file` | filter | — | `src/Services/ProductImageService.php:79` | +| `sirsoft-ecommerce.product-image.filter_upload_validation_rules` | filter | — | `src/Http/Requests/Admin/UploadProductImageRequest.php:68` | +| `sirsoft-ecommerce.product-notice-template.after_copy` | action | — | `src/Services/ProductNoticeTemplateService.php:247` | +| `sirsoft-ecommerce.product-notice-template.after_create` | action | — | `src/Services/ProductNoticeTemplateService.php:120` | +| `sirsoft-ecommerce.product-notice-template.after_delete` | action | — | `src/Services/ProductNoticeTemplateService.php:216` | +| `sirsoft-ecommerce.product-notice-template.after_list` | action | — | `src/Services/ProductNoticeTemplateService.php:42` | +| `sirsoft-ecommerce.product-notice-template.after_list_paginated` | action | — | `src/Services/ProductNoticeTemplateService.php:65` | +| `sirsoft-ecommerce.product-notice-template.after_show` | action | — | `src/Services/ProductNoticeTemplateService.php:88` | +| `sirsoft-ecommerce.product-notice-template.after_toggle_active` | action | — | `src/Services/ProductNoticeTemplateService.php:185` | +| `sirsoft-ecommerce.product-notice-template.after_update` | action | — | `src/Services/ProductNoticeTemplateService.php:158` | +| `sirsoft-ecommerce.product-notice-template.before_copy` | action | — | `src/Services/ProductNoticeTemplateService.php:240` | +| `sirsoft-ecommerce.product-notice-template.before_create` | action | — | `src/Services/ProductNoticeTemplateService.php:103` | +| `sirsoft-ecommerce.product-notice-template.before_delete` | action | — | `src/Services/ProductNoticeTemplateService.php:209` | +| `sirsoft-ecommerce.product-notice-template.before_list` | action | — | `src/Services/ProductNoticeTemplateService.php:31` 외 1곳 | +| `sirsoft-ecommerce.product-notice-template.before_show` | action | — | `src/Services/ProductNoticeTemplateService.php:79` | +| `sirsoft-ecommerce.product-notice-template.before_update` | action | — | `src/Services/ProductNoticeTemplateService.php:143` | +| `sirsoft-ecommerce.product-notice-template.create_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreProductNoticeTemplateRequest.php:43` | +| `sirsoft-ecommerce.product-notice-template.filter_create_data` | filter | — | `src/Services/ProductNoticeTemplateService.php:106` | +| `sirsoft-ecommerce.product-notice-template.filter_list_query` | filter | — | `src/Services/ProductNoticeTemplateService.php:34` 외 1곳 | +| `sirsoft-ecommerce.product-notice-template.filter_list_result` | filter | — | `src/Services/ProductNoticeTemplateService.php:39` | +| `sirsoft-ecommerce.product-notice-template.filter_show_result` | filter | — | `src/Services/ProductNoticeTemplateService.php:85` | +| `sirsoft-ecommerce.product-notice-template.filter_update_data` | filter | — | `src/Services/ProductNoticeTemplateService.php:149` | +| `sirsoft-ecommerce.product-notice-template.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateProductNoticeTemplateRequest.php:43` | +| `sirsoft-ecommerce.product-review.after_bulk_delete` | action | — | `src/Services/ProductReviewService.php:310` | +| `sirsoft-ecommerce.product-review.after_create` | action | — | `src/Services/ProductReviewService.php:165` | +| `sirsoft-ecommerce.product-review.after_delete` | action | — | `src/Services/ProductReviewService.php:253` | +| `sirsoft-ecommerce.product-review.before_bulk_delete` | action | — | `src/Services/ProductReviewService.php:288` | +| `sirsoft-ecommerce.product-review.before_create` | action | — | `src/Services/ProductReviewService.php:146` | +| `sirsoft-ecommerce.product-review.before_delete` | action | — | `src/Services/ProductReviewService.php:234` | +| `sirsoft-ecommerce.product.after_bulk_price_update` | action | — | `src/Services/ProductService.php:573` | +| `sirsoft-ecommerce.product.after_bulk_stock_update` | action | — | `src/Services/ProductService.php:606` | +| `sirsoft-ecommerce.product.after_bulk_update` | action | — | `src/Services/ProductService.php:538` 외 1곳 | +| `sirsoft-ecommerce.product.after_create` | action | — | `src/Services/ProductService.php:314` | +| `sirsoft-ecommerce.product.after_delete` | action | — | `src/Services/ProductService.php:494` | +| `sirsoft-ecommerce.product.after_new_list` | action | — | `src/Services/ProductService.php:189` | +| `sirsoft-ecommerce.product.after_options_sync` | action | — | `src/Services/ProductService.php:865` | +| `sirsoft-ecommerce.product.after_popular_list` | action | — | `src/Services/ProductService.php:168` | +| `sirsoft-ecommerce.product.after_public_list` | action | — | `src/Services/ProductService.php:147` | +| `sirsoft-ecommerce.product.after_read` | action | — | `src/Services/ProductService.php:231` | +| `sirsoft-ecommerce.product.after_stock_sync` | action | — | `src/Listeners/SyncProductFromOptionListener.php:107` 외 1곳 | +| `sirsoft-ecommerce.product.after_update` | action | — | `src/Services/ProductService.php:391` | +| `sirsoft-ecommerce.product.before_bulk_price_update` | action | — | `src/Services/ProductService.php:564` | +| `sirsoft-ecommerce.product.before_bulk_stock_update` | action | — | `src/Services/ProductService.php:598` | +| `sirsoft-ecommerce.product.before_bulk_update` | action | — | `src/Services/ProductService.php:530` 외 1곳 | +| `sirsoft-ecommerce.product.before_create` | action | — | `src/Services/ProductService.php:248` | +| `sirsoft-ecommerce.product.before_delete` | action | — | `src/Services/ProductService.php:446` | +| `sirsoft-ecommerce.product.before_new_list` | action | — | `src/Services/ProductService.php:183` | +| `sirsoft-ecommerce.product.before_popular_list` | action | — | `src/Services/ProductService.php:162` | +| `sirsoft-ecommerce.product.before_public_list` | action | — | `src/Services/ProductService.php:141` | +| `sirsoft-ecommerce.product.before_update` | action | — | `src/Services/ProductService.php:333` | +| `sirsoft-ecommerce.product.bulk_price_validation_rules` | filter | — | `src/Http/Requests/Admin/BulkUpdatePriceRequest.php:42` | +| `sirsoft-ecommerce.product.bulk_status_validation_rules` | filter | — | `src/Http/Requests/Admin/BulkUpdateStatusRequest.php:52` | +| `sirsoft-ecommerce.product.bulk_stock_validation_rules` | filter | — | `src/Http/Requests/Admin/BulkUpdateStockRequest.php:40` | +| `sirsoft-ecommerce.product.bulk_update_validation_rules` | filter | — | `src/Http/Requests/Admin/BulkUpdateProductsRequest.php:105` | +| `sirsoft-ecommerce.product.filter_bulk_update_data` | filter | — | `src/Services/ProductService.php:635` | +| `sirsoft-ecommerce.product.filter_content_thumbnail` | filter | — | `src/Models/Product.php:185` | +| `sirsoft-ecommerce.product.filter_create_data` | filter | — | `src/Services/ProductService.php:251` | +| `sirsoft-ecommerce.product.filter_list_params` | filter | — | `src/Services/ProductService.php:108` | +| `sirsoft-ecommerce.product.filter_new_list_result` | filter | — | `src/Services/ProductService.php:187` | +| `sirsoft-ecommerce.product.filter_popular_list_result` | filter | — | `src/Services/ProductService.php:166` | +| `sirsoft-ecommerce.product.filter_public_list_params` | filter | — | `src/Services/ProductService.php:143` | +| `sirsoft-ecommerce.product.filter_update_data` | filter | — | `src/Services/ProductService.php:339` | +| `sirsoft-ecommerce.product.list_validation_messages` | filter | — | `src/Http/Requests/Admin/ProductListRequest.php:166` | +| `sirsoft-ecommerce.product.list_validation_rules` | filter | — | `src/Http/Requests/Admin/ProductListRequest.php:122` | +| `sirsoft-ecommerce.product.logs_validation_rules` | filter | — | `src/Http/Requests/Admin/ProductLogsRequest.php:39` | +| `sirsoft-ecommerce.product.public_list_validation_messages` | filter | — | `src/Http/Requests/Public/PublicProductListRequest.php:71` | +| `sirsoft-ecommerce.product.public_list_validation_rules` | filter | — | `src/Http/Requests/Public/PublicProductListRequest.php:44` | +| `sirsoft-ecommerce.product.public_new_validation_rules` | filter | — | `src/Http/Requests/Public/PublicProductNewRequest.php:37` | +| `sirsoft-ecommerce.product.public_popular_validation_rules` | filter | — | `src/Http/Requests/Public/PublicProductPopularRequest.php:37` | +| `sirsoft-ecommerce.product.public_recent_validation_rules` | filter | — | `src/Http/Requests/Public/PublicProductRecentRequest.php:37` | +| `sirsoft-ecommerce.product.show_for_copy_validation_rules` | filter | — | `src/Http/Requests/Admin/ProductShowForCopyRequest.php:58` | +| `sirsoft-ecommerce.product.store_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreProductRequest.php:180` | +| `sirsoft-ecommerce.product.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateProductRequest.php:61` | +| `sirsoft-ecommerce.product_inquiry.after_create` | action | — | `src/Services/ProductInquiryService.php:313` | +| `sirsoft-ecommerce.product_inquiry.after_reply` | action | — | `src/Services/ProductInquiryService.php:766` | +| `sirsoft-ecommerce.product_option.after_bulk_price_update` | action | — | `src/Services/ProductOptionService.php:101` | +| `sirsoft-ecommerce.product_option.after_bulk_stock_update` | action | — | `src/Services/ProductOptionService.php:147` 외 1곳 | +| `sirsoft-ecommerce.product_option.before_bulk_price_update` | action | — | `src/Services/ProductOptionService.php:91` | +| `sirsoft-ecommerce.product_option.before_bulk_stock_update` | action | — | `src/Services/ProductOptionService.php:138` | +| `sirsoft-ecommerce.product_option.bulk_price_validation_rules` | filter | — | `src/Http/Requests/Admin/BulkUpdateOptionPriceRequest.php:49` | +| `sirsoft-ecommerce.product_option.bulk_stock_validation_rules` | filter | — | `src/Http/Requests/Admin/BulkUpdateOptionStockRequest.php:46` | +| `sirsoft-ecommerce.review-image.after_delete` | action | — | `src/Services/ProductReviewImageService.php:139` | +| `sirsoft-ecommerce.review-image.after_upload` | action | — | `src/Services/ProductReviewImageService.php:114` | +| `sirsoft-ecommerce.review-image.before_delete` | action | — | `src/Services/ProductReviewImageService.php:127` | +| `sirsoft-ecommerce.review-image.before_upload` | action | — | `src/Services/ProductReviewImageService.php:64` | +| `sirsoft-ecommerce.review-image.filter_upload_file` | filter | — | `src/Services/ProductReviewImageService.php:67` | +| `sirsoft-ecommerce.review.bulk_validation_messages` | filter | — | `src/Http/Requests/Admin/BulkReviewRequest.php:62` | +| `sirsoft-ecommerce.review.bulk_validation_rules` | filter | — | `src/Http/Requests/Admin/BulkReviewRequest.php:40` | +| `sirsoft-ecommerce.review.list_validation_messages` | filter | — | `src/Http/Requests/Admin/AdminReviewListRequest.php:101` | +| `sirsoft-ecommerce.review.list_validation_rules` | filter | — | `src/Http/Requests/Admin/AdminReviewListRequest.php:63` | +| `sirsoft-ecommerce.review.public_list_validation_messages` | filter | — | `src/Http/Requests/Public/PublicReviewListRequest.php:63` | +| `sirsoft-ecommerce.review.public_list_validation_rules` | filter | — | `src/Http/Requests/Public/PublicReviewListRequest.php:42` | +| `sirsoft-ecommerce.review.store_reply_validation_messages` | filter | — | `src/Http/Requests/Admin/StoreReviewReplyRequest.php:52` | +| `sirsoft-ecommerce.review.store_reply_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreReviewReplyRequest.php:35` | +| `sirsoft-ecommerce.review.store_validation_messages` | filter | — | `src/Http/Requests/User/StoreReviewRequest.php:66` | +| `sirsoft-ecommerce.review.store_validation_rules` | filter | — | `src/Http/Requests/User/StoreReviewRequest.php:41` | +| `sirsoft-ecommerce.review.update_status_validation_messages` | filter | — | `src/Http/Requests/Admin/UpdateReviewStatusRequest.php:51` | +| `sirsoft-ecommerce.review.update_status_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateReviewStatusRequest.php:36` | +| `sirsoft-ecommerce.search.brand.index_should_update` | filter | — | `src/Models/Brand.php:148` | +| `sirsoft-ecommerce.search.category.index_should_update` | filter | — | `src/Models/Category.php:588` | +| `sirsoft-ecommerce.search.coupon.index_should_update` | filter | — | `src/Models/Coupon.php:514` | +| `sirsoft-ecommerce.search.product.index_should_update` | filter | — | `src/Models/Product.php:683` | +| `sirsoft-ecommerce.search.product_common_info.index_should_update` | filter | — | `src/Models/ProductCommonInfo.php:176` | +| `sirsoft-ecommerce.search_preset.list_validation_rules` | filter | — | `src/Http/Requests/Admin/SearchPresetListRequest.php:32` | +| `sirsoft-ecommerce.sequence.after_generate` | action | — | `src/Services/SequenceService.php:57` 외 1곳 | +| `sirsoft-ecommerce.sequence.before_generate` | action | — | `src/Services/SequenceService.php:44` | +| `sirsoft-ecommerce.settings.after_save` | action | — | `src/Http/Controllers/Admin/EcommerceSettingsController.php:172` | +| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | — | `src/Services/EcommerceSettingsService.php:847` | +| `sirsoft-ecommerce.shipping.calculate_fee` | filter | — | `src/Services/OrderCalculationService.php:2127` | +| `sirsoft-ecommerce.shipping_carrier.after_create` | action | — | `src/Services/ShippingCarrierService.php:101` | +| `sirsoft-ecommerce.shipping_carrier.after_delete` | action | — | `src/Services/ShippingCarrierService.php:256` | +| `sirsoft-ecommerce.shipping_carrier.after_list` | action | — | `src/Services/ShippingCarrierService.php:44` | +| `sirsoft-ecommerce.shipping_carrier.after_show` | action | — | `src/Services/ShippingCarrierService.php:63` | +| `sirsoft-ecommerce.shipping_carrier.after_toggle_status` | action | — | `src/Services/ShippingCarrierService.php:167` | +| `sirsoft-ecommerce.shipping_carrier.after_update` | action | — | `src/Services/ShippingCarrierService.php:138` | +| `sirsoft-ecommerce.shipping_carrier.before_create` | action | — | `src/Services/ShippingCarrierService.php:88` | +| `sirsoft-ecommerce.shipping_carrier.before_delete` | action | — | `src/Services/ShippingCarrierService.php:250` | +| `sirsoft-ecommerce.shipping_carrier.before_list` | action | — | `src/Services/ShippingCarrierService.php:36` | +| `sirsoft-ecommerce.shipping_carrier.before_show` | action | — | `src/Services/ShippingCarrierService.php:57` | +| `sirsoft-ecommerce.shipping_carrier.before_toggle_status` | action | — | `src/Services/ShippingCarrierService.php:159` | +| `sirsoft-ecommerce.shipping_carrier.before_update` | action | — | `src/Services/ShippingCarrierService.php:123` | +| `sirsoft-ecommerce.shipping_carrier.create_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreShippingCarrierRequest.php:42` | +| `sirsoft-ecommerce.shipping_carrier.filter_create_data` | filter | — | `src/Services/ShippingCarrierService.php:90` | +| `sirsoft-ecommerce.shipping_carrier.filter_list_query` | filter | — | `src/Services/ShippingCarrierService.php:38` | +| `sirsoft-ecommerce.shipping_carrier.filter_list_result` | filter | — | `src/Services/ShippingCarrierService.php:42` | +| `sirsoft-ecommerce.shipping_carrier.filter_show_result` | filter | — | `src/Services/ShippingCarrierService.php:62` | +| `sirsoft-ecommerce.shipping_carrier.filter_update_data` | filter | — | `src/Services/ShippingCarrierService.php:128` | +| `sirsoft-ecommerce.shipping_carrier.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateShippingCarrierRequest.php:44` | +| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | action | — | `src/Services/ShippingPolicyService.php:314` | +| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | action | — | `src/Services/ShippingPolicyService.php:342` | +| `sirsoft-ecommerce.shipping_policy.after_create` | action | — | `src/Services/ShippingPolicyService.php:110` | +| `sirsoft-ecommerce.shipping_policy.after_delete` | action | — | `src/Services/ShippingPolicyService.php:262` | +| `sirsoft-ecommerce.shipping_policy.after_read` | action | — | `src/Services/ShippingPolicyService.php:62` | +| `sirsoft-ecommerce.shipping_policy.after_set_default` | action | — | `src/Services/ShippingPolicyService.php:382` | +| `sirsoft-ecommerce.shipping_policy.after_toggle_active` | action | — | `src/Services/ShippingPolicyService.php:283` | +| `sirsoft-ecommerce.shipping_policy.after_update` | action | — | `src/Services/ShippingPolicyService.php:167` | +| `sirsoft-ecommerce.shipping_policy.before_bulk_delete` | action | — | `src/Services/ShippingPolicyService.php:306` | +| `sirsoft-ecommerce.shipping_policy.before_bulk_toggle_active` | action | — | `src/Services/ShippingPolicyService.php:337` | +| `sirsoft-ecommerce.shipping_policy.before_create` | action | — | `src/Services/ShippingPolicyService.php:77` | +| `sirsoft-ecommerce.shipping_policy.before_delete` | action | — | `src/Services/ShippingPolicyService.php:251` | +| `sirsoft-ecommerce.shipping_policy.before_set_default` | action | — | `src/Services/ShippingPolicyService.php:368` | +| `sirsoft-ecommerce.shipping_policy.before_toggle_active` | action | — | `src/Services/ShippingPolicyService.php:278` | +| `sirsoft-ecommerce.shipping_policy.before_update` | action | — | `src/Services/ShippingPolicyService.php:127` | +| `sirsoft-ecommerce.shipping_policy.bulk_delete_validation_rules` | filter | — | `src/Http/Requests/Admin/ShippingPolicyBulkDeleteRequest.php:38` | +| `sirsoft-ecommerce.shipping_policy.bulk_toggle_active_validation_rules` | filter | — | `src/Http/Requests/Admin/ShippingPolicyBulkToggleActiveRequest.php:39` | +| `sirsoft-ecommerce.shipping_policy.filter_create_data` | filter | — | `src/Services/ShippingPolicyService.php:80` | +| `sirsoft-ecommerce.shipping_policy.filter_list_params` | filter | — | `src/Services/ShippingPolicyService.php:34` | +| `sirsoft-ecommerce.shipping_policy.filter_update_data` | filter | — | `src/Services/ShippingPolicyService.php:133` | +| `sirsoft-ecommerce.shipping_policy.list_validation_messages` | filter | — | `src/Http/Requests/Admin/ShippingPolicyListRequest.php:129` | +| `sirsoft-ecommerce.shipping_policy.list_validation_rules` | filter | — | `src/Http/Requests/Admin/ShippingPolicyListRequest.php:96` | +| `sirsoft-ecommerce.shipping_policy.store_validation_rules` | filter | — | `src/Http/Requests/Admin/StoreShippingPolicyRequest.php:109` | +| `sirsoft-ecommerce.shipping_policy.update_validation_rules` | filter | — | `src/Http/Requests/Admin/UpdateShippingPolicyRequest.php:24` | +| `sirsoft-ecommerce.shipping_type.after_create` | action | — | `src/Services/ShippingTypeService.php:123` | +| `sirsoft-ecommerce.shipping_type.after_delete` | action | — | `src/Services/ShippingTypeService.php:261` | +| `sirsoft-ecommerce.shipping_type.after_list` | action | — | `src/Services/ShippingTypeService.php:44` | +| `sirsoft-ecommerce.shipping_type.after_show` | action | — | `src/Services/ShippingTypeService.php:63` | +| `sirsoft-ecommerce.shipping_type.after_update` | action | — | `src/Services/ShippingTypeService.php:162` | +| `sirsoft-ecommerce.shipping_type.before_create` | action | — | `src/Services/ShippingTypeService.php:107` | +| `sirsoft-ecommerce.shipping_type.before_delete` | action | — | `src/Services/ShippingTypeService.php:252` | +| `sirsoft-ecommerce.shipping_type.before_list` | action | — | `src/Services/ShippingTypeService.php:36` | +| `sirsoft-ecommerce.shipping_type.before_show` | action | — | `src/Services/ShippingTypeService.php:57` | +| `sirsoft-ecommerce.shipping_type.before_update` | action | — | `src/Services/ShippingTypeService.php:145` | +| `sirsoft-ecommerce.shipping_type.filter_create_data` | filter | — | `src/Services/ShippingTypeService.php:109` | +| `sirsoft-ecommerce.shipping_type.filter_list_query` | filter | — | `src/Services/ShippingTypeService.php:38` | +| `sirsoft-ecommerce.shipping_type.filter_list_result` | filter | — | `src/Services/ShippingTypeService.php:42` | +| `sirsoft-ecommerce.shipping_type.filter_show_result` | filter | — | `src/Services/ShippingTypeService.php:62` | +| `sirsoft-ecommerce.shipping_type.filter_update_data` | filter | — | `src/Services/ShippingTypeService.php:149` | +| `sirsoft-ecommerce.stock.after_deduct` | action | — | `src/Services/StockService.php:115` | +| `sirsoft-ecommerce.stock.after_restore` | action | — | `src/Services/StockService.php:157` | +| `sirsoft-ecommerce.stock.before_deduct` | action | — | `src/Services/StockService.php:70` | +| `sirsoft-ecommerce.stock.before_restore` | action | — | `src/Services/StockService.php:126` | +| `sirsoft-ecommerce.temp_order.after_cleanup` | action | — | `src/Services/TempOrderService.php:569` | +| `sirsoft-ecommerce.temp_order.after_create` | action | — | `src/Services/TempOrderService.php:126` | +| `sirsoft-ecommerce.temp_order.after_delete` | action | — | `src/Services/TempOrderService.php:527` 외 1곳 | +| `sirsoft-ecommerce.temp_order.after_update` | action | — | `src/Services/TempOrderService.php:414` | +| `sirsoft-ecommerce.temp_order.before_cleanup` | action | — | `src/Services/TempOrderService.php:565` | +| `sirsoft-ecommerce.temp_order.before_create` | action | — | `src/Services/TempOrderService.php:77` | +| `sirsoft-ecommerce.temp_order.before_delete` | action | — | `src/Services/TempOrderService.php:523` 외 1곳 | +| `sirsoft-ecommerce.temp_order.before_update` | action | — | `src/Services/TempOrderService.php:341` | +| `sirsoft-ecommerce.user_address.after_create` | action | — | `src/Services/UserAddressService.php:149` | +| `sirsoft-ecommerce.user_address.after_delete` | action | — | `src/Services/UserAddressService.php:214` | +| `sirsoft-ecommerce.user_address.after_set_default` | action | — | `src/Services/UserAddressService.php:240` | +| `sirsoft-ecommerce.user_address.after_update` | action | — | `src/Services/UserAddressService.php:180` | +| `sirsoft-ecommerce.user_address.before_create` | action | — | `src/Services/UserAddressService.php:117` | +| `sirsoft-ecommerce.user_address.before_delete` | action | — | `src/Services/UserAddressService.php:200` | +| `sirsoft-ecommerce.user_address.before_set_default` | action | — | `src/Services/UserAddressService.php:234` | +| `sirsoft-ecommerce.user_address.before_update` | action | — | `src/Services/UserAddressService.php:170` | +| `sirsoft-ecommerce.user_address.store_validation_rules` | filter | — | `src/Http/Requests/User/StoreUserAddressRequest.php:77` | +| `sirsoft-ecommerce.user_address.update_validation_rules` | filter | — | `src/Http/Requests/User/UpdateUserAddressRequest.php:93` | +| `sirsoft-ecommerce.user_coupon.after_available` | action | — | `src/Services/UserCouponService.php:69` | +| `sirsoft-ecommerce.user_coupon.after_download` | action | — | `src/Services/UserCouponService.php:338` | +| `sirsoft-ecommerce.user_coupon.after_downloadable_list` | action | — | `src/Services/UserCouponService.php:302` | +| `sirsoft-ecommerce.user_coupon.after_list` | action | — | `src/Services/UserCouponService.php:49` | +| `sirsoft-ecommerce.user_coupon.before_available` | action | — | `src/Services/UserCouponService.php:63` | +| `sirsoft-ecommerce.user_coupon.before_download` | action | — | `src/Services/UserCouponService.php:318` | +| `sirsoft-ecommerce.user_coupon.before_downloadable_list` | action | — | `src/Services/UserCouponService.php:279` | +| `sirsoft-ecommerce.user_coupon.before_list` | action | — | `src/Services/UserCouponService.php:43` | +| `sirsoft-ecommerce.user_coupon.filter_available_result` | filter | — | `src/Services/UserCouponService.php:67` | +| `sirsoft-ecommerce.user_coupon.filter_downloadable_result` | filter | — | `src/Services/UserCouponService.php:300` | +| `sirsoft-ecommerce.user_coupon.filter_list_result` | filter | — | `src/Services/UserCouponService.php:47` | +| `sirsoft-ecommerce.user_coupon.filter_product_downloadable_result` | filter | — | `src/Services/UserCouponService.php:526` | +| `sirsoft-ecommerce.user_mileage.after_balance` | action | — | `src/Services/UserMileageService.php:147` | +| `sirsoft-ecommerce.user_mileage.before_balance` | action | — | `src/Services/UserMileageService.php:130` | +| `sirsoft-ecommerce.user_mileage.filter_balance` | filter | — | `src/Services/UserMileageService.php:145` | +| `sirsoft-ecommerce.wishlist.after_toggle` | action | — | `src/Services/ProductWishlistService.php:30` | +| `sirsoft-ecommerce.wishlist.before_toggle` | action | — | `src/Services/ProductWishlistService.php:26` | +| `sirsoft-ecommerce.wishlist.toggle_validation_rules` | filter | — | `src/Http/Requests/Public/ToggleWishlistRequest.php:34` | + + + +508종이라는 수에 압도될 필요는 없습니다. 도메인 하나가 같은 4종을 반복하기 때문입니다 — +`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 · `brand` 19 · +`shipping_carrier` 19 · `extra_fee_template` 19 · `label` 17 · `claim_reason` 17 · +`shipping_type` 15 에 그대로 적용됩니다. + +그 패턴에서 벗어나는 것이 이 모듈 고유의 확장점입니다: + +| 훅 무리 | 수 | 무엇을 열어 주는가 | +|---|---|---| +| `calculation.*` | 18 | 9단계 금액 계산의 단계 사이. 옵션 소계·배송비·쿠폰 적용·최종 결과에 개입할 수 있습니다 | +| `adjustment.filter_restore_promotions` | 1 | 취소 시 이미 적용된 쿠폰·마일리지를 되돌리는 안분 규칙 | +| `payment.*` | 6 | 결제 승인·취소·입금 확인 전후. 본인인증 정책 3종이 이 훅을 target 으로 삼습니다 | +| `checkout.*` | 4 | 주문서 조립과 결제 직전. `checkout.before_payment` 이 구매자측 본인인증 게이트 지점입니다 | +| `stock.*` | 4 | 재고 차감·복원 | +| `mileage.*` · `user_mileage.*` | 8 | 마일리지 적립·차감·소멸 | +| `search.*` · `preset.*` · `search_preset.*` | 12 | 상품 검색 술어와 관리자 검색 프리셋 | + +`core.module_settings.after_save` 는 이 모듈이 발행하지만 **코어 이름공간**입니다 — 환경설정 +저장 후 캐시를 비우는 리스너들이 이 훅으로 붙습니다. + +훅 이름이 상수·변수로 조립되는 호출이 5곳 있어 표에 위치가 다 실리지 않습니다. 그 자리를 정확히 +알아야 하면 소스에서 `HookManager::` 호출부를 직접 확인합니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.activity_log.filter_description_params` | filter | `ActivityLogDescriptionResolver` | `resolveDescriptionParams` | 10 | +| `core.auth.after_login` | action (미선언) | `MergeCartOnLoginListener` | `handle` | 20 | +| `core.auth.after_register` | action (미선언) | `AssignDefaultCurrencyOnRegisterListener` | `handleRegister` | 20 | +| `core.auth.after_register` | action (미선언) | `AssignDefaultShippingCountryOnRegisterListener` | `handleRegister` | 20 | +| `core.auth.register_validation_rules` | filter | `AssignDefaultCurrencyOnRegisterListener` | `addCurrencyRule` | 20 | +| `core.auth.register_validation_rules` | filter | `AssignDefaultShippingCountryOnRegisterListener` | `addShippingCountryRule` | 20 | +| `core.frontend.filter_app_config` | filter | `InjectAppConfigDeviceListener` | `injectDeviceFlags` | 20 | +| `core.module_settings.after_save` | action (미선언) | `SeoSettingsCacheListener` | `onModuleSettingsSave` | 20 | +| `core.notification.filter_default_definitions` | filter | `EcommerceNotificationDataListener` | `contributeDefaultDefinitions` | 20 | +| `core.search.build_response` | filter | `SearchProductsListener` | `buildProductsResponse` | 20 | +| `core.search.index_validation_rules` | filter | `SearchProductsListener` | `addValidationRules` | 20 | +| `core.search.results` | filter | `SearchProductsListener` | `searchProducts` | 20 | +| `core.user.after_create` | action (미선언) | `AssignDefaultCurrencyOnRegisterListener` | `handleAdminCreate` | 20 | +| `core.user.after_create` | action (미선언) | `AssignDefaultShippingCountryOnRegisterListener` | `handleAdminCreate` | 20 | +| `core.user.before_delete` | action (미선언) | `UserMileageCleanupListener` | `handleUserRemoval` | 10 | +| `core.user.before_withdraw` | action (미선언) | `UserMileageCleanupListener` | `handleUserRemoval` | 10 | +| `core.user.filter_resource_data` | filter | `UserCurrencyInfoListener` | `injectPreferredCurrency` | 25 | +| `core.user.filter_resource_data` | filter | `UserMileageInfoListener` | `injectMileageTotal` | 20 | +| `core.user.filter_resource_data` | filter | `UserShippingCountryInfoListener` | `injectPreferredShippingCountry` | 26 | +| `sirsoft-board.board.posts.before_force_delete` | action (미선언) | `ProductInquiryBoardListener` | `handleBoardPostsForceDeleting` | 20 | +| `sirsoft-board.post.after_delete` | action (미선언) | `ProductInquiryBoardListener` | `handlePostDeleted` | 20 | +| `sirsoft-board.post.after_restore` | action (미선언) | `ProductInquiryBoardListener` | `handlePostRestored` | 20 | +| `sirsoft-ckeditor5.image.filter_reference_sources` | filter | `Ckeditor5ReferenceSourcesListener` | `addEcommerceSources` | 10 | +| `sirsoft-ecommerce.admin.user_currency.changed` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleUserCurrencyChanged` | 20 | +| `sirsoft-ecommerce.admin.user_shipping_country.changed` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleUserShippingCountryChanged` | 20 | +| `sirsoft-ecommerce.brand.after_create` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleBrandAfterCreate` | 20 | +| `sirsoft-ecommerce.brand.after_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleBrandAfterDelete` | 20 | +| `sirsoft-ecommerce.brand.after_toggle_status` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleBrandAfterToggleStatus` | 20 | +| `sirsoft-ecommerce.brand.after_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleBrandAfterUpdate` | 20 | +| `sirsoft-ecommerce.cart.after_add` | action (미선언) | `EcommerceUserActivityLogListener` | `handleCartAfterAdd` | 20 | +| `sirsoft-ecommerce.cart.after_change_option` | action (미선언) | `EcommerceUserActivityLogListener` | `handleCartAfterChangeOption` | 20 | +| `sirsoft-ecommerce.cart.after_delete` | action (미선언) | `EcommerceUserActivityLogListener` | `handleCartAfterDelete` | 20 | +| `sirsoft-ecommerce.cart.after_delete_all` | action (미선언) | `EcommerceUserActivityLogListener` | `handleCartAfterDeleteAll` | 20 | +| `sirsoft-ecommerce.cart.after_update_quantity` | action (미선언) | `EcommerceUserActivityLogListener` | `handleCartAfterUpdateQuantity` | 20 | +| `sirsoft-ecommerce.category.after_create` | action (미선언) | `CategoryActivityLogListener` | `handleAfterCreate` | 20 | +| `sirsoft-ecommerce.category.after_create` | action (미선언) | `SeoCategoryCacheListener` | `onCategoryChange` | 20 | +| `sirsoft-ecommerce.category.after_delete` | action (미선언) | `CategoryActivityLogListener` | `handleAfterDelete` | 20 | +| `sirsoft-ecommerce.category.after_delete` | action (미선언) | `SeoCategoryCacheListener` | `onCategoryDelete` | 20 | +| `sirsoft-ecommerce.category.after_reorder` | action (미선언) | `CategoryActivityLogListener` | `handleAfterReorder` | 20 | +| `sirsoft-ecommerce.category.after_toggle_status` | action (미선언) | `CategoryActivityLogListener` | `handleAfterToggleStatus` | 20 | +| `sirsoft-ecommerce.category.after_update` | action (미선언) | `CategoryActivityLogListener` | `handleAfterUpdate` | 20 | +| `sirsoft-ecommerce.category.after_update` | action (미선언) | `SeoCategoryCacheListener` | `onCategoryChange` | 20 | +| `sirsoft-ecommerce.coupon.after_bulk_status` | action (미선언) | `CouponActivityLogListener` | `handleAfterBulkStatus` | 20 | +| `sirsoft-ecommerce.coupon.after_create` | action (미선언) | `CouponActivityLogListener` | `handleAfterCreate` | 20 | +| `sirsoft-ecommerce.coupon.after_delete` | action (미선언) | `CouponActivityLogListener` | `handleAfterDelete` | 20 | +| `sirsoft-ecommerce.coupon.after_direct_issue` | action (미선언) | `CouponActivityLogListener` | `handleAfterDirectIssue` | 20 | +| `sirsoft-ecommerce.coupon.after_issue_cancel` | action (미선언) | `CouponActivityLogListener` | `handleAfterIssueCancel` | 20 | +| `sirsoft-ecommerce.coupon.after_update` | action (미선언) | `CouponActivityLogListener` | `handleAfterUpdate` | 20 | +| `sirsoft-ecommerce.coupon.restore` | action (미선언) | `CouponRestoreListener` | `restoreCouponsByIds` | 10 | +| `sirsoft-ecommerce.coupon.restore` | action (미선언) | `OrderActivityLogListener` | `handleCouponRestore` | 20 | +| `sirsoft-ecommerce.coupon.use` | action (미선언) | `CouponUseListener` | `markCouponsUsed` | 10 | +| `sirsoft-ecommerce.coupon.use` | action (미선언) | `OrderActivityLogListener` | `handleCouponUse` | 20 | +| `sirsoft-ecommerce.extra_fee_template.after_bulk_create` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleExtraFeeAfterBulkCreate` | 20 | +| `sirsoft-ecommerce.extra_fee_template.after_bulk_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleExtraFeeAfterBulkDelete` | 20 | +| `sirsoft-ecommerce.extra_fee_template.after_bulk_toggle_active` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleExtraFeeAfterBulkToggleActive` | 20 | +| `sirsoft-ecommerce.extra_fee_template.after_create` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleExtraFeeAfterCreate` | 20 | +| `sirsoft-ecommerce.extra_fee_template.after_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleExtraFeeAfterDelete` | 20 | +| `sirsoft-ecommerce.extra_fee_template.after_toggle_active` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleExtraFeeAfterToggleActive` | 20 | +| `sirsoft-ecommerce.extra_fee_template.after_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleExtraFeeAfterUpdate` | 20 | +| `sirsoft-ecommerce.inquiry.store_validation_rules` | filter | `ProductInquiryBoardListener` | `injectBoardValidationRules` | 10 | +| `sirsoft-ecommerce.inquiry.update_validation_rules` | filter | `ProductInquiryBoardListener` | `injectBoardValidationRules` | 10 | +| `sirsoft-ecommerce.label.after_create` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleLabelAfterCreate` | 20 | +| `sirsoft-ecommerce.label.after_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleLabelAfterDelete` | 20 | +| `sirsoft-ecommerce.label.after_toggle_status` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleLabelAfterToggleStatus` | 20 | +| `sirsoft-ecommerce.label.after_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleLabelAfterUpdate` | 20 | +| `sirsoft-ecommerce.mileage.earn` | action (미선언) | `OrderActivityLogListener` | `handleMileageEarn` | 20 | +| `sirsoft-ecommerce.mileage.restore` | action (미선언) | `MileageTransactionListener` | `handleRestore` | 10 | +| `sirsoft-ecommerce.mileage.restore` | action (미선언) | `OrderActivityLogListener` | `handleMileageRestore` | 20 | +| `sirsoft-ecommerce.mileage.use` | action (미선언) | `MileageTransactionListener` | `handleUse` | 10 | +| `sirsoft-ecommerce.mileage.use` | action (미선언) | `OrderActivityLogListener` | `handleMileageUse` | 20 | +| `sirsoft-ecommerce.notification.extract_data` | filter | `EcommerceNotificationDataListener` | `extractData` | 20 | +| `sirsoft-ecommerce.option.after_bulk_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleOptionAfterBulkUpdate` | 20 | +| `sirsoft-ecommerce.option.after_bulk_update` | action (미선언) | `SyncOptionGroupsListener` | `syncOptionGroupsFromBulkUpdate` | 10 | +| `sirsoft-ecommerce.option.after_bulk_update` | action (미선언) | `SyncProductFromOptionListener` | `syncProductStockFromBulkUpdate` | 10 | +| `sirsoft-ecommerce.order-option.after_confirm` | action (미선언) | `EcommerceUserActivityLogListener` | `handleOrderOptionAfterConfirm` | 20 | +| `sirsoft-ecommerce.order-option.after_confirm` | action (미선언) | `MileageTransactionListener` | `handleAfterConfirm` | 10 | +| `sirsoft-ecommerce.order-option.after_confirm` | action (미선언) | `OrderActivityLogListener` | `handleOrderOptionAfterConfirm` | 20 | +| `sirsoft-ecommerce.order.after_bulk_shipping_update` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterBulkShippingUpdate` | 20 | +| `sirsoft-ecommerce.order.after_bulk_status_update` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterBulkStatusUpdate` | 20 | +| `sirsoft-ecommerce.order.after_bulk_update` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterBulkUpdate` | 20 | +| `sirsoft-ecommerce.order.after_cancel` | action (미선언) | `CouponRestoreListener` | `restoreCoupons` | 10 | +| `sirsoft-ecommerce.order.after_cancel` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterCancel` | 20 | +| `sirsoft-ecommerce.order.after_create` | action (미선언) | `EcommerceUserActivityLogListener` | `handleOrderAfterCreate` | 20 | +| `sirsoft-ecommerce.order.after_create` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterCreate` | 20 | +| `sirsoft-ecommerce.order.after_delete` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterDelete` | 20 | +| `sirsoft-ecommerce.order.after_deposit_recorded` | action (미선언) | `IssueCashReceiptOnDepositListener` | `handleDeposit` | 50 | +| `sirsoft-ecommerce.order.after_partial_cancel` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterPartialCancel` | 20 | +| `sirsoft-ecommerce.order.after_payment_complete` | action (미선언) | `IssueCashReceiptOnDepositListener` | `handleDeposit` | 50 | +| `sirsoft-ecommerce.order.after_payment_complete` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterPaymentComplete` | 20 | +| `sirsoft-ecommerce.order.after_purchase_confirmed` | action (미선언) | `PurgeCashReceiptIdentifierListener` | `purgeIdentifier` | 50 | +| `sirsoft-ecommerce.order.after_reset_guest_password` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterResetGuestPassword` | 20 | +| `sirsoft-ecommerce.order.after_send_email` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterSendEmail` | 20 | +| `sirsoft-ecommerce.order.after_status_change` | action (미선언) | `OrderStatusNotificationListener` | `handleStatusChange` | 10 | +| `sirsoft-ecommerce.order.after_update` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterUpdate` | 20 | +| `sirsoft-ecommerce.order.after_update_shipping_address` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterUpdateShippingAddress` | 20 | +| `sirsoft-ecommerce.order.payment_failed` | action (미선언) | `OrderActivityLogListener` | `handleOrderAfterPaymentFailed` | 20 | +| `sirsoft-ecommerce.order_option.after_bulk_status_change` | action (미선언) | `MileageTransactionListener` | `handleAfterBulkStatusChange` | 10 | +| `sirsoft-ecommerce.order_option.after_bulk_status_change` | action (미선언) | `OrderActivityLogListener` | `handleOrderOptionAfterBulkStatusChange` | 20 | +| `sirsoft-ecommerce.order_option.after_status_change` | action (미선언) | `MileageTransactionListener` | `handleAfterStatusChange` | 10 | +| `sirsoft-ecommerce.order_option.after_status_change` | action (미선언) | `OrderActivityLogListener` | `handleOrderOptionAfterStatusChange` | 20 | +| `sirsoft-ecommerce.product-common-info.after_create` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleCommonInfoAfterCreate` | 20 | +| `sirsoft-ecommerce.product-common-info.after_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleCommonInfoAfterDelete` | 20 | +| `sirsoft-ecommerce.product-common-info.after_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleCommonInfoAfterUpdate` | 20 | +| `sirsoft-ecommerce.product-image.after_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleImageAfterDelete` | 20 | +| `sirsoft-ecommerce.product-image.after_reorder` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleImageAfterReorder` | 20 | +| `sirsoft-ecommerce.product-image.after_upload` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleImageAfterUpload` | 20 | +| `sirsoft-ecommerce.product-notice-template.after_copy` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleNoticeTemplateAfterCopy` | 20 | +| `sirsoft-ecommerce.product-notice-template.after_create` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleNoticeTemplateAfterCreate` | 20 | +| `sirsoft-ecommerce.product-notice-template.after_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleNoticeTemplateAfterDelete` | 20 | +| `sirsoft-ecommerce.product-notice-template.after_toggle_active` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleNoticeTemplateAfterToggleActive` | 20 | +| `sirsoft-ecommerce.product-notice-template.after_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleNoticeTemplateAfterUpdate` | 20 | +| `sirsoft-ecommerce.product-review.after_bulk_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleReviewAfterBulkDelete` | 20 | +| `sirsoft-ecommerce.product-review.after_create` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleReviewAfterCreate` | 20 | +| `sirsoft-ecommerce.product-review.after_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleReviewAfterDelete` | 20 | +| `sirsoft-ecommerce.product.after_bulk_price_update` | action (미선언) | `ProductActivityLogListener` | `handleProductAfterBulkPriceUpdate` | 20 | +| `sirsoft-ecommerce.product.after_bulk_stock_update` | action (미선언) | `ProductActivityLogListener` | `handleProductAfterBulkStockUpdate` | 20 | +| `sirsoft-ecommerce.product.after_bulk_update` | action (미선언) | `ProductActivityLogListener` | `handleProductAfterBulkUpdate` | 20 | +| `sirsoft-ecommerce.product.after_create` | action (미선언) | `ProductActivityLogListener` | `handleProductAfterCreate` | 20 | +| `sirsoft-ecommerce.product.after_create` | action (미선언) | `SeoProductCacheListener` | `onProductCreate` | 20 | +| `sirsoft-ecommerce.product.after_delete` | action (미선언) | `ProductActivityLogListener` | `handleProductAfterDelete` | 20 | +| `sirsoft-ecommerce.product.after_delete` | action (미선언) | `SeoProductCacheListener` | `onProductDelete` | 20 | +| `sirsoft-ecommerce.product.after_options_sync` | action (미선언) | `SyncOptionGroupsListener` | `syncOptionGroupsFromOptions` | 10 | +| `sirsoft-ecommerce.product.after_stock_sync` | action (미선언) | `ProductActivityLogListener` | `handleProductAfterStockSync` | 20 | +| `sirsoft-ecommerce.product.after_update` | action (미선언) | `ProductActivityLogListener` | `handleProductAfterUpdate` | 20 | +| `sirsoft-ecommerce.product.after_update` | action (미선언) | `SeoProductCacheListener` | `onProductUpdate` | 20 | +| `sirsoft-ecommerce.product_option.after_bulk_price_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleOptionAfterBulkPriceUpdate` | 20 | +| `sirsoft-ecommerce.product_option.after_bulk_stock_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleOptionAfterBulkStockUpdate` | 20 | +| `sirsoft-ecommerce.product_option.after_bulk_stock_update` | action (미선언) | `SyncProductFromOptionListener` | `syncProductStockFromOptions` | 10 | +| `sirsoft-ecommerce.settings.after_save` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleSettingsAfterSave` | 20 | +| `sirsoft-ecommerce.shipping_carrier.after_create` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleCarrierAfterCreate` | 20 | +| `sirsoft-ecommerce.shipping_carrier.after_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleCarrierAfterDelete` | 20 | +| `sirsoft-ecommerce.shipping_carrier.after_toggle_status` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleCarrierAfterToggleStatus` | 20 | +| `sirsoft-ecommerce.shipping_carrier.after_update` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleCarrierAfterUpdate` | 20 | +| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleShippingPolicyAfterBulkDelete` | 20 | +| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | action (미선언) | `EcommerceAdminActivityLogListener` | `handleShippingPolicyAfterBulkToggleActive` | 20 | +| `sirsoft-ecommerce.shipping_policy.after_create` | action (미선언) | `ShippingPolicyActivityLogListener` | `handleAfterCreate` | 20 | +| `sirsoft-ecommerce.shipping_policy.after_delete` | action (미선언) | `ShippingPolicyActivityLogListener` | `handleAfterDelete` | 20 | +| `sirsoft-ecommerce.shipping_policy.after_set_default` | action (미선언) | `ShippingPolicyActivityLogListener` | `handleAfterSetDefault` | 20 | +| `sirsoft-ecommerce.shipping_policy.after_toggle_active` | action (미선언) | `ShippingPolicyActivityLogListener` | `handleAfterToggleActive` | 20 | +| `sirsoft-ecommerce.shipping_policy.after_update` | action (미선언) | `ShippingPolicyActivityLogListener` | `handleAfterUpdate` | 20 | +| `sirsoft-ecommerce.user_coupon.after_download` | action (미선언) | `EcommerceUserActivityLogListener` | `handleUserCouponAfterDownload` | 20 | +| `sirsoft-ecommerce.wishlist.after_toggle` | action (미선언) | `EcommerceUserActivityLogListener` | `handleWishlistAfterToggle` | 20 | + + + +142개 구독 중 119개는 **자기 자신이 발행한 훅**입니다 — Service 가 발행하고 리스너가 받는 +내부 레인이며, 이것이 이 모듈의 부가효과(활동 로그·검색 색인·SEO 캐시·카운트 동기화)를 Service +바깥에 두는 방식입니다. + +바깥을 향한 구독은 23개뿐이고, 그 셋이 이 모듈이 다른 확장과 맞물리는 전부입니다: + +| 상대 | 수 | 무엇을 위해 | +|---|---|---| +| `core.*` | 19 | 로그인 시 비회원 장바구니 병합 · 회원가입 시 기본 통화/배송국가 배정과 그 검증 규칙 주입 · 활동 로그 설명 변수 해석 · 앱 설정에 기기 유형 주입 · 회원 탈퇴 시 마일리지 정리 | +| `sirsoft-board.*` | 3 | 문의 글이 삭제·복원·일괄 삭제될 때 상품↔글 피벗 정리 (`'sync' => true` — 큐 워커가 없는 환경에서도 누락되지 않도록) | +| `sirsoft-ckeditor5.*` | 1 | 편집기가 참조할 수 있는 이커머스 리소스 목록 제공 | + +`sirsoft-board` · `sirsoft-ckeditor5` 는 **manifest 의존에 없습니다.** 훅 구독은 상대가 없으면 +발화하지 않을 뿐이라, 게시판이나 편집기 플러그인이 없어도 이 모듈은 정상 동작하고 그 기능만 +비어 있습니다. 반대로 말하면 이 세 훅 이름이 상대 확장에서 바뀌면 **예외 없이 조용히** 연동이 +끊기므로, 상대 확장의 `docs/extension-points.md` 와 함께 확인해야 합니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `ActivityLogDescriptionResolver` | 1개 | 명시 등록 | ✅ | `src/Listeners/ActivityLogDescriptionResolver.php` | +| `AssignDefaultCurrencyOnRegisterListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/AssignDefaultCurrencyOnRegisterListener.php` | +| `AssignDefaultShippingCountryOnRegisterListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/AssignDefaultShippingCountryOnRegisterListener.php` | +| `CategoryActivityLogListener` | 5개 | 명시 등록 | ✅ | `src/Listeners/CategoryActivityLogListener.php` | +| `CategoryTreeCacheListener` | 0개 | 명시 등록 | ✅ | `src/Listeners/CategoryTreeCacheListener.php` | +| `Ckeditor5ReferenceSourcesListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/Ckeditor5ReferenceSourcesListener.php` | +| `CouponActivityLogListener` | 6개 | 명시 등록 | ✅ | `src/Listeners/CouponActivityLogListener.php` | +| `CouponRestoreListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/CouponRestoreListener.php` | +| `CouponUseListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CouponUseListener.php` | +| `EcommerceAdminActivityLogListener` | 41개 | 명시 등록 | ✅ | `src/Listeners/EcommerceAdminActivityLogListener.php` | +| `EcommerceNotificationDataListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/EcommerceNotificationDataListener.php` | +| `EcommerceUserActivityLogListener` | 9개 | 명시 등록 | ✅ | `src/Listeners/EcommerceUserActivityLogListener.php` | +| `InjectAppConfigDeviceListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/InjectAppConfigDeviceListener.php` | +| `IssueCashReceiptOnDepositListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/IssueCashReceiptOnDepositListener.php` | +| `MergeCartOnLoginListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/MergeCartOnLoginListener.php` | +| `MileageTransactionListener` | 5개 | 명시 등록 | ✅ | `src/Listeners/MileageTransactionListener.php` | +| `OrderActivityLogListener` | 21개 | 명시 등록 | ✅ | `src/Listeners/OrderActivityLogListener.php` | +| `OrderStatusNotificationListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/OrderStatusNotificationListener.php` | +| `ProductActivityLogListener` | 7개 | 명시 등록 | ✅ | `src/Listeners/ProductActivityLogListener.php` | +| `ProductInquiryBoardListener` | 5개 | 명시 등록 | ✅ | `src/Listeners/ProductInquiryBoardListener.php` | +| `PurgeCashReceiptIdentifierListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PurgeCashReceiptIdentifierListener.php` | +| `SearchProductsListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SearchProductsListener.php` | +| `SeoCategoryCacheListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SeoCategoryCacheListener.php` | +| `SeoProductCacheListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SeoProductCacheListener.php` | +| `SeoSettingsCacheListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/SeoSettingsCacheListener.php` | +| `ShippingPolicyActivityLogListener` | 5개 | 명시 등록 | ✅ | `src/Listeners/ShippingPolicyActivityLogListener.php` | +| `ShippingPolicyCacheListener` | 0개 | 명시 등록 | ✅ | `src/Listeners/ShippingPolicyCacheListener.php` | +| `SyncOptionGroupsListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/SyncOptionGroupsListener.php` | +| `SyncProductFromOptionListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/SyncProductFromOptionListener.php` | +| `UserCurrencyInfoListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/UserCurrencyInfoListener.php` | +| `UserMileageCleanupListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/UserMileageCleanupListener.php` | +| `UserMileageInfoListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/UserMileageInfoListener.php` | +| `UserShippingCountryInfoListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/UserShippingCountryInfoListener.php` | + + + +33개 리스너는 전부 `HookListenerInterface` 를 구현하고 `getSubscribedHooks()` 로 자기 구독을 +선언합니다(명시 등록). 역할별로 네 무리입니다: + +- **활동 로그 6종** (`EcommerceAdminActivityLogListener` 41훅 · `OrderActivityLogListener` 21 · + `EcommerceUserActivityLogListener` 9 · `Product`/`Coupon`/`Category`/`ShippingPolicy` 각 5~7): + 관리자·구매자 행위를 코어 `activity_logs` 단일 테이블에 기록합니다. 신규 `logActivity()` 를 + 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문, 그리고 + 번들 ja 팩까지 함께 정의해야 합니다. +- **캐시·색인 5종** (`SearchProductsListener` · `SeoProductCacheListener` · + `SeoCategoryCacheListener` · `SeoSettingsCacheListener` · `CategoryTreeCacheListener` · + `ShippingPolicyCacheListener`): 도메인 변경 시 검색 색인과 봇 화면 캐시를 무효화합니다. +- **금전·정합 5종** (`MileageTransactionListener` · `CouponUseListener` · `CouponRestoreListener` · + `UserMileageCleanupListener` · `IssueCashReceiptOnDepositListener`): 호출자 트랜잭션과 함께 + 되돌아가야 하므로 `'sync' => true` 로 구독합니다. +- **연동·주입 나머지**: 장바구니 병합 · 가입 시 통화/배송국가 배정 · 문의 피벗 정리 · 옵션↔상품 + 대표값 동기화 · 회원 정보 화면에 마일리지/통화/배송국가 주입. + +구독 수가 0인 두 리스너(`CategoryTreeCacheListener` · `ShippingPolicyCacheListener`)는 훅 +이름을 상수·변수로 조립해 정적 수집에 잡히지 않을 뿐 실제로는 등록되어 있습니다. + +리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 를 직접 부르지 않습니다 — 데이터 +접근은 Repository 인터페이스 주입으로만 합니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/admin-user-currency-field.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin-user-mileage-tab.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin-user-shipping-country-field.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin_dashboard_commerce.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin_dashboard_quick_menu.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/checkout_cash_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/header-currency-selector-admin.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/header-currency-selector-user.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/mypage-profile-mileage-card.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/mypage_order_cash_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/register-currency-field.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/register-shipping-country-field.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +12개 조각은 전부 **다른 확장·템플릿이 소유한 화면에 끼워 넣는** 것입니다. 이 모듈이 코어나 +템플릿 레이아웃을 직접 고치지 않기 위한 장치이며, 대상별로 세 무리입니다: + +- **관리자 회원 화면**: 통화·배송국가 필드와 마일리지 탭 (`admin-user-*`) +- **관리자 대시보드**: 커머스 요약 위젯과 빠른 메뉴 (`admin_dashboard_*`) +- **템플릿 사용자 화면**: 헤더 통화 선택기 · 회원가입 폼의 통화/배송국가 필드 · 마이페이지 + 마일리지 카드 · 주문서와 마이페이지의 현금영수증 영역 + +끼워 넣는 대상 화면을 소유한 쪽이 그 자리(슬롯)를 없애면 조각은 **오류 없이 사라집니다.** +그래서 대상 확장을 업그레이드한 뒤에는 이 조각들이 여전히 화면에 나타나는지 눈으로 확인해야 +합니다. 반대로 새 조각을 추가할 때는 대상 화면이 그 자리를 제공하는지 먼저 확인합니다. + + +## 미들웨어 + + +| 미들웨어 | 부착 대상(targets) | 우선순위 | +|---|---|---| +| `DetectDevice` | `/` | - | +| `ResolveShippingCountry` | `api.modules.sirsoft-ecommerce.products.*`, `api.modules.sirsoft-ecommerce.cart.*`, `api.modules.sirsoft-ecommerce.checkout.*`, `api.modules.sirsoft-ecommerce.user.orders.store` | - | +| `VerifyGuestOrderToken` | `api.modules.sirsoft-ecommerce.guest.orders.cancel`, `api.modules.sirsoft-ecommerce.guest.orders.estimate-refund`, `api.modules.sirsoft-ecommerce.guest.orders.update-shipping-address`, `api.modules.sirsoft-ecommerce.guest.orders.confirm-option`, `api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.*` | - | + + + +세 미들웨어 모두 `getMiddleware()` 로 **부착 대상을 스스로 선언**합니다(self-gate). 커널 +미들웨어 그룹을 직접 조작하거나 라우트 파일에 FQCN 을 붙이지 않습니다. + +| 미들웨어 | 왜 필요한가 | 대상 | +|---|---|---| +| `DetectDevice` | 주문에 기기 유형(`DeviceTypeEnum`)을 남겨 매출을 채널별로 볼 수 있게 합니다 | 전역(`/`) — 유일한 광역 타게팅이며, 판정 결과를 요청에 실을 뿐 흐름을 바꾸지 않습니다 | +| `ResolveShippingCountry` | 배송 국가가 정해져야 배송비와 취급 통화가 결정됩니다. 상품·장바구니·체크아웃 응답이 국가에 따라 달라지는 이유입니다 | 상품·장바구니·체크아웃·주문 생성 라우트 | +| `VerifyGuestOrderToken` | 비회원 주문 조회·취소는 로그인 대신 발급된 토큰으로 신원을 증명합니다 | 비회원 주문 라우트 5종 | + +`VerifyGuestOrderToken` 이 붙은 라우트를 새로 추가할 때는 **그 라우트 이름을 이 선언에 함께 +추가**해야 합니다. 빠뜨리면 인증 없이 남의 주문에 접근할 수 있는 경로가 생기는데, 정상 응답이 +나가므로 오류도 로그도 남지 않습니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +0개입니다. 이 모듈은 WebSocket 실시간 반영을 제공하지 않습니다 — 주문 알림은 전부 코어 +`GenericNotification`(mail/database) 경유이고, 관리자 화면의 새 주문 표시는 화면 재조회로 +갱신됩니다. + +실시간이 필요하면 이 모듈이 발행하는 `order.*` action 훅을 구독해 소비하는 쪽에서 +`HookManager::broadcast()` 로 자기 채널에 내보냅니다. 이 모듈에 채널을 추가하는 것이 아니라 +그 방향이 맞습니다 — 상점마다 실시간이 필요한 화면이 다르기 때문입니다. + + +## 스케줄 + + +| 스케줄 | 주기 | 설명 | +|---|---|---| +| `sirsoft-ecommerce:cancel-pending-orders` | `daily` | 입금 기한 만료 주문 자동 취소 | +| `sirsoft-ecommerce:prune-expired-carts` | `daily` | 보관기간 만료 장바구니 자동 삭제 | +| `sirsoft-ecommerce:prune-temp-product-images` | `daily` | 미연결 임시 상품 이미지 자동 삭제 | +| `sirsoft-ecommerce:prune-temp-orders` | `hourly` | 만료 임시 주문 자동 삭제 | +| `sirsoft-ecommerce:earn-mileage` | `hourly` | 지연 마일리지 적립 | +| `sirsoft-ecommerce:expire-mileage` | `daily` | 마일리지 자동 소멸 | +| `sirsoft-ecommerce:notify-expiring-mileage` | `daily` | 소멸 예정 마일리지 알림 | +| `sirsoft-ecommerce:reconcile-mileage-balance` | `daily` | 마일리지 잔액 캐시 정합 교정 | +| `sirsoft-ecommerce:aggregate-stats` | `hourly` | 대시보드 판매 현황 집계 | + + + +9개 스케줄이 이 모듈의 **시간 축 동작 전부**입니다. 이 중 6개는 환경설정 토글 +(`enabled_config`)에 묶여 있어 그 설정이 꺼져 있으면 실행되지 않습니다 — +`cancel-pending-orders`(주문 설정의 미입금 자동취소) · 마일리지 4종(마일리지 사용/소멸/소멸 +알림) · `aggregate-stats`(대시보드 집계). 임시 데이터 정리 3종은 토글 없이 상시 동작하며 보존 +기간을 커맨드 안에서 판정합니다. `enabled_config` 가 가리키는 설정 키가 없으면 **켜진 것으로 +간주**되므로, 새 토글을 도입할 때는 설정 기본값을 먼저 넣어야 의도한 초기 상태가 됩니다. + +| 커맨드 | 없으면 생기는 일 | +|---|---| +| `cancel-pending-orders` | 입금하지 않은 주문이 재고를 계속 점유합니다 | +| `prune-expired-carts` · `prune-temp-orders` · `prune-temp-product-images` | 임시 데이터가 무한히 쌓입니다 | +| `earn-mileage` | "배송완료 N일 후 적립" 같은 지연 적립이 영영 이루어지지 않습니다 | +| `expire-mileage` · `notify-expiring-mileage` | 소멸 기한이 지난 마일리지가 계속 사용 가능하고, 사전 안내가 나가지 않습니다 | +| `reconcile-mileage-balance` | 표시용 잔액 캐시가 원장과 어긋난 채 남습니다 (원장이 SSoT 이므로 금전 판정 자체는 안전합니다) | +| `aggregate-stats` | 대시보드 판매 현황이 갱신되지 않습니다 | + +이 중 **재고와 금전에 직접 영향을 주는 것은 `cancel-pending-orders` 와 마일리지 3종**입니다. +서버에 스케줄러가 등록되지 않은 설치에서는 이 넷이 침묵하는 것이 유일한 증상이며, 오류가 나지 +않으므로 운영자가 알아채기 어렵습니다. + +새 스케줄을 추가할 때는 `command` 와 함께 **`schedule` 키를 반드시 선언**합니다 — 이 키가 +없으면 코어 스케줄 등록부가 그 항목을 건너뛰는데, 예외도 경고도 남지 않아 "등록했는데 돌지 +않는다"가 됩니다. + + +## 알림 정의 + + +| 알림 키 | 채널 | +|---|---| +| `order_confirmed` | `mail`, `database` | +| `order_pending_deposit` | `mail`, `database` | +| `order_shipped` | `mail`, `database` | +| `order_delivered` | `mail`, `database` | +| `order_completed` | `mail`, `database` | +| `order_cancelled` | `mail`, `database` | +| `new_order_admin` | `mail`, `database` | +| `inquiry_received` | `mail`, `database` | +| `inquiry_replied` | `mail`, `database` | +| `mileage_expiring_soon` | `mail`, `database` | + + + +10종 모두 코어 `GenericNotification` 을 쓰며 개별 Notification 클래스를 두지 않습니다. 채널은 +전부 `mail` + `database` 이고, 어느 채널로 보낼지는 환경설정 "알림" 탭에서 운영자가 정합니다. + +수신자는 셋으로 갈립니다 — 구매자에게 가는 것(`order_confirmed` · `order_pending_deposit` · +`order_shipped` · `order_delivered` · `order_completed` · `order_cancelled` · +`mileage_expiring_soon` · `inquiry_replied`), 운영자에게 가는 것(`new_order_admin` · +`inquiry_received`)입니다. + +**비회원 주문도 알림을 받습니다.** 이때 리스너는 `trigger_user_id` 대신 context 표준 키 +`guest_recipient: {email, name, locale}` 만 채우고, 수신자 해석·채널 게이트·발송은 전적으로 +코어가 담당합니다. 채널별 비회원 발송 허용은 코어 `config/notification.php` 의 채널 메타 +`allow_guest` 가 정하므로, 새 채널을 쓰려면 그 선언을 먼저 확인해야 합니다. + +새 알림을 추가할 때는 이 선언과 함께 메일 템플릿(`ecommerce_mail_templates`)과 다국어 키가 +필요합니다 — 선언만 추가하면 발송은 되지만 본문이 비어 나갑니다. + + +## 활동 로그 훅 + +> 이 확장이 코어 활동 로그(`activity_logs`)에 기록을 남기기 위해 구독하는 훅 **92종**입니다 +> (등록 건수는 94건 — `order.after_create` 와 `order-option.after_confirm` 은 관리자 관점과 +> 구매자 관점 두 리스너가 각각 구독합니다). +> 코어 `docs/backend/activity-log-hooks.md` 에 있던 목록을 이 확장 소유로 옮긴 것입니다(#601) — +> 확장이 훅을 더할 때 코어 문서를 고쳐야 하던 역방향 의존을 없애기 위해서입니다. 코어 문서에는 +> 총계와 이 문서로의 링크만 남습니다. + +> 새 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문, +> 그리고 번들 일본어 팩까지 함께 정의해야 합니다 — **모듈 lang 파일에 넣으면 해석되지 +> 않습니다.** + +### 이커머스 모듈 훅 + +**모듈**: `sirsoft-ecommerce` +**등록 94건 / 훅 92종** (7개 Listener) + +#### OrderActivityLogListener (21훅) + +**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/OrderActivityLogListener.php` + +##### OrderService (8훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `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 | - | +| `sirsoft-ecommerce.order.after_reset_guest_password` | `handleOrderAfterResetGuestPassword` | `order.reset_guest_password` | Admin | Order | + +##### 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 (4훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `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 | + +##### OrderService (구매확인) (1훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.order-option.after_confirm` | `handleOrderOptionAfterConfirm` | `order_option.confirm` | Admin | OrderOption | + +##### 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 | + +#### ProductActivityLogListener (7훅) + +**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/ProductActivityLogListener.php` + +> 이 리스너는 코어 표준 패턴(`ResolvesActivityLogType` + `ChangeDetector`)을 씁니다. +> 수정 전 스냅샷은 이 리스너가 `before_*` 훅으로 직접 잡지 않고, **Service 가 잡아 +> `after_*` 훅의 인자로 넘깁니다**(`ProductService::update()` → `product.after_update`). +> 그래서 이 표에 `before_*` 훅이 없습니다 — `before_*` 는 발행되지만 이 리스너의 +> 구독 대상이 아니며, 그 목록은 위 「발행 훅」 절에 있습니다. + +| 훅 이름 | Listener 메서드 | Priority | 비고 | +|---------|----------------|----------|------| +| `sirsoft-ecommerce.product.after_create` | `handleProductAfterCreate` | 50 | 상품 생성 로그 | +| `sirsoft-ecommerce.product.after_update` | `handleProductAfterUpdate` | 50 | 변경사항 비교 후 로그 | +| `sirsoft-ecommerce.product.after_delete` | `handleProductAfterDelete` | 20 | 삭제 후 로그 | +| `sirsoft-ecommerce.product.after_bulk_update` | `handleProductAfterBulkUpdate` | 20 | 일괄 수정 로그 | +| `sirsoft-ecommerce.product.after_bulk_price_update` | `handleProductAfterBulkPriceUpdate` | 20 | 일괄 가격 수정 로그 | +| `sirsoft-ecommerce.product.after_bulk_stock_update` | `handleProductAfterBulkStockUpdate` | 20 | 일괄 재고 수정 로그 | +| `sirsoft-ecommerce.product.after_stock_sync` | `handleProductAfterStockSync` | 20 | 재고 동기화 로그 | + +#### 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.after_update` | `handleAfterUpdate` | `coupon.update` | Admin | Coupon | +| `sirsoft-ecommerce.coupon.after_delete` | `handleAfterDelete` | `coupon.delete` | Admin | - | +| `sirsoft-ecommerce.coupon.after_bulk_status` | `handleAfterBulkStatus` | `coupon.bulk_status` | Admin | - | +| `sirsoft-ecommerce.coupon.after_direct_issue` | `handleAfterDirectIssue` | `coupon.direct_issue` | Admin | CouponIssue | +| `sirsoft-ecommerce.coupon.after_issue_cancel` | `handleAfterIssueCancel` | `coupon.issue_cancel` | Admin | CouponIssue | + +#### ShippingPolicyActivityLogListener (5훅) + +**파일**: `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.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 | + +#### CategoryActivityLogListener (5훅) + +**파일**: `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.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 | - | + +#### EcommerceAdminActivityLogListener (41훅) + +**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/EcommerceAdminActivityLogListener.php` + +##### Brand (4훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.brand.after_create` | `handleBrandAfterCreate` | `brand.create` | Admin | Brand | +| `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 (4훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.label.after_create` | `handleLabelAfterCreate` | `label.create` | Admin | ProductLabel | +| `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 (3훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.product-common-info.after_create` | `handleCommonInfoAfterCreate` | `common_info.create` | Admin | ProductCommonInfo | +| `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.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 | +| `sirsoft-ecommerce.product-notice-template.after_toggle_active` | `handleNoticeTemplateAfterToggleActive` | `product_notice_template.toggle_active` | Admin | ProductNoticeTemplate | + +##### ExtraFeeTemplate (7훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.extra_fee_template.after_create` | `handleExtraFeeAfterCreate` | `extra_fee_template.create` | Admin | ExtraFeeTemplate | +| `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.after_bulk_delete` | `handleExtraFeeAfterBulkDelete` | `extra_fee_template.bulk_delete` | Admin | - | +| `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 (4훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.shipping_carrier.after_create` | `handleCarrierAfterCreate` | `shipping_carrier.create` | Admin | ShippingCarrier | +| `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 (2훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | `handleShippingPolicyAfterBulkDelete` | `shipping_policy.bulk_delete` | Admin | - | +| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | `handleShippingPolicyAfterBulkToggleActive` | `shipping_policy.bulk_toggle_active` | Admin | - | + +##### ProductOption (3훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.product_option.after_bulk_price_update` | `handleOptionAfterBulkPriceUpdate` | `product_option.bulk_price_update` | Admin | - | +| `sirsoft-ecommerce.product_option.after_bulk_stock_update` | `handleOptionAfterBulkStockUpdate` | `product_option.bulk_stock_update` | Admin | - | +| `sirsoft-ecommerce.option.after_bulk_update` | `handleOptionAfterBulkUpdate` | `product_option.bulk_update` | Admin | - | + +##### ProductReview (3훅) + +| 훅 이름 | 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.after_bulk_delete` | `handleReviewAfterBulkDelete` | `product_review.bulk_delete` | Admin | - | + +##### 설정 · 회원 기준값 (3훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.settings.after_save` | `handleSettingsAfterSave` | `ecommerce_settings.update` | Admin | - | +| `sirsoft-ecommerce.admin.user_currency.changed` | `handleUserCurrencyChanged` | `user_currency.change` | Admin | User | +| `sirsoft-ecommerce.admin.user_shipping_country.changed` | `handleUserShippingCountryChanged` | `user_shipping_country.change` | Admin | User | + +#### EcommerceUserActivityLogListener (9훅) + +**파일**: `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 | + +##### 주문 (구매자 관점) (2훅) + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.order.after_create` | `handleOrderAfterCreate` | `order.create` | **User** | Order | +| `sirsoft-ecommerce.order-option.after_confirm` | `handleOrderOptionAfterConfirm` | `order_option.confirm` | **User** | OrderOption | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/frontend.md b/modules/_bundled/sirsoft-ecommerce/docs/frontend.md new file mode 100644 index 00000000..2b8ca5b4 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/frontend.md @@ -0,0 +1,483 @@ +# 이커머스 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 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 | - | + + + +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 에 없으면** 그 +스타일만 조용히 빠지므로, 기존 레이아웃에 쓰이지 않던 클래스를 도입할 때는 확인이 필요합니다. + + +## 액션 핸들러 + + +핸들러 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` | + + + +핸들러 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` 이 남지 않아야 합니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftEcommerce` | +| 재등록 진입점 | `initModule()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftEcommerce.initModule()` 이 재등록 진입점입니다. 로케일을 전환하면 코어가 이 +함수를 다시 불러 핸들러를 재등록하는데, **이 함수가 없거나 이름이 다르면 로케일 전환 직후 +이 모듈의 액션 160개가 전부 무반응이 됩니다** — 오류도 토스트도 없이 버튼만 동작하지 않습니다. + +그래서 이 진입점은 **핸들러 재등록만** 수행합니다. 1회성 부팅 작업(초기 상태 시드·전역 이벤트 +구독 등)을 여기 넣으면 로케일을 바꿀 때마다 다시 실행되어 상태가 초기화되거나 리스너가 +중복 등록됩니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/css/module.css` | 빌드 산출물 (커밋 대상) | +| `dist/js/module.iife.js` | 빌드 산출물 (커밋 대상) | +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +로딩 전략이 `global` 이라 이 모듈의 JS·CSS 는 **모든 페이지에서 로드**됩니다. 관리자 화면 +전용이 아니라 템플릿의 방문자 화면도 이 모듈의 핸들러를 쓰기 때문입니다. `priority: 100` 은 +확장 번들 안에서의 실행 순서로, 다른 확장이 이보다 먼저 나가야 한다면 그쪽이 더 작은 값을 +선언합니다 — 특정 확장 이름을 지목하는 분기를 두지 않는 것이 규칙입니다. + +`dist/` 는 **커밋되는 배포 산출물**입니다. 소스(`resources/js/**`)를 고치면 `--production` +으로 다시 굽고 그 결과를 함께 커밋합니다. 새 소스 리터럴이 `dist/` 에 없으면 stale 빌드이며, +브라우저가 받는 것은 커밋된 `dist/` 이므로 소스만 고친 변경은 사이트에 반영되지 않습니다. + +구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다. CDN 도달 실패는 예외도 +서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다. 자산 URL 을 문자열로 조립하지 +않고 `G7Core.asset.module` 을 쓰는 것도 같은 이유입니다 — 확장자를 정적 location 이 가로채는 +서버에서는 조립한 URL 만 404 가 됩니다. + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/settings.md new file mode 100644 index 00000000..11fde25d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/settings.md @@ -0,0 +1,179 @@ +# 이커머스 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +_`getSettingsSchema()` 선언이 없습니다._ + +기본값 파일: `config/settings/defaults.json` + + + +`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`)만은 성격이 다릅니다. **저장값과 플러그인이 +등록한 카탈로그의 병합**이라, 플러그인을 삭제·비활성화하면 저장값은 남아 있는데 카탈로그에서 +사라지는 고아 항목이 생깁니다. 공개 응답은 고아 항목을 걸러 내보내고 관리자 응답은 그대로 +노출하는 것이 규칙입니다 — 운영자는 그것을 보고 지워야 하기 때문입니다. + + +## 권한 + + +| 카테고리 | 이름 | 액션 | 라우트 키 | +|---|---|---|---| +| `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` | - | + + + +권한 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 입니다. + + +## 메뉴 + + +| 구분 | slug | 이름 | URL | 하위 | +|---|---|---|---|---| +| 관리자 | `sirsoft-ecommerce` | 이커머스 | - | 11개 | + + + +최상위 `sirsoft-ecommerce` 아래 11개 하위 메뉴가 붙습니다 — 환경설정 · 상품 · 카테고리 · +브랜드 · 상품정보제공고시 · 공통정보 · 주문 · 쿠폰 · 배송정책 · 리뷰 · 마일리지 내역. + +메뉴는 **권한과 짝을 이룰 때만 보입니다.** 운영자에게 역할이 부여되어도 그 역할에 해당 권한이 +없으면 메뉴가 렌더되지 않으므로, 새 화면을 추가할 때는 `getPermissions()` 와 `getAdminMenus()` +를 함께 바꿉니다. + +권한 표에는 있는데 메뉴가 없는 것들(`dashboard` · `identity.policies` · `product-labels` 등)은 +독립 메뉴가 아니라 다른 화면 안에 들어 있기 때문입니다 — 대시보드는 코어 관리자 첫 화면에 +레이아웃 조각으로 주입되고, 본인인증 정책은 코어 IDV 설정 화면에서 함께 다뤄지며, 상품 라벨은 +상품 관리 화면 안에 있습니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/modules/sirsoft-ecommerce/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +라우트 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 정책의 라우트명 인덱스가 그 라우트를 찾지 못해, 보호가 걸린 것처럼 보이지만 실제로는 +통과합니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `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` | + + + +이 모듈은 **아무 확장에도 의존하지 않습니다.** 코어만 있으면 동작하며, 관계는 전부 한 방향으로 +들어옵니다 — 결제 플러그인 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` 최소 버전 상향이 필요한지 검토합니다. + diff --git a/modules/_bundled/sirsoft-ecommerce/editor-spec.json b/modules/_bundled/sirsoft-ecommerce/editor-spec.json index 324b47d0..705ac5f2 100644 --- a/modules/_bundled/sirsoft-ecommerce/editor-spec.json +++ b/modules/_bundled/sirsoft-ecommerce/editor-spec.json @@ -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": { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md index 6eadf69f..8849c6d4 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md @@ -23,7 +23,7 @@ modules/_bundled/sirsoft-ecommerce/ ## 데이터 생성 위치 분리 (CRITICAL) E2E spec 이 "특정 유저 + 특정 역할 + 특정 도메인 상황" 에서 동작하려면 백엔드 데이터 생성 코드가 -필요하다. **그 코드는 데이터를 소유한 영역에 위치해야 한다** — 기존 G7 의 Seeder/Factory 분리 원칙과 동일. +필요하다. **그 코드는 데이터를 소유한 영역에 위치해야 한다** — 기존 그누보드7 의 Seeder/Factory 분리 원칙과 동일. | 데이터 종류 | 위치 | |---|---| diff --git a/modules/_bundled/sirsoft-page/AGENTS.md b/modules/_bundled/sirsoft-page/AGENTS.md new file mode 100644 index 00000000..23ad7e76 --- /dev/null +++ b/modules/_bundled/sirsoft-page/AGENTS.md @@ -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. 이 확장은 무엇인가 + + +회사소개·이용약관·개인정보처리방침처럼 **고정된 주소를 갖는 단일 문서**를 관리하는 모듈입니다. +게시판이 "여러 글이 목록을 이루는 것"이라면 이 모듈은 "글 하나가 곧 하나의 주소"이며, 그 +차이가 설계의 대부분을 설명합니다 — 목록·댓글·신고·카테고리가 없고 대신 **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 가 없습니다)· +알림·브로드캐스트·미들웨어·레이아웃 확장. 이 모듈은 다른 확장 화면에 무엇도 주입하지 않습니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + + +## 3. 핵심 흐름 + + +**페이지 저장 → 버전 적재**: `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` 가 코어 +검색 훅에 응답을 얹기 때문입니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 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#알림-정의) | + + + +발행 훅 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개입니다. 이 모듈은 다른 화면에 개입하지 +않습니다. + + +## 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. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 페이지를 저장하면서 버전 스냅샷 적재를 건너뛰기 | `PageService` 의 저장 경로를 거친다 (스냅샷 + `current_version` 증가가 같은 트랜잭션) | 버전이 빠진 수정은 되돌릴 수 없다. 소프트 삭제를 걷어낸 뒤로 **되돌리기 수단이 버전 이력뿐**이다 | +| 버전 복원을 현재 행 덮어쓰기로 구현 | 복원도 새 버전을 만든다 (`current_version` +1 후 스냅샷) | 되돌린 사실이 이력에서 사라지면 "누가 언제 무엇으로 되돌렸는가"를 추적할 수 없다 | +| 첨부 공개 서빙(`download`)에만 발행 상태를 확인하고 `preview` 는 그대로 노출 | 두 경로 모두 같은 게이트를 재적용 | 한쪽만 막으면 같은 파일이 형제 엔드포인트로 새어나간다 | +| 첨부 URL 을 순번 ID 로 조립 | 해시 경로(`/pages/attachment/{hash}`) | ID 노출은 다른 페이지의 첨부를 훑을 수 있는 열쇠가 된다 | +| 소프트 삭제를 다시 도입 | 삭제는 실삭제, 되돌리기는 버전 이력 | 지운 페이지가 남아 있으면 같은 slug 를 다시 쓸 수 없고, slug 는 이 도메인에서 주소 그 자체다 | +| 첨부 개수·용량 상한을 서비스에 리터럴로 재클램프 | 설정(`attachment.*`) 을 읽고 검증은 FormRequest 에 둔다 | 이중 클램프가 생기면 설정을 올려도 반영되지 않는다 | +| 페이지 목록을 만들기 위해 공개 목록 API 를 추가 | 목록이 필요하면 게시판 모듈을 쓴다 | 페이지는 "주소 하나 = 문서 하나" 도메인이다. 목록을 들이면 게시판과 역할이 겹치면서 둘 다 애매해진다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| 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 + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 6c2f3b9c..a2e8aa9d 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -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 diff --git a/modules/_bundled/sirsoft-page/README.md b/modules/_bundled/sirsoft-page/README.md new file mode 100644 index 00000000..5ba30c6e --- /dev/null +++ b/modules/_bundled/sirsoft-page/README.md @@ -0,0 +1,183 @@ +# 페이지 + +**그누보드7 모듈 · sirsoft-page** +정적 페이지(정보/정책/안내) 관리 모듈 + + +

+ version 1.1.1 + type 모듈 + 그누보드7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +회사소개·이용약관·개인정보처리방침처럼 **주소가 고정된 문서 한 장**을 만들고 관리하는 +모듈입니다. 관리자 화면에서 주소(slug)와 내용을 정해 저장하면 `/{slug}` 로 공개됩니다. + +게시판과 헷갈리기 쉬운데 역할이 다릅니다. 게시판은 여러 글이 목록을 이루고 댓글·검색·신고가 +따라오지만, 페이지는 **글 하나가 곧 주소 하나**입니다. 목록도 댓글도 없고, 대신 수정할 때마다 +이전 내용이 자동으로 보관되어 언제든 되돌릴 수 있습니다. + +방문자가 보는 페이지 화면은 템플릿(`sirsoft-basic`)이 그립니다. 이 모듈은 내용을 관리하고 +넘겨주는 역할까지 맡습니다. + +의도적으로 두지 않은 것: 관리자 환경설정 화면(첨부 제한은 설정 파일에서 조정합니다)·알림· +페이지 목록 API. 여러 글을 목록으로 보여줘야 한다면 게시판 모듈이 맞는 선택입니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 페이지 관리 | 주소(slug)·제목·본문 작성, 다국어 제목, 발행/미발행 전환, 여러 페이지 한 번에 발행 | +| 버전 이력 | 저장할 때마다 자동 스냅샷, 이전 버전 내용 확인과 되돌리기 | +| 첨부파일 | 파일 업로드·순서 변경·삭제, 개수/용량/형식 제한, 공개 내려받기와 미리보기 | +| 미리보기 | 아직 발행하지 않은 페이지를 운영자만 실제 화면으로 확인 | +| 검색 노출 | 사이트 통합 검색 결과에 페이지가 함께 나옴 | +| SEO | 페이지별 메타 정보 설정, 내용이 바뀌면 검색엔진용 화면 캐시 자동 갱신 | +| 본문 대표 이미지 | 본문에서 첫 이미지를 자동으로 뽑아 목록·공유 미리보기에 사용 | +| 편집기 연동 | 편집기에서 이미지를 고를 때 페이지 첨부를 함께 제시 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[운영자] -->|작성·수정| ADM[페이지 관리] + ADM --> SAVE[저장] + SAVE --> PAGE[(현재 내용)] + SAVE --> VER[(버전 이력)] + VER -.되돌리기.-> SAVE + V[방문자] -->|/slug 접속| T[템플릿 화면] + T --> PAGE +``` + +저장할 때마다 현재 내용과 버전 이력이 함께 갱신됩니다. 되돌리기도 "예전으로 덮어쓰기"가 아니라 +**그 내용으로 다시 한 번 저장**하는 것이라, 되돌린 사실 자체가 이력에 남습니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | + + +## 설치 + + +```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 + + +## 관리자 설정 + + +_별도의 관리자 설정 항목이 없습니다._ + + + +위 표가 비어 있는 것은 이 모듈에 **관리자 환경설정 화면이 없기** 때문입니다. 조정할 수 있는 +값은 첨부 제한 셋뿐이고, 설정 파일(`config/settings/defaults.json`)에 들어 있습니다. + +| 항목 | 기본값 | 바꾸면 달라지는 것 | +|---|---|---| +| `attachment.max_count` | 5 | 페이지 하나에 붙일 수 있는 파일 개수 | +| `attachment.max_size_mb` | 10 | 파일 하나의 최대 용량(MB) | +| `attachment.allowed_types` | JPEG·PNG·GIF·WebP·PDF·ZIP | 업로드를 허용할 파일 형식 | + +값을 바꾸려면 설치된 모듈의 설정 파일을 고친 뒤 모듈 캐시를 비웁니다. 화면 입력이 없는 이유는 +이 셋이 개점 후 거의 바뀌지 않는 값이라 판단했기 때문이며, 조정이 잦아지면 그때 설정 화면을 +추가하는 것이 맞습니다. + + +## 사용 방법 + + +**페이지 만들기**: `/admin/pages` → "페이지 추가" → 주소(slug)와 제목·본문을 입력합니다. 주소는 +저장 전에 중복 여부를 확인해 주며, 한번 공개한 주소를 바꾸면 기존 링크가 끊기므로 신중히 +정합니다. 작성 중에는 "미발행" 으로 두고 미리보기로 확인한 뒤 발행합니다. + +**예전 내용으로 되돌리기**: 페이지 상세의 버전 목록에서 원하는 시점을 골라 내용을 확인한 뒤 +복원합니다. 복원해도 그 사이의 버전이 지워지지 않고 **새 버전이 하나 더 생기므로**, 되돌린 +것을 다시 되돌릴 수 있습니다. + +**약관 개정 공지처럼 여러 페이지를 동시에 여는 경우**: 각 페이지를 미발행 상태로 준비해 두고 +목록에서 대상을 체크한 뒤 일괄 발행합니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `sirsoft-marketing` | 플러그인 | `>=1.0.0` | +| `sirsoft-basic` | 템플릿 | `>=1.1.0` | + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 페이지 주소로 들어가면 404 | 아직 발행하지 않았거나 주소를 바꿈 | 관리자 화면에서 발행 상태와 현재 주소를 확인합니다. 운영자 계정으로는 미발행 페이지도 미리보기로 열립니다 | +| 첨부 파일이 내려받아지지 않음 | 그 페이지가 미발행 상태 | 페이지를 발행하면 첨부도 함께 공개됩니다. 미발행 상태의 첨부는 권한 있는 운영자에게만 열립니다 | +| 파일 업로드가 거부됨 | 개수·용량·형식 제한에 걸림 | 기본값은 5개·10MB·이미지/PDF/ZIP 입니다. 설정 파일에서 조정할 수 있습니다 | +| 내용을 고쳤는데 검색 결과가 예전 그대로 | 검색 색인이 아직 갱신되지 않음 | 잠시 후 다시 확인하고, 계속 그렇다면 코어 검색 색인 점검을 실행합니다 | +| 페이지를 지웠는데 되돌릴 수 없음 | 이 모듈은 삭제를 실제 삭제로 처리 | 삭제 전 되돌리기 수단은 버전 이력뿐입니다. 삭제 대신 "미발행" 으로 두면 언제든 되살릴 수 있습니다 | +| 공유했을 때 미리보기 이미지가 나오지 않음 | 본문에 이미지가 없거나 대표 이미지를 뽑지 못함 | 본문 첫머리에 이미지를 넣거나 SEO 설정에서 직접 지정합니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/modules/_bundled/sirsoft-page/composer.json b/modules/_bundled/sirsoft-page/composer.json index b7e3aed3..133f79e8 100644 --- a/modules/_bundled/sirsoft-page/composer.json +++ b/modules/_bundled/sirsoft-page/composer.json @@ -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": { diff --git a/modules/_bundled/sirsoft-page/docs/README.md b/modules/_bundled/sirsoft-page/docs/README.md new file mode 100644 index 00000000..a5ac3747 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/README.md @@ -0,0 +1,23 @@ +# 페이지 개발자 문서 + +> modules/_bundled/sirsoft-page · 모듈 + + +**훅 수**: 21 · **구독 훅 수**: 17 · **라우트 수**: 17 · **모델 수**: 3 · **테이블 수**: 3 · **마이그레이션 수**: 8 · **레이아웃 수**: 3 · **핸들러 수**: 0 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/modules/_bundled/sirsoft-page/docs/architecture.md b/modules/_bundled/sirsoft-page/docs/architecture.md new file mode 100644 index 00000000..4fd8ea98 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/architecture.md @@ -0,0 +1,86 @@ +# 페이지 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +"문서 하나 = 주소 하나" 라는 전제 하나가 이 모듈의 모든 선택을 설명합니다. + +- **목록이 없다.** 공개 API 는 `GET /pages/{slug}` 뿐이고 목록 엔드포인트가 없습니다. 여러 글을 + 목록으로 다루는 것은 게시판 모듈의 역할이며, 두 모듈이 그 역할을 나눠 갖지 않으면 둘 다 + 애매해집니다. 방문자가 페이지를 찾는 통로는 사이트 메뉴와 통합 검색입니다. +- **삭제가 실삭제다.** 초기 스키마의 SoftDeletes 를 마이그레이션 두 개로 걷어냈습니다. slug 가 + 주소이므로 지운 페이지가 보이지 않게 남아 있으면 같은 주소를 다시 쓸 수 없습니다. 되돌리기의 + 책임은 삭제 플래그가 아니라 **버전 이력**이 집니다. +- **모든 수정이 버전을 남긴다.** 그래서 되돌리기도 덮어쓰기가 아니라 새 버전 생성입니다 — + 되돌린 사실 자체가 이력에 남아야 하기 때문입니다. +- **검색·SEO 를 스스로 만들지 않는다.** 코어 검색 훅에 결과를 얹고 코어 SEO 캐시에 무효화를 + 통지할 뿐, 자기 검색 화면이나 자기 캐시를 두지 않습니다. +- **관리자 설정 화면이 없다.** 조정 가능한 값은 첨부 제한 셋뿐이고 개점 후 거의 바뀌지 않아 + 설정 파일에 두었습니다. 조정이 잦아지면 그때 화면을 더하는 것이 맞습니다. + +**의도적으로 하지 않는 것**: 알림·브로드캐스트·미들웨어·레이아웃 확장·프론트 액션 핸들러. +이 모듈은 다른 확장의 화면이나 요청 흐름에 개입하지 않습니다. + + +## 계층 지도 + + +``` +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 는 이 부가효과를 알지 못하며, 그래서 새 부가효과는 리스너 추가만으로 +끝납니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + diff --git a/modules/_bundled/sirsoft-page/docs/data-model.md b/modules/_bundled/sirsoft-page/docs/data-model.md new file mode 100644 index 00000000..fc073f3e --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/data-model.md @@ -0,0 +1,138 @@ +# 페이지 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | 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 | - | + + + +세 모델의 관계는 `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` 필터가 정합니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `page_attachments` | `PageAttachment` | +| `page_versions` | `PageVersion` | +| `pages` | `Page` | + + + +세 테이블 모두 모델과 1:1 이며 피벗이 없습니다. 접두사가 `page_` 로 짧은 것은 이 모듈이 +코어에 가까운 기본 기능이라는 초기 판단 때문이며, 다른 확장이 같은 이름을 쓰지 않도록 +주의합니다. + +`pages` 에는 FULLTEXT 인덱스(`2026_04_01_000004`)와 발행 정렬 인덱스(`2026_08_02_000001`)가 +따로 붙어 있습니다. 목록·검색 쿼리를 새로 만들 때 이 두 인덱스를 쓰는 형태인지 확인합니다 — +컬럼에 함수를 씌우거나(`whereDate` 등) 정렬 컬럼을 바꾸면 인덱스가 쓰이지 않습니다. + +삭제는 **DB CASCADE 에 맡기지 않습니다.** 페이지를 지울 때 `PageService` 가 첨부를 하나씩 +`PageAttachmentService::deleteAttachment()` 로 지웁니다 — 물리 파일 삭제와 훅 발행이 함께 +일어나야 하는데, CASCADE 로 지우면 그 둘이 통째로 건너뛰어지고 아무 오류도 남지 않습니다. + + +## 마이그레이션 + + +마이그레이션 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` | ✅ | + + + +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 정의에 붙어 조용히 사라집니다). + + +## Enum + + +_Enum 이 없습니다._ + + + +없습니다. 이 도메인의 상태는 `published` 불리언 하나뿐이라 분류 어휘가 생기지 않았습니다. + +`content_mode` 는 문자열 컬럼입니다 — 편집기(위지윅/평문)가 무엇을 저장했는지를 나타내며, +편집기 확보에 실패했을 때의 폴백 계약(`text`)과 짝을 이룹니다. 값의 가짓수가 늘어나 +분기가 생기기 시작하면 그때 Enum 으로 올리는 것이 맞습니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `PageAttachmentRepository` | 구현 | 페이지 첨부파일 Repository | +| `PageAttachmentRepositoryInterface` | 인터페이스 | 페이지 첨부파일 Repository 인터페이스 | +| `PageRepository` | 구현 | 페이지 Repository | +| `PageRepositoryInterface` | 인터페이스 | 페이지 Repository 인터페이스 | +| `PageVersionRepository` | 구현 | 페이지 버전 Repository | +| `PageVersionRepositoryInterface` | 인터페이스 | 페이지 버전 Repository 인터페이스 | + + + +세 Repository 모두 인터페이스와 1:1 이며 서비스는 **인터페이스만 주입**받습니다(구체 클래스 +타입힌트 금지). + +이 모듈에서 특히 걸리는 것 둘: + +- **버전 조회는 반드시 페이지 스코프로.** `PageVersionRepository::findForPage($pageId, $versionId)` + 처럼 상위 리소스 ID 를 where 절에 반영합니다. 버전 ID 만으로 찾으면 다른 페이지의 버전을 + 현재 페이지에 복원할 수 있는 교차 접근 경로가 생기는데, 정상 응답이 나가므로 오류도 로그도 + 남지 않습니다. +- **목록 쿼리의 컬럼 프루닝과 정렬 화이트리스트.** 페이지 본문은 큰 컬럼이라 목록에 실으면 + 오버플로 페이지 읽기가 발생합니다. 정렬은 `PageService::SEARCH_SORT_MAP` 이 닫힌 집합을 + 정하며, 화면 정렬 옵션 ⊆ 검증 게이트 ⊆ 이 선언 순서로 포함 관계가 유지되어야 합니다. + diff --git a/modules/_bundled/sirsoft-page/docs/editor-spec.md b/modules/_bundled/sirsoft-page/docs/editor-spec.md new file mode 100644 index 00000000..ecc42ce8 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/editor-spec.md @@ -0,0 +1,115 @@ +# 페이지 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `modules/_bundled/sirsoft-page/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 6 · 엔드포인트 샘플 3 · 페이지 상태 3 + + + +페이지 모듈의 스펙은 세 블록뿐입니다. 화면이 "목록 · 편집 · 공개 보기" 로 단순하고, +운영자가 편집기에서 손대는 대상이 페이지 **내용**이 아니라 그것을 감싸는 레이아웃이기 +때문입니다. + +`sampleGlobal` 을 두지 않은 것은 누락이 아닙니다 — 페이지 도메인은 `_global` 키를 +자기 것으로 쓰지 않습니다. 필요 없는 블록을 빈 값으로라도 선언해 두면 다음 사람이 그 +빈 값을 채워야 할 자리로 오해합니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 6 | `editor-spec.json (인라인)` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 3 | `editor-spec.json (인라인)` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 3 | `editor-spec.json (인라인)` | + + + +`byDataSourceId` 6종 중 `termsContent`·`privacyContent` 는 다른 넷과 성격이 다릅니다. +약관·개인정보 페이지는 슬러그가 고정된 특수 페이지라 편집기에서 그 자리에 무엇이 들어갈지 +미리 보여 줘야 합니다. `byEndpointPattern` 3종도 같은 이유로 이 둘을 따로 덮습니다. + +`states.groups` 3종은 공개 페이지와 관리자 편집·상세를 하나씩 맡습니다. 페이지는 상태 +변종이 적은 도메인이라 이 수가 늘어난다면 화면이 복잡해지고 있다는 신호입니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | 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` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +페이지 모듈에서 주의할 것은 `/p/:slug` 처럼 **슬러그가 열려 있는 라우트**입니다. +편집기는 특정 슬러그 하나를 골라 프리뷰를 그리므로, 그 샘플이 실제 운영 페이지 중 +가장 단순한 것을 닮아 있으면 복잡한 페이지에서 레이아웃이 깨지는 것을 편집기에서 +미리 볼 수 없습니다. + +샘플을 고를 때는 가장 짧은 페이지가 아니라 **가장 많은 요소를 가진 페이지**를 기준으로 +삼습니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan module:update sirsoft-page --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + diff --git a/modules/_bundled/sirsoft-page/docs/extension-points.md b/modules/_bundled/sirsoft-page/docs/extension-points.md new file mode 100644 index 00000000..f7b16d18 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/extension-points.md @@ -0,0 +1,238 @@ +# 페이지 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 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` | + + + +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()` 선언에 없어 소스에서 자동 감지된 상태입니다. 선언에 추가하면 유형과 +설명이 표에 함께 실리며, 이 모듈처럼 훅 수가 적은 확장은 선언을 채우는 비용이 낮습니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `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 | + + + +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 인터페이스 주입으로만 합니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | 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` | + + + +5개 전부 `HookListenerInterface` 를 구현하고 `getSubscribedHooks()` 로 자기 구독을 선언합니다. + +| 리스너 | 역할 | +|---|---| +| `PageActivityLogListener` | 페이지·첨부 변경을 코어 `activity_logs` 에 기록 | +| `ActivityLogDescriptionResolver` | 그 기록의 설명 변수(ID → 표시명) 해석 | +| `SeoPageCacheListener` | 내용이 바뀌면 봇 화면 캐시 무효화 | +| `SearchPagesListener` | 코어 통합 검색에 페이지 결과 편입 | +| `Ckeditor5ReferenceSourcesListener` | 편집기 이미지 출처에 페이지 첨부 제공 | + +새 활동 로그 항목을 더할 때는 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description +본문이 함께 필요합니다 — **모듈 lang 파일에 넣으면 해석되지 않습니다.** 번들 일본어 팩도 같은 +작업 단위에서 동기화합니다. + + +## 레이아웃 확장 + + +_레이아웃 확장이 없습니다._ + + + +없습니다. 이 모듈은 다른 확장·템플릿의 화면에 조각을 주입하지 않습니다. + +페이지 내용을 다른 화면에 노출하고 싶다면 그 화면을 소유한 쪽(템플릿 또는 그 모듈)이 이 +모듈의 공개 API 를 호출하는 것이 맞는 방향입니다. 여기에 조각을 더하면 대상 화면이 슬롯을 +없앨 때 오류 없이 사라지는 결합이 생깁니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +없습니다. 이 모듈의 라우트는 코어가 제공하는 인증 미들웨어(`auth:sanctum` · +`optional.sanctum`)와 요율 제한만 씁니다. + +발행 상태·열람 권한 판정은 미들웨어가 아니라 **컨트롤러 안에서** 이루어집니다. 페이지 본문과 +첨부 두 종류의 응답에 서로 다른 판정이 필요하고(첨부 미리보기는 서명 링크도 인정), 그 차이를 +미들웨어 하나로 표현하면 어느 쪽이든 과하거나 모자라기 때문입니다. 그 대신 **경로마다 게이트를 +재적용해야 한다**는 의무가 생깁니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +없습니다. 페이지는 실시간 갱신이 필요한 콘텐츠가 아닙니다 — 발행 시점이 운영자의 조작이고, +방문자는 그 시점 이후의 접속에서 새 내용을 봅니다. + +실시간이 필요한 화면이 생기면 이 모듈에 채널을 더하는 것이 아니라, `page.after_publish` 를 +구독하는 쪽에서 자기 채널로 내보냅니다. + + +## 스케줄 + + +| 스케줄 | 주기 | 설명 | +|---|---|---| +| `sirsoft-page:prune-temp-attachments` | `daily` | 미연결 임시 페이지 첨부 자동 삭제 | + + + +하나뿐입니다. `prune-temp-attachments` 는 **업로드했지만 페이지 저장까지 이어지지 않은 파일**을 +정리합니다 — 편집 중 창을 닫은 세션의 부산물이라 운영 데이터가 아니며, 그래서 설정 토글 없이 +상시 동작합니다(보존 기간은 커맨드 옵션). + +이미 페이지에 연결된 첨부는 이 스케줄의 대상이 아닙니다. 페이지를 지우면 그 첨부는 삭제 흐름 +안에서 함께 정리됩니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +없습니다. 페이지 발행은 특정 수신자를 향한 사건이 아니라 사이트 전체에 대한 게시라, 누구에게 +보내야 할지가 정해지지 않습니다. + +약관 개정 안내처럼 발행을 계기로 알림을 보내야 한다면 `page.after_publish` 를 구독해 코어 +`GenericNotification` 으로 발송하는 리스너를 **그 알림을 필요로 하는 확장 쪽에** 둡니다. +수신자 범위가 사이트마다 다르므로 이 모듈이 정할 수 없습니다. + + +## 활동 로그 훅 + +> 이 확장이 코어 활동 로그(`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 | diff --git a/modules/_bundled/sirsoft-page/docs/frontend.md b/modules/_bundled/sirsoft-page/docs/frontend.md new file mode 100644 index 00000000..9e3476cb --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/frontend.md @@ -0,0 +1,85 @@ +# 페이지 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 3개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 3개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `admin_page_detail` | `admin` | 화면 | `_admin_base` | +| `admin_page_form` | `admin` | 화면 | `_admin_base` | +| `admin_page_list` | `admin` | 화면 | `_admin_base` | + + + +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 에 없으면 그 스타일만 조용히 빠지므로, +기존 레이아웃에 없던 클래스를 도입할 때는 확인이 필요합니다. + + +## 액션 핸들러 + + +_등록하는 액션 핸들러가 없습니다._ + + + +없습니다. 이 모듈의 관리자 화면은 코어 엔진의 기본 핸들러(`apiCall` · `navigate` · `setState` +등)만으로 충분해서 자체 핸들러를 두지 않았습니다. + +그래서 **전역 진입점(`initModule`)도 없고 빌드 산출물(`dist/`)도 없습니다.** 핸들러를 처음 +추가할 때는 셋이 함께 필요합니다 — 엔트리 파일, `window.__SirsoftPage.initModule()` 재등록 +진입점, 그리고 `module:build --production` 으로 구운 `dist/` 커밋. 진입점을 빠뜨리면 로케일 +전환 직후 그 핸들러들이 오류 없이 무반응이 됩니다. + + +## 전역 진입점 + + +_프론트 엔트리포인트가 없습니다._ + + + +없습니다 — 등록할 액션 핸들러가 없기 때문입니다. + +핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록 +진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부 +무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 +작업을 포함하지 않습니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +JS·CSS 산출물이 없고 `editor-spec.json` 하나만 있습니다 — 레이아웃 편집기가 이 모듈의 화면을 +편집할 때 쓰는 팔레트·중첩 규칙 선언이며, 실행 코드가 아니라 manifest 입니다. + +로딩 설정(`strategy: global`, `priority: 100`)은 골격 기본값이 그대로 남은 것입니다. 실을 +자산이 없으므로 현재는 아무 영향이 없지만, 나중에 JS 를 더하면 이 선언이 확장 번들 안에서의 +순서를 정하게 됩니다. + +`editor-spec.json` 을 고친 뒤에는 빌드 없이 `php artisan module:update sirsoft-page --force` +만 실행합니다. 편집기는 활성 디렉토리 기준으로 서빙하므로 `_bundled` 만 고치면 반영되지 +않습니다. + diff --git a/modules/_bundled/sirsoft-page/docs/settings.md b/modules/_bundled/sirsoft-page/docs/settings.md new file mode 100644 index 00000000..07ec28c3 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/settings.md @@ -0,0 +1,130 @@ +# 페이지 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +_`getSettingsSchema()` 선언이 없습니다._ + +기본값 파일: `config/settings/defaults.json` + + + +`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.*`)는 바꾸지 않아야 기존 설치의 값이 유지됩니다. + + +## 권한 + + +| 카테고리 | 이름 | 액션 | 라우트 키 | +|---|---|---|---| +| `pages` | 페이지 관리 | `read`, `create`, `update`, `delete` | `page` | + + + +`pages` 하나에 `read`/`create`/`update`/`delete` 네 액션이 전부입니다. 라우트 키 `page` 가 +선언되어 있어 관리자 라우트에 스코프 미들웨어가 걸립니다. + +`read` 가 관장하는 범위에 주의가 필요합니다 — 관리자 목록·상세뿐 아니라 **미발행 페이지의 +공개 화면 미리보기**와 **미발행 페이지 첨부의 서빙**까지 이 권한이 판정합니다. 그래서 이 +권한을 넓게 주면 아직 공개하지 않은 문서가 그 계정에 열립니다. + +역할(`getRoles()`)은 선언하지 않습니다. 게시판처럼 대상마다 담당자가 갈리는 도메인이 아니라 +페이지 전체를 한 사람이 관리하는 경우가 대부분이므로, 코어 역할에 이 권한을 부여하는 것으로 +충분하다고 보았습니다. + + +## 메뉴 + + +| 구분 | slug | 이름 | URL | 하위 | +|---|---|---|---|---| +| 관리자 | `sirsoft-page` | 페이지 관리 | `/admin/pages` | - | + + + +최상위 메뉴 하나(`/admin/pages`)뿐이고 하위 메뉴가 없습니다. 화면이 목록·작성/수정·상세 셋뿐이며 +셋 다 목록에서 이어지므로 별도 진입점이 필요 없습니다. + +메뉴는 **권한과 짝을 이룰 때만 보입니다.** `pages.read` 가 없는 역할에는 이 메뉴가 렌더되지 +않습니다. 새 화면을 더한다면 권한·메뉴·라우트 이름 셋이 서로를 정확히 가리키는지 함께 +확인합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/modules/sirsoft-page/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +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 가 됩니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `sirsoft-marketing` | 플러그인 | `>=1.0.0` | +| `sirsoft-basic` | 템플릿 | `>=1.1.0` | + + + +이 모듈은 아무 확장에도 의존하지 않습니다. 관계는 한 방향으로 들어옵니다 — +`sirsoft-marketing` 플러그인과 `sirsoft-basic` 템플릿이 이 모듈을 요구합니다. + +manifest 에는 없지만 **훅으로 맞물리는 확장이 하나 더** 있습니다: `sirsoft-ckeditor5` 가 +없으면 편집기 이미지 출처 제공만 비고 나머지는 정상 동작하므로, 의존으로 올리지 않는 것이 +맞습니다. + +이 모듈의 공개 표면(Service·Repository·Contracts·라우트·발행 훅)을 바꿀 때는 위 확장들의 +`dependencies` 최소 버전 상향이 필요한지 검토합니다. 특히 공개 조회 API 의 응답 형태는 +템플릿이 그대로 화면에 그리므로, 필드를 빼면 그 템플릿의 페이지 화면이 빈 채로 렌더됩니다. + diff --git a/modules/_bundled/sirsoft-page/editor-spec.json b/modules/_bundled/sirsoft-page/editor-spec.json index 802806e8..acee4bc9 100644 --- a/modules/_bundled/sirsoft-page/editor-spec.json +++ b/modules/_bundled/sirsoft-page/editor-spec.json @@ -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": { diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json index 49d69062..d5c43683 100644 --- a/modules/_bundled/sirsoft-page/module.json +++ b/modules/_bundled/sirsoft-page/module.json @@ -5,7 +5,7 @@ "ko": "페이지", "en": "Page" }, - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "description": { "ko": "정적 페이지(정보/정책/안내) 관리 모듈", diff --git a/modules/_bundled/sirsoft-page/package-lock.json b/modules/_bundled/sirsoft-page/package-lock.json index a786b89a..e20deeb6 100644 --- a/modules/_bundled/sirsoft-page/package-lock.json +++ b/modules/_bundled/sirsoft-page/package-lock.json @@ -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", diff --git a/modules/_bundled/sirsoft-page/package.json b/modules/_bundled/sirsoft-page/package.json index 74bea862..3047c5fa 100644 --- a/modules/_bundled/sirsoft-page/package.json +++ b/modules/_bundled/sirsoft-page/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-page", - "version": "1.1.0", + "version": "1.1.1", "description": "그누보드7 페이지 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/plugins/_bundled/gnuboard7-hello_plugin/AGENTS.md b/plugins/_bundled/gnuboard7-hello_plugin/AGENTS.md new file mode 100644 index 00000000..0ba92ccd --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/AGENTS.md @@ -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. 이 확장은 무엇인가 + + +**학습용 최소 샘플 플러그인**입니다. 플러그인의 핵심 역할인 **훅 구독**을 두 종류로 시연하는 +것이 유일한 목적입니다 — 부가 작업을 수행하는 Action 리스너 하나와, 흐름 중간에서 값을 가공하는 +Filter 리스너 하나. + +대상은 학습용 모듈(`gnuboard7-hello_module`)의 메모입니다. 메모가 생성되면 로그를 남기고(Action), +메모 제목이 화면에 나가기 전에 접두사를 붙입니다(Filter). **모듈 코드는 한 줄도 고치지 +않습니다** — 그것이 훅 시스템이 존재하는 이유입니다. + +**모듈과 플러그인의 경계**도 함께 보여줍니다. 플러그인은 완전한 페이지 레이아웃을 등록할 수 +없고, 설정 화면(`plugin_settings.json`)과 `layout_extensions`(다른 화면에 끼워 넣는 조각)만 +허용됩니다. 이 샘플에는 설정 화면 하나가 있습니다. + +`manifest.hidden = true` 라 관리자 UI 의 플러그인 목록에 나타나지 않습니다. artisan CLI 로는 +정상 설치·활성화됩니다. + +**의도적으로 하지 않는 것**: 모델·테이블·마이그레이션·API 라우트. 플러그인이 자기 데이터를 가질 +수는 있지만(다른 플러그인들이 그렇습니다), 이 샘플은 **훅만** 보이면 되므로 두지 않았습니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + + +## 3. 핵심 흐름 + + +**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 +으로 취급해 **반환값을 버립니다** — 리스너는 정상 실행되고 오류도 없는데 가공만 반영되지 +않습니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 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#알림-정의) | + + + +이 샘플이 보여주는 것은 **구독 쪽**이지만, 발행도 하나 있습니다. + +| 방향 | 훅 | 무엇을 보여주는가 | +|---|---|---| +| 구독 (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 의존을 +선언합니다. 구독 대상이 없으면 훅이 발화하지 않을 뿐이지만, 이 샘플은 **그 모듈의 훅을 보는 +것 자체가 목적**이라 모듈 없이는 존재 이유가 없습니다. 실제 플러그인에서는 "없으면 그 기능만 +비는" 관계인지 "없으면 성립하지 않는" 관계인지를 보고 의존 선언 여부를 정합니다. + +레이아웃 확장·미들웨어·브로드캐스트·스케줄·알림·권한·메뉴는 없습니다. + + +## 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. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| Filter 훅을 구독하면서 `'type' => 'filter'` 를 빠뜨리기 | 선언 필수 | 코어가 Action 으로 취급해 **반환값을 버린다** — 리스너는 실행되고 오류도 없는데 가공만 반영되지 않는다 | +| 대상 모듈의 코드를 직접 고쳐 부가 동작을 넣기 | 훅 구독 | 모듈이 업그레이드될 때마다 충돌하고, 플러그인을 꺼도 그 동작이 남는다 | +| 부가 동작을 설정 없이 무조건 수행 | 설정 토글(`log_enabled`) 뒤에 둔다 | 설치한 사이트가 끌 방법이 없다 | +| 리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 직접 호출 | Repository 인터페이스 주입 | 리스너가 데이터 접근 규약의 예외가 되면 그 예외가 번진다 | +| 플러그인에 완전한 페이지 레이아웃을 등록 | 설정 화면(`plugin_settings.json`)과 `layout_extensions` 만 | 페이지 소유권은 모듈·템플릿에 있다 — 경로를 다투면 설치 순서에 따라 화면이 바뀐다 | +| 이 샘플에 기능을 더해 "쓸모 있게" 만들기 | 짧게 유지하고, 필요한 기능은 별도 확장으로 | 샘플의 가치는 한눈에 읽히는 것이다 | +| `manifest.hidden` 을 제거 | 그대로 둔다 (복제본에서만 제거) | 학습용 플러그인이 운영 사이트의 목록에 섞인다 | +| 금전이 오가는 훅을 기본 설정(큐)으로 구독 | `'sync' => true` | 커밋 뒤 실행이라 예외를 던져도 롤백되지 않는다 (이 샘플에는 해당 없으나 실제 플러그인에서 자주 걸린다) | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| 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='<대상클래스>' + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md b/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md index d22ec620..a4e228fb 100644 --- a/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md +++ b/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md @@ -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 diff --git a/plugins/_bundled/gnuboard7-hello_plugin/README.md b/plugins/_bundled/gnuboard7-hello_plugin/README.md new file mode 100644 index 00000000..6add9dcc --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/README.md @@ -0,0 +1,183 @@ +# Hello 플러그인 + +**그누보드7 플러그인 · gnuboard7-hello_plugin** +학습용 최소 샘플 플러그인 (Hello 모듈 훅 소비) + + +

+ version 0.1.2 + type 플러그인 + 그누보드7 >=7.0.0 + license MIT + requires gnuboard7-hello_module +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +그누보드7 **플러그인이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 업무에 쓰는 기능은 +없습니다. + +플러그인의 핵심 역할은 **다른 확장의 코드를 고치지 않고 그 동작에 끼어드는 것**입니다. 이 +샘플은 그 두 가지 방식을 하나씩 보여줍니다 — 학습용 모듈에 메모가 등록되면 기록을 남기고, +메모 제목이 화면에 나가기 전에 앞에 표시를 붙이는 것입니다. + +관리자 화면의 플러그인 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로 +설치·활성화할 수 있으며, 학습용 모듈이 함께 설치되어 있어야 동작을 확인할 수 있습니다. + +플러그인은 자기 페이지를 가질 수 없습니다 — 설정 화면과 "다른 화면에 끼워 넣는 조각" 만 +허용됩니다. 이 샘플에는 설정 화면 하나가 있습니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 기록 남기기 | 학습용 모듈에 메모가 등록되면 로그 파일에 기록 (설정으로 끌 수 있음) | +| 제목 가공 | 메모 제목 앞에 표시를 붙이는 예시 | +| 설정 화면 | 기록 사용 여부를 켜고 끄는 관리자 설정 | +| 연결점 제공 | 기록을 남긴 직후 다른 확장이 반응할 수 있는 연결점 | +| 다국어 | 한국어·영어 화면 문구 | +| 테스트 | 모듈이 신호를 보내고 이 플러그인이 받는지 확인하는 예시 | + + +## 동작 방식 + + +```mermaid +flowchart LR + M[학습용 모듈
메모 등록] -->|생성 신호| P[이 플러그인] + P -->|설정이 켜져 있으면| LOG[(로그 기록)] + LOG -->|기록 완료 신호| X[다른 확장] + M -.제목 가공 요청.-> P2[제목 앞에 표시 붙이기] +``` + +모듈은 이 플러그인의 존재를 모릅니다. 모듈이 "메모가 등록되었다" 는 신호를 보내면, 그 신호를 +듣고 있던 이 플러그인이 자기 일을 합니다. 그래서 플러그인을 꺼도 모듈은 그대로 동작합니다. + +기록을 남긴 뒤에는 이 플러그인도 신호를 보냅니다 — 신호를 받은 확장이 다시 신호를 보내며 +이어지는 것이 확장 시스템의 기본 구조입니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.0` | +| PHP | `^8.2` | +| 의존 모듈 | `gnuboard7-hello_module` `>=0.1.0` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install gnuboard7-hello_plugin + +# 활성화 +php artisan plugin:activate gnuboard7-hello_plugin + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update gnuboard7-hello_plugin --force +``` + + +## 관리자 설정 + + +| 키 | 의미 | 기본값 | +|---|---|---| +| `log_enabled` | 로그 기록 사용 | `true` | + +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + + + +설정 항목은 하나뿐입니다. + +| 항목 | 기본값 | 바꾸면 달라지는 것 | +|---|---|---| +| 로그 기록 사용 | 켜짐 | 끄면 메모가 등록되어도 기록을 남기지 않습니다 (모듈 동작에는 영향 없음) | + +부가 동작을 **설정으로 끌 수 있게 만드는 것**이 이 항목의 학습 포인트입니다. 설정 없이 무조건 +동작하면 그 플러그인을 설치한 사이트는 동작을 멈출 방법이 없습니다. + + +## 사용 방법 + + +**설치해 보기**: 학습용 모듈을 먼저 설치한 뒤 이 플러그인을 설치합니다. + +```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 메모" 를 등록하면 로그에 기록이 남습니다. 플러그인 설정에서 기록을 끈 뒤 +다시 등록해 보면 기록이 남지 않는 것을 확인할 수 있습니다 — 모듈 동작 자체는 그대로입니다. + +**새 플러그인의 출발점으로 쓰기**: 이 디렉토리를 복제한 뒤 식별자·네임스페이스를 모두 바꾸고 +학습용 표시를 지우면 새 플러그인이 됩니다. 자세한 절차는 확장 시스템 문서의 "학습용 샘플 확장" +항목을 참고합니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `gnuboard7-hello_module` | 모듈 | `>=0.1.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 관리자 플러그인 목록에 이 플러그인이 없음 | 학습용이라 목록에서 제외됨 | 정상입니다. 명령줄로 설치·활성화합니다 | +| 메모를 등록해도 기록이 남지 않음 | 설정에서 기록이 꺼져 있거나 학습용 모듈이 비활성 | 플러그인 설정과 모듈 활성화 상태를 확인합니다 | +| 제목 가공이 반영되지 않음 | 이 예시가 기대하는 가공 요청 지점이 모듈에 없음 | 정상입니다. 연결점이 없어도 등록 자체는 유효하며, 그 지점이 생기면 자동으로 동작합니다 | +| 복제해서 만든 플러그인이 관리자 목록에 안 보임 | 복제본에 학습용 표시가 남아 있음 | 복제본의 `hidden` 표시를 지웁니다 | +| 복제한 플러그인에서 값 가공이 무시됨 | 가공용 구독에 종류 표시가 빠짐 | 가공(Filter) 구독에는 종류를 명시해야 반환값이 반영됩니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/plugins/_bundled/gnuboard7-hello_plugin/composer.json b/plugins/_bundled/gnuboard7-hello_plugin/composer.json index 52de65aa..52f84aeb 100644 --- a/plugins/_bundled/gnuboard7-hello_plugin/composer.json +++ b/plugins/_bundled/gnuboard7-hello_plugin/composer.json @@ -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/", "./"] diff --git a/plugins/_bundled/gnuboard7-hello_plugin/docs/README.md b/plugins/_bundled/gnuboard7-hello_plugin/docs/README.md new file mode 100644 index 00000000..db5cadab --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/docs/README.md @@ -0,0 +1,22 @@ +# Hello 플러그인 개발자 문서 + +> plugins/_bundled/gnuboard7-hello_plugin · 플러그인 + + +**훅 수**: 1 · **구독 훅 수**: 2 · **라우트 수**: 0 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 0 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/gnuboard7-hello_plugin/docs/architecture.md b/plugins/_bundled/gnuboard7-hello_plugin/docs/architecture.md new file mode 100644 index 00000000..deb0277b --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/docs/architecture.md @@ -0,0 +1,71 @@ +# Hello 플러그인 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +"플러그인은 무엇을 하는가" 에 대한 답을 **가장 짧게** 보이는 것이 목표입니다. 플러그인의 핵심은 +**다른 확장의 코드를 고치지 않고 그 동작에 끼어드는 것**이며, 그 방식이 둘(Action·Filter) +이므로 리스너도 둘입니다. + +거기에 두 가지를 덧붙였습니다: + +- **부가 동작은 설정으로 끌 수 있어야 한다** — `log_enabled` 가 그 본보기입니다. 리스너가 + 무조건 동작하면 그 확장을 설치한 사이트는 멈출 방법이 없습니다. +- **구독한 확장이 다시 발행할 수 있다** — `log.written` 이 그 예입니다. 훅은 한 번 받고 끝나는 + 것이 아니라 연쇄를 이룹니다. + +**플러그인의 경계**도 구조로 드러납니다. 완전한 페이지 레이아웃을 등록할 수 없고, 설정 화면 +(`plugin_settings.json`)과 `layout_extensions`(다른 화면에 끼워 넣는 조각)만 허용됩니다. 이 +샘플에는 설정 화면 하나가 있고 `layout_extensions` 는 없습니다. + +**의도적으로 하지 않는 것**: 모델·테이블·마이그레이션·API 라우트·권한·메뉴. 플러그인이 자기 +데이터를 가질 수는 있지만(실제 플러그인들이 그렇습니다), 이 샘플은 훅만 보이면 되므로 두지 +않았습니다. `manifest.hidden = true` 로 관리자 UI 목록에서 제외되며 CLI 로는 정상 동작합니다. + + +## 계층 지도 + + +``` +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` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를 +찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `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` 재실행 | + diff --git a/plugins/_bundled/gnuboard7-hello_plugin/docs/data-model.md b/plugins/_bundled/gnuboard7-hello_plugin/docs/data-model.md new file mode 100644 index 00000000..886128e6 --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/docs/data-model.md @@ -0,0 +1,76 @@ +# Hello 플러그인 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +_소유 모델이 없습니다._ + + + +없습니다. 이 샘플은 자기 데이터를 갖지 않습니다. + +플러그인이 모델과 테이블을 가질 수는 있고 실제로 그런 플러그인이 많습니다(결제 이력·동의 기록· +메시지 발송 기록 등). 다만 이 샘플의 목적은 **훅 구독**을 보이는 것이라, 데이터 계층을 두면 +읽어야 할 코드만 늘어납니다. + +모델·Repository·마이그레이션이 있는 플러그인 예시가 필요하면 실제 도메인 플러그인의 문서를 +참고합니다. + + +## 소유 테이블 + + +_소유 테이블이 없습니다._ + + + +없습니다. 저장하는 데이터가 없습니다. + +플러그인이 테이블을 가질 때는 **확장 식별자를 접두사로** 붙입니다 — 확장은 같은 데이터베이스를 +공유하므로 짧은 이름을 쓰면 다른 확장과 충돌합니다. 그리고 플러그인 제거 시 정리 대상임을 +`getDynamicTables()` 로 코어에 알립니다. + + +## 마이그레이션 + + +_마이그레이션이 없습니다._ + + + +없습니다. 스키마가 없으므로 마이그레이션도 없습니다. + +플러그인이 마이그레이션을 가질 때의 규약은 모듈과 같습니다 — 한국어 `comment` 와 `down()` +필수, 초기 `create_*` 파일을 나중에 고치지 않기, 기존 행을 손봐야 하는 변경에는 `upgrades/` +업그레이드 스텝 백필 동반. + + +## Enum + + +_Enum 이 없습니다._ + + + +없습니다. 이 샘플에는 상태도 분류도 없습니다. + +실제 확장에서 상태·타입·분류를 다룰 때는 문자열 리터럴이 아니라 Enum 을 단일 출처로 둡니다 — +화면 필터 옵션·검증 게이트·실제 기록 값 셋이 같은 Enum 에서 파생되지 않으면, 빠진 값으로 +기록된 행이 어떤 필터로도 도달할 수 없게 됩니다. + + +## Repository + + +_Repository 가 없습니다._ + + + +없습니다. 데이터 접근 자체가 없습니다. + +리스너에서 데이터에 접근해야 한다면 `Model::query()` · `DB::table()` · `$row->save()` 를 직접 +부르지 않고 **Repository 인터페이스를 주입**받습니다. 리스너가 데이터 접근 규약의 예외가 되면 +그 예외가 다른 리스너로 번집니다. + diff --git a/plugins/_bundled/gnuboard7-hello_plugin/docs/editor-spec.md b/plugins/_bundled/gnuboard7-hello_plugin/docs/editor-spec.md new file mode 100644 index 00000000..019c28aa --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/docs/editor-spec.md @@ -0,0 +1,82 @@ +# Hello 플러그인 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._ + + + +학습용 샘플 플러그인이라 편집기 스펙을 두지 않았고, 지금 상태에서는 **둘 필요도 +없습니다.** 이 플러그인이 소유한 화면은 설정 화면 하나이고 그 화면이 읽는 `settings` 는 +여러 확장이 공유하는 공용 ID 라 admin 템플릿 스펙이 이미 채웁니다. + +이것이 "스펙 없음" 의 정상 형태입니다 — 아래 미커버 목록이 비어 있다는 사실이 그 +근거입니다. + + +## 선언 블록 + + +_선언된 편집기 스펙 블록이 없습니다._ + + + +선언한 블록이 없습니다. 공용 ID 만 쓰는 확장은 자기 스펙을 갖지 않는 것이 규율에 +맞습니다 — 같은 ID 의 샘플을 확장마다 두면 어느 것이 쓰이는지가 합본 순서에 좌우되고, +둘이 갈라져도 오류가 나지 않습니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다. +편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도 +`componentPalette` 는 여전히 비어 있을 것입니다. + + +## 샘플 데이터와 페이지 상태 + + +_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._ + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +미커버가 없습니다. 이 플러그인의 레이아웃이 쓰는 `data_source` 는 전부 번들 템플릿 +스펙이 채우므로, 편집기에서 설정 화면을 열면 값이 채워진 상태로 보입니다. + +스펙을 갖지 않은 확장이 이 상태여야 정상입니다. + + +## 수정 시 동반 의무 + + +_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._ + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + + + +편집기 스펙을 신설해야 하는 시점은 하나입니다 — **이 확장이 소유한 레이아웃에 +이 확장만 쓰는 `data_source` 가 생겼을 때**. 그 순간부터 편집기 캔버스의 그 영역은 +빈 화면이 되고, 실제 화면은 정상 동작하므로 오류도 경고도 남지 않습니다. + +신설 절차는 확장 루트에 `editor-spec.json` 을 만들고 `sampleData.byDataSourceId` 에 +그 ID 를 넣는 것으로 시작합니다. 팔레트·컨트롤은 템플릿의 일이므로 넣지 않습니다. +파일을 만든 뒤 update 커맨드로 활성 디렉토리에 반영해야 편집기가 읽습니다 — +`_bundled` 폴백이 없습니다. + diff --git a/plugins/_bundled/gnuboard7-hello_plugin/docs/extension-points.md b/plugins/_bundled/gnuboard7-hello_plugin/docs/extension-points.md new file mode 100644 index 00000000..93c642a7 --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/docs/extension-points.md @@ -0,0 +1,146 @@ +# Hello 플러그인 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 1종 / 호출 지점 1곳. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `gnuboard7-hello_plugin.log.written` | action | Hello 플러그인이 로그 파일에 기록을 남긴 직후 실행되는 액션 훅 | `src/Listeners/LogMemoCreatedListener.php:83` | + + + +하나이며, **구독한 플러그인이 다시 발행하는** 형태를 보이기 위한 것입니다. + +`LogMemoCreatedListener` 가 로그를 기록한 직후 `gnuboard7-hello_plugin.log.written` 을 +발행합니다. 훅은 한 번 받고 끝나는 것이 아니라 연쇄를 이루며, 또 다른 확장이 이 플러그인의 +동작에 반응할 수 있습니다. + +`getHooks()` 선언이 있어 표에 유형과 설명이 함께 실립니다 — 발행 훅을 선언하면 구독하려는 +쪽에 계약이 드러납니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `gnuboard7-hello_module.memo.created` | action (미선언) | `LogMemoCreatedListener` | `onMemoCreated` | 10 | +| `gnuboard7-hello_module.memo.title.filter` | filter | `FilterMemoTitleListener` | `prependHelloPrefix` | 10 | + + + +둘이며 **두 종류를 하나씩** 보여줍니다. + +| 훅 | 종류 | 무엇을 보여주는가 | +|---|---|---| +| `gnuboard7-hello_module.memo.created` | Action | 흐름에 부가 작업을 붙인다. 반환값은 흐름에 영향을 주지 않는다 | +| `gnuboard7-hello_module.memo.title.filter` | Filter | 흐름 중간의 값을 가공해 **반환**한다. 반환값이 다시 흐름에 들어간다 | + +**Filter 구독에는 `'type' => 'filter'` 선언이 반드시 필요합니다.** 빠뜨리면 코어가 Action 으로 +취급해 반환값을 버립니다 — 리스너는 정상 실행되고 오류도 없는데 가공만 반영되지 않습니다. + +Filter 쪽 훅은 **학습용 모듈이 실제로 발행하지 않습니다.** 리스너 docblock 이 "발행한다고 +가정하고" 라고 밝히고 있으며, 그 자체가 학습 포인트입니다 — 훅이 발행되지 않아도 리스너 등록은 +유효하고, 나중에 발행 지점이 생기면 그때부터 자동으로 호출됩니다. 구독은 발행자에게 아무 부담을 +주지 않으므로 확장이 서로를 몰라도 됩니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `FilterMemoTitleListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/FilterMemoTitleListener.php` | +| `LogMemoCreatedListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/LogMemoCreatedListener.php` | + + + +둘 다 `HookListenerInterface` 를 구현하고 `getSubscribedHooks()` 로 자기 구독을 선언합니다 +(명시 등록). + +`LogMemoCreatedListener` 는 **설정을 먼저 확인**한 뒤 동작합니다 — +`log_enabled` 가 `false` 면 조용히 건너뜁니다. 부가 동작을 설정 뒤에 두는 것이 규약이며, 설정 +없이 무조건 동작하면 그 확장을 설치한 사이트가 멈출 방법이 없습니다. + +`FilterMemoTitleListener` 는 `'type' => 'filter'` 를 선언합니다. 이 선언이 없으면 반환값이 +버려집니다. + +두 리스너 모두 데이터에 직접 접근하지 않습니다. 접근이 필요하면 `Model::query()` 나 +`DB::table()` 이 아니라 Repository 인터페이스를 주입받습니다. + + +## 레이아웃 확장 + + +_레이아웃 확장이 없습니다._ + + + +없습니다. 이 샘플은 다른 화면에 조각을 주입하지 않습니다. + +플러그인이 화면에 관여하는 통로는 둘뿐입니다 — 설정 화면(`plugin_settings.json`)과 +`layout_extensions`(다른 확장·템플릿 화면에 끼워 넣는 조각). **완전한 페이지 레이아웃은 등록할 +수 없습니다** — 페이지 소유권은 모듈·템플릿에 있고, 경로를 다투면 어느 쪽이 이기는지가 설치 +순서에 좌우됩니다. + +주입 예시가 필요하면 실제로 그렇게 하는 플러그인(마케팅의 회원가입 동의 항목, 편집기의 본문 +입력 자리)의 문서를 참고합니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +없습니다. 이 샘플은 요청 흐름에 개입하지 않습니다. + +플러그인이 미들웨어를 등록할 때는 `getMiddleware()` 로 **부착 대상(targets)을 스스로 선언** +합니다(self-gate). 커널 미들웨어 그룹을 직접 조작하거나 라우트 파일에 FQCN 을 붙이지 않습니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +없습니다. 샘플에 넣으면 훅을 보러 온 사람이 읽어야 할 코드가 늘어납니다. + +채널을 등록할 때는 `getChannels()` 를 오버라이드합니다 — `routes/channels.php` 에 하드코딩 +하지 않습니다. 채널명에는 확장 프리픽스(`plugin.{id}.*`)를 붙입니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +없습니다. 샘플에는 시간 축 동작이 없습니다. + +스케줄 선언 형태(`command` · `schedule` · `description` · `enabled_config`)는 실제로 스케줄을 +쓰는 확장의 문서를 참고합니다. `schedule` 키를 빠뜨리면 코어 등록부가 그 항목을 건너뛰는데 +예외도 경고도 남지 않습니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +없습니다. 알림은 수신자 해석·채널 게이트·템플릿까지 함께 필요해 "하나씩만" 원칙으로 담기 +어렵습니다. + +알림이 필요한 예시는 실제로 알림을 발송하는 확장의 문서를 참고합니다. 코어 +`GenericNotification` 범용 클래스 하나로 처리하며 개별 Notification 클래스를 만들지 않습니다. + diff --git a/plugins/_bundled/gnuboard7-hello_plugin/docs/frontend.md b/plugins/_bundled/gnuboard7-hello_plugin/docs/frontend.md new file mode 100644 index 00000000..d01a06c0 --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/docs/frontend.md @@ -0,0 +1,79 @@ +# Hello 플러그인 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +설정 화면(`plugin_settings`) 하나뿐입니다. **플러그인은 완전한 페이지 레이아웃을 등록할 수 +없습니다** — 설정 화면과 `layout_extensions`(다른 화면에 끼워 넣는 조각)만 허용됩니다. + +`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를 +찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다. + +이 레이아웃은 설정 자동 바인딩 패턴의 예시이기도 합니다 — 입력 항목의 `name` 이 설정 키와 +맞으면 값 로드·저장이 자동으로 배선됩니다. + +레이아웃 JSON 만 고쳤다면 빌드 없이 +`php artisan plugin:update gnuboard7-hello_plugin --force` 로 반영합니다. + + +## 액션 핸들러 + + +_등록하는 액션 핸들러가 없습니다._ + + + +없습니다. 설정 화면은 코어 엔진의 기본 핸들러(`apiCall` · `setState` 등)만으로 충분합니다. + +핸들러를 처음 추가할 때는 셋이 함께 필요합니다 — 엔트리 파일, +`window.__[Name].initPlugin()` 재등록 진입점, 그리고 `--production` 으로 구운 `dist/` 커밋. +진입점을 빠뜨리면 로케일 전환 직후 그 핸들러들이 오류 없이 무반응이 됩니다. + + +## 전역 진입점 + + +_프론트 엔트리포인트가 없습니다._ + + + +없습니다 — 등록할 액션 핸들러가 없기 때문입니다. + +핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록 +진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부 +무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 +작업을 포함하지 않습니다. + + +## 에셋 + + +_프론트 에셋이 없습니다._ + + + +없습니다. 이 샘플의 프론트엔드는 설정 화면 레이아웃 JSON 하나와 다국어 JSON 뿐이라 빌드할 +실행 코드가 없습니다. + +그래서 반영이 `php artisan plugin:update gnuboard7-hello_plugin --force` 하나로 끝납니다. +JS 를 더하면 그때 빌드(`plugin:build --production`)·`dist/` 커밋·전역 진입점 셋이 함께 +필요해집니다. + +구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도 +서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다. 부득이 외부 호스트가 필요하면 +`trusted_script_hosts` 와 **그 사유**를 manifest 에 함께 선언합니다. + diff --git a/plugins/_bundled/gnuboard7-hello_plugin/docs/settings.md b/plugins/_bundled/gnuboard7-hello_plugin/docs/settings.md new file mode 100644 index 00000000..69763d91 --- /dev/null +++ b/plugins/_bundled/gnuboard7-hello_plugin/docs/settings.md @@ -0,0 +1,110 @@ +# Hello 플러그인 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `log_enabled` | `boolean` | `true` | 로그 기록 사용 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +하나뿐이며 그 하나가 규약의 본보기입니다. + +`log_enabled` 는 이 플러그인의 부가 동작(로그 기록)을 끄는 토글입니다. **부가 동작은 설정으로 +끌 수 있어야 한다**는 것이 규약이며, 설정 없이 무조건 동작하면 그 확장을 설치한 사이트는 멈출 +방법이 없습니다. + +읽기는 `plugin_setting()`(또는 `PluginSettingsService`)으로 하며, 리스너가 동작 **직전에** +확인합니다 — 등록 시점에 확인하면 설정을 바꿔도 다음 재부팅까지 반영되지 않습니다. + +설정 화면은 `resources/layouts/admin/plugin_settings.json` 이 그립니다. **파일 이름이 +계약**이므로 코어가 이 고정 경로를 찾으며, 이름을 바꾸면 설정 화면 자체가 사라집니다. + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +선언하지 않습니다. 이 샘플에는 접근을 나눌 화면도 데이터도 없습니다. + +설정 변경은 코어의 플러그인 설정 권한이 관장합니다. 플러그인이 자기 권한을 선언할 때는 +`{확장식별자}.{카테고리}.{액션}` 으로 이름이 조립되므로 다른 확장과 겹칠 걱정이 없습니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +등록하지 않습니다. 이 샘플에는 자체 관리 화면이 없습니다. + +설정은 코어의 플러그인 목록에서 이 플러그인의 설정으로 들어가는 공통 경로를 씁니다 — 코어가 +`resources/layouts/admin/plugin_settings.json` 을 찾아 그리므로 자체 메뉴가 필요 없습니다. + +메뉴를 등록할 때는 권한과 짝을 이뤄야 합니다. 권한만 추가하고 메뉴를 빠뜨리면 화면에 도달할 +길이 없고, 반대면 눌러도 403 입니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `web` | `src/routes/web.php` | `/plugins/gnuboard7-hello_plugin/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +`web.php` 하나뿐이며, **플러그인도 라우트를 가질 수 있다**는 사실을 보이기 위한 예시입니다. + +URL prefix 가 `/plugins/{식별자}/` 로 고정되는 것에 주의합니다. 확장이 다른 확장이나 코어의 +경로를 침범하지 않도록 코어가 강제하는 규칙이며, 이 네임스페이스 밖의 경로를 선언하면 어느 +쪽이 이기는지가 설치 순서에 좌우됩니다. + +모든 라우트에 `name()` 이 필요합니다. 라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다 — +확장 라우트는 활성 상태인 확장의 것만 등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 +404 가 됩니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `gnuboard7-hello_module` | 모듈 | `>=0.1.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +`gnuboard7-hello_module` 에 의존합니다(`>=0.1.0`). + +**이 의존 선언 자체가 학습 포인트**입니다. 훅 구독은 상대가 없으면 발화하지 않을 뿐이라 보통은 +manifest 의존으로 올리지 않습니다 — "없으면 그 기능만 비는" 관계이기 때문입니다. 그런데 이 +샘플은 **그 모듈의 훅을 보는 것 자체가 목적**이라 모듈 없이는 존재 이유가 없습니다. + +실제 플러그인에서는 이 둘을 구분해 판단합니다: + +| 관계 | 의존 선언 | +|---|---| +| 없으면 그 기능만 비고 나머지는 정상 | 선언하지 않는다 (훅 구독으로 충분) | +| 없으면 확장이 성립하지 않는다 | manifest 의존으로 선언 | + +의존을 과하게 선언하면 그 확장을 비활성화할 때 이쪽까지 함께 막히고, 부족하게 선언하면 상대가 +없을 때 조용히 아무 일도 하지 않습니다. + diff --git a/plugins/_bundled/gnuboard7-hello_plugin/plugin.json b/plugins/_bundled/gnuboard7-hello_plugin/plugin.json index db229b15..b27503b0 100644 --- a/plugins/_bundled/gnuboard7-hello_plugin/plugin.json +++ b/plugins/_bundled/gnuboard7-hello_plugin/plugin.json @@ -5,7 +5,7 @@ "ko": "Hello 플러그인", "en": "Hello Plugin" }, - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "description": { "ko": "학습용 최소 샘플 플러그인 (Hello 모듈 훅 소비)", diff --git a/plugins/_bundled/sirsoft-ckeditor5/AGENTS.md b/plugins/_bundled/sirsoft-ckeditor5/AGENTS.md new file mode 100644 index 00000000..5e4c0a73 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/AGENTS.md @@ -0,0 +1,209 @@ +# CKEditor 5 WYSIWYG 에디터 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-ckeditor5) — 코어 확장점 `html_editor`/`html_content` 에 CKEditor 5 를 끼워 넣는다. 편집기 자산은 CDN 이 아니라 `dist/vendor/ckeditor5/43.3.1/` 동봉본을 same-origin 으로 서빙 +2. 확장 방식: 발행 훅 4개. 업로드 전후 개입은 `image.before_upload`/`after_upload`/`filter_upload_file`, **본문에 이미지를 담는 확장은 `image.filter_reference_sources` 에 자기 테이블을 반드시 등록** +3. 건드리면 안 되는 것: 자산을 CDN 으로 되돌리기, 편집기 실패 시 빈 컨테이너 방치(평문 폴백 + `{name}_mode='text'` 유지), 참조 판정을 토큰 하나로 축소, 로그 사본 테이블을 참조 소스로 등록 +4. 작업 위치: `plugins/_bundled/sirsoft-ckeditor5` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-ckeditor5 --force` +``` + +## 1. 이 확장은 무엇인가 + + +코어가 정의한 두 확장점(`html_editor` · `html_content`)에 CKEditor 5 구현을 끼워 넣는 +플러그인입니다. 게시판 본문·상품 설명·페이지 내용 어디든 위지윅이 필요한 자리는 코어가 확장점만 +비워 두고, 이 플러그인이 `mode: replace` 로 그 자리를 차지합니다 — 그래서 편집기를 다른 것으로 +바꾸는 일은 코어를 고치는 것이 아니라 **이 플러그인을 다른 플러그인으로 교체하는 것**입니다. + +**설계 원칙 셋**: + +1. **편집기 자산을 자체 제공한다.** CKEditor 5 는 CDN 이 아니라 `dist/vendor/ckeditor5/43.3.1/` + 에 동봉되어 same-origin 으로 서빙됩니다. CDN 도달 실패는 예외도 서버 로그도 남기지 않고 + 편집기만 조용히 사라지기 때문입니다(폐쇄망·방화벽·광고차단기에서 재현). +2. **편집기를 못 불러와도 글은 쓸 수 있어야 한다.** 자산 확보에 실패하면 평문 입력창으로 + 내려가고 저장 계약(`{name}_mode = 'text'`)을 유지합니다. 재시도로 편집기가 뜨면 그때 + `_mode` 를 `'html'` 로 되돌리며, 그 사이에 쓴 내용은 승계됩니다. +3. **이미지 삭제 판정은 fail-closed 다.** 업로드 이미지가 어디서도 참조되지 않을 때만 지우는데, + 그 "어디"를 각 모듈이 훅으로 등록합니다. 설치돼 있으나 **비활성**인 모듈이 있으면 그 + 콘텐츠가 판정에서 빠져 실제로 쓰이는 이미지를 미참조로 오판하므로, 그 상태를 감지해 + 정리를 멈춥니다. + +**의도적으로 하지 않는 것**: 훅 구독 0 · 리스너 0 · 미들웨어 0 · 브로드캐스트 0 · 알림 0. +이 플러그인은 다른 확장의 흐름에 개입하지 않고, 자기 확장점 안에서만 삽니다. 본문 정화 +(sanitize)도 이 플러그인의 일이 아닙니다 — 저장측 검증과 봇 화면 정화는 코어가 담당합니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.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/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-ckeditor5 --force` (빌드 불필요) | +| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-ckeditor5 --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-ckeditor5 --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-ckeditor5 --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-ckeditor5 --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**편집기 장착**: 어떤 화면이 `html_editor` 확장점을 열면 → 코어가 +`resources/extensions/html-editor.json` 을 그 자리에 치환 → 조각의 `scripts` 가 동봉된 +`ckeditor5.umd.js` 를 same-origin 으로 로드 → 컨테이너 `onMount` 에서 +`sirsoft-ckeditor5.initEditor` 핸들러가 실행되어 편집기를 붙이고 +`form.{name}_mode = 'html'` 을 세웁니다. 화면을 떠날 때 `destroyEditor` 가 인스턴스를 +해제합니다. 자산 로드가 실패하면 `renderTextareaFallback` 이 평문 입력창을 그리고 사용자에게 +사실을 알린 뒤 재시도 통로를 남깁니다. + +**이미지 업로드 → 서빙**: 편집기가 `POST /api/plugins/sirsoft-ckeditor5/upload` 호출 → +`ImageUploadService`(`before_upload` → `filter_upload_file` → 저장 → `after_upload`) → +`ckeditor5_image_uploads` 에 기록. 서빙은 **해시 경로**(`GET images/{hash}`)이며, 설정 +디스크가 공개 URL 을 주는 환경에서는 본문에 디스크 직접 URL 이 박힙니다 — 그래서 본문에 +남는 URL 형태가 두 가지입니다. + +**미참조 이미지 정리**: `sirsoft-ckeditor5:prune-unused-images --scheduled`(일 1회, 설정 +`unusedImageCleanup` 이 켜져 있을 때만) → `ImageReferenceScanService` 가 코어 소스 6개 +테이블 + 모듈이 `image.filter_reference_sources` 로 등록한 소스를 훑어, **해시와 저장 +파일명 두 토큰을 OR 로** 검사합니다(한쪽만 보면 다른 형태로 저장된 이미지를 미참조로 +오판합니다). 보존기간(`unusedImageRetentionDays`, 기본 30일)이 지난 것만 대상이며, +비활성 설치 모듈이 있으면 판정 자체를 중단합니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 4개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 0개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 0개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 1개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +발행 훅은 4종뿐이지만 성격이 둘로 갈립니다. + +| 훅 | 무엇을 열어 주는가 | +|---|---| +| `image.before_upload` · `image.after_upload` | 업로드 전후. 본인인증 강제·쿼터 제한·외부 저장소 미러링을 붙이는 자리입니다 | +| `image.filter_upload_file` | 저장 직전 파일 변형 (압축·리사이즈·형식 변환) | +| `image.filter_reference_sources` | **정리 대상 판정에 자기 콘텐츠를 등록하는 자리** | + +`image.filter_reference_sources` 가 이 플러그인에서 가장 중요한 훅입니다. 본문에 이미지를 +담는 확장(게시판 글·상품 설명·페이지 내용)은 **반드시 자기 테이블·컬럼을 여기에 등록**해야 +합니다. 등록하지 않으면 그 확장의 콘텐츠는 참조 판정에서 통째로 빠지고, 실제로 화면에 보이는 +이미지가 "미참조" 로 분류되어 정리 대상이 됩니다 — 오류 없이 이미지가 깨지는 형태로만 드러납니다. + +등록할 때 **로그 사본 테이블을 소스로 삼지 않습니다.** 알림 발송 로그·메일 로그·신고 스냅샷· +레이아웃 미리보기는 자체 보존기간으로 지워지는 사본이라, 소스로 넣으면 "로그가 지워지는 순간 +이미지가 고아가 되는" 역전이 생깁니다. 코어가 그 넷을 명시적으로 제외한 이유입니다. + +레이아웃 확장 2개(`html-editor.json` · `html-content.json`)는 코어 확장점을 `replace` 로 +차지합니다. 다른 편집기 플러그인이 같은 확장점을 노리면 어느 쪽이 이기는지가 설치 순서에 +좌우되므로, 편집기 플러그인은 하나만 활성화하는 것이 전제입니다. + +구독 훅·리스너·미들웨어·브로드캐스트 채널·알림은 전부 0개입니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-ckeditor5 --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-ckeditor5` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 동봉 CKEditor 5 버전을 올렸다면 디렉토리명 · `resources/extensions/html-editor.json` 의 `scripts.src` · 소스 상수 · 테스트 단언을 **한 버전으로** 맞춘다 (하나만 어긋나면 그 자산이 404 인데 빌드·테스트는 통과한다) +- [ ] `dist/` 는 커밋되는 배포 산출물 — TS 를 고쳤으면 `--production` 재빌드 후 커밋 (`sourceMappingURL` 잔존 금지) +- [ ] 참조 소스 목록(코어 6종)을 바꿨다면 로그 사본 테이블이 섞이지 않았는지 확인 +- [ ] 정리 커맨드의 판정 로직을 고쳤다면 fail-open 가드(`hasPotentiallyMissingSources()`)가 여전히 앞에 있는지 확인 — 이 가드가 빠지면 이미지가 조용히 지워진다 +- [ ] 편집기 폴백 경로를 고쳤다면 저장 계약(`{name}_mode`)과 재시도 시 내용 승계가 유지되는지 확인 +- [ ] 프론트엔드를 고쳤다면 Playwright 위지윅 spec 을 함께 갱신·실행한다 (단위 테스트만으로는 편집기 장착 회귀가 드러나지 않는다) +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어 `ckeditor5Uploads` 가 편집기 캔버스에서 빈 화면으로 보인다. `data_source` 를 더 늘리면 그 자리도 같은 상태가 된다 + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 편집기 자산을 CDN 에서 로드 | `dist/vendor/ckeditor5/{version}/` 동봉 + same-origin 서빙 | CDN 도달 실패는 예외도 로그도 남기지 않고 편집기만 사라진다 — 폐쇄망·광고차단기에서 재현되고 서버에 흔적이 없다 | +| 자산 URL 을 문자열로 조립 (`'/api/plugins/assets/'+id+'/…'`) | `G7Core.asset.plugin` | 확장자를 정적 location 이 가로채는 서버에서 조립한 URL 만 404 가 된다 | +| 편집기 확보 실패 시 빈 컨테이너를 남기기 | 평문 입력창 폴백 + 저장 계약(`{name}_mode='text'`) 유지 + 재시도 시 내용 승계 | 빈 컨테이너는 "글을 쓸 수 없다" 인데 화면에는 아무 설명이 없다 | +| 본문에 이미지를 담는 확장이 `image.filter_reference_sources` 에 등록하지 않음 | 자기 테이블·컬럼을 등록 | 그 콘텐츠가 참조 판정에서 빠져, 화면에 보이는 이미지가 미참조로 분류되어 삭제된다 | +| 로그 사본 테이블(알림 로그·메일 로그·신고 스냅샷·레이아웃 미리보기)을 참조 소스로 등록 | 원본 콘텐츠 테이블만 등록 | 사본은 자체 보존기간으로 지워진다 — 로그가 지워지는 순간 이미지가 고아가 되는 역전이 생긴다 | +| 참조 판정을 해시 토큰 하나로만 수행 | 해시와 저장 파일명 두 토큰을 OR 로 검사 | 본문에 박히는 URL 형태가 둘(API 폴백형·디스크 직접형)이라, 한쪽만 보면 다른 형태를 미참조로 오판한다 | +| 비활성 설치 모듈이 있는 상태에서 정리를 강행 | `hasPotentiallyMissingSources()` 로 감지해 중단 | 비활성 모듈의 콘텐츠는 훅을 등록하지 않으므로 판정에서 빠진다 (fail-open 방지) | +| 동봉 자산 버전을 올리면서 일부 기재만 갱신 | 디렉토리명·레이아웃 조각의 `scripts.src`·의존성 핀·소스 상수·테스트 단언을 한 버전으로 | 하나만 어긋나도 그 자산이 404 가 되는데 빌드와 테스트는 통과한다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 15개 | `plugins/_bundled/sirsoft-ckeditor5/tests` | +| Vitest | 9개 | `vitest.config.ts` | +| Playwright | 3개 | `tests/Playwright` | +| 시나리오 매니페스트 | 1개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-ckeditor5/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-ckeditor5 && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md index 92ce7335..86a50993 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md @@ -10,6 +10,9 @@ - CKEditor 5 본체·스타일·번역 파일을 플러그인에 함께 담았습니다. 이제 외부 CDN 에 연결하지 않고 사이트 자신의 서버에서 불러오므로, 폐쇄망이나 외부 접속이 제한된 환경에서도 에디터가 동작합니다. - 에디터를 불러오지 못한 경우 안내와 함께 임시 입력창으로 자동 전환됩니다. 작성한 내용은 그대로 저장되고, 이미 저장된 글을 수정할 때는 기존 본문이 임시 입력창에 그대로 실립니다. [다시 시도] 로 편집기를 되살리면 입력해 둔 내용이 그대로 이어집니다. +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. ### Fixed diff --git a/plugins/_bundled/sirsoft-ckeditor5/README.md b/plugins/_bundled/sirsoft-ckeditor5/README.md new file mode 100644 index 00000000..7f02da6f --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/README.md @@ -0,0 +1,205 @@ +# CKEditor 5 WYSIWYG 에디터 + +**그누보드7 플러그인 · sirsoft-ckeditor5** +CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다. + + +

+ version 1.0.3 + type 플러그인 + 그누보드7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +글을 쓰는 자리에 **위지윅 편집기**를 제공하는 플러그인입니다. 설치·활성화하면 게시판 글쓰기, +상품 설명, 페이지 내용처럼 본문을 입력하는 화면이 자동으로 CKEditor 5 로 바뀝니다 — 각 +화면을 따로 설정할 필요가 없습니다. + +편집기 프로그램은 외부 서버에서 받아오지 않고 이 플러그인 안에 함께 들어 있습니다. 인터넷이 +차단된 사내망이나 광고 차단 프로그램을 쓰는 환경에서도 편집기가 정상적으로 뜨게 하기 위한 +선택입니다. 혹시 편집기를 불러오지 못하더라도 **일반 입력창으로 자동 전환되어 글은 계속 쓸 수 +있고**, 작성 중이던 내용도 그대로 유지됩니다. + +편집기로 올린 이미지는 따로 관리됩니다. 어느 글에서도 더 이상 쓰이지 않는 이미지를 찾아 +정리하는 기능이 있으며, 실수로 지우는 일이 없도록 여러 안전장치를 두고 기본값은 꺼져 +있습니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 위지윅 편집 | 서식·표·목록·링크 등 문서 편집 기능. 툴바 구성을 간단형·표준형·전체 중에서 선택 | +| 이미지 업로드 | 편집기에서 바로 이미지 붙여넣기·끌어놓기, 용량 제한 설정 | +| 업로드 이미지 관리 | 관리자 화면에서 올린 이미지 목록 확인과 개별·일괄 삭제 | +| 미사용 이미지 정리 | 어느 글에서도 쓰이지 않는 이미지를 보존기간 경과 후 자동 정리 (기본 꺼짐) | +| 다국어 본문 | 언어별로 본문을 따로 작성 | +| 자동 폴백 | 편집기를 불러오지 못하면 일반 입력창으로 전환하고 알림, 재시도 시 내용 승계 | +| 저장소 선택 | 이미지를 어느 디스크에 둘지 지정 (로컬·외부 저장소) | + + +## 동작 방식 + + +```mermaid +flowchart LR + S[본문 입력 화면] -->|편집기 자리| P[이 플러그인] + P -->|동봉 자산 로드| E[CKEditor 5] + E -.실패.-> F[일반 입력창 전환] + E -->|이미지 업로드| U[(업로드 이미지)] + U -->|본문에 주소 삽입| C[콘텐츠] +``` + +본문 입력 화면은 "편집기가 들어갈 자리" 만 비워 두고, 그 자리를 이 플러그인이 채웁니다. 그래서 +편집기를 다른 것으로 바꾸고 싶으면 각 화면을 고치는 것이 아니라 이 플러그인을 다른 편집기 +플러그인으로 교체하면 됩니다. + +```mermaid +flowchart LR + SCAN[정리 검사] --> Q{어느 글에서든 쓰이는가} + Q -->|쓰임| KEEP[보관] + Q -->|안 쓰임| AGE{보존기간 지났나} + AGE -->|아니오| KEEP + AGE -->|예| DEL[정리] +``` + +미사용 이미지 정리는 "본문 어디에도 그 이미지 주소가 없고, 올린 지 보존기간이 지났을 때"만 +동작합니다. 게다가 **비활성 상태인 모듈이 하나라도 있으면 검사 자체를 멈춥니다** — 그 모듈의 +글을 확인할 수 없어 쓰이는 이미지를 안 쓰인다고 오판할 수 있기 때문입니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-ckeditor5 + +# 활성화 +php artisan plugin:activate sirsoft-ckeditor5 + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-ckeditor5 --force +``` + +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-ckeditor5 + + +## 관리자 설정 + + +| 키 | 의미 | 기본값 | +|---|---|---| +| `imageUpload` | 이미지 업로드 | `true` | +| `imageMaxSizeMb` | 이미지 최대 크기 (MB) | `2` | +| `editorHeight` | 에디터 높이 (px) | `400` | +| `toolbar` | 툴바 유형 | `standard` | +| `public_asset_disk` | 공개 자산 디스크 | - | +| `unusedImageCleanup` | 미사용 이미지 자동 정리 | `false` | +| `unusedImageRetentionDays` | 미사용 이미지 보존기간 (일) | `30` | + +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + + + +설정은 관리자의 플러그인 목록에서 이 플러그인의 설정으로 들어가 조정합니다. + +| 항목 | 언제 바꾸는가 | 바꾸면 달라지는 것 | +|---|---|---| +| 이미지 업로드 | 편집기에서 이미지 첨부를 막고 싶을 때 | 끄면 편집기 툴바에서 이미지 버튼이 사라집니다 | +| 이미지 최대 크기 (MB) | 큰 사진을 그대로 올려야 할 때 | 이 값을 넘는 파일은 업로드가 거부됩니다 (기본 2MB) | +| 에디터 높이 (px) | 본문이 긴 화면에서 | 편집 영역의 기본 높이 (기본 400px) | +| 툴바 유형 | 필요한 기능만 남기고 싶을 때 | 툴바 버튼 구성 — 간단형(minimal)·표준형(standard)·전체(full) | +| 공개 자산 디스크 | 이미지를 외부 저장소·CDN 에 둘 때 | 업로드 이미지가 저장되고 서빙되는 위치 | +| 미사용 이미지 자동 정리 | 저장 공간을 관리할 때 | **기본은 꺼짐.** 켜면 하루 한 번 미사용 이미지를 정리합니다 | +| 미사용 이미지 보존기간 (일) | 정리 시점을 조정할 때 | 올린 지 이 기간이 지난 것만 정리 대상 (기본 30일) | + +미사용 이미지 정리를 켜기 전에 확인할 것: 본문에 이미지를 담는 모듈이 **모두 활성 상태**여야 +합니다. 설치만 되어 있고 꺼진 모듈이 있으면 그 모듈의 글을 검사할 수 없어, 안전을 위해 정리가 +수행되지 않습니다. + + +## 사용 방법 + + +**도입**: 플러그인을 설치·활성화하면 끝입니다. 본문을 입력하는 화면들이 자동으로 위지윅 +편집기로 바뀝니다. 편집기 플러그인은 **하나만 활성화**합니다 — 둘 이상 켜면 어느 것이 표시될지 +설치 순서에 좌우됩니다. + +**업로드 이미지 관리**: `/admin/plugins/sirsoft-ckeditor5/uploads` 에서 편집기로 올린 이미지를 +목록으로 보고, 필요 없는 것을 개별 또는 일괄로 지웁니다. 여기서 지운 이미지가 아직 본문에 +쓰이고 있으면 그 자리가 깨지므로, 삭제 전에 사용처를 확인합니다. + +**저장 공간 정리**: 이미지가 계속 쌓여 용량이 부담되면 설정에서 "미사용 이미지 자동 정리" 를 +켜고 보존기간을 정합니다. 처음 켤 때는 보존기간을 넉넉히(예: 90일) 잡아 한 주기 동안 어떤 +것이 정리되는지 확인한 뒤 줄이는 편이 안전합니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 본문 자리에 편집기 대신 일반 입력창이 뜨고 안내가 나옴 | 편집기 자산을 불러오지 못함 | 안내의 재시도를 누릅니다. 작성한 내용은 유지됩니다. 반복되면 플러그인 파일이 온전히 설치되었는지 확인합니다 | +| 편집기가 두 번 뜨거나 엉뚱한 편집기가 나옴 | 편집기 플러그인이 둘 이상 활성화됨 | 하나만 남기고 나머지를 비활성화합니다 | +| 이미지 업로드가 거부됨 | 용량 제한 초과 또는 업로드 기능이 꺼짐 | 설정에서 "이미지 업로드" 사용 여부와 최대 크기를 확인합니다 (기본 2MB) | +| 본문의 이미지가 깨져 보임 | 그 이미지를 관리 화면에서 지웠거나 저장 디스크 설정이 바뀜 | 업로드 관리 화면에서 삭제 여부를 확인하고, 저장 디스크를 바꿨다면 이전 위치의 파일이 접근 가능한지 확인합니다 | +| 미사용 이미지 정리를 켰는데 아무것도 정리되지 않음 | 비활성 상태의 모듈이 있어 검사가 중단됨, 또는 보존기간이 아직 지나지 않음 | 설치된 모듈을 모두 활성화하거나 쓰지 않는 모듈은 삭제합니다. 보존기간도 함께 확인합니다 | +| 편집기 툴바에 원하는 버튼이 없음 | 툴바 유형이 간단형(minimal) | 설정에서 툴바 유형을 표준형(standard) 또는 전체(full)로 바꿉니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/README.md b/plugins/_bundled/sirsoft-ckeditor5/docs/README.md new file mode 100644 index 00000000..a64e0dc5 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/README.md @@ -0,0 +1,23 @@ +# CKEditor 5 WYSIWYG 에디터 개발자 문서 + +> plugins/_bundled/sirsoft-ckeditor5 · 플러그인 + + +**훅 수**: 4 · **구독 훅 수**: 0 · **라우트 수**: 5 · **모델 수**: 1 · **테이블 수**: 1 · **마이그레이션 수**: 2 · **레이아웃 수**: 2 · **핸들러 수**: 3 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/architecture.md b/plugins/_bundled/sirsoft-ckeditor5/docs/architecture.md new file mode 100644 index 00000000..5f40c982 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/architecture.md @@ -0,0 +1,96 @@ +# CKEditor 5 WYSIWYG 에디터 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +편집기는 **교체 가능해야 한다**는 전제에서 출발합니다. 그래서 본문 입력 화면들은 편집기를 +직접 알지 않고 코어 확장점(`html_editor` · `html_content`)만 열어 두고, 이 플러그인이 +`mode: replace` 로 그 자리를 차지합니다. 편집기를 바꾸는 일은 화면들을 고치는 것이 아니라 +플러그인을 교체하는 것입니다. + +그 구조의 대가로 **같은 확장점을 노리는 편집기 플러그인이 둘이면 승자가 설치 순서에 +좌우됩니다.** 편집기 플러그인은 하나만 활성화하는 것이 전제이며, 이는 규칙이 아니라 구조적 +성질입니다. + +나머지 설계는 전부 "**조용한 실패를 만들지 않는다**" 로 수렴합니다: + +- **자산을 자체 제공한다** — CKEditor 5 를 `dist/vendor/ckeditor5/43.3.1/` 에 동봉해 + same-origin 으로 서빙합니다. CDN 도달 실패는 서버 로그에 흔적이 없고 브라우저에서 편집기만 + 사라지므로, 운영자가 원인을 특정할 수 없습니다. +- **실패해도 글은 쓸 수 있다** — 자산 확보에 실패하면 평문 입력창으로 내려가되 저장 계약 + (`{name}_mode`)을 유지하고, 사용자에게 사실과 재시도 통로를 제시합니다. 빈 컨테이너를 남기는 + 것은 "글을 쓸 수 없다" 인데 화면에는 아무 설명이 없는 상태입니다. +- **이미지 정리는 fail-closed** — 참조 판정에 필요한 소스를 다 모으지 못한 정황(비활성 설치 + 모듈)이 있으면 정리를 아예 하지 않습니다. 잘못 지운 이미지는 되돌릴 수 없기 때문입니다. + +**의도적으로 하지 않는 것**: 본문 정화(sanitize)·훅 구독·리스너·미들웨어·브로드캐스트·알림. +저장측 검증과 봇 화면 정화는 코어의 일이며, 이 플러그인이 정화까지 맡으면 편집기를 교체하는 +순간 그 방어가 함께 사라집니다. + + +## 계층 지도 + + +``` +[프론트] 레이아웃 확장 조각 (html-editor.json / html-content.json) + │ scripts: 동봉 CKEditor 5 UMD (same-origin) + ▼ + 핸들러 3종 (initEditor / destroyEditor / injectContentCss) + │ 실패 시 → renderTextareaFallback + 재시도 + `_mode='text'` + ▼ +[백엔드] Http/Controllers + ├─ ImageUploadController (업로드) + ├─ ImageServeController (해시 서빙) + └─ Admin/ImageUploadAdminController (목록·삭제) + │ + ▼ + Services + ├─ ImageUploadService : before_upload → filter_upload_file → after_upload + └─ ImageReferenceScanService : 참조 판정 (코어 소스 6 + 훅 등록 소스) + │ + ▼ + Repositories (Interface 경유) + │ + ▼ + Ckeditor5ImageUpload / ckeditor5_image_uploads +``` + +`ImageReferenceScanService` 만 다른 계층과 성격이 다릅니다 — 이 플러그인의 데이터가 아니라 +**다른 확장의 콘텐츠 테이블**을 읽습니다. 그래서 두 가지 방어가 붙어 있습니다: 소스 목록을 +요청 수명 동안 memoize 하고, 비활성 설치 모듈을 감지하면 판정을 중단합니다 +(`hasPotentiallyMissingSources()`). + +`getDynamicTables()` 로 `ckeditor5_image_uploads` 를 선언하는 것은 이 테이블이 플러그인 +제거와 함께 정리되는 대상임을 코어에 알리기 위해서입니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.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/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-ckeditor5 --force` (빌드 불필요) | +| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-ckeditor5 --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-ckeditor5 --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-ckeditor5 --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-ckeditor5 --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/data-model.md b/plugins/_bundled/sirsoft-ckeditor5/docs/data-model.md new file mode 100644 index 00000000..0a16ef1c --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/data-model.md @@ -0,0 +1,104 @@ +# CKEditor 5 WYSIWYG 에디터 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `Ckeditor5ImageUpload` | `ckeditor5_image_uploads` | 7 | uploader→User | - | + + + +모델 하나뿐입니다. `Ckeditor5ImageUpload` 는 편집기로 올린 이미지의 **기록**이며, 파일 자체는 +설정된 디스크(`public_asset_disk`)에 있습니다. + +이 기록이 존재하는 이유는 두 가지입니다 — 관리자 화면에서 업로드 이미지를 목록으로 보여주기 +위해서, 그리고 미참조 정리 판정의 대상 목록을 얻기 위해서입니다. 본문은 이 기록의 ID 를 +참조하지 않고 **URL 문자열**을 담으므로, 기록을 지운다고 본문의 이미지 태그가 사라지지는 +않습니다(그 자리가 깨질 뿐입니다). + +`uploader→User` 관계 하나만 있고 콘텐츠와의 관계는 없습니다. 이미지가 어느 글에 쓰이는지는 +관계가 아니라 **본문 문자열 검색**으로 판정합니다 — 본문을 가진 확장이 늘 때마다 이 플러그인이 +그 관계를 알아야 한다면 결합이 무한히 늘어나기 때문입니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `ckeditor5_image_uploads` | `Ckeditor5ImageUpload` | + + + +`ckeditor5_image_uploads` 하나입니다. `plugin.php` 의 `getDynamicTables()` 가 이 이름을 +선언하는데, 플러그인 제거 시 정리 대상임을 코어에 알리기 위해서입니다. + +기록을 지우는 것과 **파일을 지우는 것은 별개**입니다. 관리 화면의 삭제는 둘 다 수행하지만, +DB 행만 사라지고 파일이 남는 경로를 만들지 않도록 주의합니다 — 남은 파일은 어떤 목록에도 +뜨지 않아 영영 정리되지 않습니다. + + +## 마이그레이션 + + +마이그레이션 2개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_04_13_000001_create_ckeditor5_uploads_table.php` | `ckeditor5_image_uploads` | `ckeditor5_image_uploads` | ✅ | +| `2026_08_14_000001_add_created_at_index_to_ckeditor5_image_uploads.php` | - | `ckeditor5_image_uploads` | ✅ | + + + +2개입니다. 초기 테이블 생성 하나와 `created_at` 인덱스 추가 하나. + +인덱스가 나중에 추가된 것은 정리 커맨드가 보존기간으로 대상을 고르기 때문입니다 — +`created_at` 범위 조건이 인덱스를 타지 못하면 업로드가 쌓일수록 정리 배치가 느려집니다. + +새 컬럼을 더할 때 초기 `create_*` 파일을 고치지 않습니다. 이미 설치된 사이트는 그 파일을 다시 +실행하지 않으므로 반영되지 않으며, 기존 행을 손봐야 하는 변경은 `upgrades/` 의 업그레이드 스텝 +백필이 함께 필요합니다. + + +## Enum + + +_Enum 이 없습니다._ + + + +없습니다. 이 플러그인에는 상태 전이가 없습니다 — 이미지는 올라오거나 지워질 뿐입니다. + +설정의 `toolbar` 만 닫힌 어휘(`standard`/`minimal`/`full`)를 갖는데, 이는 설정 스키마의 +`enum` 타입으로 선언되어 있어 별도 PHP Enum 을 두지 않았습니다. 이 어휘를 코드에서 분기로 +비교하는 자리가 늘어나면 그때 Enum 으로 올리는 것이 맞습니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `ImageReferenceSourceRepository` | 구현 | 에디터 이미지 참조 소스 조회 Repository 구현체 | +| `ImageReferenceSourceRepositoryInterface` | 인터페이스 | 에디터 이미지 참조 소스 조회 Repository 인터페이스 | +| `ImageUploadRepository` | 구현 | CKEditor5 이미지 업로드 Repository 구현체 | +| `ImageUploadRepositoryInterface` | 인터페이스 | CKEditor5 이미지 업로드 Repository 인터페이스 | + + + +두 갈래입니다. + +- **`ImageUploadRepository`** — 자기 테이블(`ckeditor5_image_uploads`) 접근. +- **`ImageReferenceSourceRepository`** — **다른 확장의 콘텐츠 테이블**을 읽습니다. 이 플러그인이 + 소유하지 않은 테이블을 훑는 유일한 자리이며, 그래서 소스 목록의 유효성(테이블·컬럼이 실제로 + 존재하는가)을 스스로 검증합니다. + +두 번째 Repository 의 쿼리는 **본문 문자열 검색**이라 비용이 큽니다. 대상은 보존기간이 지난 +업로드로 한정되고, 검색 토큰은 해시와 저장 파일명 **두 개를 OR** 로 겁니다 — 본문에 박히는 +URL 형태가 둘(API 폴백형·디스크 직접형)이라 한쪽만 보면 다른 형태를 미참조로 오판합니다. + +서비스는 인터페이스만 주입받습니다(구체 클래스 타입힌트 금지). + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/editor-spec.md b/plugins/_bundled/sirsoft-ckeditor5/docs/editor-spec.md new file mode 100644 index 00000000..097a5cce --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/editor-spec.md @@ -0,0 +1,85 @@ +# CKEditor 5 WYSIWYG 에디터 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._ + + + +위지윅 에디터 플러그인이지만 편집기 스펙은 두지 않았습니다. 이 플러그인이 다루는 것은 +**본문 작성기**이고, 레이아웃 편집기가 다루는 것은 그 작성기를 **배치하는 화면**이라 +서로 다른 층이기 때문입니다. + +에디터 자체의 팔레트(툴바 구성)는 이 플러그인의 설정 화면에서 정하지, 레이아웃 편집기 +스펙과는 무관합니다. + + +## 선언 블록 + + +_선언된 편집기 스펙 블록이 없습니다._ + + + +선언한 블록이 없습니다. 다만 이 플러그인은 설정 화면 외에 **업로드 관리 화면**을 하나 +더 갖고 있고, 그 화면은 자기 도메인 데이터를 읽습니다 — 아래 미커버 목록에 그 결과가 +드러납니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다. +편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도 +`componentPalette` 는 여전히 비어 있을 것입니다. + + +## 샘플 데이터와 페이지 상태 + + +_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._ + +**프리뷰 샘플이 없는 `data_source` 1개** — 편집기 캔버스에서 이 자리만 빈 화면이 됩니다. 실제 화면은 정상이라 오류도 경고도 남지 않습니다. + +`ckeditor5Uploads` + + + +`ckeditor5Uploads` 가 미커버입니다. 업로드 관리 화면의 목록 영역이 편집기 캔버스에서 +빈 채로 보입니다. + +설정 화면 쪽 `settings` 는 공용 ID 라 템플릿 스펙이 채우므로 문제가 없습니다. 즉 이 +플러그인은 **화면 둘 중 하나만** 편집기에서 온전히 보이는 상태입니다. + + +## 수정 시 동반 의무 + + +_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._ + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + + + +편집기 스펙을 신설해야 하는 시점은 하나입니다 — **이 확장이 소유한 레이아웃에 +이 확장만 쓰는 `data_source` 가 생겼을 때**. 그 순간부터 편집기 캔버스의 그 영역은 +빈 화면이 되고, 실제 화면은 정상 동작하므로 오류도 경고도 남지 않습니다. + +신설 절차는 확장 루트에 `editor-spec.json` 을 만들고 `sampleData.byDataSourceId` 에 +그 ID 를 넣는 것으로 시작합니다. 팔레트·컨트롤은 템플릿의 일이므로 넣지 않습니다. +파일을 만든 뒤 update 커맨드로 활성 디렉토리에 반영해야 편집기가 읽습니다 — +`_bundled` 폴백이 없습니다. + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/extension-points.md b/plugins/_bundled/sirsoft-ckeditor5/docs/extension-points.md new file mode 100644 index 00000000..142900c8 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/extension-points.md @@ -0,0 +1,155 @@ +# CKEditor 5 WYSIWYG 에디터 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 4종 / 호출 지점 4곳. 훅 이름이 상수·변수로 조립된 호출이 1곳 있어 호출 위치가 표에 다 실리지 않을 수 있습니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-ckeditor5.image.after_upload` | action | 에디터 이미지 업로드 기록 생성 후 발화 | `src/Services/ImageUploadService.php:72` | +| `sirsoft-ckeditor5.image.before_upload` | action | 에디터 이미지 업로드 직전 발화 (본인인증·쿼터 등 확장 지점) | `src/Services/ImageUploadService.php:40` | +| `sirsoft-ckeditor5.image.filter_reference_sources` | filter | 에디터 이미지 참조 스캔 대상 테이블/컬럼 목록에 확장 콘텐츠를 추가 | 선언 (호출 위치 미확인) | +| `sirsoft-ckeditor5.image.filter_upload_file` | filter | 업로드 파일 변형 지점 (압축·리사이즈 등) | `src/Services/ImageUploadService.php:45` | + + + +4종 중 셋은 업로드 파이프라인(`before_upload` → `filter_upload_file` → `after_upload`)이고, +나머지 하나가 이 플러그인에서 가장 중요한 훅입니다. + +**`image.filter_reference_sources`** — 업로드 이미지가 어느 콘텐츠에서 쓰이는지 판정할 때 +훑을 테이블·컬럼 목록을 만드는 자리입니다. 본문에 이미지를 담는 확장(게시판 글·상품 설명· +페이지 내용)은 **반드시 자기 테이블·컬럼을 여기에 등록**합니다. 등록하지 않으면 그 확장의 +콘텐츠가 판정에서 통째로 빠지고, 화면에 멀쩡히 보이는 이미지가 "미참조" 로 분류되어 정리 +대상이 됩니다 — 오류 없이 이미지가 깨지는 형태로만 드러납니다. + +등록 시 **로그 사본 테이블을 소스로 삼지 않습니다.** 알림 발송 로그·메일 로그·신고 스냅샷· +레이아웃 미리보기는 자체 보존기간으로 지워지는 사본이라, 소스로 넣으면 "로그가 지워지는 순간 +이미지가 고아가 되는" 역전이 생깁니다. 코어가 그 넷을 명시적으로 제외한 이유입니다. + +`before_upload` 는 본인인증 강제·업로드 쿼터 같은 게이트를 붙이는 자리이고, +`filter_upload_file` 은 저장 직전 압축·리사이즈·형식 변환 자리입니다. `after_upload` 는 기록 +생성 후이므로 외부 저장소 미러링처럼 사후 처리에 씁니다. + + +## 구독 훅 + + +_이 확장은 훅을 구독하지 않습니다._ + + + +하나도 구독하지 않습니다. 이 플러그인은 다른 확장의 흐름에 개입하지 않고, 자기 확장점 안에서만 +동작합니다. + +관계는 반대 방향으로 흐릅니다 — 다른 확장이 **이 플러그인의 훅을 구독**합니다. 게시판·페이지· +이커머스가 `image.filter_reference_sources` 에 자기 콘텐츠 테이블을 등록하는 것이 그 +예입니다. 그래서 이 플러그인의 훅 이름을 바꾸면 그 확장들의 등록이 예외 없이 조용히 끊기고, +결과는 "쓰이는 이미지가 정리 대상이 되는" 형태로 나타납니다. + + +## 훅 리스너 + + +_훅 리스너가 없습니다._ + + + +없습니다. 구독하는 훅이 없으므로 리스너도 필요하지 않습니다. + +이미지 정리는 훅이 아니라 **스케줄 커맨드**가 수행합니다. 콘텐츠 변경마다 반응하는 것이 아니라 +주기적으로 전체를 훑는 방식인데, 본문에서 이미지 주소가 빠지는 사건을 훅으로 잡으려면 본문을 +가진 모든 확장이 그 사실을 발행해야 하기 때문입니다. 주기 검사는 그 협조 없이도 성립합니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/html-content.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/html-editor.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +두 조각이 이 플러그인의 **본체**입니다. 백엔드가 아니라 이 조각들이 편집기를 화면에 올립니다. + +| 조각 | 확장점 | 하는 일 | +|---|---|---| +| `html-editor.json` | `html_editor` | 동봉 CKEditor 5 UMD 를 로드하고 컨테이너 `onMount` 에서 `initEditor` 실행 | +| `html-content.json` | `html_content` | 저장된 본문을 읽기 화면에 렌더 | + +둘 다 `mode: replace` 입니다 — 확장점 자리를 비우고 대신 들어갑니다. 같은 확장점을 노리는 +다른 편집기 플러그인이 함께 활성화되면 어느 쪽이 이기는지가 설치 순서에 좌우되므로, 편집기 +플러그인은 하나만 켭니다. + +조각 안의 `scripts.src` 에 **동봉 자산의 버전 경로가 문자열로 박혀 있습니다.** CKEditor 5 +버전을 올릴 때 디렉토리명만 바꾸고 이 값을 빠뜨리면 그 자산이 404 가 되는데, 빌드도 테스트도 +통과합니다. 버전 기재는 디렉토리명 · 이 조각 · 소스 상수 · 테스트 단언이 **한 벌**로 움직여야 +합니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +없습니다. 업로드·서빙 라우트는 코어 인증 미들웨어만 씁니다. + +업로드 게이트가 필요하면 미들웨어가 아니라 `image.before_upload` 훅을 잡습니다 — 그 편이 +확장에 열려 있고, 미들웨어 부착 대상(targets) 선언을 늘리지 않아도 됩니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +없습니다. 편집기 동작은 각 사용자의 브라우저 안에서 끝나므로 서버가 다른 접속자에게 알릴 +사건이 없습니다. + +여러 사람이 같은 문서를 동시에 편집하는 협업 기능은 CKEditor 5 상용 부가 기능의 영역이며 이 +플러그인의 범위 밖입니다. + + +## 스케줄 + + +| 스케줄 | 주기 | 설명 | +|---|---|---| +| `sirsoft-ckeditor5:prune-unused-images --scheduled` | `daily` | 미참조 에디터 업로드 이미지 정리 | + + + +하나뿐입니다. `prune-unused-images --scheduled` 는 어느 콘텐츠에서도 참조되지 않고 보존기간 +(`unusedImageRetentionDays`, 기본 30일)이 지난 업로드 이미지를 정리합니다. + +**기본값이 꺼짐**(`unusedImageCleanup: false`)인 것이 이 스케줄의 핵심입니다. 잘못 지운 +이미지는 되돌릴 수 없고, 참조 판정은 다른 확장들의 협조(훅 등록)에 의존하므로 사이트마다 +정확도가 다를 수 있습니다. 운영자가 자기 사이트에서 무엇이 정리되는지 확인한 뒤 켜는 것이 +전제입니다. + +켜져 있어도 **비활성 설치 모듈이 하나라도 있으면 판정을 중단**합니다. 그 모듈의 콘텐츠가 +소스 목록에 등록되지 않아 실제로 쓰이는 이미지를 미참조로 오판하기 때문입니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +없습니다. 편집기 사용은 알릴 사건이 아니고, 이미지 정리는 운영자가 설정으로 켠 배치 작업이라 +그 결과는 커맨드 출력과 로그로 남습니다. + +정리된 건수를 운영자에게 통지해야 한다면 `prune-unused-images` 를 감싸는 별도 확장에서 +코어 `GenericNotification` 으로 보내는 것이 맞습니다 — 수신자 범위를 이 플러그인이 정할 수 +없습니다. + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/frontend.md b/plugins/_bundled/sirsoft-ckeditor5/docs/frontend.md new file mode 100644 index 00000000..8193ee2e --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/frontend.md @@ -0,0 +1,118 @@ +# CKEditor 5 WYSIWYG 에디터 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 2개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 2개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `ckeditor5_uploads` | `admin` | 화면 | `_admin_base` | +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +관리자 화면 2개뿐입니다 — 업로드 이미지 목록(`ckeditor5_uploads`)과 플러그인 설정 +(`plugin_settings`). + +**이 플러그인의 실제 UI 는 여기 없습니다.** 편집기는 레이아웃이 아니라 확장점 조각 +(`resources/extensions/html-editor.json` · `html-content.json`)으로 다른 화면 안에 들어가므로, +편집기 모양을 바꾸는 작업은 이 두 레이아웃이 아니라 그 조각과 핸들러 쪽입니다. + +`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 +경로를 찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면이 사라집니다. + +레이아웃 JSON 만 고쳤다면 빌드는 필요 없고 `php artisan plugin:update sirsoft-ckeditor5 --force` +로 반영합니다. + + +## 액션 핸들러 + + +핸들러 3개 (정의: `resources/js/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `initEditor` | `sirsoft-ckeditor5.initEditor` | +| `destroyEditor` | `sirsoft-ckeditor5.destroyEditor` | +| `injectContentCss` | `sirsoft-ckeditor5.injectContentCss` | + + + +셋뿐이지만 이 플러그인의 동작 대부분이 여기 있습니다. + +| 핸들러 | 하는 일 | +|---|---| +| `initEditor` | 컨테이너에 편집기를 붙이고 `form.{name}_mode = 'html'` 설정. **자산 확보 실패 시 평문 입력창 폴백 + 사용자 통지 + 재시도 통로** | +| `destroyEditor` | 화면을 떠날 때 인스턴스 해제 (누수 방지) | +| `injectContentCss` | 읽기 화면에 본문 스타일 주입 | + +`initEditor` 의 폴백 경로가 이 플러그인에서 가장 조심스러운 코드입니다. 편집기를 못 불러왔을 +때 **빈 컨테이너를 남기면 안 되고**(글을 쓸 수 없는데 화면에 설명이 없습니다), 폴백으로 내려간 +뒤에도 저장 계약을 지켜야 하며(`{name}_mode = 'text'`), 재시도로 편집기가 뜨면 그때 `_mode` 를 +`'html'` 로 되돌리면서 **그 사이에 쓴 내용을 승계**해야 합니다. 이 셋 중 하나라도 빠지면 사용자 +입력이 사라집니다. + +핸들러 TS 를 고치면 빌드가 필요합니다 — `php artisan plugin:build` 후 +`plugin:update --force`. 그리고 프론트엔드 변경은 Playwright 위지윅 spec 을 함께 갱신·실행 +합니다. 편집기 장착 회귀는 단위 테스트가 초록인 상태에서도 브라우저에서만 드러납니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftCkeditor5` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftCkeditor5.initPlugin()` 이 재등록 진입점입니다. 로케일을 전환하면 코어가 이 +함수를 다시 불러 핸들러를 재등록하는데, 없거나 이름이 다르면 **로케일 전환 직후 편집기가 +장착되지 않습니다** — 오류도 토스트도 없이 본문 자리만 비게 됩니다. + +진입점은 핸들러 재등록만 수행합니다. 편집기 인스턴스 생성·자산 로드 같은 1회성 작업을 여기 +넣으면 로케일을 바꿀 때마다 다시 실행되어 인스턴스가 중복되거나 작성 중인 내용이 날아갑니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | +| `dist/vendor/ckeditor5/43.3.1` | 동봉 제3자 자산 (자체 제공) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +두 항목의 성격이 다릅니다. + +| 경로 | 성격 | +|---|---| +| `dist/js/plugin.iife.js` | 이 플러그인의 빌드 산출물 — 소스(`resources/js/**`)를 고치면 `--production` 으로 다시 굽고 커밋 | +| `dist/vendor/ckeditor5/43.3.1/` | **동봉한 제3자 자산** — CKEditor 5 본체. CDN 이 아니라 여기서 same-origin 으로 서빙 | + +동봉 자산이 이 플러그인 설계의 핵심입니다. CDN 도달 실패는 예외도 서버 로그도 남기지 않고 +편집기만 사라지므로(폐쇄망·방화벽·광고차단기), 운영자가 원인을 특정할 수 없습니다. 동봉본은 +어떤 잠금파일에도 없어 의존성 감사 도구가 원리상 볼 수 없으므로, **버전 상향은 사람이 +확인**합니다. + +버전을 올릴 때 기재가 여러 곳에 흩어져 있습니다 — 디렉토리명 · `resources/extensions/ +html-editor.json` 의 `scripts.src` · 소스 상수 · 테스트 단언. 하나만 어긋나도 그 자산이 +404 가 되는데 빌드와 테스트는 통과하므로, 한 벌로 함께 고칩니다. + +배포 산출물이므로 `sourceMappingURL` 참조를 남기지 않습니다(`.map` 은 커밋 대상이 아니라 +404 가 됩니다). 자산 URL 은 문자열로 조립하지 않고 `G7Core.asset.plugin` 을 씁니다. + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/settings.md b/plugins/_bundled/sirsoft-ckeditor5/docs/settings.md new file mode 100644 index 00000000..f24d7746 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/settings.md @@ -0,0 +1,135 @@ +# CKEditor 5 WYSIWYG 에디터 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `imageUpload` | `boolean` | `true` | 이미지 업로드 | +| `imageMaxSizeMb` | `integer` | `2` | 이미지 최대 크기 (MB) | +| `editorHeight` | `integer` | `400` | 에디터 높이 (px) | +| `toolbar` | `enum` | `standard` | 툴바 유형 | +| `public_asset_disk` | `string` | - | 공개 자산 디스크 | +| `unusedImageCleanup` | `boolean` | `false` | 미사용 이미지 자동 정리 | +| `unusedImageRetentionDays` | `integer` | `30` | 미사용 이미지 보존기간 (일) | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +7개 항목이 세 무리입니다. + +| 무리 | 항목 | 성격 | +|---|---|---| +| 편집기 표현 | `toolbar` · `editorHeight` | 화면에만 영향. 잘못 설정해도 데이터는 안전합니다 | +| 업로드 | `imageUpload` · `imageMaxSizeMb` · `public_asset_disk` | 저장 위치와 허용 범위 | +| 정리 | `unusedImageCleanup` · `unusedImageRetentionDays` | **파일을 지우는 설정** — 기본이 꺼짐입니다 | + +`public_asset_disk` 만 `enum` 이 아니라 `string` 인 이유가 있습니다. 선택지가 코어 카탈로그 + +플러그인이 훅으로 등록한 디스크로 **동적**이라 스키마 단계에서 열거할 수 없습니다. 존재하지 +않는 디스크 값이 들어오면 `resolvePublicAssetDisk()` 가 스트리밍 서빙으로 안전하게 폴백하므로, +설정 오타가 이미지 소실로 이어지지는 않습니다. + +`unusedImageCleanup` 의 기본값이 `false` 인 것은 **의도적인 보수 설정**입니다. 참조 판정이 +다른 확장들의 훅 등록에 의존하므로 사이트마다 정확도가 다를 수 있고, 잘못 지운 이미지는 +되돌릴 수 없습니다. + +설정 화면은 `resources/layouts/admin/plugin_settings.json` 이 그립니다 — 코어가 플러그인 +디렉토리의 이 고정 경로를 찾으므로 파일 이름을 바꾸면 설정 화면 자체가 사라집니다. + + +## 권한 + + +| 카테고리 | 이름 | 액션 | 라우트 키 | +|---|---|---|---| +| `uploads` | 에디터 업로드 이미지 | `read`, `delete` | - | + + + +`uploads` 하나에 `read`/`delete` 두 액션뿐입니다. 업로드 이미지 **관리 화면**에 대한 권한이며, +편집기를 쓰는 권한이 아닙니다 — 편집기는 본문을 쓸 수 있는 사람이면 누구나 씁니다. + +`create` 가 없는 것은 이미지가 관리 화면이 아니라 **편집기에서** 올라오기 때문입니다. 그 +경로의 게이트는 권한이 아니라 훅(`image.before_upload`)이 담당합니다 — 업로드 제한 정책이 +사이트마다 다르고(회원 등급별 쿼터, 본인인증 요구 등) 권한 하나로 표현되지 않습니다. + +`delete` 는 파일을 실제로 지우는 권한입니다. 본문에서 아직 쓰이는 이미지를 지우면 그 자리가 +깨지므로 넓게 부여하지 않습니다. + + +## 메뉴 + + +| 구분 | slug | 이름 | URL | 하위 | +|---|---|---|---|---| +| 관리자 | `sirsoft-ckeditor5-uploads` | 에디터 업로드 이미지 | `/admin/plugins/sirsoft-ckeditor5/uploads` | - | + + + +관리자 메뉴 하나(`/admin/plugins/sirsoft-ckeditor5/uploads`)뿐이며 업로드 이미지 목록으로 +갑니다. + +설정 화면은 이 메뉴에 없습니다 — 코어의 플러그인 목록에서 이 플러그인의 설정으로 들어가는 +공통 경로를 씁니다. 플러그인 설정은 코어가 `resources/layouts/admin/plugin_settings.json` 을 +찾아 그리므로 자체 메뉴가 필요하지 않습니다. + +메뉴는 권한과 짝을 이룰 때만 보입니다 — `sirsoft-ckeditor5.uploads.read` 가 없는 역할에는 +렌더되지 않습니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-ckeditor5/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +5개가 두 무리입니다. + +| 무리 | 경로 | 인증 | +|---|---|---| +| 편집기용 | `POST upload` · `GET images/{hash}` | 업로드는 인증 필요, 서빙은 본문을 보는 사람이 접근 | +| 관리자 | `GET admin/uploads` · `POST admin/uploads/bulk-delete` · `DELETE admin/uploads/{id}` | `auth:sanctum` + 권한 | + +**서빙 경로가 해시 기반**(`images/{hash}`)인 것은 순번 ID 를 노출하지 않기 위해서입니다. +다만 이 경로는 항상 쓰이지는 않습니다 — 설정 디스크가 공개 URL 을 주는 환경에서는 본문에 +디스크 직접 URL 이 박히므로, 같은 이미지가 사이트 설정에 따라 두 형태의 주소를 갖습니다. +미참조 판정이 두 토큰을 모두 검사하는 이유가 여기 있습니다. + +라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 확장 라우트는 활성 상태인 확장의 것만 +등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 404 가 됩니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +양방향 모두 비어 있습니다. 이 플러그인은 코어만으로 동작하고, manifest 상 이 플러그인을 +요구하는 확장도 없습니다. + +**그런데 실제 관계는 표가 보여주는 것보다 많습니다.** 게시판·페이지·이커머스가 이 플러그인의 +`image.filter_reference_sources` 를 구독해 자기 콘텐츠 테이블을 등록합니다. manifest 의존이 +아닌 이유는 편집기가 없어도 그 확장들이 정상 동작하기 때문이며(본문을 평문으로 쓸 뿐), +그 판단은 맞습니다. + +대신 그 대가로 **이 플러그인이 훅 이름을 바꾸면 구독하던 확장들의 등록이 예외 없이 조용히 +끊깁니다.** 그 결과는 "쓰이는 이미지가 정리 대상이 되는" 형태로 나타나므로, 훅 이름·페이로드 +스키마를 바꿀 때는 구독 확장을 전수 확인하고 그 확장들의 `dependencies` 최소 버전 상향이 +필요한지 검토합니다. + diff --git a/plugins/_bundled/sirsoft-daum_postcode/AGENTS.md b/plugins/_bundled/sirsoft-daum_postcode/AGENTS.md new file mode 100644 index 00000000..f5bdcd40 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/AGENTS.md @@ -0,0 +1,177 @@ +# Daum 우편번호 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-daum_postcode) — 주소 입력 자리(`address_search_slot`)에 Daum 우편번호 검색을 붙인다. 백엔드 0(라우트·모델·테이블 없음), 조각 1 + 핸들러 2 가 전부 +2. 확장 방식: 발행 훅 2개 — 필드에 쓰기 전 가공은 `filter_address_data`, 확정 후 후속 동작은 `address.selected` +3. 건드리면 안 되는 것: 외부 호스트 선언에서 사유(`trusted_script_hosts_reason`) 누락, SDK 실패 시 직접 입력 폴백 제거, 확보 확인 전에 필드를 읽기 전용으로 잠그기 +4. 작업 위치: `plugins/_bundled/sirsoft-daum_postcode` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-daum_postcode --force` +``` + +## 1. 이 확장은 무엇인가 + + +주소 입력 자리에 **Daum 우편번호 검색 창**을 붙이는 플러그인입니다. 백엔드 코드가 없고 +(라우트 0 · 모델 0 · 테이블 0 · 마이그레이션 0), 실체는 레이아웃 확장 조각 하나와 프론트 +핸들러 둘입니다. + +`address_search_slot` 확장점을 여는 화면(이커머스 배송지 입력 등)이 있으면 그 자리에 검색 +버튼이 나타나고, 사용자가 주소를 고르면 지정된 필드들(우편번호·기본주소·도로명·지번)이 +채워집니다. + +**이 확장은 코어 규정의 예외를 하나 갖습니다.** 구동 자산을 자체 제공하지 않고 Daum 의 +CDN(`t1.daumcdn.net`)에서 로드합니다 — 우편번호 SDK 는 라이브러리가 아니라 **Daum 이 운영하는 +서비스의 클라이언트**라, 자체 호스팅해도 그 서버와 통신하지 않으면 동작하지 않습니다. 그래서 +manifest 에 `trusted_script_hosts` 와 **그 사유(`trusted_script_hosts_reason`)를 함께 선언** +합니다. 사유 없는 외부 호스트 선언은 금지이며, 이 플러그인이 그 예외 기재의 선례입니다. + +예외를 두는 대신 **실패 경로를 갖춥니다.** SDK 를 못 불러오면(폐쇄망·광고차단기·Daum 장애) +사용자에게 사실을 알리고 재시도 통로를 남기며, 주소를 직접 입력할 수 있게 합니다 — 검색이 +안 된다고 주문을 못 하게 되면 안 되기 때문입니다. + +**의도적으로 하지 않는 것**: 백엔드 저장·주소 검증·좌표 변환·해외 주소. 선택된 주소를 어디에 +어떻게 저장할지는 그 화면을 소유한 확장(이커머스 등)의 일입니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-daum_postcode --force` (빌드 불필요) | +| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-daum_postcode --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-daum_postcode --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-daum_postcode --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-daum_postcode --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | + + +## 3. 핵심 흐름 + + +백엔드 흐름이 없으므로 전부 프론트에서 일어납니다. + +**장착**: 어떤 화면이 `address_search_slot` 확장점을 열면 → 코어가 +`resources/extensions/ecommerce-address-search.json` 을 그 자리에 넣음 → 조각의 `scripts` 가 +Daum SDK 를 로드 → 컨테이너 `onMount` 에서 `sirsoft-daum_postcode.setFieldReadOnly` 가 대상 +주소 필드를 읽기 전용으로 바꿉니다(검색으로만 채우게 해서 오타를 막습니다). 이 핸들러는 +**SDK 확보를 먼저 확인**하고, 확보하지 못했으면 필드를 편집 가능한 상태로 남깁니다 — 읽기 +전용 + 검색 불가 조합은 곧 입력 불가이기 때문입니다. 해제(`readOnly: false`)는 언제나 안전 +하므로 확보를 기다리지 않습니다. + +**검색 → 필드 채움**: 사용자가 버튼을 누름 → `openPostcode` 핸들러가 설정 +(`display_mode`: 레이어/팝업, 팝업 크기, 테마 색상)대로 검색 창을 엶 → 주소를 고르면 +`filter_address_data` 필터로 데이터를 가공할 기회를 준 뒤 지정된 필드들에 값을 쓰고 +`address.selected` 액션을 발행합니다. + +**SDK 확보 실패**: `postcodeSdk.ts` 가 스크립트 로드에 `onerror` 를 걸어 실패를 감지하고, +`G7Core.assets.notifyFailure` 로 사용자에게 사실과 재시도 통로를 제시합니다. 필드는 편집 +가능한 채로 남아 **직접 입력**이 가능하며, 재시도가 성공하면 그때 검색 흐름으로 돌아옵니다 — +이 경로가 없으면 SDK 가 막힌 환경에서 주소를 아예 넣을 수 없습니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 2개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 0개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 0개 | [훅 리스너](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#알림-정의) | + + + +발행 훅 2종은 **선택된 주소를 가로채는** 자리입니다. + +| 훅 | 언제 쓰는가 | +|---|---| +| `filter_address_data` | 주소 데이터를 필드에 쓰기 **전**에 가공. 도로명/지번 중 어느 것을 기본으로 쓸지, 건물명을 상세주소에 미리 넣을지 등 | +| `address.selected` | 주소가 확정된 **후**. 배송비 재계산·배송 가능 지역 판정 같은 후속 동작 | + +두 훅 모두 `getHooks()` 에 파라미터까지 선언되어 있습니다 — +`zonecode`(우편번호) · `address`(기본) · `roadAddress`(도로명) · `jibunAddress`(지번) · +`buildingName`(건물명). 발행 위치가 "선언(호출 위치 미확인)" 인 것은 실제 발행이 **프론트 +핸들러**에서 이루어져 PHP 소스 스캔에 잡히지 않기 때문입니다. + +레이아웃 조각 하나(`ecommerce-address-search.json`)가 이 플러그인의 UI 전부입니다. 파일 +이름에 `ecommerce` 가 붙어 있지만 **확장점 이름(`address_search_slot`)으로 매칭**되므로, +그 확장점을 여는 화면이면 어디든 붙습니다 — 이커머스 전용이 아닙니다. + +구독 훅·리스너·미들웨어·브로드캐스트 채널·스케줄·알림·권한·메뉴·라우트는 전부 0개입니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-daum_postcode --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 외부 스크립트 호스트를 늘린다면 `trusted_script_hosts` 와 **사유**(`trusted_script_hosts_reason`)를 함께 선언 — 자체 제공이 원칙이고 이 플러그인은 예외 기재의 선례다 +- [ ] SDK 확보 실패 경로(통지·재시도·직접 입력)를 건드렸다면 `resources/js/__tests__/postcode-fallback.test.ts` 를 함께 갱신·실행 +- [ ] `dist/` 는 커밋되는 배포 산출물 — TS 를 고쳤으면 `--production` 재빌드 후 커밋 (`sourceMappingURL` 잔존 금지) +- [ ] 조각이 붙는 확장점(`address_search_slot`)을 여는 화면이 그 자리를 없애면 오류 없이 사라진다 — 대상 확장 업그레이드 후 노출 확인 +- [ ] 프론트엔드를 고쳤다면 Playwright spec 을 함께 갱신·실행한다 (단위 테스트만으로는 장착 회귀가 드러나지 않는다) +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어도 되는 상태(공용 ID 만 사용)다. 이 확장만 쓰는 `data_source` 를 새로 붙이는 순간 `editor-spec.json` 신설이 필요해진다 + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| `trusted_script_hosts` 만 선언하고 사유를 생략 | `trusted_script_hosts_reason` 에 호스트별 사유 동반 | 자체 제공이 원칙이고 외부 호스트는 예외다 — 예외의 근거가 코드에 남지 않으면 다음 사람이 무심코 CDN 을 늘린다 | +| SDK 확보 실패 시 검색 버튼만 죽이고 끝내기 | 사용자 통지 + 재시도 + **필드를 편집 가능하게 남겨 직접 입력 폴백** | 검색이 막힌 환경에서 주소를 아예 넣을 수 없게 되면 그 화면 전체(주문·배송지 등록)가 불능이 된다 | +| 스크립트 로드에 `onerror` 를 걸지 않거나 실패를 `resolve()` 로 삼키기 | 실패를 명시적으로 감지해 폴백으로 분기 | 삼키면 "버튼을 눌러도 아무 일이 없다" 가 되고 콘솔 외에는 흔적이 없다 | +| SDK 확보를 확인하지 않고 필드를 먼저 읽기 전용으로 만들기 | 확보 확인 후에만 읽기 전용 적용 (해제는 확인 없이) | 읽기 전용 + 검색 불가 = 입력 불가. 순서가 뒤집히면 실패 환경에서 필드가 잠긴 채 남는다 | +| 선택된 주소를 이 플러그인이 직접 저장 | 필드에 쓰고 `address.selected` 발행까지 | 저장 위치·형식은 화면을 소유한 확장이 정한다. 여기서 저장하면 그 확장마다 분기가 늘어난다 | +| 도로명/지번 중 하나를 코드에 고정 | `filter_address_data` 로 소비처가 고르게 | 사이트마다 표기 정책이 다르다 | +| 자산 URL 을 문자열로 조립 | `G7Core.asset.plugin` | 확장자를 정적 location 이 가로채는 서버에서 조립한 URL 만 404 가 된다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 0개 | — | +| Vitest | 1개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 0개 | — | + +```bash +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-daum_postcode && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md b/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md index 1bf286ef..41bcfbb1 100644 --- a/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md @@ -6,6 +6,12 @@ ## [1.0.3] - 2026-08-25 +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ### Fixed - 주소 검색 서비스를 불러오지 못한 상태에서 우편번호·주소 입력란이 읽기 전용으로 고정되어, 검색도 직접 입력도 할 수 없던 문제를 고쳤습니다. 이제 검색을 쓸 수 있을 때만 읽기 전용이 되고, 그렇지 않으면 직접 입력할 수 있습니다. diff --git a/plugins/_bundled/sirsoft-daum_postcode/README.md b/plugins/_bundled/sirsoft-daum_postcode/README.md new file mode 100644 index 00000000..aafabed0 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/README.md @@ -0,0 +1,184 @@ +# Daum 우편번호 + +**그누보드7 플러그인 · sirsoft-daum_postcode** +Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다. + + +

+ version 1.0.3 + type 플러그인 + 그누보드7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +주소를 입력하는 자리에 **우편번호 검색 창**을 붙여 주는 플러그인입니다. 설치·활성화하면 +배송지 입력 같은 주소 입력 화면에 검색 버튼이 생기고, 검색해서 고른 주소가 우편번호·기본 +주소·도로명·지번 칸에 자동으로 채워집니다. + +Daum(카카오)이 제공하는 무료 서비스를 사용하므로 **API 키 발급이나 별도 계약이 필요 없습니다.** +다만 주소 검색 창 자체는 Daum 서버에서 내려받으므로, 인터넷이 차단된 환경에서는 검색이 +동작하지 않습니다. 그럴 때는 안내가 뜨고 **주소를 직접 입력**할 수 있으므로 주문이나 배송지 +등록이 막히지는 않습니다. + +이 플러그인은 주소를 찾아 칸에 넣어 주는 데까지만 합니다. 그 주소를 어디에 어떻게 저장할지는 +주소 입력 화면을 가진 확장(예: 이커머스)이 정합니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 주소 검색 | 도로명·지번·건물명·우편번호로 검색해 정확한 주소 선택 | +| 자동 입력 | 선택한 주소를 우편번호·기본주소·도로명·지번 칸에 자동으로 채움 | +| 오타 방지 | 검색으로 채우는 칸은 직접 수정할 수 없게 잠금 (상세주소는 직접 입력) | +| 표시 방식 | 화면 안에 겹쳐 띄우는 레이어 방식과 별도 창 팝업 방식 중 선택 | +| 모양 조정 | 팝업 크기와 테마 색상을 사이트에 맞게 설정 | +| 연결 실패 대비 | 검색을 불러오지 못하면 안내 후 직접 입력 허용, 재시도 제공 | +| 연동 지점 | 주소 선택 시점에 다른 확장이 반응할 수 있는 확장점 제공 | + + +## 동작 방식 + + +```mermaid +flowchart LR + F[주소 입력 화면] -->|검색 자리| P[이 플러그인] + P -->|검색 창 열기| D[Daum 우편번호 서비스] + D -->|선택한 주소| P + P --> FIELD[우편번호·주소 칸 채움] + P -.연결 실패.-> M[안내 + 직접 입력] +``` + +주소 입력 화면은 "검색 버튼이 들어갈 자리" 만 비워 두고 이 플러그인이 그 자리를 채웁니다. +그래서 이커머스 배송지든 다른 확장의 주소 입력이든 같은 방식으로 동작합니다. + +검색 창을 불러오지 못하면 잠겨 있던 주소 칸이 **편집 가능한 상태로 남아** 직접 입력할 수 +있습니다. 검색이 안 된다고 화면 전체가 막히지 않도록 한 것입니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | +| 외부 스크립트 호스트 | `t1.daumcdn.net` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-daum_postcode + +# 활성화 +php artisan plugin:activate sirsoft-daum_postcode + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-daum_postcode --force +``` + +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-daum_postcode + + +## 관리자 설정 + + +| 키 | 의미 | 기본값 | +|---|---|---| +| `display_mode` | 표시 방식 | `layer` | +| `popup_width` | 팝업 너비 (px) | `500` | +| `popup_height` | 팝업 높이 (px) | `600` | +| `theme_color` | 테마 색상 | `#1D4ED8` | + +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + + + +설정은 관리자의 플러그인 목록에서 이 플러그인의 설정으로 들어가 조정합니다. + +| 항목 | 언제 바꾸는가 | 바꾸면 달라지는 것 | +|---|---|---| +| 표시 방식 | 팝업 차단 프로그램 사용자가 많을 때 | `layer`(기본)는 화면 안에 겹쳐 띄우고, `popup`은 별도 창을 엽니다 | +| 팝업 너비 / 높이 (px) | 팝업 방식일 때 창이 작거나 클 때 | 별도 창의 크기 (기본 500 × 600) | +| 테마 색상 | 사이트 색과 맞출 때 | 검색 창의 강조 색 (기본 `#1D4ED8`) | + +표시 방식은 `layer` 를 기본값으로 둡니다 — 팝업은 브라우저나 확장 프로그램에 의해 차단될 수 +있고, 차단되면 사용자에게는 "버튼을 눌러도 아무 일이 없는" 것으로 보이기 때문입니다. + + +## 사용 방법 + + +**도입**: 플러그인을 설치·활성화하면 끝입니다. 주소 입력 자리를 제공하는 화면(이커머스 배송지 +입력 등)에 검색 버튼이 자동으로 나타납니다. 별도의 키 발급이나 신청 절차는 없습니다. + +**주소 입력**: 검색 버튼을 눌러 도로명·건물명·지번 중 아는 것으로 검색하고 결과를 고릅니다. +우편번호와 주소 칸이 자동으로 채워지며, 상세주소(동·호수)만 직접 입력하면 됩니다. + +**팝업이 뜨지 않을 때**: 설정에서 표시 방식을 `layer` 로 바꿉니다. 화면 안에 겹쳐 뜨는 방식이라 +팝업 차단의 영향을 받지 않습니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `sirsoft-basic` | 템플릿 | `>=1.0.0` | + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 검색 버튼을 눌러도 창이 뜨지 않음 | 표시 방식이 팝업인데 브라우저가 팝업을 차단 | 설정에서 표시 방식을 `layer` 로 바꿉니다 | +| "주소 검색을 불러오지 못했습니다" 안내가 뜸 | 인터넷 차단·방화벽·광고차단 프로그램이 Daum 서버 접속을 막음 | 안내의 재시도를 눌러 봅니다. 계속 실패하면 주소를 직접 입력하면 되며, 사내망이라면 `t1.daumcdn.net` 접속을 허용합니다 | +| 주소 칸을 직접 고칠 수 없음 | 오타 방지를 위해 검색으로만 채우도록 잠금 | 정상 동작입니다. 상세주소 칸은 직접 입력할 수 있습니다 | +| 검색이 안 되는 환경인데 주소 칸도 잠겨 있음 | 정상이라면 발생하지 않는 상태 | 검색을 불러오지 못하면 칸이 편집 가능한 상태로 남습니다. 잠겨 있다면 플러그인이 온전히 설치되었는지 확인합니다 | +| 주소 입력 화면에 검색 버튼이 없음 | 그 화면이 주소 검색 자리를 제공하지 않음 | 해당 화면을 가진 확장이 주소 검색 확장 자리를 지원하는지 확인합니다 | +| 해외 주소를 검색할 수 없음 | 국내 우편번호 서비스 | 해외 주소는 직접 입력해야 합니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/plugins/_bundled/sirsoft-daum_postcode/docs/README.md b/plugins/_bundled/sirsoft-daum_postcode/docs/README.md new file mode 100644 index 00000000..11e6bc92 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/docs/README.md @@ -0,0 +1,22 @@ +# Daum 우편번호 개발자 문서 + +> plugins/_bundled/sirsoft-daum_postcode · 플러그인 + + +**훅 수**: 2 · **구독 훅 수**: 0 · **라우트 수**: 0 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 2 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-daum_postcode/docs/architecture.md b/plugins/_bundled/sirsoft-daum_postcode/docs/architecture.md new file mode 100644 index 00000000..6ea0b217 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/docs/architecture.md @@ -0,0 +1,73 @@ +# Daum 우편번호 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +백엔드가 없는 것이 이 플러그인의 설계 그 자체입니다 — 라우트 0 · 모델 0 · 테이블 0 · +마이그레이션 0 · 리스너 0. 주소 검색은 브라우저에서 시작해 브라우저에서 끝나는 일이고, +선택된 주소를 저장하는 것은 그 화면을 소유한 확장의 책임이라 서버에 남길 것이 없습니다. + +**외부 호스트 예외.** 코어 규정은 "구동 자산을 자체 제공한다" 입니다. 이 플러그인은 그 +예외이며, 근거는 대상이 라이브러리가 아니라 **서비스**라는 점입니다 — Daum 우편번호 SDK 는 +자체 호스팅해도 Daum 서버와 통신하지 않으면 주소 데이터를 얻을 수 없습니다. 그래서 +`trusted_script_hosts` 와 **호스트별 사유**를 manifest 에 함께 선언합니다. 사유 없는 외부 +호스트 선언은 금지이며, 이 플러그인이 그 예외 기재의 선례입니다. + +**예외의 대가는 실패 경로다.** 외부 호스트에 의존하는 순간 그 도달 실패가 가능해지고, 그 +실패는 예외도 서버 로그도 남기지 않습니다. 그래서 세 가지를 갖춥니다 — 스크립트 로드에 +`onerror` 를 걸어 실패를 **감지**하고, `G7Core.assets.notifyFailure` 로 사용자에게 **통지** +하며, 필드를 편집 가능한 채로 남겨 **직접 입력**을 허용합니다. 특히 마지막이 중요합니다: +읽기 전용은 SDK 확보를 확인한 **뒤에만** 적용하고, 해제는 확인 없이 즉시 수행합니다. + +**의도적으로 하지 않는 것**: 주소 저장·주소 검증·좌표 변환·해외 주소·자체 화면. 이 플러그인의 +UI 는 다른 화면에 끼워 넣는 조각 하나와 관리자 설정 화면 하나뿐입니다. + + +## 계층 지도 + + +``` +[주입] resources/extensions/ecommerce-address-search.json + │ extension_point: address_search_slot + │ scripts: Daum 우편번호 SDK (외부 호스트, 사유 선언됨) + ▼ + onMount → setFieldReadOnly 핸들러 + │ SDK 확보 확인 → 확보 시에만 필드 잠금 + ▼ + 버튼 클릭 → openPostcode 핸들러 + │ 설정(display_mode / popup_* / theme_color) 적용 + │ 실패 → notifyPostcodeSdkFailure (통지 + 재시도) + ▼ + 주소 선택 → filter_address_data (가공) → 필드 기록 → address.selected (통지) + +[백엔드] plugin.php 만 존재 — 설정 스키마 · 훅 선언 · 기본값 +``` + +`postcodeSdk.ts` 가 이 플러그인의 **위험 관리 전부**를 담습니다 — 로드·재로드·준비 판정·실패 +통지·통지 해제. 두 핸들러가 이 모듈 하나를 공유하므로 실패 처리 방식이 갈라지지 않습니다. +새 핸들러를 추가할 때도 SDK 접근은 반드시 이 모듈을 거칩니다. + +`plugin.php` 는 클래스 하나에 메서드 넷(`getMetadata` · `getSettingsSchema` · +`getConfigValues` · `getHooks`)뿐입니다. 서버가 하는 일이 설정 제공과 훅 선언밖에 없다는 +사실이 그대로 드러납니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-daum_postcode --force` (빌드 불필요) | +| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-daum_postcode --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-daum_postcode --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-daum_postcode --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-daum_postcode --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | + diff --git a/plugins/_bundled/sirsoft-daum_postcode/docs/data-model.md b/plugins/_bundled/sirsoft-daum_postcode/docs/data-model.md new file mode 100644 index 00000000..53e4b687 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/docs/data-model.md @@ -0,0 +1,71 @@ +# Daum 우편번호 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +_소유 모델이 없습니다._ + + + +없습니다. 이 플러그인은 아무것도 저장하지 않습니다. + +선택된 주소는 화면의 입력 칸에 채워질 뿐이며, 그 값을 어디에 어떻게 저장할지는 주소 입력 +화면을 소유한 확장(이커머스의 배송지 등)이 정합니다. 여기서 저장을 맡으면 소비하는 확장마다 +저장 형식 분기가 늘어나고, 그 확장의 데이터 소유권도 흐려집니다. + + +## 소유 테이블 + + +_소유 테이블이 없습니다._ + + + +없습니다. 저장하는 데이터가 없으므로 테이블도 없습니다. + +이 플러그인을 삭제해도 정리할 데이터가 없다는 뜻이기도 합니다 — 주소 검색만 사라지고 이미 +입력된 주소는 그 주소를 소유한 확장에 그대로 남습니다. + + +## 마이그레이션 + + +_마이그레이션이 없습니다._ + + + +없습니다. 스키마가 없으므로 마이그레이션도 없습니다. + +나중에 저장할 것이 생긴다면(예: 검색 사용 통계) 먼저 "그것이 정말 이 플러그인의 데이터인가"를 +따져야 합니다 — 이 플러그인이 상태를 갖지 않는 것은 누락이 아니라 설계입니다. + + +## Enum + + +_Enum 이 없습니다._ + + + +없습니다. 설정의 `display_mode`(`layer` / `popup`)만 닫힌 어휘를 갖는데, 설정 스키마의 `enum` +타입으로 선언되어 있어 별도 PHP Enum 을 두지 않았습니다. + +이 값을 코드에서 분기로 비교하는 자리가 프론트 핸들러 한 곳뿐이라 어휘가 갈라질 여지가 +없습니다. 비교 지점이 늘어나기 시작하면 그때 Enum 으로 올리는 것이 맞습니다. + + +## Repository + + +_Repository 가 없습니다._ + + + +없습니다. 데이터 접근 자체가 없습니다. + +이 플러그인의 PHP 코드는 `plugin.php` 하나이며, 메서드 넷(`getMetadata` · +`getSettingsSchema` · `getConfigValues` · `getHooks`)이 전부입니다 — 설정 제공과 훅 선언 +외에 서버가 하는 일이 없다는 사실이 그대로 드러납니다. + diff --git a/plugins/_bundled/sirsoft-daum_postcode/docs/editor-spec.md b/plugins/_bundled/sirsoft-daum_postcode/docs/editor-spec.md new file mode 100644 index 00000000..6dfd1633 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/docs/editor-spec.md @@ -0,0 +1,79 @@ +# Daum 우편번호 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._ + + + +주소 검색 플러그인은 자기 화면을 거의 갖지 않습니다. 주소 검색 창은 외부 서비스가 +띄우는 것이고, 이 플러그인이 소유한 화면은 그 창의 모양·크기를 정하는 설정 하나입니다. +그래서 편집기 스펙을 두지 않으며, 지금 상태에서는 둘 필요도 없습니다. + + +## 선언 블록 + + +_선언된 편집기 스펙 블록이 없습니다._ + + + +선언한 블록이 없습니다. 설정 화면이 읽는 `settings` 는 공용 ID 라 admin 템플릿 스펙이 +채웁니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다. +편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도 +`componentPalette` 는 여전히 비어 있을 것입니다. + + +## 샘플 데이터와 페이지 상태 + + +_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._ + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +미커버가 없습니다. 이 플러그인의 레이아웃이 쓰는 `data_source` 는 전부 번들 템플릿 +스펙이 채우므로 편집기에서 설정 화면이 온전히 보입니다. + +주소 검색 창 자체는 외부 스크립트가 띄우므로 편집기 캔버스에는 나타나지 않습니다. +그것은 스펙으로 해결할 수 있는 종류가 아닙니다. + + +## 수정 시 동반 의무 + + +_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._ + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + + + +편집기 스펙을 신설해야 하는 시점은 하나입니다 — **이 확장이 소유한 레이아웃에 +이 확장만 쓰는 `data_source` 가 생겼을 때**. 그 순간부터 편집기 캔버스의 그 영역은 +빈 화면이 되고, 실제 화면은 정상 동작하므로 오류도 경고도 남지 않습니다. + +신설 절차는 확장 루트에 `editor-spec.json` 을 만들고 `sampleData.byDataSourceId` 에 +그 ID 를 넣는 것으로 시작합니다. 팔레트·컨트롤은 템플릿의 일이므로 넣지 않습니다. +파일을 만든 뒤 update 커맨드로 활성 디렉토리에 반영해야 편집기가 읽습니다 — +`_bundled` 폴백이 없습니다. + diff --git a/plugins/_bundled/sirsoft-daum_postcode/docs/extension-points.md b/plugins/_bundled/sirsoft-daum_postcode/docs/extension-points.md new file mode 100644 index 00000000..1fdf40b2 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/docs/extension-points.md @@ -0,0 +1,128 @@ +# Daum 우편번호 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 2종 / 호출 지점 0곳. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-daum_postcode.address.selected` | action | 주소 선택 완료 시 실행되는 액션 훅 | 선언 (호출 위치 미확인) | +| `sirsoft-daum_postcode.filter_address_data` | filter | 선택된 주소 데이터를 필터링하는 훅 | 선언 (호출 위치 미확인) | + + + +2종이며 **선택된 주소를 가로채는 전/후 한 쌍**입니다. + +| 훅 | 시점 | 파라미터 | +|---|---|---| +| `filter_address_data` | 필드에 쓰기 **전** | `data`(주소 배열) → 가공된 배열 반환 | +| `address.selected` | 확정 **후** | `zonecode` · `address` · `roadAddress` · `jibunAddress` · `buildingName` | + +앞의 것은 "무엇을 어느 칸에 넣을 것인가" 를 사이트가 정하는 자리입니다 — 도로명과 지번 중 +어느 것을 기본 주소로 쓸지, 건물명을 상세주소 칸에 미리 채울지는 사이트마다 다르므로 코드에 +고정하지 않습니다. 뒤의 것은 "주소가 정해졌으니 이제 무엇을 할 것인가" 로, 배송비 재계산이나 +배송 가능 지역 판정을 붙이는 자리입니다. + +발행 위치가 "선언(호출 위치 미확인)" 인 것은 실제 발행이 **프론트 핸들러**에서 이루어져 PHP +소스 스캔에 잡히지 않기 때문입니다. `getHooks()` 에 파라미터까지 선언되어 있으므로 계약은 +그 선언이 SSoT 입니다. + +구독 훅은 없습니다 — 이 플러그인은 다른 확장의 흐름에 개입하지 않습니다. + + +## 구독 훅 + + +_이 확장은 훅을 구독하지 않습니다._ + + + +없습니다. 이 플러그인은 자기 확장점 안에서만 동작하며 다른 확장의 흐름에 끼어들지 않습니다. + +관계는 반대 방향입니다 — 주소를 다루는 확장이 **이 플러그인의 훅을 구독**합니다. 그래서 이 +플러그인이 훅 이름이나 파라미터를 바꾸면 그쪽 배선이 예외 없이 조용히 끊깁니다. + + +## 훅 리스너 + + +_훅 리스너가 없습니다._ + + + +없습니다. 구독하는 훅이 없고 서버에서 하는 일도 없으므로 리스너가 필요하지 않습니다. + +이 플러그인의 동작은 전부 프론트 핸들러 둘(`setFieldReadOnly` · `openPostcode`)에 있습니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/ecommerce-address-search.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +조각 하나가 이 플러그인의 **UI 본체**입니다. + +파일 이름은 `ecommerce-address-search.json` 이지만 **확장점 이름(`address_search_slot`)으로 +매칭**되므로 이커머스 전용이 아닙니다. 그 확장점을 여는 화면이면 어디든 붙습니다 — 이름은 +최초 도입 맥락이 남은 것일 뿐입니다. + +조각의 `scripts` 에 **외부 호스트 URL 이 문자열로 박혀 있습니다.** 같은 URL 이 +`resources/js/handlers/postcodeSdk.ts` 의 `DAUM_POSTCODE_SDK_URL` 상수에도 있으므로, 주소가 +바뀌면 **두 곳을 함께** 고쳐야 합니다. 한쪽만 고치면 조각이 로드한 스크립트와 핸들러가 찾는 +스크립트가 달라져 확보 판정이 어긋납니다. + +대상 화면을 소유한 쪽이 그 확장점을 없애면 조각은 오류 없이 사라집니다 — 증상은 "주소 입력 +화면에 검색 버튼이 없다" 뿐입니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +없습니다. 이 플러그인에는 라우트가 없으므로 요청 흐름 자체가 없습니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +없습니다. 주소 검색은 한 사용자의 브라우저 안에서 끝나는 일이라 다른 접속자에게 알릴 사건이 +없습니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +없습니다. 저장하는 데이터가 없으므로 주기적으로 정리하거나 갱신할 대상이 없습니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +없습니다. 주소 선택은 사용자 자신의 조작이므로 통지할 사건이 아닙니다. + +SDK 확보 실패 통지는 알림 시스템이 아니라 **화면 안의 자산 실패 통지** +(`G7Core.assets.notifyFailure`)로 처리합니다. 지금 이 화면에서 무엇을 할 수 없는지를 그 +자리에서 알려야 하고, 메일이나 앱 알림으로 보낼 성질이 아니기 때문입니다. + diff --git a/plugins/_bundled/sirsoft-daum_postcode/docs/frontend.md b/plugins/_bundled/sirsoft-daum_postcode/docs/frontend.md new file mode 100644 index 00000000..b8b15d27 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/docs/frontend.md @@ -0,0 +1,108 @@ +# Daum 우편번호 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +관리자 설정 화면(`plugin_settings`) 하나뿐입니다. **이 플러그인의 실제 UI 는 레이아웃이 아니라 +확장 조각**(`resources/extensions/ecommerce-address-search.json`)이며, 다른 화면 안에 +들어갑니다. + +`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를 +찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다. + +레이아웃·조각 JSON 만 고쳤다면 빌드는 필요 없고 +`php artisan plugin:update sirsoft-daum_postcode --force` 로 반영합니다. + + +## 액션 핸들러 + + +핸들러 2개 (정의: `resources/js/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `setFieldReadOnly` | `sirsoft-daum_postcode.setFieldReadOnly` | +| `openPostcode` | `sirsoft-daum_postcode.openPostcode` | + + + +둘뿐이고 역할이 명확히 갈립니다. + +| 핸들러 | 하는 일 | +|---|---| +| `setFieldReadOnly` | 지정된 주소 필드를 읽기 전용으로 전환. **SDK 확보를 먼저 확인**하고 확보하지 못했으면 편집 가능한 채로 둡니다. 해제(`readOnly: false`)는 확인 없이 즉시 수행 | +| `openPostcode` | 설정대로 검색 창을 열고, 선택 결과를 `filter_address_data` → 필드 기록 → `address.selected` 순으로 처리. 실패 시 통지 + 재시도 | + +두 핸들러 모두 SDK 접근을 `postcodeSdk.ts` 한 모듈로 모읍니다 — 로드·재로드·준비 판정·실패 +통지·통지 해제가 거기 있습니다. 새 핸들러를 추가할 때도 SDK 접근은 반드시 이 모듈을 거쳐야 +실패 처리 방식이 갈라지지 않습니다. + +**읽기 전용 적용 순서가 이 플러그인에서 가장 조심스러운 부분입니다.** 확보 확인 전에 잠그면 +SDK 가 막힌 환경에서 필드가 잠긴 채 남아 주소를 아예 입력할 수 없게 됩니다 — 그 조합은 화면 +전체(주문·배송지 등록)를 불능으로 만듭니다. + +핸들러 TS 를 고치면 빌드가 필요합니다 — `php artisan plugin:build` 후 +`plugin:update --force`. 폴백 경로를 건드렸다면 +`resources/js/__tests__/postcode-fallback.test.ts` 를 함께 갱신·실행합니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftDaumPostcode` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftDaumPostcode.initPlugin()` 이 재등록 진입점입니다. 로케일을 전환하면 코어가 +이 함수를 다시 불러 핸들러를 재등록하는데, 없거나 이름이 다르면 **로케일 전환 직후 검색 +버튼이 무반응**이 됩니다 — 오류도 토스트도 남지 않습니다. + +진입점은 핸들러 재등록만 수행합니다. SDK 로드 같은 1회성 작업을 여기 넣으면 로케일을 바꿀 +때마다 스크립트를 다시 붙이게 됩니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +커밋되는 산출물은 `dist/js/plugin.iife.js` 하나이며, 동봉 제3자 자산은 없습니다 — **Daum SDK +는 동봉하지 않고 외부 호스트에서 로드**하기 때문입니다. + +그 예외의 근거는 대상이 라이브러리가 아니라 **서비스**라는 점입니다. 자체 호스팅해도 Daum +서버와 통신하지 않으면 주소 데이터를 얻을 수 없습니다. manifest 에 `trusted_script_hosts` 와 +호스트별 사유를 함께 선언하며, **사유 없는 외부 호스트 선언은 금지**입니다. + +SDK URL 은 두 곳에 있습니다 — 확장 조각의 `scripts.src` 와 `postcodeSdk.ts` 의 +`DAUM_POSTCODE_SDK_URL` 상수. 주소가 바뀌면 **함께** 고쳐야 하며, 한쪽만 고치면 조각이 로드한 +스크립트와 핸들러가 찾는 스크립트가 달라져 확보 판정이 어긋납니다. + +`dist/` 는 배포 산출물이므로 소스를 고치면 `--production` 으로 다시 굽고 커밋합니다 +(`sourceMappingURL` 잔존 금지 — `.map` 은 커밋 대상이 아니라 404 가 됩니다). + diff --git a/plugins/_bundled/sirsoft-daum_postcode/docs/settings.md b/plugins/_bundled/sirsoft-daum_postcode/docs/settings.md new file mode 100644 index 00000000..c77ed1c4 --- /dev/null +++ b/plugins/_bundled/sirsoft-daum_postcode/docs/settings.md @@ -0,0 +1,107 @@ +# Daum 우편번호 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `display_mode` | `enum` | `layer` | 표시 방식 | +| `popup_width` | `integer` | `500` | 팝업 너비 (px) | +| `popup_height` | `integer` | `600` | 팝업 높이 (px) | +| `theme_color` | `string` | `#1D4ED8` | 테마 색상 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +4개 전부 **표시 방식**에 관한 것입니다. 기능을 켜고 끄는 토글이 없는 것은 플러그인 활성화 +자체가 곧 기능 활성화이기 때문입니다. + +| 키 | 기본값 | 왜 그 기본값인가 | +|---|---|---| +| `display_mode` | `layer` | 팝업은 브라우저·확장 프로그램에 차단될 수 있고, 차단되면 사용자에게는 "버튼을 눌러도 아무 일이 없는" 것으로 보입니다. 레이어는 그 위험이 없습니다 | +| `popup_width` · `popup_height` | 500 × 600 | 팝업 모드에서만 쓰입니다 | +| `theme_color` | `#1D4ED8` | 검색 창의 강조 색 | + +`getConfigValues()` 가 같은 값을 한 번 더 선언합니다 — 스키마의 `default` 는 설정 화면의 +초기값이고, 이쪽은 설정이 아직 저장되지 않은 상태에서 코드가 읽는 폴백입니다. **두 곳이 +어긋나면** 설정을 한 번도 저장하지 않은 사이트와 저장한 사이트의 동작이 달라지므로 함께 +고칩니다. + +설정 화면은 `resources/layouts/admin/plugin_settings.json` 이 그립니다 — 코어가 플러그인 +디렉토리의 이 고정 경로를 찾으므로 파일 이름을 바꾸면 설정 화면 자체가 사라집니다. + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +선언하지 않습니다. 주소 검색은 그 화면을 볼 수 있는 사람이면 누구나 쓰는 보조 기능이고, +저장하는 데이터가 없어 접근을 나눌 대상 자체가 없습니다. + +설정 변경은 코어의 플러그인 설정 권한이 이미 관장합니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +등록하지 않습니다. 자체 관리 화면이 없기 때문입니다. + +설정은 코어의 플러그인 목록에서 이 플러그인의 설정으로 들어가는 공통 경로를 씁니다 — 코어가 +`resources/layouts/admin/plugin_settings.json` 을 찾아 그리므로 자체 메뉴가 필요 없습니다. + + +## 라우트 + + +_라우트 파일이 없습니다._ + + + +없습니다. `resources/routes.json` 의 `routes` 가 빈 배열이고 서버 라우트 파일도 없습니다. + +이 플러그인은 서버와 통신하지 않습니다 — 주소 데이터는 브라우저가 Daum 서버에서 직접 +받아옵니다. 그래서 라우트 캐시·미들웨어·인증 같은 서버측 관심사가 전부 해당하지 않습니다. + +만약 서버 라우트가 필요해진다면(예: 검색 결과 프록시) 그 순간 이 플러그인의 성격이 바뀝니다 — +외부 서비스 호출이 서버에서 일어나면 타임아웃·요율 제한·자격 증명 관리가 따라옵니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `sirsoft-basic` | 템플릿 | `>=1.0.0` | + + + +양방향 모두 비어 있습니다. 코어만으로 동작하고, manifest 상 이 플러그인을 요구하는 확장도 +없습니다. + +**실제 관계는 확장점으로 맺어집니다.** 이커머스의 배송지 입력 화면이 `address_search_slot` +을 열어 두고 있고, 이 플러그인이 그 자리를 채웁니다. manifest 의존이 아닌 것은 방향이 +맞습니다 — 이 플러그인이 없어도 그 화면은 주소를 직접 입력받아 정상 동작합니다. + +대신 그 대가로 **대상 화면이 확장점을 없애면 이 플러그인은 오류 없이 무력해집니다.** 조각이 +붙는 확장점 이름은 상대가 소유하므로, 상대 확장을 업그레이드한 뒤에는 검색 버튼이 여전히 +보이는지 확인합니다. + +manifest 의 `trusted_script_hosts` 는 의존이 아니라 **외부 서비스에 대한 신뢰 선언**입니다. +이 목록을 늘릴 때는 반드시 `trusted_script_hosts_reason` 에 사유를 함께 적습니다. + diff --git a/plugins/_bundled/sirsoft-gdpr/AGENTS.md b/plugins/_bundled/sirsoft-gdpr/AGENTS.md new file mode 100644 index 00000000..3446949b --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/AGENTS.md @@ -0,0 +1,186 @@ +# GDPR (일반 데이터 보호 규정) — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-gdpr) — 쿠키 동의 배너·동의 이력·GDPR/개인정보보호법 대응을 소유 +2. 확장 방식: `sirsoft-gdpr.consent.granted`/`revoked` 훅 구독, `data-gdpr-category` HTML 속성으로 자체 호스팅 자원 등록 +3. 건드리면 안 되는 것: `CookieConsentMiddleware`(functional 미동의 시 Set-Cookie 게이팅)의 strictly-necessary allowlist, 동의 이력(immutable append-only) 직접 수정 +4. 작업 위치: `plugins/_bundled/sirsoft-gdpr` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-gdpr --force` +``` + +## 1. 이 확장은 무엇인가 + + +GDPR(EU) 및 한국 개인정보보호법이 요구하는 "동의 전 처리 금지" 원칙을 서버·클라이언트 양쪽에서 +강제하는 플러그인입니다. 두 계층이 서로 다른 것을 막습니다 — 서버 계층(`CookieConsentMiddleware`) +은 백엔드가 심으려는 Set-Cookie 헤더를, 클라이언트 계층(자동 차단 스크립트)은 외부 추적 +스크립트·iframe·1st-party 저장소(localStorage/sessionStorage) 접근을 각각 동의 전까지 막습니다. +"동의했다"는 사실 자체도 상태(`gdpr_user_consents`, mutable)와 이력(`gdpr_user_consent_histories`, +immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와 "언제 무엇에 동의했었는가" 입증 +(Art.7(1))은 서로 다른 질문이라 하나로 합칠 수 없습니다. + +**설계 원칙**: 정책 버전 발행은 수동입니다 — 정책 본문이 바뀌었다고 자동으로 전 회원 재동의를 +트리거하지 않습니다. 운영자가 "이 변경이 재동의가 필요한 변경인가"를 판단해 명시적으로 발행 +버튼을 눌러야 합니다(README "사용 방법" 표 참고). 자동화하면 사소한 오탈자 수정에도 전 회원이 +재동의 화면을 보게 되어 UX 를 해칩니다. + +**의도적으로 하지 않는 것**: 게스트 → 회원 동의 자동 승계(§README 소개 참고), 그리고 운영자가 +등록한 "허용" functional 쿠키 화이트리스트도 두지 않습니다 — functional 미동의 시 strictly +necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제외한 **모든** 쿠키를 +차단합니다. EDPB Guidelines 2/2023 §16 원칙이 "비필수는 동의 전 전면 차단"이지 "등록된 것만 +차단"이 아니기 때문입니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.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()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-gdpr --force` (빌드 불필요) | +| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-gdpr --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-gdpr --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-gdpr --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-gdpr --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-gdpr --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**동의 부여**: `Public\GdprCookieConsentController` → `GdprConsentService::grantConsent()` — +회원이면 `user_id`, 게스트면 서명된 `gdpr_session` 쿠키로 식별한 뒤 `GdprUserConsent`(현재 +상태, upsert)와 `GdprUserConsentHistory`(이력, insert-only) 를 같은 트랜잭션에서 함께 기록하고 +`sirsoft-gdpr.consent.granted` 훅을 발행합니다. 이 흐름은 배너·마이페이지·회원가입·전체 +재동의 4개 진입점이 전부 공유합니다 — 진입점마다 다른 저장 로직을 만들지 않습니다. + +**요청마다 반복되는 게이팅**: `CookieConsentMiddleware`(web/api 그룹에 prepend)가 응답 직전 +`GdprConsentService::getCurrentCookieConsents()` 로 현재 functional 동의 여부를 조회하고, +미동의면 strictly-necessary 4종을 제외한 모든 Set-Cookie 헤더를 응답에서 제거합니다. 이 +서버측 게이팅과 별개로, 클라이언트에서는 `data-gdpr-category` 속성이 붙은 스크립트/iframe 과 +분석/마케팅 카테고리 도메인 매칭 리소스가 동의 전까지 로드되지 않습니다. + +**회원탈퇴와 완전삭제는 다른 훅, 다른 처리**입니다 — `GdprUserWithdrawListener` +(`core.user.after_withdraw`)는 코어가 user 행 자체를 보존하는 "탈퇴"에 반응해 활성 동의를 +전부 철회 처리(UPDATE + `source=withdraw` revoked 이력 INSERT)할 뿐 신원 정보는 그대로 +남깁니다 — 탈퇴는 "의사 표시 종료"이지 신원 삭제가 아니기 때문입니다. 반대로 +`GdprUserDeleteListener`(`core.user.before_delete`, 완전 삭제/hard delete)는 이력 행의 +`user_id`/IP/User-Agent 만 NULL 로 **익명화**하고 행 자체는 남깁니다(Art.17 삭제권과 Art.7(1) +입증 의무 양립). 두 훅을 헷갈리면 탈퇴 시점에 신원이 조기 삭제되거나, 완전삭제 시점에 입증 +자료 행까지 통째로 사라집니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 2개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 4개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 4개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +다른 확장이 이 플러그인 없이는 몰랐을 사실(방문자가 방금 분석 카테고리에 동의/철회했다)을 +알아야 할 때 `sirsoft-gdpr.consent.granted`/`revoked` 훅을 잡습니다 — 예: 분석 SDK 초기화를 +"페이지 로드 시 무조건"이 아니라 "동의 부여 시에만" 하고 싶은 확장. 반대로 자체 호스팅 +추적 자원을 이 플러그인의 자동 차단·복원 대상에 포함시키고 싶다면 훅이 아니라 HTML 속성 +(`data-gdpr-category="analytics"`)을 붙이는 쪽이 맞습니다 — 이 플러그인이 그 속성을 스캔해 +차단/복원을 대신 수행하므로 소비 측 코드가 필요 없습니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-gdpr --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-gdpr` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] `gdpr_user_consent_histories` 는 append-only — UPDATE/DELETE 로 기존 행을 고치지 않는다 (완전삭제 시 익명화 UPDATE 예외는 `GdprUserDeleteListener` 단일 지점에서만 수행) +- [ ] 새 자동 차단 카테고리(기능/분석/마케팅 외)를 추가하면 배너 UI·`blocked_domains` 스키마·차단 스크립트 3곳 동기화 +- [ ] `CookieConsentMiddleware` 의 strictly-necessary allowlist(4종)를 확장할 때는 ePrivacy Art.5(3) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-gdpr --force` + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| `gdpr_user_consent_histories` 행을 UPDATE/DELETE 로 직접 정정 | 정정이 필요하면 새 이력 행을 INSERT | 이력은 시점별 스냅샷이 생명 — 과거 행을 고치면 Art.7(1) 입증 자료로서 효력을 잃는다 | +| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 | +| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | strictly necessary 4종 고정 목록만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 | +| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 24개 | `plugins/_bundled/sirsoft-gdpr/tests` | +| Vitest | 12개 | `vitest.config.ts` | +| Playwright | 3개 | `tests/Playwright` | +| 시나리오 매니페스트 | 5개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-gdpr && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 255bdaa4..256ada28 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -4,6 +4,19 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + +### Fixed + +- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다. +- 쿠키 동의 저장 요청이 서버만 기록해야 하는 출처(회원가입 시 동의)를 직접 지정할 수 있던 문제를 수정했습니다 — 동의 이력의 출처가 실제 동의 경로와 일치합니다. + ## [1.0.3] - 2026-08-19 ### Fixed diff --git a/plugins/_bundled/sirsoft-gdpr/README.md b/plugins/_bundled/sirsoft-gdpr/README.md index 729a99b7..8a5ecde2 100644 --- a/plugins/_bundled/sirsoft-gdpr/README.md +++ b/plugins/_bundled/sirsoft-gdpr/README.md @@ -1,151 +1,210 @@ -# GDPR Plugin for G7 +# GDPR -GDPR(유럽 일반 데이터 보호 규정) 및 한국 개인정보보호법 대응 핵심 기능을 제공하는 G7 플러그인입니다. 쿠키 동의 배너·동의 전 자동 차단·동의 이력 영구 저장·마이페이지 동의 철회를 한 패키지로 제공합니다. +**그누보드7 플러그인 · sirsoft-gdpr** +GDPR·개인정보보호법 대응 쿠키 동의 배너와 동의 이력 관리를 제공하는 플러그인 -## 핵심 기능 + +

+ version 1.0.4 + type 플러그인 + 그누보드7 >=7.0.6 + license MIT +

+ -| 기능 | 설명 | -|------|------| -| 쿠키 동의 배너 | 필수/기능/분석/마케팅 4분류 (ICO·CNIL 권장 표준), 다크 모드, 4 위치(하단 바·좌하단·우하단·중앙 모달) | -| 동의 전 자동 차단 | 외부 추적 스크립트·iframe + 기능 카테고리 1st-party 저장소(localStorage·sessionStorage·1st-party 쿠키) 게이팅 | -| 동의 이력 저장 | 정책 버전·출처·카테고리 스냅샷을 함께 immutable 보존 (GDPR Art.7(1) 입증 자료) | +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +GDPR(유럽 일반 데이터 보호 규정) 및 한국 개인정보보호법 대응 핵심 기능을 제공하는 플러그인입니다. +쿠키 동의 배너·동의 전 자동 차단·동의 이력 영구 보존·마이페이지 동의 철회를 한 패키지로 +제공합니다. + +이 플러그인이 의도적으로 하지 않는 것 하나는 **게스트 → 회원 동의 자동 승계**입니다. GDPR +Art.6/ePrivacy Art.5(3) 관점에서 게스트(디바이스 단위)와 회원(주체 단위)은 별도 동의 모델이며, +회원가입 폼 동의로 Art.7(1) 입증 책임이 별도로 충족됩니다. 게스트 시절 동의 이력은 세션 기준 +으로 보존될 뿐 회원 계정과 자동으로 이어붙지 않습니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 쿠키 동의 배너 | 필수/기능/분석/마케팅 4분류(ICO·CNIL 권장 표준), 다크 모드, 4가지 위치(하단 바·좌하단·우하단·중앙 모달) | +| 동의 전 자동 차단 | 외부 추적 스크립트·iframe + 기능 카테고리 1st-party 저장소(localStorage·sessionStorage·쿠키) 게이팅 | +| 동의 이력 저장 | 정책 버전·출처·카테고리 스냅샷을 함께 immutable 보존(GDPR Art.7(1) 입증 자료) | | 마이페이지 동의 관리 | 회원이 자신의 동의 현황을 조회·개별 철회·재동의·전체 일괄 재동의 | -| 관리자 동의 이력 조회 | 회원/게스트 동의 변경 이력 (이메일·세션 검색, 카테고리·출처 다중 필터, 카테고리 스냅샷 표) | -| 정책 버전 수동 발행 | 정책 본문 변경 시 「+ 새 버전 발행」 클릭으로 모든 회원 재동의 트리거 | +| 관리자 동의 이력 조회 | 회원/게스트 동의 변경 이력(이메일·세션 검색, 카테고리·출처 다중 필터, 카테고리 스냅샷 표) | +| 정책 버전 발행 | 정책 본문 변경 시 수동 발행으로 모든 회원에게 재동의를 트리거 | + + +## 동작 방식 + + +```mermaid +flowchart LR + V[방문자] -->|첫 방문| Banner[쿠키 배너 노출] + Banner -->|동의 전| Block[외부 스크립트·iframe·1st-party 저장소 자동 차단] + Banner -->|동의| Grant[GdprUserConsent 저장 + 훅 발행] + Grant --> Restore[차단 해제·스크립트 로드] + Grant --> History[(동의 이력 append-only 보존)] + + Admin[운영자] -->|정책 본문 변경| Publish[새 정책 버전 발행] + Publish -->|다음 방문| Renew[모든 회원 재동의 안내] +``` + +동의는 부여든 철회든 상태 저장(`gdpr_user_consents`, mutable)과 이력 기록 +(`gdpr_user_consent_histories`, immutable append-only)이 항상 함께 일어납니다 — "지금 동의 +상태가 무엇인가"와 "언제 무엇에 동의했었는가"를 구분해서 답할 수 있어야 하기 때문입니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.6` | +| PHP | `^8.2` | + ## 설치 + ```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) php artisan plugin:install sirsoft-gdpr + +# 활성화 php artisan plugin:activate sirsoft-gdpr + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-gdpr --force ``` -설치 직후 쿠키 배너는 **비활성** 상태로 제공됩니다. 운영자가 운영 주체명·데이터 저장 위치·정책 페이지 슬러그를 입력한 뒤 「쿠키 배너 노출」 토글을 켜야 사이트에 노출됩니다. +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-gdpr + -## 설정 +설치 직후 쿠키 배너는 **비활성** 상태입니다. 운영자가 운영 주체명·데이터 저장 위치·정책 페이지 +슬러그를 입력한 뒤 "쿠키 배너 노출" 토글을 켜야 사이트에 노출됩니다. -`관리자 → 플러그인 → GDPR 설정` 에서 구성합니다. +## 관리자 설정 -### 운영 정보 + +| 키 | 의미 | 기본값 | +|---|---|---| +| `privacy_policy_slug` | 개인정보처리방침 페이지 슬러그 | `privacy` | +| `legal_entity_name` | 운영 주체명 | - | +| `data_storage_location` | 데이터 저장 위치 | - | +| `banner_enabled` | 쿠키 배너 노출 | `true` | +| `banner_position` | 배너 위치 | `bottom_bar` | +| `blocked_domains` | 추적 도메인 차단 목록 | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` | +| `cookie_categories` | 쿠키 카테고리 정의 | `[]` | -| 항목 | 설명 | -|------|------| -| 운영 주체명 (`legal_entity_name`) | 쿠키 배너 푸터·마이페이지 동의 카드에 노출되는 사이트 운영 주체 (예: "(주)홍길동컴퍼니") | -| 데이터 저장 위치 (`data_storage_location`) | 사용자에게 안내할 데이터 저장 국가 (예: "대한민국", "미국 (AWS)"). IP 주소·CIDR·클라우드 리전 코드(예: `ap-northeast-2`)는 보안상 자동 거부됩니다 | -| 개인정보처리방침 슬러그 (`privacy_policy_slug`) | sirsoft-page 플러그인에 등록된 처리방침 페이지의 슬러그. 비어있으면 쿠키 배너의 정책 링크가 자동 숨겨집니다 | +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + -### 쿠키 배너 + +`쿠키 배너 노출` 은 마스터 토글입니다 — ON 하면 배너와 자동 차단이 **함께** 켜집니다. GDPR +Art.6 "동의 전 처리 금지"를 강제하는 메커니즘인 자동 차단만 단독으로 끌 수 없도록 의도적으로 +하나의 토글에 묶었습니다. 반대로 마이페이지 동의 관리 카드는 이 토글과 무관하게, 동의/철회 +이력이 있는 회원에게는 항상 노출됩니다(Art.7(3) 철회 대칭성 보장 — 동의를 배너로 받았다면 +철회도 언제든 마이페이지에서 가능해야 합니다). -| 항목 | 설명 | -|------|------| -| 쿠키 배너 노출 (`banner_enabled`) | 마스터 토글 — ON 시 배너 + 자동 차단이 함께 활성화됩니다. GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 자동 차단을 단독 OFF 할 수 없도록 단일 토글로 통합되어 있습니다. 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됩니다 (Art.7(3) 철회 대칭성 보장) | -| 배너 위치 (`banner_position`) | 하단 바 / 좌하단 팝업 / 우하단 팝업 / 중앙 모달 | +데이터 저장 위치(`data_storage_location`)에는 IP 주소·CIDR·클라우드 리전 코드(예: +`ap-northeast-2`)를 입력할 수 없습니다 — 보안상 자동 거부됩니다. "대한민국", "미국 (AWS)"처럼 +사용자에게 안내할 국가/지역명만 입력합니다. -### 자동 차단 정책 +자동 차단 대상 도메인은 카테고리(기능/분석/마케팅)별 카탈로그로 관리하며, 기본 카탈로그(예: +Google Analytics, Facebook Pixel, Kakao Pixel 등)가 시드되어 있고 운영자가 추가·삭제할 수 +있습니다. 도메인 형식은 `example.com` 또는 와일드카드 `*.example.com` 만 지원하며, `localhost` +같은 단일 라벨과 한글 도메인(xn-- 변환)은 지원하지 않습니다. + -쿠키 배너가 ON 일 때 다음 카테고리의 외부 도메인 리소스가 동의 전까지 자동 차단됩니다. 카탈로그 기본값이 시드되어 있으며, 운영자가 카테고리별로 추가·삭제할 수 있습니다. +## 사용 방법 -| 카테고리 | 기본 카탈로그 예시 | -|---------|------------------| -| 기능 (functional) | Crisp, Intercom, Tawk.to, Weglot 등 | -| 분석 (analytics) | Google Analytics, Hotjar, Mixpanel, 네이버 프리미엄 로그분석 등 | -| 마케팅 (marketing) | Facebook Pixel, Google Ads, Kakao Pixel, YouTube embed 등 | - -도메인 형식: `example.com` 또는 와일드카드 `*.example.com`. `localhost` 같은 단일 라벨, 한글 도메인(xn-- 변환)은 미지원. - -## 자체 호스팅 추적 자원 분류 - -자체 도메인에서 호스팅되는 추적 스크립트·iframe·임베드는 도메인 매칭 대상이 아니므로 HTML 속성으로 분류합니다. + +**자체 호스팅 추적 자원 등록**: 자체 도메인에서 서빙하는 추적 스크립트·iframe·임베드는 도메인 +매칭 대상이 아니므로 HTML 속성으로 분류합니다. ```html ``` -동의 전까지 자동 차단되며, 동의 후 자동 복원됩니다. 동의 철회 시 다시 차단됩니다. +동의 전까지 자동 차단되고, 동의 후 자동 복원되며, 동의 철회 시 다시 차단됩니다. -## 정책 버전 발행 - -`관리자 → 플러그인 → GDPR 설정` 의 「정책 버전」 카드에서 「+ 새 버전 발행」 을 클릭합니다. 발행 즉시 모든 회원이 다음 방문 시 재동의 화면(amber 안내 박스 + 「최신 정책으로 갱신」 버튼)을 보게 됩니다. +**정책 버전 발행**: `관리자 → 플러그인 → GDPR 설정` 의 "정책 버전" 카드에서 "+ 새 버전 발행"을 +누르면 모든 회원이 다음 방문 시 재동의 화면을 보게 됩니다. 아래 기준으로 발행 여부를 판단합니다. | 발행이 필요한 변경 | 발행이 필요 없는 변경 | -|------------------|---------------------| -| 정책 본문 (개인정보처리방침 페이지) 변경 | 차단 도메인 추가/삭제 | +|---|---| +| 정책 본문(개인정보처리방침 페이지) 변경 | 차단 도메인 추가/삭제 | | 카테고리 의미 변경 | UI 라벨/설명 정정 | | 위탁자·데이터 보관 정보 변경 | 운영 주체명·저장 위치 정정 | -발행 시 변경 사유 메모를 함께 저장합니다 (GDPR Art.30 처리 기록 의무). +발행 시 변경 사유 메모를 함께 저장합니다(GDPR Art.30 처리 기록 의무). -## 동의 이력 조회 +**동의 이력 조회**: `관리자 → GDPR 동의 이력` 메뉴에서 이메일 부분 일치·세션 ID 로 검색하고, +카테고리·출처(banner/mypage/register/withdraw 등)·동의 액션(granted/revoked)으로 필터링합니다. +각 행을 펼치면 동의 시점의 전체 카테고리 의사 스냅샷을 확인할 수 있습니다(Art.7(1) 입증 자료). + -`관리자 → GDPR 동의 이력` 메뉴에서 회원/게스트의 모든 동의 변경 이력을 조회할 수 있습니다. +## 다른 확장과의 연동 -- **검색**: 이메일 부분 일치, 세션 ID -- **필터**: 카테고리(필수/기능/분석/마케팅), 출처(banner/mypage/register/withdraw 등), 동의 액션(granted/revoked) -- **카테고리 스냅샷**: 각 행 펼침에서 동의 시점의 전체 카테고리 의사 표를 immutable 보존 (GDPR Art.7(1) 입증 자료) + +**이 확장이 의존하는 확장** -## 가용 훅 (Hook) +없음 — 코어만으로 동작합니다. -다른 확장에서 본 플러그인의 동의 이벤트를 구독할 수 있습니다. +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) -### 액션 훅 +없음. + -| 훅 이름 | 시점 | 인수 | -|---------|------|------| -| `sirsoft-gdpr.consent.granted` | 동의 부여 시 | `GdprUserConsent $consent, string $source` | -| `sirsoft-gdpr.consent.revoked` | 동의 철회 시 | `GdprUserConsent $consent, string $source` | + +`sirsoft-page` 는 소프트 의존(런타임 체크)입니다 — 미설치 시 쿠키 배너의 "자세히" 정책 링크만 +자동으로 숨겨지고 나머지 기능은 정상 동작합니다. `sirsoft-basic` 템플릿은 배너가 주입되는 +지점(`_user_base.json` 의 공용 확장 지점)을 제공해야 하므로, 다른 사용자 템플릿을 쓰려면 +그 템플릿에도 같은 주입 지점이 있어야 배너가 정상 노출됩니다. + -`$source` 값: `banner` / `mypage` / `mypage_renew_all` / `register` / `withdraw`. +## 문서 -### 훅 등록 예시 + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + -```php -use App\Extension\HookManager; +## 트러블슈팅 -HookManager::addAction( - 'sirsoft-gdpr.consent.granted', - function ($consent, string $source) { - // 예: 분석 동의 부여 시 외부 분석 도구에 사용자 식별 전송 - if ($consent->consent_key === 'cookie_analytics' && $consent->is_consented) { - AnalyticsService::identify($consent->user_id); - } - }, - priority: 10 -); -``` + +| 증상 | 원인 | 조치 | +|---|---|---| +| 쿠키 배너 설정을 저장했는데도 배너가 안 뜸 | "쿠키 배너 노출" 마스터 토글이 꺼져 있음 | 관리자 설정에서 토글을 켠다 — 개별 항목 저장만으로는 배너가 켜지지 않는다 | +| 정책 페이지 링크가 배너에 안 보임 | `privacy_policy_slug` 미설정 또는 `sirsoft-page` 미설치 | 슬러그를 입력하거나, 링크 없이 운영할지 결정한다(자동 숨김은 정상 동작) | +| 배너에서 동의했는데 마이페이지에 이력이 안 보임 | 게스트 상태에서 동의 후 회원가입한 경우 — 게스트→회원 자동 승계를 제공하지 않음 | 의도된 동작이다(§소개 참고). 회원가입 시 별도 동의를 다시 받는다 | +| 자체 호스팅 스크립트가 동의 전에도 로드됨 | `data-gdpr-category` 속성 누락 | 스크립트/iframe 태그에 카테고리 속성을 추가한다 | + -## 데이터베이스 +## 변경 이력 -| 테이블 | 용도 | 보존 정책 | -|--------|------|----------| -| `gdpr_user_consents` | 회원 현재 동의 상태 (mutable) | 사용자 삭제 시 명시 삭제 | -| `gdpr_user_consent_histories` | 동의 변경 이력 (immutable append-only) | 사용자 삭제 시 `user_id`/IP/UA 만 NULL 익명화하여 행 보존 (GDPR Art.17 + Art.7(1) 양립) | -| `gdpr_policy_versions` | 정책 버전 발행 이력 (불변) | 영구 보존 (Art.30 처리 기록) | - -플러그인 제거 시 위 3 테이블이 자동 DROP 됩니다. - -## 게스트 → 회원 동의 승계 - -본 플러그인은 게스트 → 회원 동의 자동 승계를 제공하지 않습니다. GDPR Art.6/ePrivacy Art.5(3) 관점에서 게스트(디바이스 단위)와 회원(주체 단위)은 별도 동의 모델이며, 글로벌 CMP 대부분도 자동 승계를 기본으로 제공하지 않습니다. 회원가입 폼 동의로 Art.7(1) 입증 책임이 충족되며, 게스트 시절 동의 이력은 세션 기준으로 보존됩니다. - -## 의존성 - -| 대상 | 의존 수준 | 미설치 시 동작 | -|------|----------|---------------| -| `sirsoft-page` 모듈 | 소프트 (런타임 체크) | 배너 "자세히" 링크만 자동 숨김. 나머지 정상 | -| `sirsoft-basic` 템플릿 | 주입 지점 의존 | 다른 사용자 템플릿 사용 시 해당 템플릿에도 `_user_base.json` 의 공용 확장 지점이 있어야 정상 동작 | - -## 테스트 실행 - -```bash -# 백엔드 -php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests - -# 프론트엔드 -cd plugins/_bundled/sirsoft-gdpr -npm run test:run -``` +[CHANGELOG.md](CHANGELOG.md) ## 라이선스 -MIT \ No newline at end of file +MIT diff --git a/plugins/_bundled/sirsoft-gdpr/composer.json b/plugins/_bundled/sirsoft-gdpr/composer.json index d8fa5fea..fd0b4152 100644 --- a/plugins/_bundled/sirsoft-gdpr/composer.json +++ b/plugins/_bundled/sirsoft-gdpr/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-gdpr", "description": "GDPR (General Data Protection Regulation) plugin for G7 platform by sirsoft", "type": "library", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "authors": [ { diff --git a/plugins/_bundled/sirsoft-gdpr/docs/README.md b/plugins/_bundled/sirsoft-gdpr/docs/README.md new file mode 100644 index 00000000..3ce257b0 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/README.md @@ -0,0 +1,23 @@ +# GDPR (일반 데이터 보호 규정) 개발자 문서 + +> plugins/_bundled/sirsoft-gdpr · 플러그인 + + +**훅 수**: 2 · **구독 훅 수**: 4 · **라우트 수**: 15 · **모델 수**: 3 · **테이블 수**: 3 · **마이그레이션 수**: 4 · **레이아웃 수**: 4 · **핸들러 수**: 1 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/architecture.md b/plugins/_bundled/sirsoft-gdpr/docs/architecture.md new file mode 100644 index 00000000..d3891fe0 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/architecture.md @@ -0,0 +1,78 @@ +# GDPR (일반 데이터 보호 규정) — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +"동의 전 처리 금지"를 소스 하나가 아니라 **서버(쿠키)·클라이언트(스크립트/저장소) 두 표면 +모두**에서 강제하는 것이 이 플러그인의 핵심 설계입니다. 서버 표면만 막으면 클라이언트에서 +직접 실행되는 추적 스크립트를 막지 못하고, 클라이언트 표면만 막으면 백엔드가 심는 분석용 +쿠키를 막지 못합니다. 두 표면은 서로 다른 코드 경로(미들웨어 vs 프론트 차단 로직)이지만 +같은 동의 판정 소스(`GdprConsentService`)를 공유해야 판정이 갈리지 않습니다. + +동의 "상태"와 "이력"을 분리 보존하는 것도 설계 결정입니다 — 상태(mutable)는 지금 게이팅 +판정에 쓰이고, 이력(immutable append-only)은 감사 대응(Art.7(1))에 쓰입니다. 회원탈퇴·완전삭제 +두 이벤트에서 서로 다른 처리(철회 vs 익명화)를 하는 것도 이 분리 때문에 가능합니다 — 하나의 +테이블이었다면 "지금 상태를 지울까 이력을 지울까"를 매번 다시 판단해야 했을 것입니다. + + +## 계층 지도 + + +``` +Http/Controllers (Admin/ 관리자 설정·동의이력·정책버전, Public/ 배너 API, User/ 마이페이지) + │ + ▼ +Services (GdprConsentService/GdprSettingsService/GdprPolicyVersionService/ + GdprConsentLogService/CookieCategoryService) + │ + ├──▶ Models (GdprUserConsent 상태 · GdprUserConsentHistory 이력 · GdprPolicyVersion) + │ + └──▶ 훅 발행 (consent.granted/revoked) ──▶ 다른 확장 리스너 + +CookieConsentMiddleware (web/api 그룹 prepend) + │ + └──▶ GdprConsentService::getCurrentCookieConsents() 조회 후 응답 Set-Cookie 게이팅 + +core.user.after_withdraw / before_delete 훅 + │ + └──▶ GdprUserWithdrawListener(철회 처리) / GdprUserDeleteListener(이력 익명화) +``` + +미들웨어는 위 Service 계층과 별도 레인에서 **매 요청마다** 동작하고, 회원탈퇴/삭제 리스너는 +코어 회원 도메인 이벤트에 반응하는 또 다른 별도 레인입니다. 세 레인 모두 같은 +`GdprConsentService`/모델을 공유하므로 그 계층 하나만 잘 유지하면 나머지는 일관됩니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.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()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-gdpr --force` (빌드 불필요) | +| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-gdpr --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-gdpr --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-gdpr --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-gdpr --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-gdpr --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/data-model.md b/plugins/_bundled/sirsoft-gdpr/docs/data-model.md new file mode 100644 index 00000000..41d25b46 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/data-model.md @@ -0,0 +1,119 @@ +# GDPR (일반 데이터 보호 규정) — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `GdprPolicyVersion` | `gdpr_policy_versions` | 5 | createdBy→User | - | +| `GdprUserConsent` | `gdpr_user_consents` | 11 | user→User | - | +| `GdprUserConsentHistory` | `gdpr_user_consent_histories` | 9 | user→User | - | + + + +`GdprUserConsent`(mutable, "지금 동의 상태")와 `GdprUserConsentHistory`(immutable append-only, +"동의 변경 이력")를 별도 모델·테이블로 분리한 것이 이 도메인의 핵심 결정입니다. 게이팅 +판정(`CookieConsentMiddleware`)은 상태만 읽고, 감사 대응(Art.7(1))은 이력만 봅니다. 하나로 +합쳤다면 상태를 UPDATE 할 때마다 과거 값을 별도 보존하는 로직을 매번 다시 구현해야 했을 +것입니다. `GdprPolicyVersion` 은 정책 발행 이력이며 이 역시 immutable — 발행된 버전은 그 +시점의 정책 내용을 그대로 유지해야 "그 버전에 동의했다"는 이력이 의미를 가집니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `gdpr_policy_versions` | `GdprPolicyVersion` | +| `gdpr_user_consent_histories` | `GdprUserConsentHistory` | +| `gdpr_user_consents` | `GdprUserConsent` | + + + +3개 테이블이 전부입니다 — 쿠키 카테고리 정의(`blocked_domains`/`cookie_categories`)는 별도 +테이블이 아니라 `getSettingsSchema()` 의 JSON 설정 값으로 저장됩니다(§settings.md). 별도 +테이블로 만들지 않은 이유는 그 값이 "운영자가 조정하는 설정"이지 "사용자별로 쌓이는 데이터"가 +아니기 때문입니다 — 전자는 설정 스키마, 후자만 전용 테이블을 둡니다. + + +## 마이그레이션 + + +마이그레이션 4개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_04_27_000001_create_gdpr_user_consents_table.php` | `gdpr_user_consents` | `gdpr_user_consents` | ✅ | +| `2026_04_27_000002_create_gdpr_user_consent_histories_table.php` | `gdpr_user_consent_histories` | `gdpr_user_consent_histories` | ✅ | +| `2026_05_12_000003_create_gdpr_policy_versions_table.php` | `gdpr_policy_versions` | `gdpr_policy_versions` | ✅ | +| `2026_07_14_000001_add_rejection_to_gdpr_user_consents.php` | - | `gdpr_user_consents` | ✅ | + + + +`add_rejection_to_gdpr_user_consents`(2026-07-14)는 "동의 안 함"을 명시적으로 기록하기 위한 +추가입니다 — 그전에는 동의 행이 없으면 "아직 응답 안 함"과 "거부함"을 구분할 수 없었습니다. +`ConsentAction::Rejected` 케이스와 짝을 이루는 마이그레이션입니다. + + +## Enum + + +| Enum | backing | case 수 | case | +|---|---|---|---| +| `ConsentAction` | `string` | 3 | `granted`, `revoked`, `rejected` | +| `ConsentSource` | `string` | 6 | `banner`, `preference_center`, `register`, `mypage`, `mypage_renew_all`, `withdraw` | +| `CookieCategory` | `string` | 4 | `cookie_necessary`, `cookie_functional`, `cookie_analytics`, `cookie_marketing` | +| `GdprPolicyChangeType` | `string` | 3 | `material`, `non_material`, `initial` | + + + +`ConsentSource` 는 자기 docblock에 "어휘를 이 enum 밖(서비스/리스너 리터럴)에 흩어 두면 화면 +필터가 실제 기록 어휘의 부분집합이 되어 일부 행이 어떤 필터로도 도달하지 못한다"고 명시합니다 +(#492 과거 결함). `withdraw`(회원탈퇴 시 일괄 철회, `GdprConsentService::revokeAllOnWithdraw()` +/ `GdprUserConsentRepository::revokeAllForUser()`)가 정확히 이 결함군으로 한 번 더 발생했던 +case입니다 — 두 지점 모두 enum이 아닌 `'withdraw'` 리터럴을 직접 기록해, 그렇게 기록된 행이 +관리자 동의 이력 화면의 어떤 출처 필터로도 걸러지지 않고 라벨도 원시 문자열로 노출됐습니다. +`ConsentSourceVocabularyParityTest`(기록 경로가 enum 을 참조하는지 검사)가 이미 있었는데도 +놓친 이유는 두 가지입니다 — 검사 대상 파일 목록에 `GdprUserConsentRepository.php` 가 +빠져 있었고, 정규식이 `'source' =>`/`'last_source' =>` 형태만 잡아 `updateConsent(..., +'withdraw')` 같은 **위치 인자** 형태는 못 봤습니다. `Withdraw` case 추가 + 두 지점을 +`ConsentSource::Withdraw->value` 참조로 교체 + 테스트의 스캔 대상 파일 목록에 Repository +추가로 정정했습니다. 새 기록 지점을 추가할 때 위치 인자로 리터럴을 넘기면 이 가드가 여전히 +못 볼 수 있다는 점을 유의하세요 — 가능하면 `'source' =>`/`'last_source' =>` 형태(배열 키)를 +쓰거나 이 테스트의 정규식을 함께 넓힙니다. + +전체 어휘(`ConsentSource::allValues()`)와 **공개 요청이 지정할 수 있는 부분집합** +(`ConsentSource::requestSelectableValues()`)은 다릅니다. `register`(회원가입 시 동의) · +`mypage_renew_all`(정책 개정 후 일괄 재동의) · `withdraw`(회원탈퇴 시 일괄 철회)는 서버가 +스스로 기록하는 경로이므로 `StoreCookieConsentRequest` 의 `Rule::in` 에서 제외됩니다 — +이 엔드포인트는 `optional.sanctum` 이라 비인증 방문자도 도달하므로, 공개 요청이 이 값을 +실을 수 있으면 가입하지도 탈퇴하지도 재동의하지도 않은 사람의 이력이 그렇게 기록됩니다. +동의 이력은 출처가 존재 이유이고, 그렇게 기록되어도 오류도 로그도 남지 않습니다. 반대로 관리자 +동의 이력 화면의 출처 필터(`IndexConsentLogRequest`)는 `allValues()` 를 씁니다: 기록된 +어휘 전부가 필터로 도달 가능해야 하기 때문입니다. 새 case 를 추가할 때 어느 쪽에 속하는지 +판단하고, 제외한다면 `ConsentSourceVocabularyParityTest` 에 제외 단언을 함께 남기세요 — +목록에서 빠뜨려도 단언이 없으면 아무 테스트도 red 가 되지 않습니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `GdprPolicyVersionRepository` | 구현 | GDPR 정책 버전 Repository 구현체 (immutable append-only) | +| `GdprPolicyVersionRepositoryInterface` | 인터페이스 | GDPR 정책 버전 Repository 인터페이스 (immutable append-only) | +| `GdprUserConsentHistoryRepository` | 구현 | GDPR 동의 변경 이력 Repository 구현체 (immutable append-only) | +| `GdprUserConsentHistoryRepositoryInterface` | 인터페이스 | GDPR 동의 변경 이력 Repository 인터페이스 (immutable append-only) | +| `GdprUserConsentRepository` | 구현 | GDPR 사용자 현재 동의 상태 Repository 구현체 | +| `GdprUserConsentRepositoryInterface` | 인터페이스 | GDPR 사용자 현재 동의 상태 Repository 인터페이스 | + + + +`GdprPolicyVersionRepository`·`GdprUserConsentHistoryRepository` 설명에 "immutable +append-only"가 반복 명시된 것은 우연이 아닙니다 — 이 두 Repository 에는 `update()`/`delete()` +류 메서드를 추가하지 않습니다(§AGENTS.md 금지 패턴). 상태를 고치는 메서드가 필요하다면 그것은 +`GdprUserConsentRepository`(mutable) 의 몫이며, 두 종류를 같은 Repository 에 섞으면 "이 +메서드가 이력을 고치는지 상태를 고치는지"를 매 호출부에서 다시 확인해야 합니다. + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/editor-spec.md b/plugins/_bundled/sirsoft-gdpr/docs/editor-spec.md new file mode 100644 index 00000000..a3143c28 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/editor-spec.md @@ -0,0 +1,112 @@ +# GDPR (일반 데이터 보호 규정) — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `plugins/_bundled/sirsoft-gdpr/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 9 · 페이지 상태 2 + + + +GDPR 플러그인은 관리자 설정·동의 이력 화면과 **사용자 화면에 얹히는 쿠키 배너**를 +함께 갖습니다. 스펙이 두 블록만으로 끝나는 것은 이 플러그인이 컴포넌트를 만들지 않고 +템플릿 컴포넌트로 배너를 조립하기 때문입니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 9 | `editor-spec.json (인라인)` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 2 | `editor-spec.json (인라인)` | + + + +`byDataSourceId` 9종이 설정·정책 버전·동의 이력 세 갈래를 덮습니다. `gdprPublicSettings` +와 `gdprSettings` 가 따로 있는 것이 이 스펙의 핵심입니다 — 공개 응답과 관리자 응답은 +같은 저장값에서 나오지만 **내보내는 항목이 다릅니다.** 샘플을 하나로 합치면 편집기에서 +사용자 배너를 편집할 때 관리자만 볼 수 있는 항목까지 보이게 됩니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 9 | `gdprSettings` · `gdprPolicyVersionCurrent` · `gdprPolicyVersionHistory` · `gdprPolicyVersionSnapshot` · `gdprConsentLog` · `gdprPublicSettings` · `gdprMyConsent` · `gdprMeConsents` · `gdprMyConsents` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 2 | `*/admin/plugins/sirsoft-gdpr/consent-log` · `_user_base` | + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +`states.groups` 가 `_user_base` 를 범위로 갖는 것이 이 플러그인의 특징입니다. 쿠키 +배너는 특정 라우트가 아니라 **모든 사용자 화면의 베이스 레이아웃**에 얹히므로, 편집기가 +배너를 보여 주려면 베이스 레이아웃에 상태를 주입해야 합니다. + +배너는 동의 전에만 보입니다. 편집기 캔버스는 정적 시뮬레이션이라 "아직 동의하지 않은 +방문자" 상태를 만들어 주지 않으면 배너가 화면에 나타나지 않아 **편집 자체가 불가능**합니다. +`_user_base` 상태 변종이 그 역할입니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan plugin:update sirsoft-gdpr --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +동의 항목을 추가·제거할 때는 `gdprPublicSettings` 와 `gdprSettings` 샘플을 **함께** +고칩니다. 한쪽만 고치면 편집기에서 관리자 화면과 사용자 배너가 서로 다른 항목 목록을 +보여 주는데, 어느 쪽이 맞는지 화면만 봐서는 알 수 없습니다. + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/extension-points.md b/plugins/_bundled/sirsoft-gdpr/docs/extension-points.md new file mode 100644 index 00000000..8baf1e1b --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/extension-points.md @@ -0,0 +1,128 @@ +# GDPR (일반 데이터 보호 규정) — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 2종 / 호출 지점 12곳. 훅 이름이 상수·변수로 조립된 호출이 12곳 있어 호출 위치가 표에 다 실리지 않을 수 있습니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-gdpr.consent.granted` | action | 동의 부여 시 발화 | 선언 (호출 위치 미확인) | +| `sirsoft-gdpr.consent.revoked` | action | 동의 철회 시 발화 | 선언 (호출 위치 미확인) | + + + +두 훅 모두 `GdprUserConsent $consent, string $source` 를 인자로 넘깁니다. `$source` 값은 +`banner`/`mypage`/`mypage_renew_all`/`register`/`withdraw` 중 하나이며, 어떤 화면에서 동의가 +바뀌었는지 구분해야 하는 리스너(예: 배너 동의만 특정 방식으로 처리하고 싶은 경우)는 이 값으로 +분기합니다. `revoked` 는 명시적 철회(마이페이지)뿐 아니라 회원탈퇴로 인한 일괄 철회 +(`source=withdraw`)에서도 발화됩니다 — "동의 취소" 이벤트를 하나로 통일해 구독자가 두 경로를 +따로 처리하지 않아도 되게 합니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.auth.logout` | action (미선언) | `GdprAuthLogoutListener` | `forgetGdprCookies` | 10 | +| `core.auth.record_consents` | action (미선언) | `GdprAuthConsentListener` | `recordRegisterConsents` | 10 | +| `core.user.after_withdraw` | action (미선언) | `GdprUserWithdrawListener` | `handleWithdraw` | 10 | +| `core.user.before_delete` | action (미선언) | `GdprUserDeleteListener` | `cascadePluginData` | 10 | + + + +`core.auth.record_consents` 는 회원가입 폼에서 받은 동의 값을 코어가 이 플러그인에 **위임**하는 +자리입니다 — 회원가입 컨트롤러는 동의 저장 로직을 몰라도 되고, 이 플러그인이 폼 데이터에서 +동의 관련 키만 추출해 회원가입과 같은 트랜잭션에서 기록합니다. 나머지 3개 +(`logout`/`after_withdraw`/`before_delete`)는 전부 회원 생명주기 이벤트에 반응하는 정리 로직이며, +§핵심 흐름(AGENTS.md)에서 다룬 대로 탈퇴와 완전삭제는 반드시 구분해서 처리합니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `GdprAuthConsentListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprAuthConsentListener.php` | +| `GdprAuthLogoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprAuthLogoutListener.php` | +| `GdprUserDeleteListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprUserDeleteListener.php` | +| `GdprUserWithdrawListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprUserWithdrawListener.php` | + + + +4개 리스너가 전부 훅 1개씩만 구독하는 것은 각자 트리거가 회원 생명주기의 서로 다른 순간 +(로그인 로그아웃/동의 기록/탈퇴/완전삭제)이라 합쳐도 이득이 없기 때문입니다. +`GdprAuthLogoutListener` 는 로그아웃 시 `gdpr_session` 게스트 쿠키를 폐기합니다 — 로그인 +후에는 신원이 회원으로 바뀌므로, 로그아웃 시 남아 있는 게스트 세션 쿠키가 다음 방문자와 +뒤섞이지 않도록 정리하는 것입니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/cookie_banner.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/mypage_privacy_tab.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +`cookie_banner.json` 은 사이트 전역에 배너를 띄우는 조각(코어/템플릿 공용 확장 지점에 주입)이고, +`mypage_privacy_tab.json` 은 마이페이지에 "개인정보/동의 관리" 탭을 추가하는 조각입니다. 둘 다 +`sirsoft-basic` 템플릿의 확장 지점에 의존하므로, 다른 사용자 템플릿을 쓰려면 그 템플릿에도 +같은 지점이 있어야 두 UI 가 정상 노출됩니다(README "다른 확장과의 연동" 참고). + + +## 미들웨어 + + +| 미들웨어 | 부착 대상(targets) | 우선순위 | +|---|---|---| +| `CookieConsentMiddleware` | `everything` | - | + + + +대상이 `everything`(모든 요청)인 이유는 functional 미동의 상태에서 어느 응답이 쿠키를 +심으려 하는지 이 플러그인이 미리 알 수 없기 때문입니다 — 특정 라우트만 골라 부착하면 그 +목록에서 빠진 응답의 쿠키는 게이팅되지 않습니다. 등록은 `GdprServiceProvider::boot()` 에서 +Laravel 커널의 `prependMiddlewareToGroup('web'|'api')` 로 이뤄지며, 코어의 미들웨어 +self-gate 규정(대상 명시)에 대한 근거가 바로 이 전면 적용 필요성입니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +동의 상태 변경은 실시간 브로드캐스트 대상이 아닙니다 — 같은 방문자가 여러 탭을 열어둔 상태를 +동기화해야 할 만큼 시급한 이벤트가 아니고, 다음 페이지 요청 시 미들웨어가 최신 상태를 다시 +평가하므로 자연스럽게 수렴합니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +동의 이력은 영구 보존이 원칙(Art.30)이라 배치로 정리할 대상이 없습니다. 게스트 세션 데이터의 +만료·정리는 코어 세션 메커니즘에 위임하며, 이 플러그인이 별도 정리 스케줄을 두지 않습니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +동의 부여/철회는 방문자 본인의 조작 결과이므로 본인에게 알림을 보낼 이유가 없고, 정책 버전 +발행처럼 운영자가 이미 인지하고 수행한 조작도 마찬가지입니다. 관리자에게 알려야 할 이벤트가 +생기면(예: 대량 철회 급증 같은 이상 신호) 그때 코어 알림 정의를 신설합니다. + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/frontend.md b/plugins/_bundled/sirsoft-gdpr/docs/frontend.md new file mode 100644 index 00000000..1f81e491 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/frontend.md @@ -0,0 +1,84 @@ +# GDPR (일반 데이터 보호 규정) — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 4개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 4개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `gdpr_consent_log` | `admin` | 화면 | `_admin_base` | +| `_policy_version_snapshot_modal` | `admin` | partial | - | +| `_policy_version_publish_modal` | `admin` | partial | - | +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +관리자 화면 4개뿐이고 **쿠키 배너·마이페이지 동의 탭은 이 표에 없습니다** — 그 둘은 +"레이아웃"이 아니라 "레이아웃 확장 조각"(`resources/extensions/cookie_banner.json`, +`mypage_privacy_tab.json`, §extension-points.md)으로 다른 확장/템플릿 레이아웃에 주입되는 +형태라 별도 수집 축에 잡힙니다. 방문자가 실제로 보는 UI를 찾으려면 이 표가 아니라 +확장점 문서를 봐야 합니다. + + +## 액션 핸들러 + + +핸들러 1개 (정의: `resources/js/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `syncConsent` | `sirsoft-gdpr.syncConsent` | + + + +`syncConsent` 하나뿐인 이유는 배너·마이페이지 UI 가 사실상 "동의 상태를 서버에 반영하고 +화면을 갱신한다"는 단일 동작만 필요로 하기 때문입니다. 자동 차단/복원 로직(외부 스크립트· +iframe·1st-party 저장소 게이팅)은 이 액션 핸들러가 아니라 `dist/js/plugin.iife.js` 가 페이지 +로드 시 스스로 수행합니다 — 사용자 조작에 반응하는 것과 페이지 로드마다 항상 실행되는 것을 +액션 핸들러/전역 스크립트로 구분한 것입니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftGdpr` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`initPlugin()` 이 "핸들러 재등록만" 하도록 좁혀 둔 것은 코어 규정(§CLAUDE.md "확장 미들웨어는 +...")을 그대로 따른 결과입니다 — 자동 차단 스크립트의 부팅(도메인 카탈로그 로드, DOM 스캔 +시작)을 여기 넣으면 로케일 전환마다 그 부팅이 중복 실행됩니다. 자동 차단 부팅은 `blocker.ts`/ +`preblocker.ts` 가 페이지 최초 로드 시 1회만 수행합니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/css/plugin.css` | 빌드 산출물 (커밋 대상) | +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":0,"dependencies":[]}` + + + +`priority: 0`(다른 확장보다 먼저 로드)인 이유는 자동 차단이 **다른 확장의 추적 스크립트가 +실행되기 전에** 걸려 있어야 하기 때문입니다 — 이 플러그인이 늦게 로드되면 동의 없이 이미 +로드된 스크립트를 사후에 막을 방법이 없습니다. `strategy: "global"` 도 같은 이유로, 특정 +페이지에서만 지연 로드하면 그 페이지에서는 동의 전 차단이 통째로 빠집니다. + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/settings.md b/plugins/_bundled/sirsoft-gdpr/docs/settings.md new file mode 100644 index 00000000..19e19b86 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/settings.md @@ -0,0 +1,96 @@ +# GDPR (일반 데이터 보호 규정) — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `privacy_policy_slug` | `string` | `privacy` | 개인정보처리방침 페이지 슬러그 | +| `legal_entity_name` | `string` | - | 운영 주체명 | +| `data_storage_location` | `string` | - | 데이터 저장 위치 | +| `banner_enabled` | `boolean` | `true` | 쿠키 배너 노출 | +| `banner_position` | `string` | `bottom_bar` | 배너 위치 | +| `blocked_domains` | `json` | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` | 추적 도메인 차단 목록 | +| `cookie_categories` | `json` | `[]` | 쿠키 카테고리 정의 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +`blocked_domains` 가 카테고리별(functional/analytics/marketing) 배열을 담은 단일 JSON 컬럼인 +것은, 카테고리 추가·삭제가 스키마 변경이 아니라 값 변경으로 끝나게 하기 위해서입니다 — 새 +카테고리를 추가할 때 마이그레이션이 필요하지 않습니다(다만 배너 UI 의 카테고리 목록은 별도 +동기화가 필요합니다, §AGENTS.md 수정 시 동반 의무). `cookie_categories` 가 기본값 `[]` 로 +비어 있는 것은 4대 표준 카테고리(필수/기능/분석/마케팅)가 이미 코드/Enum(`CookieCategory`)에 +고정돼 있어, 이 설정은 그 표준을 벗어나는 **추가** 카테고리를 위한 자리이기 때문입니다. + + +## 권한 + + +| 카테고리 | 이름 | 액션 | 라우트 키 | +|---|---|---|---| +| `privacy` | 개인정보 보호 | `view`, `update` | - | + + + +권한이 `view`/`update` 하나씩만 있고 관리자 동의 이력·정책 버전 발행이 별도 권한으로 세분화 +되지 않은 것은, 이 플러그인의 관리자 기능 전체가 "개인정보 보호 담당자"라는 하나의 역할 +단위로 다뤄지기 때문입니다. 조회와 변경(설정 수정·정책 발행)을 분리해 둔 것은 감사 목적으로 +"누가 정책을 발행했는지"와 "누가 그냥 보기만 했는지"를 구분할 필요가 있어서입니다. + + +## 메뉴 + + +| 구분 | slug | 이름 | URL | 하위 | +|---|---|---|---|---| +| 관리자 | `sirsoft-gdpr-consent-log` | GDPR 동의 이력 | `/admin/plugins/sirsoft-gdpr/consent-log` | - | + + + +"GDPR 설정"(정책 버전 발행 포함) 화면은 별도 메뉴가 아니라 플러그인 공통 설정 화면 +(`관리자 → 플러그인 → GDPR 설정`)에 얹혀 있고, "동의 이력" 조회만 독립 메뉴입니다 — 설정은 +가끔 바꾸는 화면이라 플러그인 목록에서 진입해도 충분하지만, 동의 이력은 자주 확인하는 +운영 화면이라 별도 메뉴로 빠르게 도달할 수 있어야 하기 때문입니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-gdpr/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +`Public\GdprCookieConsentController`/`GdprSettingsController` 는 인증 없이 호출됩니다 — +게스트 방문자도 배너를 봐야 하고 동의를 기록해야 하므로, 공개 라우트로 두되 게스트 식별은 +`gdpr_session` 서명 쿠키로 처리합니다. 반대로 관리자·마이페이지 라우트는 코어 인증/권한 +미들웨어가 걸립니다 — 이 셋을 하나의 미들웨어 그룹으로 묶지 않고 컨트롤러 계층(Public/User/Admin) +으로 분리한 것이 인증 요구사항의 차이를 드러냅니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +formal `dependencies` 선언이 둘 다 "없음"인데도 README 는 `sirsoft-page`(소프트, 런타임 체크)와 +`sirsoft-basic`(레이아웃 확장 주입 지점)을 명시합니다 — 둘 다 manifest 의존성 제약으로 +선언할 만큼 강한 결합이 아니기 때문입니다. `sirsoft-page` 미설치는 기능 저하(링크 숨김)로 +그치고, `sirsoft-basic` 미사용은 다른 템플릿이 같은 주입 지점을 제공하면 해소됩니다 — 둘 다 +"없으면 설치가 막히는" 수준의 의존이 아니라 manifest 의존성 목록에 넣지 않습니다. + diff --git a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php index 4a3f4b48..3d48465d 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/en/messages.php @@ -201,6 +201,7 @@ return [ 'register' => 'Sign-up', 'mypage' => 'MyPage', 'mypage_renew_all' => 'MyPage bulk re-consent', + 'withdraw' => 'Account withdrawal', ], 'col' => [ 'created_at' => 'Time', diff --git a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php index 993e3aa6..4784e3f1 100644 --- a/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-gdpr/lang/ko/messages.php @@ -201,6 +201,7 @@ return [ 'register' => '회원가입', 'mypage' => '마이페이지', 'mypage_renew_all' => '마이페이지 일괄 재동의', + 'withdraw' => '회원탈퇴', ], 'col' => [ 'created_at' => '시점', diff --git a/plugins/_bundled/sirsoft-gdpr/package-lock.json b/plugins/_bundled/sirsoft-gdpr/package-lock.json new file mode 100644 index 00000000..086be7a9 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/package-lock.json @@ -0,0 +1,2689 @@ +{ + "name": "@plugins/sirsoft-gdpr", + "version": "1.0.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@plugins/sirsoft-gdpr", + "version": "1.0.4", + "devDependencies": { + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22.0.0", + "@vitest/ui": "^2.1.0", + "jsdom": "^25.0.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz", + "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "fflate": "^0.8.2", + "flatted": "^3.3.1", + "pathe": "^1.1.2", + "sirv": "^3.0.0", + "tinyglobby": "^0.2.10", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "2.1.9" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/plugins/_bundled/sirsoft-gdpr/package.json b/plugins/_bundled/sirsoft-gdpr/package.json index bf745274..94582e63 100644 --- a/plugins/_bundled/sirsoft-gdpr/package.json +++ b/plugins/_bundled/sirsoft-gdpr/package.json @@ -1,6 +1,6 @@ { "name": "@plugins/sirsoft-gdpr", - "version": "1.0.3", + "version": "1.0.4", "private": true, "type": "module", "scripts": { diff --git a/plugins/_bundled/sirsoft-gdpr/plugin.json b/plugins/_bundled/sirsoft-gdpr/plugin.json index 5398adca..c6e0f1f0 100644 --- a/plugins/_bundled/sirsoft-gdpr/plugin.json +++ b/plugins/_bundled/sirsoft-gdpr/plugin.json @@ -5,7 +5,7 @@ "ko": "GDPR (일반 데이터 보호 규정)", "en": "GDPR (General Data Protection Regulation)" }, - "version": "1.0.3", + "version": "1.0.4", "description": { "ko": "쿠키 동의 배너, 자동 차단, 동의 이력 저장, 마이페이지 동의 철회를 제공하는 GDPR 대응 플러그인입니다.", "en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal." diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json index 441bf3ad..5515f6f8 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/en.json @@ -238,7 +238,8 @@ "preference_center": "Preferences", "mypage": "MyPage", "register": "Sign-up", - "mypage_renew_all": "MyPage bulk re-consent" + "mypage_renew_all": "MyPage bulk re-consent", + "withdraw": "Account withdrawal" }, "col": { "created_at": "Time", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json index 8ab8e172..51172713 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/lang/ko.json @@ -238,7 +238,8 @@ "preference_center": "환경설정", "mypage": "마이페이지", "register": "회원가입", - "mypage_renew_all": "마이페이지 일괄 재동의" + "mypage_renew_all": "마이페이지 일괄 재동의", + "withdraw": "회원탈퇴" }, "col": { "created_at": "시점", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json index 9bebf3a7..5c40109b 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json @@ -1119,6 +1119,52 @@ "text": "$t:sirsoft-gdpr.admin.consent_log.source.mypage_renew_all" } ] + }, + { + "type": "basic", + "name": "Label", + "props": { + "className": "inline-clickable" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "checkbox", + "className": "checkbox", + "checked": "{{(_local.filter.sources || []).includes('withdraw')}}" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "filter.sources": "{{$event.target.checked ? [...(_local.filter.sources || []).filter(s => s !== 'withdraw'), 'withdraw'] : (_local.filter.sources || []).filter(s => s !== 'withdraw')}}" + } + }, + { + "actionRef": "searchConsentLogs" + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-label" + }, + "text": "$t:sirsoft-gdpr.admin.consent_log.source.withdraw" + } + ] } ] } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentSource.php b/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentSource.php index b7ae22a1..d728da27 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentSource.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Enums/ConsentSource.php @@ -17,6 +17,7 @@ namespace Plugins\Sirsoft\Gdpr\Enums; * - register: 회원가입 시 동의 (GdprAuthConsentListener) * - mypage: 마이페이지 동의 관리에서 변경 * - mypage_renew_all: 정책 개정 후 마이페이지에서 일괄 재동의 (GdprConsentService::renewAll) + * - withdraw: 회원탈퇴 시 활성 동의 일괄 철회 (GdprConsentService::revokeAllOnWithdraw) */ enum ConsentSource: string { @@ -25,6 +26,7 @@ enum ConsentSource: string case Register = 'register'; case Mypage = 'mypage'; case MypageRenewAll = 'mypage_renew_all'; + case Withdraw = 'withdraw'; /** * 사용자 친화 라벨을 반환합니다. @@ -49,8 +51,15 @@ enum ConsentSource: string /** * 사용자 요청으로 직접 지정할 수 있는 출처 값 목록. * - * `register` / `mypage_renew_all` 은 서버가 스스로 기록하는 경로이므로 - * 공개 요청 본문에서 지정할 수 없습니다. + * `register`(회원가입 시 동의) · `mypage_renew_all`(정책 개정 후 일괄 재동의) · + * `withdraw`(회원탈퇴 시 일괄 철회) 는 서버가 스스로 기록하는 경로이므로 공개 요청 + * 본문에서 지정할 수 없습니다. 이 엔드포인트는 비인증 방문자도 도달하므로, 지정을 + * 허용하면 가입·재동의·탈퇴를 하지 않은 사람의 이력이 그렇게 기록됩니다 — 동의 이력은 + * 출처가 존재 이유이고, 그렇게 기록되어도 오류도 로그도 남지 않습니다. + * + * 새 case 를 추가할 때는 그것이 공개 요청으로 지정 가능한지 판단해 이 목록에 넣거나 + * 빼고, 뺐다면 `ConsentSourceVocabularyParityTest` 에 제외 단언을 함께 추가합니다 — + * 목록에서 빠뜨려도 검증이 없으면 아무 테스트도 red 가 되지 않습니다. * * @return array */ @@ -59,7 +68,6 @@ enum ConsentSource: string return [ self::Banner->value, self::PreferenceCenter->value, - self::Register->value, self::Mypage->value, ]; } diff --git a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php index 1af4bb23..33fe1814 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Repositories/GdprUserConsentRepository.php @@ -3,6 +3,7 @@ namespace Plugins\Sirsoft\Gdpr\Repositories; use Illuminate\Support\Collection; +use Plugins\Sirsoft\Gdpr\Enums\ConsentSource; use Plugins\Sirsoft\Gdpr\Models\GdprUserConsent; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface; @@ -14,8 +15,8 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface /** * 사용자 ID와 동의 키로 동의 상태를 조회합니다. * - * @param int $userId 사용자 ID - * @param string $consentKey 동의 항목 키 + * @param int $userId 사용자 ID + * @param string $consentKey 동의 항목 키 * @return GdprUserConsent|null */ public function findByUserAndKey(int $userId, string $consentKey): ?GdprUserConsent @@ -28,7 +29,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface /** * 사용자 ID로 모든 동의 상태를 조회합니다. * - * @param int $userId 사용자 ID + * @param int $userId 사용자 ID * @return Collection */ public function getAllByUserId(int $userId): Collection @@ -39,7 +40,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface /** * 사용자 ID로 활성 동의(is_consented=true)만 조회합니다. * - * @param int $userId 사용자 ID + * @param int $userId 사용자 ID * @return Collection */ public function getActiveByUserId(int $userId): Collection @@ -52,14 +53,14 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface /** * 가상의 비활성 동의 상태 모델을 합성합니다 (DB 미저장). * - * @param int $userId 사용자 ID - * @param string $consentKey 동의 항목 키 (cookie_ 접두사 포함) - * @param string|null $consentCategory 카테고리 + * @param int $userId 사용자 ID + * @param string $consentKey 동의 항목 키 (cookie_ 접두사 포함) + * @param string|null $consentCategory 카테고리 * @return GdprUserConsent 합성된 비활성 모델 (DB 미저장) */ public function buildVirtualStatus(int $userId, string $consentKey, ?string $consentCategory = null): GdprUserConsent { - $row = new GdprUserConsent(); + $row = new GdprUserConsent; $row->user_id = $userId; $row->consent_key = $consentKey; $row->consent_category = $consentCategory; @@ -79,9 +80,9 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface /** * 동의 상태 레코드를 생성하거나 업데이트합니다. * - * @param int $userId 사용자 ID - * @param string $consentKey 동의 항목 키 - * @param array $data 업데이트 데이터 + * @param int $userId 사용자 ID + * @param string $consentKey 동의 항목 키 + * @param array $data 업데이트 데이터 * @return GdprUserConsent */ public function upsert(int $userId, string $consentKey, array $data): GdprUserConsent @@ -99,7 +100,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface /** * 사용자의 모든 활성 동의를 일괄 철회 처리합니다 (탈퇴 시 사용). * - * @param int $userId 사용자 ID + * @param int $userId 사용자 ID * @return int 영향받은 행 수 */ public function revokeAllForUser(int $userId): int @@ -109,14 +110,14 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface ->update([ 'is_consented' => false, 'revoked_at' => now(), - 'last_source' => 'withdraw', + 'last_source' => ConsentSource::Withdraw->value, ]); } /** * 특정 동의 키에 동의한 사용자 수를 반환합니다. * - * @param string $consentKey 동의 항목 키 + * @param string $consentKey 동의 항목 키 * @return int */ public function countConsentedByKey(string $consentKey): int @@ -129,7 +130,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface /** * 사용자 ID로 모든 동의 상태 레코드를 삭제합니다. * - * @param int $userId 사용자 ID + * @param int $userId 사용자 ID * @return void */ public function deleteByUserId(int $userId): void diff --git a/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php b/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php index fbb9a7e4..1f5fb781 100644 --- a/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php +++ b/plugins/_bundled/sirsoft-gdpr/src/Services/GdprConsentService.php @@ -73,7 +73,7 @@ class GdprConsentService * @param string|null $sessionId 게스트 세션 ID (회원이면 NULL) * @param string $consentKey 동의 항목 키 * @param bool $value 동의 여부 - * @param string $source 변경 경로 (허용 어휘는 ConsentSource enum — banner/preference_center/register/mypage/mypage_renew_all) + * @param string $source 변경 경로 (허용 어휘는 {@see ConsentSource} 가 SSoT) * @param array|null $categories 카테고리 스냅샷 (배너 일괄 변경 시) * @param bool $isRejection 명시적 거부 신호 (이슈 #430). 선택형 미동의 항목을 is_rejected=true 로 저장. * @return void @@ -302,7 +302,7 @@ class GdprConsentService // 멱등하게 작성해야 한다. DB::transaction(function () use ($userId, $activeConsents) { foreach ($activeConsents as $consent) { - $this->updateConsent($userId, null, $consent->consent_key, false, 'withdraw'); + $this->updateConsent($userId, null, $consent->consent_key, false, ConsentSource::Withdraw->value); } }); } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/consent-log-source-filter.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/consent-log-source-filter.spec.ts new file mode 100644 index 00000000..8fd04ad0 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/consent-log-source-filter.spec.ts @@ -0,0 +1,55 @@ +/** + * E2E: 관리자 「GDPR 동의 이력」 화면 — 출처 필터 「회원탈퇴」 체크박스 (#601 문서화 세션 중 발견) + * + * @scenario admin_gdpr_consent_log_source_filter_withdraw + * @effects withdraw_checkbox_visible, withdraw_filter_updates_query_and_refetches + * + * 배경: `ConsentSource` enum 에 `withdraw`(회원탈퇴 시 일괄 철회) case 가 없어, 그 출처로 + * 기록된 동의 이력 행이 출처 필터 어디로도 걸러지지 않던 결함을 발견해 enum·기록 지점(Service· + * Repository)·라벨(ko/en/ja)·이 필터 체크박스를 함께 추가했다. PHPUnit 쪽은 + * `ConsentSourceVocabularyParityTest` 가 JSON 안의 `includes('withdraw')`/라벨 바인딩 + * 존재를 정적으로 검증하지만, 그 체크박스가 실제 브라우저에서 보이고 클릭 시 목록이 다시 + * 조회되는지는 별도로 확인해야 한다. + * + * 검증: + * 1. 동의 이력 화면에 "회원탈퇴" 출처 필터 체크박스가 보인다 + * 2. 체크하면 URL 쿼리에 `sources` 값으로 `withdraw` 가 반영되고 목록이 재조회된다 + * 3. 다시 해제하면 `withdraw` 가 쿼리에서 빠진다 + */ +import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth'; + +const WITHDRAW_FILTER_LABEL = '회원탈퇴'; + +async function gotoConsentLog(page: import('@playwright/test').Page): Promise { + await page.goto('/admin/plugins/sirsoft-gdpr/consent-log'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator('#gdpr_consent_log_datagrid__body')).toBeAttached({ timeout: 20_000 }); +} + +// @scenario source_filter=withdraw +// @effects withdraw_checkbox_visible +test('#601 - 동의 이력 출처 필터에 "회원탈퇴" 체크박스가 보인다', async ({ page, privacyManageToken }) => { + await authenticatePage(page, privacyManageToken); + await gotoConsentLog(page); + + const checkbox = page.getByLabel(WITHDRAW_FILTER_LABEL); + await expect(checkbox).toBeAttached({ timeout: 10_000 }); + await expect(checkbox).not.toBeChecked(); +}); + +// @scenario source_filter=withdraw, toggle=on_then_off +// @effects withdraw_filter_updates_query_and_refetches +test('#601 - "회원탈퇴" 체크 시 URL 쿼리에 반영되고, 해제하면 빠진다', async ({ page, privacyManageToken }) => { + await authenticatePage(page, privacyManageToken); + await gotoConsentLog(page); + + const checkbox = page.getByLabel(WITHDRAW_FILTER_LABEL); + await checkbox.check(); + + // searchConsentLogs 가 navigate(mergeQuery) 로 sources 배열을 쿼리에 싣는다. + await expect(page).toHaveURL(/withdraw/, { timeout: 10_000 }); + await expect(checkbox).toBeChecked(); + + await checkbox.uncheck(); + await expect(page).not.toHaveURL(/withdraw/, { timeout: 10_000 }); +}); diff --git a/plugins/_bundled/sirsoft-gdpr/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-gdpr/tests/PluginTestCase.php index 9bb299c6..f272f29c 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/PluginTestCase.php @@ -3,10 +3,12 @@ namespace Plugins\Sirsoft\Gdpr\Tests; use App\Enums\PermissionType; +use App\Extension\HookManager; use App\Models\Permission; use App\Models\Role; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Route; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprPolicyVersionRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface; @@ -48,6 +50,39 @@ abstract class PluginTestCase extends TestCase $this->app->bind(GdprUserConsentRepositoryInterface::class, GdprUserConsentRepository::class); $this->app->bind(GdprUserConsentHistoryRepositoryInterface::class, GdprUserConsentHistoryRepository::class); $this->app->bind(GdprPolicyVersionRepositoryInterface::class, GdprPolicyVersionRepository::class); + + $this->registerPluginApiRoutes(); + } + + /** + * 플러그인 API 라우트를 테스트 앱에 등록합니다. + * + * `PluginRouteServiceProvider` 는 `plugins` 테이블의 활성 행을 대조해서만 라우트를 + * 등록합니다(#603). 테스트는 `RefreshDatabase` 로 매번 빈 테이블에서 부팅하므로 그 + * 게이트가 항상 닫히고, 이 플러그인의 모든 엔드포인트가 404 가 됩니다 — 실패는 + * 권한·검증이 아니라 "주소 없음" 으로 나타나 원인이 드러나지 않습니다. + * + * 활성 행을 심는 것으로는 낫지 않습니다. 라우트 등록은 `parent::setUp()` 의 앱 부팅 + * 시점에 끝나고 DB 초기화는 그 뒤에 오기 때문입니다. 다른 번들 확장의 테스트 베이스도 + * 같은 이유로 라우트 파일을 직접 그룹에 물립니다. + * + * prefix·name·middleware 는 프로바이더와 동일하게 맞춥니다 — 어긋나면 테스트가 + * 통과해도 운영 주소와 다른 곳을 밟게 됩니다. + * + * @return void + */ + protected function registerPluginApiRoutes(): void + { + $apiRoutesFile = dirname(__DIR__).'/src/routes/api.php'; + + if (! file_exists($apiRoutesFile)) { + return; + } + + Route::prefix('api/plugins/sirsoft-gdpr') + ->name('api.plugins.sirsoft-gdpr.') + ->middleware('api') + ->group($apiRoutesFile); } /** @@ -69,10 +104,10 @@ abstract class PluginTestCase extends TestCase */ private function snapshotHookManager(): void { - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $this->hookSnapshot = [ - 'hooks' => $ref->getProperty('hooks')->getValue(), - 'filters' => $ref->getProperty('filters')->getValue(), + 'hooks' => $ref->getProperty('hooks')->getValue(), + 'filters' => $ref->getProperty('filters')->getValue(), 'dispatching' => $ref->getProperty('dispatching')->getValue(), ]; } @@ -88,7 +123,7 @@ abstract class PluginTestCase extends TestCase return; } - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); @@ -165,17 +200,17 @@ abstract class PluginTestCase extends TestCase { $paths = ['database/migrations']; foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { - $paths[] = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $p); + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); } foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { - $paths[] = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $p); + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); } return [ '--drop-views' => $this->shouldDropViews(), '--drop-types' => $this->shouldDropTypes(), - '--seed' => false, - '--path' => $paths, + '--seed' => false, + '--path' => $paths, ]; } } diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Unit/ConsentSourceVocabularyParityTest.php b/plugins/_bundled/sirsoft-gdpr/tests/Unit/ConsentSourceVocabularyParityTest.php index 6c3ab263..12620501 100644 --- a/plugins/_bundled/sirsoft-gdpr/tests/Unit/ConsentSourceVocabularyParityTest.php +++ b/plugins/_bundled/sirsoft-gdpr/tests/Unit/ConsentSourceVocabularyParityTest.php @@ -17,6 +17,7 @@ use Plugins\Sirsoft\Gdpr\Enums\ConsentSource; * * DB·라우트에 의존하지 않는 정적 검사이므로 `Tests\TestCase` 가 아니라 순수 TestCase 를 상속합니다. */ +// audit:allow test-extension-base-class reason: 파일 내용·enum 값만 비교하는 순수 정적 검사 — DB/라우트/오토로드 부팅이 필요 없어 PluginTestCase 상속 시 불필요한 부팅 비용만 늘어난다 class ConsentSourceVocabularyParityTest extends TestCase { /** 플러그인 루트 경로 */ @@ -36,9 +37,14 @@ class ConsentSourceVocabularyParityTest extends TestCase $declared = ConsentSource::allValues(); // 실제 기록 지점 — source:/'source' =>/'last_source' => 인자로 넘어가는 리터럴 + // + // Repository 도 포함한다 — GdprUserConsentRepository::revokeAllForUser() 가 + // Service 를 거치지 않고 직접 'last_source' => 리터럴을 UPDATE 쿼리에 싣는다. + // 이 파일이 빠져 있으면 그 리터럴은 어떤 축으로도 검사되지 않는다. $sources = [ 'src/Listeners/GdprAuthConsentListener.php', 'src/Services/GdprConsentService.php', + 'src/Repositories/GdprUserConsentRepository.php', 'src/Http/Controllers/User/GdprConsentController.php', ]; @@ -177,6 +183,13 @@ class ConsentSourceVocabularyParityTest extends TestCase $selectable = ConsentSource::requestSelectableValues(); $this->assertNotContains(ConsentSource::MypageRenewAll->value, $selectable); + // 회원탈퇴 철회는 `GdprConsentService::revokeAllOnWithdraw()` 만 기록하는 경로다. + // 공개 요청이 이 값을 실을 수 있으면 탈퇴하지 않은 사용자의 이력이 탈퇴로 기록된다. + $this->assertNotContains(ConsentSource::Withdraw->value, $selectable); + // 가입 동의는 `GdprAuthConsentListener::recordRegisterConsents()` 만 기록하는 + // 경로다. 이 엔드포인트는 비인증 방문자도 도달하므로, 지정을 허용하면 가입하지 + // 않은 방문자의 이력이 가입 동의로 기록된다. + $this->assertNotContains(ConsentSource::Register->value, $selectable); $this->assertContains(ConsentSource::Banner->value, $selectable); } } diff --git a/plugins/_bundled/sirsoft-marketing/AGENTS.md b/plugins/_bundled/sirsoft-marketing/AGENTS.md new file mode 100644 index 00000000..843d293a --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/AGENTS.md @@ -0,0 +1,200 @@ +# 마케팅 동의 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-marketing) — 회원의 마케팅 수신 동의를 항목별로 받고 이력을 남긴다. 자기 화면 없이 코어 회원 화면에 조각 5개를 주입 +2. 확장 방식: 동의 상태 변화를 알리는 발행 훅 4개(`user.consent_changed` / `subscribed` / `unsubscribed` / `filter_consent_data`). 동의 항목 추가는 코드가 아니라 `channels` 설정 +3. 건드리면 안 되는 것: 항목마다 컬럼 추가(EAV 구조가 전제), 이력 없는 상태 갱신, 코어 User 모델·컨트롤러 직접 수정, 회원 삭제 정리를 CASCADE 에 위임 +4. 작업 위치: `plugins/_bundled/sirsoft-marketing` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-marketing --force` +``` + +## 1. 이 확장은 무엇인가 + + +회원의 **마케팅 정보 수신 동의**를 항목별로 받고, 그 동의·철회 이력을 남기는 플러그인입니다. +이메일·SMS 같은 수신 채널을 운영자가 자유롭게 추가할 수 있고, 제3자 제공 동의·정보 공개 +동의처럼 채널이 아닌 항목도 같은 구조로 다룹니다. + +**자기 화면이 없습니다.** 관리자 설정 화면 하나를 빼면 이 플러그인의 UI 는 전부 **다른 화면에 +끼워 넣는 조각**입니다 — 회원가입 폼·회원 상세·회원 수정 폼·마이페이지 프로필에 동의 항목이 +나타나는 것이 그것입니다. 코어 회원 화면을 고치지 않고 동의 항목을 늘리기 위한 구조입니다. + +**설계 원칙 셋**: + +1. **EAV 구조로 항목을 데이터화한다.** 동의 항목마다 컬럼을 만들면 항목을 늘릴 때 스키마 + 변경이 필요합니다. 대신 `user_marketing_consents` 한 테이블에 `consent_key` 별 행을 두어, + 채널 추가가 **설정 변경만으로** 끝나게 했습니다. +2. **코어 회원 흐름에 훅으로 붙는다.** 구독 훅 11개가 전부 코어 것입니다 — 가입·생성·수정· + 삭제·조회 각 지점의 검증 규칙과 데이터에 동의 항목을 얹습니다. 코어 `User` 모델이나 회원 + 컨트롤러를 고치지 않습니다. +3. **철회는 동의만큼 쉬워야 한다.** 마이페이지 조각이 항상 노출되고, 동의·철회가 모두 + `user_marketing_consent_histories` 에 기록됩니다(행위·출처·IP). 동의를 받은 경로와 철회 + 경로가 대칭이 아니면 그 동의는 법적 근거로 쓸 수 없습니다. + +**의도적으로 하지 않는 것**: 실제 발송(메일·SMS)·권한 선언·관리자 메뉴·프론트 액션 핸들러. +이 플러그인은 "누가 무엇에 동의했는가"만 답하고, 그 동의를 근거로 무엇을 보낼지는 발송을 +담당하는 확장의 일입니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-marketing --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-marketing --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-marketing --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-marketing --force` | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**회원가입 시 동의 수집**: 템플릿의 가입 폼이 `user-marketing-register.json` 조각을 그 자리에 +받아 동의 체크박스를 그림 → 제출 → 코어 가입 흐름에서 `core.auth.register_validation_rules` +필터가 동의 항목의 검증 규칙을 더함 → 가입 완료 후 `core.auth.register` 액션에서 +`MarketingConsentListener::afterRegister()` 가 동의 값을 `user_marketing_consents` 에 기록하고 +이력을 남깁니다. **코어 가입 코드는 이 플러그인을 알지 못합니다.** + +**동의 변경 → 이력 적재**: 회원이 마이페이지에서, 또는 운영자가 회원 수정 화면에서 동의를 +바꾸면 → `core.user.filter_update_data` / `update_validation_rules` 로 동의 필드가 흐름에 +편입 → `core.user.after_update` 에서 리스너가 `MarketingConsentService` 로 위임 → 항목별 +현재 상태(`is_consented` · `consented_at` · `revoked_at` · `consent_count` · `last_source`)를 +갱신하고 이력 한 줄(`action` · `source` · `ip_address`)을 적재한 뒤 +`sirsoft-marketing.user.consent_changed` 를 발행합니다. + +**채널 추가**: 운영자가 설정 화면에서 채널을 더함 → `PUT admin/channels` → +`core.plugin_settings.filter_save_data` 필터가 저장 형태를 정규화 → `channels` 설정(JSON)에 +반영. 다음 요청부터 가입 폼·마이페이지 조각에 그 항목이 나타납니다 — **스키마 변경도 배포도 +필요 없습니다.** + +**회원 삭제**: `core.user.before_delete` 에서 그 회원의 동의 기록을 정리합니다. 코어 회원 +삭제가 이 플러그인의 테이블을 알지 못하므로, 이 훅이 없으면 고아 행이 남습니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 4개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 11개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 1개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +발행 훅 4종은 전부 **동의 상태 변화를 다른 확장에 알리는** 것입니다. + +| 훅 | 언제 쓰는가 | +|---|---| +| `user.consent_changed` | 동의 상태가 바뀔 때마다. 외부 마케팅 도구 동기화 지점 | +| `user.subscribed` · `user.unsubscribed` | 동의/철회로 갈라진 지점. 수신 목록 추가·제거를 각각 배선할 때 | +| `filter_consent_data` | 동의 데이터를 다른 확장이 가공해야 할 때 | + +**구독 방향이 이 플러그인의 성격을 더 잘 보여줍니다.** 11개 구독이 전부 코어 회원·가입 +흐름이며, 이것이 곧 "코어를 고치지 않고 회원 도메인에 필드를 더하는 방법" 의 선례입니다: +검증 규칙은 `*_validation_rules` 필터로, 저장 데이터는 `filter_update_data` 로, 응답 표현은 +`filter_resource_data` 로, 생명주기 정리는 `before_delete` 로 붙습니다. 회원에 자기 필드를 +더하려는 확장은 이 리스너 하나를 읽으면 됩니다. + +레이아웃 조각 5개가 UI 전부입니다 — 가입 폼 · 회원 상세 · 회원 수정 폼 · 마이페이지 프로필 +(보기/수정). 대상 화면이 그 자리(슬롯)를 없애면 조각은 **오류 없이 사라지므로**, 템플릿이나 +코어 회원 화면을 업그레이드한 뒤에는 동의 항목이 여전히 보이는지 눈으로 확인합니다. + +미들웨어·브로드캐스트 채널·스케줄·알림은 0개입니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-marketing --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-marketing` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 동의 상태를 바꾸는 경로를 추가했다면 이력 적재(`user_marketing_consent_histories`)가 같은 트랜잭션에 있는지 확인 +- [ ] 코어 회원·가입 훅 11종 중 하나라도 이름·페이로드가 바뀌면 이 플러그인이 조용히 끊기므로, 코어 회원 흐름 변경 시 함께 확인 +- [ ] 레이아웃 조각 5개는 대상 화면의 슬롯이 사라지면 오류 없이 빠진다 — 템플릿·코어 회원 화면 업그레이드 후 노출 확인 +- [ ] 약관 페이지 slug 설정은 `sirsoft-page` 모듈의 페이지를 가리킨다 (manifest 의존 `>=1.0.0`) +- [ ] 동의 항목을 늘릴 때는 설정만 바꾼다 — 마이그레이션이 필요해졌다면 EAV 구조를 벗어난 설계라는 신호 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-marketing --force` + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 동의 항목을 추가하려고 `user_marketing_consents` 에 컬럼을 더하기 | `channels` 설정에 항목을 추가 (EAV 구조) | 항목마다 컬럼을 만들면 운영자가 채널을 늘릴 때마다 배포가 필요해진다 — 이 플러그인이 존재하는 이유가 사라진다 | +| 동의 상태만 갱신하고 이력을 남기지 않기 | 상태 갱신과 이력 적재를 같은 트랜잭션에 | 이력이 없는 동의는 법적 근거로 쓸 수 없다. "언제 어느 경로로 동의했는가"가 동의 그 자체다 | +| 마이페이지 철회 조각을 설정 토글로 감추기 | 동의 이력이 있는 회원에게는 항상 노출 | 동의를 받은 경로와 철회 경로가 대칭이 아니면 그 동의는 무효가 된다 | +| 코어 `User` 모델·회원 컨트롤러를 고쳐 동의 필드를 넣기 | `core.user.*` 훅 11종 | 코어 수정은 업그레이드마다 충돌하고, 이 플러그인을 비활성화해도 필드가 남는다 | +| 회원 삭제 시 동의 기록 정리를 DB CASCADE 에 맡기기 | `core.user.before_delete` 구독 | CASCADE 는 훅 발행·이력 처리를 건너뛰고, 아무 오류도 남기지 않는다 | +| 동의 여부를 근거로 이 플러그인이 직접 메일·SMS 를 보내기 | 발송은 발송 담당 확장이, 이 플러그인은 `user.subscribed`/`unsubscribed` 발행까지 | 동의 관리와 발송이 한 확장에 묶이면 발송 수단을 바꿀 때 동의 이력까지 흔들린다 | +| 약관 slug 를 코드에 리터럴로 박기 | 설정(`*_terms_slug`)을 읽어 페이지 모듈에서 조회 | 약관 문서는 운영자가 만들고 고치는 콘텐츠다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 5개 | `plugins/_bundled/sirsoft-marketing/tests` | +| Vitest | 2개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 0개 | — | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-marketing/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-marketing && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md index e81bd506..f40accfd 100644 --- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-marketing/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ## [1.0.3] - 2026-08-22 ### Security diff --git a/plugins/_bundled/sirsoft-marketing/README.md b/plugins/_bundled/sirsoft-marketing/README.md new file mode 100644 index 00000000..8e1f9146 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/README.md @@ -0,0 +1,201 @@ +# 마케팅 동의 + +**그누보드7 플러그인 · sirsoft-marketing** +이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인 + + +

+ version 1.0.4 + type 플러그인 + 그누보드7 >=7.0.0 + license MIT + requires sirsoft-page +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +회원에게 **마케팅 정보 수신 동의**를 받고 그 이력을 남기는 플러그인입니다. 이메일·SMS 같은 +수신 채널을 관리자 화면에서 원하는 만큼 추가할 수 있고, 제3자 제공 동의·정보 공개 동의처럼 +채널이 아닌 법정 동의 항목도 함께 다룹니다. + +동의 항목은 회원가입 폼과 마이페이지, 관리자의 회원 상세·수정 화면에 자동으로 나타납니다. +이 플러그인은 자기 화면을 갖지 않고 **기존 화면에 항목을 얹는** 방식이라, 도입해도 회원 +관리 흐름이 달라지지 않습니다. + +동의와 철회는 모두 기록됩니다 — 언제, 어느 경로로, 어느 IP 에서 이루어졌는지가 남습니다. +동의 여부만 남기면 나중에 "동의를 받았다" 는 사실을 증명할 수 없기 때문입니다. + +의도적으로 하지 않는 것: **실제 발송**. 이 플러그인은 "누가 무엇에 동의했는가" 까지만 +답하고, 그 동의를 근거로 메일이나 문자를 보내는 것은 발송 담당 확장의 몫입니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 수신 채널 관리 | 이메일·SMS 등 수신 채널을 관리자 화면에서 추가·수정·사용중지 | +| 법정 동의 항목 | 마케팅 활용 동의·제3자 제공 동의·정보 공개 동의를 각각 켜고 끔 | +| 약관 연결 | 동의 항목마다 약관 페이지를 지정해 회원이 내용을 확인하고 동의 | +| 가입 시 수집 | 회원가입 폼에 동의 항목이 자동으로 나타남 | +| 마이페이지 관리 | 회원이 언제든 스스로 동의·철회 | +| 관리자 조회·수정 | 회원 상세·수정 화면에서 동의 상태 확인과 변경 | +| 동의 이력 | 동의·철회 행위마다 시각·경로·IP 기록, 동의 횟수 누적 | +| 연동 지점 | 동의·철회 시점에 다른 확장이 반응할 수 있는 확장점 제공 | + + +## 동작 방식 + + +```mermaid +flowchart LR + R[회원가입 폼] -->|동의 항목 주입| P[이 플러그인] + M[마이페이지] -->|동의/철회| P + A[관리자 회원 화면] -->|조회·변경| P + P --> S[(현재 동의 상태)] + P --> H[(동의 이력)] + P -.동의/철회 알림.-> X[발송·연동 확장] +``` + +이 플러그인은 회원 화면들을 고치지 않고 **그 화면에 항목만 얹습니다.** 어느 경로로 동의가 +바뀌든 현재 상태와 이력이 함께 기록되고, 그 변화를 다른 확장이 받아 수신 목록에 반영할 수 +있습니다. + +수신 채널을 새로 추가하는 것은 **설정 변경만으로** 끝납니다. 프로그램을 다시 배포하거나 +데이터베이스를 고칠 필요가 없습니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.0` | +| PHP | `^8.2` | +| 의존 모듈 | `sirsoft-page` `>=1.0.0` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-marketing + +# 활성화 +php artisan plugin:activate sirsoft-marketing + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-marketing --force +``` + +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-marketing + + +## 관리자 설정 + + +| 키 | 의미 | 기본값 | +|---|---|---| +| `marketing_consent_enabled` | 마케팅 동의 사용 | `true` | +| `marketing_consent_terms_slug` | 마케팅 동의 약관 페이지 Slug | `marketing-terms` | +| `channels` | 채널 목록 | `[]` | +| `third_party_consent_enabled` | 제3자 제공 동의 사용 | `true` | +| `third_party_consent_terms_slug` | 제3자 제공 동의 약관 페이지 Slug | - | +| `info_disclosure_enabled` | 정보 공개 동의 사용 | `true` | +| `info_disclosure_terms_slug` | 정보 공개 동의 약관 페이지 Slug | - | + +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + + + +설정은 관리자의 플러그인 목록에서 이 플러그인의 설정으로 들어가 조정합니다. + +| 항목 | 언제 바꾸는가 | 바꾸면 달라지는 것 | +|---|---|---| +| 마케팅 동의 사용 | 마케팅 수신 동의를 받지 않을 때 | 끄면 가입 폼·마이페이지에서 마케팅 동의 항목이 사라집니다 | +| 마케팅 동의 약관 페이지 | 약관 문서를 만든 뒤 | 동의 항목 옆 "내용 보기" 가 가리키는 페이지 (기본 `marketing-terms`) | +| 채널 목록 | 수신 수단을 늘리거나 줄일 때 | 이메일·SMS 등 개별 수신 채널. 여기서 추가하면 즉시 가입 폼과 마이페이지에 나타납니다 | +| 제3자 제공 동의 사용 / 약관 페이지 | 개인정보를 제휴사에 제공할 때 | 해당 동의 항목의 노출 여부와 약관 링크 | +| 정보 공개 동의 사용 / 약관 페이지 | 회원 정보를 공개 영역에 노출할 때 | 해당 동의 항목의 노출 여부와 약관 링크 | + +약관 페이지는 **페이지 모듈에서 만든 문서**를 가리킵니다. 슬러그만 지정하면 되고, 문서가 +없으면 링크가 열리지 않으므로 약관을 먼저 작성합니다. + +채널을 **사용중지**로 바꾸면 새 가입자에게는 보이지 않지만, 이미 동의한 회원의 기록은 +남습니다 — 나중에 다시 켰을 때 그 회원이 다시 동의할 필요가 없도록 하기 위해서입니다. + + +## 사용 방법 + + +**도입**: 페이지 모듈로 마케팅 수신 동의 약관 문서를 먼저 만듭니다(예: 슬러그 +`marketing-terms`). 그다음 이 플러그인의 설정에서 약관 페이지 슬러그를 지정하고, 수신 채널을 +필요한 만큼 추가합니다. 저장하면 회원가입 폼과 마이페이지에 동의 항목이 바로 나타납니다. + +**수신 채널 늘리기**: 예를 들어 이메일만 받다가 카카오 알림톡을 추가하려면, 설정의 채널 +목록에 항목을 하나 더하고 표시할 이름과 약관 페이지를 지정합니다. 기존 회원은 새 항목에 +대해 미동의 상태로 시작하며, 마이페이지에서 개별적으로 동의할 수 있습니다. + +**동의 현황 확인**: 관리자의 회원 상세 화면에서 그 회원의 항목별 동의 상태를 볼 수 있습니다. +운영자가 대신 변경할 수도 있지만, 그 변경도 이력에 "관리자에 의한 변경" 으로 남습니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-page` | 모듈 | `>=1.0.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 가입 폼에 동의 항목이 보이지 않음 | 해당 동의 항목이 꺼져 있거나 채널이 하나도 없음 | 설정에서 사용 여부를 확인하고 채널을 하나 이상 추가합니다 | +| 동의 항목의 "내용 보기" 를 눌러도 약관이 열리지 않음 | 지정한 슬러그의 페이지가 없거나 미발행 | 페이지 모듈에서 그 슬러그의 문서를 만들고 발행합니다 | +| 템플릿을 바꾸거나 업데이트한 뒤 동의 항목이 사라짐 | 새 화면에 항목이 들어갈 자리가 없음 | 해당 템플릿이 회원 화면의 확장 자리를 제공하는지 확인합니다 | +| 채널을 지웠는데 이미 동의한 회원 기록이 남아 있음 | 기록은 의도적으로 보존됨 | 정상 동작입니다. 채널을 다시 켜면 그 회원은 다시 동의할 필요가 없습니다 | +| 동의했는데 마케팅 메일이 오지 않음 | 이 플러그인은 동의만 관리하고 발송은 하지 않음 | 발송을 담당하는 확장의 설정과 발송 대상 조건을 확인합니다 | +| 회원을 지웠는데 동의 이력이 남아 있는지 확인하고 싶음 | 회원 삭제 시 함께 정리됨 | 정상 동작입니다. 삭제된 회원의 동의 기록은 삭제 흐름에서 함께 정리됩니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/plugins/_bundled/sirsoft-marketing/composer.json b/plugins/_bundled/sirsoft-marketing/composer.json index f7b59173..1e3bc17f 100644 --- a/plugins/_bundled/sirsoft-marketing/composer.json +++ b/plugins/_bundled/sirsoft-marketing/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-marketing", "description": "Marketing consent and subscription management plugin for Gnuboard7 platform", "type": "library", - "version": "1.0.3", + "version": "1.0.4", "autoload": { "psr-4": { "Plugins\\Sirsoft\\Marketing\\": ["src/", "./"] diff --git a/plugins/_bundled/sirsoft-marketing/docs/README.md b/plugins/_bundled/sirsoft-marketing/docs/README.md new file mode 100644 index 00000000..e6016401 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/README.md @@ -0,0 +1,23 @@ +# 마케팅 동의 개발자 문서 + +> plugins/_bundled/sirsoft-marketing · 플러그인 + + +**훅 수**: 4 · **구독 훅 수**: 11 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 3 · **레이아웃 수**: 1 · **핸들러 수**: 0 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-marketing/docs/architecture.md b/plugins/_bundled/sirsoft-marketing/docs/architecture.md new file mode 100644 index 00000000..0edfb26f --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/architecture.md @@ -0,0 +1,86 @@ +# 마케팅 동의 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +"동의 항목은 운영자가 늘린다" 는 전제 하나가 이 플러그인의 구조를 결정했습니다. + +- **EAV 구조.** 동의 항목마다 컬럼을 만들면 채널을 하나 늘릴 때마다 마이그레이션과 배포가 + 필요합니다. `user_marketing_consents` 에 `consent_key` 별 행을 두어, 채널 추가가 **설정 + 변경만으로** 끝나게 했습니다. 그 대가로 "회원의 이메일 동의 여부"를 SQL 한 줄로 얻기가 + 덜 직관적이지만, 항목이 데이터인 이상 그 편이 맞습니다. +- **자기 화면을 갖지 않는다.** 관리자 설정 화면 하나를 빼면 UI 는 전부 다른 화면에 끼워 넣는 + 조각 5개입니다. 코어 회원 화면을 고치지 않고 필드를 더하려면 이 방법뿐입니다. +- **코어에 훅으로만 붙는다.** 구독 11종이 전부 코어 회원·가입 흐름이며, 코어 `User` 모델이나 + 회원 컨트롤러는 한 줄도 건드리지 않습니다. 이 플러그인을 비활성화하면 동의 항목이 화면과 + 응답에서 함께 사라집니다. +- **상태와 이력을 분리한다.** `user_marketing_consents` 는 "지금 어떤가", `user_marketing_ + consent_histories` 는 "어떻게 여기까지 왔는가" 입니다. 동의 여부만 남기면 나중에 "동의를 + 받았다" 는 사실을 증명할 수 없습니다. + +**의도적으로 하지 않는 것**: 실제 발송·권한 선언·관리자 메뉴·프론트 액션 핸들러. 동의 관리와 +발송이 한 확장에 묶이면 발송 수단을 바꿀 때 동의 이력까지 흔들립니다. 발송 확장은 +`user.subscribed`/`user.unsubscribed` 를 구독해 자기 수신 목록을 관리합니다. + + +## 계층 지도 + + +``` +[주입] 레이아웃 조각 5개 (가입 폼 / 회원 상세 / 회원 수정 / 마이페이지 보기·수정) + │ +[진입] 코어 회원·가입 흐름 + │ core.auth.register(+validation_rules) + │ core.user.{after_create, after_update, before_delete} + │ core.user.{create,update,update_profile}_validation_rules + │ core.user.{filter_update_data, filter_resource_data} + ▼ + MarketingConsentListener (구독 11종을 한 클래스가 모두 받는다) + │ + ▼ + MarketingConsentService + │ 채널 해석: PluginSettingsService 의 `channels` JSON + │ 상태 갱신 + 이력 적재 + user.consent_changed 발행 + ▼ + MarketingConsentRepository (Interface 경유) + │ + ▼ + MarketingConsent / MarketingConsentHistory +``` + +컨트롤러가 둘뿐입니다(`MarketingSettingsController` · `MarketingAdminController`) — 동의 +읽기·쓰기가 자기 엔드포인트가 아니라 **코어 회원 API 를 타고** 이루어지기 때문입니다. 이 +플러그인의 라우트는 프론트가 설정을 조회하는 경로와 운영자가 채널을 저장하는 경로뿐입니다. + +`MarketingConsentListener` 하나가 11개 훅을 전부 받는 구조는 의도적입니다. 훅마다 리스너를 +나누면 "회원 도메인에 필드를 더하려면 어디를 봐야 하는가" 의 답이 흩어집니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-marketing --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-marketing --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-marketing --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-marketing --force` | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-marketing/docs/data-model.md b/plugins/_bundled/sirsoft-marketing/docs/data-model.md new file mode 100644 index 00000000..b6453f9d --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/data-model.md @@ -0,0 +1,111 @@ +# 마케팅 동의 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `MarketingConsent` | `user_marketing_consents` | 7 | user→User | - | +| `MarketingConsentHistory` | `user_marketing_consent_histories` | 5 | user→User | - | + + + +두 모델의 역할이 **상태와 이력**으로 갈립니다. + +- **`MarketingConsent`** — "지금 어떤가". 회원 × 동의 항목(`consent_key`) 하나가 한 행이며, + 현재 동의 여부(`is_consented`) · 동의/철회 시각 · 누적 동의 횟수(`consent_count`) · 마지막 + 변경 출처(`last_source`)를 갖습니다. **EAV 구조**이므로 항목이 늘어도 스키마는 그대로입니다. +- **`MarketingConsentHistory`** — "어떻게 여기까지 왔는가". 변경 한 건이 한 행이며 행위 + (`action`) · 출처(`source`) · IP(`ip_address`)를 남깁니다. + +둘을 나눈 이유는 조회 성질이 다르기 때문입니다. 현재 상태는 화면을 그릴 때마다 읽히므로 회원당 +항목 수만큼만 있어야 하고, 이력은 계속 쌓이지만 평소에는 읽히지 않습니다. 한 테이블에 두면 +"현재 상태" 조회가 이력 전체를 훑게 됩니다. + +`consent_count` 는 이력에서 세도 되는 값이지만 상태에 함께 둡니다 — 이 값을 보려고 이력 +테이블을 조회하게 하면 화면 조회가 이력 크기에 묶입니다. 대신 **상태와 이력을 같은 트랜잭션에서 +갱신**해야 둘이 어긋나지 않습니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `user_marketing_consent_histories` | `MarketingConsentHistory` | +| `user_marketing_consents` | `MarketingConsent` | + + + +두 테이블 모두 `user_` 로 시작합니다 — 이 플러그인의 데이터가 회원에 종속된다는 뜻이며, +회원이 사라지면 함께 사라져야 합니다. + +그 정리는 **DB CASCADE 가 아니라 `core.user.before_delete` 훅**이 합니다. 코어 회원 삭제는 +이 플러그인의 테이블을 알지 못하므로, 이 구독이 빠지면 고아 행이 조용히 쌓입니다. 반대로 +CASCADE 로 처리하면 훅 발행과 이력 처리가 통째로 건너뛰어집니다. + +이력 테이블에는 인덱스 추가 마이그레이션이 따로 있습니다(`2026_04_01_000003`). 이력은 계속 +쌓이는 테이블이라 회원별·채널별 조회가 인덱스를 타야 합니다. + + +## 마이그레이션 + + +마이그레이션 3개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_04_01_000001_create_user_marketing_consents_table.php` | `user_marketing_consents` | `user_marketing_consents` | ✅ | +| `2026_04_01_000002_create_user_marketing_consent_histories_table.php` | `user_marketing_consent_histories` | `user_marketing_consent_histories` | ✅ | +| `2026_04_01_000003_add_indexes_to_user_marketing_consent_histories_table.php` | - | `user_marketing_consent_histories` | ✅ | + + + +3개입니다 — 상태 테이블 · 이력 테이블 · 이력 인덱스. + +**항목이 늘어도 마이그레이션이 필요 없는 것**이 이 설계의 목표입니다. 채널을 추가하려는데 +마이그레이션을 쓰고 있다면 EAV 구조를 벗어나고 있다는 신호이므로, 그 변경을 설정으로 표현할 +수 없는지 먼저 검토합니다. + +새 컬럼을 더할 때 초기 `create_*` 파일을 고치지 않습니다 — 이미 설치된 사이트는 그 파일을 +다시 실행하지 않으므로 반영되지 않으며, 기존 행을 손봐야 하는 변경은 `upgrades/` 의 업그레이드 +스텝 백필이 함께 필요합니다. 한국어 `comment` 와 `down()` 은 필수입니다. + + +## Enum + + +_Enum 이 없습니다._ + + + +없습니다. 동의는 참/거짓 하나이고 항목 목록은 **설정 데이터**라 코드의 닫힌 어휘가 아닙니다 — +Enum 으로 만들면 채널 추가가 다시 배포 작업이 됩니다. + +닫힌 어휘가 하나 있긴 합니다: 이력의 `source`(`admin` / `profile`)와 `action`. 이 값들은 +`detectSource()` 와 서비스가 문자열로 다루는데, 새 변경 경로가 늘어 분기가 생기기 시작하면 +그때 Enum 으로 올리는 것이 맞습니다. 지금은 판정 지점이 한 곳뿐이라 어휘가 갈라질 여지가 +없습니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `MarketingConsentRepository` | 구현 | 마케팅 동의 Repository 구현체 | +| `MarketingConsentRepositoryInterface` | 인터페이스 | 마케팅 동의 Repository 인터페이스 | + + + +`MarketingConsentRepository` 하나이며 인터페이스를 통해 주입됩니다(구체 클래스 타입힌트 금지). + +상태와 이력을 **한 Repository 가 함께** 다룹니다. 둘이 같은 트랜잭션에서 갱신되어야 하는데 +Repository 를 나누면 그 원자성을 호출부가 조립하게 되고, 조립을 빠뜨린 경로에서 상태만 바뀌고 +이력이 없는 행이 생깁니다. + +회원 삭제 정리(`deleteByUserId`)도 여기 있습니다. 이 메서드는 `core.user.before_delete` 에서만 +호출되며, 두 테이블을 함께 지웁니다. + diff --git a/plugins/_bundled/sirsoft-marketing/docs/editor-spec.md b/plugins/_bundled/sirsoft-marketing/docs/editor-spec.md new file mode 100644 index 00000000..b9ee6150 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/editor-spec.md @@ -0,0 +1,111 @@ +# 마케팅 동의 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `plugins/_bundled/sirsoft-marketing/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 1 + + + +마케팅 동의 플러그인의 스펙은 `sampleData` 한 블록, ID 하나가 전부입니다. 이 플러그인이 +소유한 화면이 설정 화면 하나뿐이고 그 화면이 읽는 도메인 데이터가 `marketing_settings` +하나이기 때문입니다. + +스펙이 작다는 것이 곧 부실을 뜻하지는 않습니다. 필요한 만큼만 선언하는 것이 규율이고, +쓰지 않는 블록을 빈 값으로 채워 두면 다음 사람이 그것을 채워야 할 자리로 오해합니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `editor-spec.json (인라인)` | + + + +`states.groups` 를 두지 않은 것은 이 플러그인의 설정 화면에 **상태 변종이 없기** +때문입니다. 값이 있든 없든 같은 폼이 그려지므로, 상태를 나눠도 편집기에서 보이는 화면이 +달라지지 않습니다. + +동의 항목이 여러 개로 늘거나 항목별로 화면이 갈라지는 날이 오면 그때 `states` 를 +신설합니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `marketing_settings` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 미선언 | - | + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +마케팅 동의는 회원가입 폼·마이페이지 등 **다른 확장이 소유한 화면**에도 얹힙니다. +그 자리들은 레이아웃 확장 조각으로 주입되므로 이 스펙이 아니라 그 화면을 소유한 쪽의 +샘플로 그려집니다 — 여기 `sampleData` 가 하나뿐인 이유입니다. + +이 플러그인이 주입한 조각이 편집기에서 비어 보인다면 고칠 자리는 여기가 아니라 +그 화면을 소유한 확장의 스펙입니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan plugin:update sirsoft-marketing --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + diff --git a/plugins/_bundled/sirsoft-marketing/docs/extension-points.md b/plugins/_bundled/sirsoft-marketing/docs/extension-points.md new file mode 100644 index 00000000..0349260b --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/extension-points.md @@ -0,0 +1,183 @@ +# 마케팅 동의 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 4종 / 호출 지점 2곳. 훅 이름이 상수·변수로 조립된 호출이 1곳 있어 호출 위치가 표에 다 실리지 않을 수 있습니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-marketing.filter_consent_data` | filter | 마케팅 동의 데이터를 필터링하는 훅 | 선언 (호출 위치 미확인) | +| `sirsoft-marketing.user.consent_changed` | action | 사용자 마케팅 동의 변경 시 실행되는 액션 훅 | `src/Services/MarketingConsentService.php:205` | +| `sirsoft-marketing.user.subscribed` | action | 사용자 마케팅 동의 필드가 동의(granted)로 변경될 때 실행되는 액션 훅 | 선언 (호출 위치 미확인) | +| `sirsoft-marketing.user.unsubscribed` | action | 사용자 마케팅 동의 필드가 철회(revoked)로 변경될 때 실행되는 액션 훅 | 선언 (호출 위치 미확인) | + + + +4종 전부 **동의 상태 변화를 바깥에 알리는** 용도입니다. 이 플러그인은 동의를 관리할 뿐 발송을 +하지 않으므로, 실제 수신 목록 반영은 이 훅을 구독하는 쪽이 합니다. + +| 훅 | 언제 쓰는가 | +|---|---| +| `user.consent_changed` | 동의 상태가 바뀔 때마다. 외부 마케팅 도구와 동기화하는 지점 | +| `user.subscribed` | 미동의 → 동의 전이. 수신 목록에 **추가**하는 자리 | +| `user.unsubscribed` | 동의 → 철회 전이. 수신 목록에서 **제거**하는 자리 | +| `filter_consent_data` | 동의 데이터를 다른 확장이 가공해야 할 때 | + +`subscribed`/`unsubscribed` 가 `consent_changed` 와 별도로 있는 이유는, 대부분의 소비자가 +"바뀌었다" 가 아니라 "켜졌다/꺼졌다" 에 따라 **다른 동작**을 하기 때문입니다. 한 훅에서 +전후 값을 비교하게 하면 그 비교 코드가 소비자마다 복제됩니다. + +발행 위치가 "선언(호출 위치 미확인)" 인 셋은 훅 이름이 상수·변수로 조립되어 정적 수집에 +잡히지 않은 것입니다 — 선언에는 있으므로 실제로 발행됩니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.auth.register` | action (미선언) | `MarketingConsentListener` | `afterRegister` | 10 | +| `core.auth.register_validation_rules` | filter | `MarketingConsentListener` | `addRegisterValidationRules` | 10 | +| `core.plugin_settings.filter_save_data` | filter | `MarketingConsentListener` | `normalizeChannelsSaveData` | 10 | +| `core.user.after_create` | action (미선언) | `MarketingConsentListener` | `afterCreate` | 10 | +| `core.user.after_update` | action (미선언) | `MarketingConsentListener` | `afterUpdate` | 10 | +| `core.user.before_delete` | action (미선언) | `MarketingConsentListener` | `beforeDelete` | 10 | +| `core.user.create_validation_rules` | filter | `MarketingConsentListener` | `addValidationRules` | 10 | +| `core.user.filter_resource_data` | filter | `MarketingConsentListener` | `filterResourceData` | 10 | +| `core.user.filter_update_data` | filter | `MarketingConsentListener` | `filterUpdateData` | 10 | +| `core.user.update_profile_validation_rules` | filter | `MarketingConsentListener` | `addValidationRules` | 10 | +| `core.user.update_validation_rules` | filter | `MarketingConsentListener` | `addValidationRules` | 10 | + + + +11개 전부 코어 것이며, 이 목록 자체가 **"코어를 고치지 않고 회원 도메인에 필드를 더하는 법"의 +완결된 선례**입니다. 회원에 자기 필드를 붙이려는 확장은 이 표를 그대로 따라 하면 됩니다. + +| 코어 훅 | 이 플러그인이 하는 일 | +|---|---| +| `auth.register_validation_rules` · `user.{create,update,update_profile}_validation_rules` | 동의 필드의 검증 규칙을 각 폼 흐름에 주입 | +| `auth.register` | 가입 완료 후 동의 값을 기록. `AuthService::register()` 에는 `filter_create_data` 가 없어 이 액션에서 요청을 직접 읽습니다 | +| `user.after_create` · `user.after_update` | 회원 생성·수정 시 동의 상태 반영 + 이력 적재 | +| `user.filter_update_data` | 저장 데이터에서 동의 필드를 분리 (코어 `User` 의 `$fillable` 로 새지 않도록) | +| `user.filter_resource_data` | 회원 API 응답에 동의 상태 병합 — 화면이 조건부 렌더링할 수 있도록 활성 키 목록도 함께 | +| `user.before_delete` | 회원 삭제 시 동의 기록 정리 (**CASCADE 에 맡기지 않는다**) | +| `plugin_settings.filter_save_data` | 채널 목록 저장 형태 정규화 | + +`before_delete` 구독이 빠지면 회원을 지워도 동의 행이 남습니다. 코어 회원 삭제는 이 플러그인의 +테이블을 알지 못하므로 아무 오류도 나지 않고, 고아 행만 조용히 쌓입니다. + +코어 회원·가입 흐름의 훅 이름이나 페이로드가 바뀌면 이 플러그인이 예외 없이 조용히 끊깁니다 — +증상은 "가입 폼에서 동의를 체크했는데 저장되지 않는다" 로만 나타납니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `MarketingConsentListener` | 11개 | 명시 등록 | ✅ | `src/Listeners/MarketingConsentListener.php` | + + + +`MarketingConsentListener` 하나가 11개 훅을 전부 받습니다. 훅마다 리스너를 나누지 않은 것은 +의도적입니다 — 나누면 "회원 도메인에 필드를 더하려면 어디를 봐야 하는가" 의 답이 흩어집니다. + +리스너는 판정과 기록을 직접 하지 않고 `MarketingConsentService` 에 위임합니다. 데이터 접근은 +Repository 인터페이스 주입으로만 하며, `Model::query()` · `DB::table()` · `$row->save()` 를 +직접 부르지 않습니다. + +`detectSource()` 가 현재 라우트로 출처(`admin` / `profile`)를 판정해 이력에 남깁니다. 새 변경 +경로(예: 일괄 처리·외부 연동)를 추가하면 그 출처도 여기서 구분해야 합니다 — 모든 변경이 +`profile` 로 기록되면 이력이 "누가 바꿨는가" 를 답하지 못합니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/user-marketing-detail.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user-marketing-form.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user-marketing-profile-view.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user-marketing-profile.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user-marketing-register.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +조각 5개가 이 플러그인의 **UI 전부**입니다. 관리자 설정 화면 하나를 빼면 자기 레이아웃이 +없습니다. + +| 조각 | 들어가는 자리 | +|---|---| +| `user-marketing-register.json` | 회원가입 폼 | +| `user-marketing-form.json` | 관리자 회원 수정 폼 | +| `user-marketing-detail.json` | 관리자 회원 상세 | +| `user-marketing-profile.json` · `user-marketing-profile-view.json` | 마이페이지 (수정·보기) | + +대상 화면을 소유한 쪽(템플릿·코어)이 그 자리(슬롯)를 없애면 조각은 **오류 없이 사라집니다.** +증상은 "가입 폼에 동의 항목이 안 보인다" 뿐이고 로그에는 아무것도 남지 않으므로, 템플릿이나 +코어 회원 화면을 업그레이드한 뒤에는 다섯 자리를 눈으로 확인합니다. + +마이페이지 조각은 **동의 이력이 있는 회원에게 항상 노출**되어야 합니다. 동의를 받은 경로와 +철회 경로가 대칭이 아니면 그 동의는 법적 근거로 쓸 수 없습니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +없습니다. 이 플러그인은 요청 흐름에 개입하지 않고 코어 회원 흐름의 훅 지점에서만 동작합니다. + +관리자 채널 저장 라우트는 미들웨어 대신 코어 권한 미들웨어 +(`permission:admin,core.plugins.update`)를 직접 지정합니다 — 이 플러그인이 자기 권한을 +선언하지 않고 "플러그인 설정을 고칠 수 있는 사람" 이라는 코어 권한에 얹는 방식입니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +없습니다. 동의 변경은 그 회원 자신의 조작이므로 다른 접속자에게 실시간으로 알릴 사건이 +없습니다. + +외부 마케팅 도구와의 실시간 동기화가 필요하면 `user.consent_changed` 를 구독해 그 확장에서 +자기 채널이나 외부 API 호출로 처리합니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +없습니다. 동의는 회원의 조작으로만 바뀌므로 주기적으로 훑을 대상이 없습니다. + +동의 만료(예: "2년마다 재동의")가 필요해지면 스케줄이 생길 자리입니다. 그때도 만료 판정은 +`consented_at` 과 설정값으로 하고, 만료 처리 자체는 일반 철회와 같은 경로(상태 갱신 + 이력 +적재 + `user.unsubscribed` 발행)를 타야 합니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +없습니다. 이 플러그인은 동의를 관리할 뿐 발송을 하지 않으므로, 자기 이름으로 보낼 알림이 +없습니다. + +"마케팅 수신 동의 처리 완료" 같은 확인 메일이 필요하면 `user.subscribed` 를 구독하는 확장이 +코어 `GenericNotification` 으로 보냅니다. 동의 관리와 발송을 한 확장에 묶으면 발송 수단을 +바꿀 때 동의 이력까지 함께 흔들립니다. + diff --git a/plugins/_bundled/sirsoft-marketing/docs/frontend.md b/plugins/_bundled/sirsoft-marketing/docs/frontend.md new file mode 100644 index 00000000..e052485c --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/frontend.md @@ -0,0 +1,84 @@ +# 마케팅 동의 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +관리자 설정 화면(`plugin_settings`) 하나뿐입니다. **이 플러그인의 실제 UI 는 레이아웃이 아니라 +확장 조각 5개**이며, 그것들은 다른 화면 안에 들어갑니다. + +`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를 +찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다. + +레이아웃 JSON 만 고쳤다면 빌드는 필요 없고 `php artisan plugin:update sirsoft-marketing --force` +로 반영합니다. 새로 쓴 Tailwind 클래스가 빌드된 CSS 에 없으면 그 스타일만 조용히 빠지므로, +기존 레이아웃에 없던 클래스를 도입할 때는 확인이 필요합니다. + + +## 액션 핸들러 + + +_등록하는 액션 핸들러가 없습니다._ + + + +없습니다. 동의 항목은 일반 체크박스이고 저장은 코어 회원 API 를 타므로, 자체 핸들러가 +필요하지 않습니다. + +체크박스를 다룰 때 주의할 점이 하나 있습니다 — 저장값이 `null` 일 수 있는 체크박스는 +`name` 자동바인딩만으로 묶으면 값이 `null` 로 고착됩니다. 조각에서 동의 체크박스를 손볼 때는 +`autoBinding: false` + `checked` 표현식 + `change` 액션 형태를 유지합니다. + +핸들러를 처음 추가한다면 전역 진입점(`window.__SirsoftMarketing.initPlugin()`)과 빌드 산출물 +(`dist/`)이 함께 필요합니다. + + +## 전역 진입점 + + +_프론트 엔트리포인트가 없습니다._ + + + +없습니다 — 등록할 액션 핸들러가 없기 때문입니다. + +핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록 +진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부 +무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 +작업을 포함하지 않습니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +JS·CSS 산출물이 없고 `editor-spec.json` 하나만 있습니다 — 레이아웃 편집기가 이 플러그인의 +화면을 편집할 때 쓰는 팔레트·중첩 규칙 선언이며 실행 코드가 아닙니다. 이 플러그인의 프론트엔드는 +전부 **선언형 JSON**(레이아웃 조각 5개 + 설정 화면 1개 + 편집기 스펙)입니다. + +그래서 빌드 단계가 없고, 변경 반영은 `php artisan plugin:update sirsoft-marketing --force` +하나로 끝납니다. 나중에 JS 를 더하면 그때 빌드·`dist/` 커밋·전역 진입점 셋이 함께 필요해집니다. + +구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도 +서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다. + diff --git a/plugins/_bundled/sirsoft-marketing/docs/settings.md b/plugins/_bundled/sirsoft-marketing/docs/settings.md new file mode 100644 index 00000000..badff293 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/settings.md @@ -0,0 +1,130 @@ +# 마케팅 동의 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `marketing_consent_enabled` | `boolean` | `true` | 마케팅 동의 사용 | +| `marketing_consent_terms_slug` | `string` | `marketing-terms` | 마케팅 동의 약관 페이지 Slug | +| `channels` | `json` | `[]` | 채널 목록 | +| `third_party_consent_enabled` | `boolean` | `true` | 제3자 제공 동의 사용 | +| `third_party_consent_terms_slug` | `string` | - | 제3자 제공 동의 약관 페이지 Slug | +| `info_disclosure_enabled` | `boolean` | `true` | 정보 공개 동의 사용 | +| `info_disclosure_terms_slug` | `string` | - | 정보 공개 동의 약관 페이지 Slug | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +7개 항목이 두 무리입니다. + +- **법정 동의 3종** — `marketing_consent_*` · `third_party_consent_*` · `info_disclosure_*`. + 각각 사용 여부(boolean)와 약관 페이지 slug(string) 쌍입니다. 이 셋은 성격이 정해져 있어 + 코드에 이름이 박혀 있습니다. +- **`channels`** — 운영자가 늘리는 수신 채널 목록(JSON). 이것이 이 플러그인의 핵심 설정이며, + 여기에 항목을 더하면 가입 폼·마이페이지·회원 화면에 **즉시** 나타납니다. + +`channels` 만 `frontend_schema` 에서 `expose: false` 이고 타입이 `string` 입니다 — 실제 값은 +JSON 문자열이며, 화면이 그대로 그릴 수 있는 형태가 아니라 서비스가 해석해 내려줍니다. 저장 +형태 정규화는 `core.plugin_settings.filter_save_data` 훅에서 이루어집니다. + +약관 slug 셋은 **페이지 모듈의 문서**를 가리킵니다(manifest 의존 `sirsoft-page >=1.0.0`). +문서가 없으면 링크가 열리지 않을 뿐 동의 자체는 동작하므로, 도입 시 약관을 먼저 작성하는 +순서를 안내합니다. + +채널을 **사용중지**로 바꾸면 새 노출에서는 빠지지만 기존 동의 기록은 남습니다 — +`getRegisteredChannels()`(활성만)와 `getAllChannels()`(전체)가 나뉘어 있는 이유입니다. 다시 +켰을 때 그 회원이 재동의할 필요가 없도록 하기 위한 것입니다. + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +선언하지 않습니다. 이 플러그인의 데이터는 **회원 자신의 것**이라 회원 권한 체계에 얹히고, +운영자 조작은 코어 회원 권한이 이미 관장합니다. + +관리자 채널 저장 라우트만 코어 권한(`permission:admin,core.plugins.update`)을 직접 지정합니다 — +"플러그인 설정을 고칠 수 있는 사람" 이라는 기존 권한에 얹는 방식입니다. + +자기 권한을 새로 만들지 않은 것은 의도입니다. 권한을 늘리면 운영자가 역할마다 그 권한을 +배정해야 하는데, "마케팅 동의만 따로 관리하는 담당자" 라는 역할 구분이 실제로 필요해지기 +전까지는 그 부담이 이득보다 큽니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +등록하지 않습니다. 이 플러그인은 자기 관리 화면을 갖지 않고, 동의 상태는 **회원 관리 화면 +안에서** 조각으로 보입니다. + +설정은 코어의 플러그인 목록에서 이 플러그인의 설정으로 들어가는 공통 경로를 씁니다 — 코어가 +`resources/layouts/admin/plugin_settings.json` 을 찾아 그리므로 자체 메뉴가 필요 없습니다. + +동의 현황을 회원과 분리해 따로 보는 화면(예: 채널별 동의자 목록)이 필요해지면 그때 메뉴가 +생길 자리입니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-marketing/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +2개뿐입니다. + +| 경로 | 용도 | +|---|---| +| `GET /settings` | 프론트가 동의 항목 구성(활성 채널·약관 slug·사용 여부)을 조회 | +| `PUT admin/channels` | 운영자가 채널 목록을 저장 (`permission:admin,core.plugins.update`) | + +**동의 값 자체를 읽고 쓰는 엔드포인트가 없습니다.** 그 일은 코어 회원 API 를 타고 이루어지며, +이 플러그인은 `core.user.filter_update_data` / `filter_resource_data` 훅으로 그 흐름에 +끼어듭니다. 그래서 이 플러그인을 비활성화하면 회원 API 응답에서 동의 필드가 함께 사라집니다. + +라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 확장 라우트는 활성 상태인 확장의 것만 +등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 404 가 됩니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-page` | 모듈 | `>=1.0.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +`sirsoft-page` 모듈에 의존합니다(`>=1.0.0`). 동의 항목의 **약관 문서**가 페이지 모듈의 +콘텐츠이기 때문입니다. + +manifest 의존으로 올린 것은 약관 링크가 이 플러그인의 기능 일부가 아니라 **법적 요건**이기 +때문입니다 — 회원이 동의 내용을 확인할 수 없으면 그 동의는 유효하지 않습니다. 훅 구독처럼 +"없으면 그 기능만 비는" 관계가 아니라, 없으면 이 플러그인의 존재 이유가 성립하지 않습니다. + +이 플러그인에 의존하는 확장은 없습니다. 다만 발행 훅 +(`user.subscribed`/`unsubscribed`/`consent_changed`)을 구독해 수신 목록을 관리하는 확장이 +생기면, 그 확장은 이 훅 이름에 묶입니다 — 훅 이름·페이로드를 바꿀 때는 구독 확장을 전수 +확인하고 최소 버전 상향을 검토합니다. + diff --git a/plugins/_bundled/sirsoft-marketing/package-lock.json b/plugins/_bundled/sirsoft-marketing/package-lock.json index 940e605c..747064fb 100644 --- a/plugins/_bundled/sirsoft-marketing/package-lock.json +++ b/plugins/_bundled/sirsoft-marketing/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-marketing", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-marketing", - "version": "1.0.3", + "version": "1.0.4", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-marketing/package.json b/plugins/_bundled/sirsoft-marketing/package.json index 60e78547..787cfd96 100644 --- a/plugins/_bundled/sirsoft-marketing/package.json +++ b/plugins/_bundled/sirsoft-marketing/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-marketing", - "version": "1.0.3", + "version": "1.0.4", "description": "G7 마케팅 동의 플러그인 프론트엔드 에셋", "private": true, "type": "module", diff --git a/plugins/_bundled/sirsoft-marketing/plugin.json b/plugins/_bundled/sirsoft-marketing/plugin.json index 3ca3ba1f..3f44eb11 100644 --- a/plugins/_bundled/sirsoft-marketing/plugin.json +++ b/plugins/_bundled/sirsoft-marketing/plugin.json @@ -5,7 +5,7 @@ "ko": "마케팅 동의", "en": "Marketing Consent" }, - "version": "1.0.3", + "version": "1.0.4", "description": { "ko": "이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인", "en": "Plugin for managing email subscriptions, marketing consent, and third-party data sharing consent" diff --git a/plugins/_bundled/sirsoft-message_bizppurio/AGENTS.md b/plugins/_bundled/sirsoft-message_bizppurio/AGENTS.md new file mode 100644 index 00000000..ecad0822 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/AGENTS.md @@ -0,0 +1,223 @@ +# 비즈뿌리오 메시지 발송 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-message_bizppurio) — 코어 알림에 문자(SMS/LMS)·카카오 알림톡 채널을 추가한다. 알림 자체는 정의하지 않고 발송 수단만 담당 +2. 확장 방식: 발행 훅 1개 — 잔액 부족·한도 초과 시 `balance.low`(쿨다운 내 1회). 그 외 배선은 구독 6종(코어 알림 로그·설정, 이커머스 비회원 연락처) +3. 건드리면 안 되는 것: webhook 라우트의 IP 화이트리스트 제거, 승인 템플릿 직접 수정(승인 취소 선행), 잔액부족 통지를 같은 채널로만 보내기, 크리덴셜 프론트 노출 +4. 작업 위치: `plugins/_bundled/sirsoft-message_bizppurio` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-message_bizppurio --force` +``` + +## 1. 이 확장은 무엇인가 + + +코어 알림 시스템에 **문자(SMS/LMS)와 카카오 알림톡 채널을 추가**하는 플러그인입니다. 알림을 +새로 만들지 않고, 코어와 모듈이 이미 발화하는 알림을 비즈뿌리오 API 로 내보냅니다. + +**설계 원칙 넷**: + +1. **채널만 추가하고 알림은 만들지 않는다.** 어떤 사건에 알림을 보낼지는 코어와 각 모듈이 + 정합니다. 이 플러그인은 `RegisterNotificationChannelsListener` 로 채널을 등록하고 + `SeedChannelTemplatesListener` 로 그 채널의 템플릿 자리를 시드할 뿐입니다. 그래서 삭제하면 + 채널만 사라지고 알림 자체는 남습니다. +2. **발송 결과는 나중에 돌아온다.** 비즈뿌리오는 발송 API 응답이 아니라 **webhook 통보**로 + 최종 결과를 줍니다. 그래서 발송과 결과 기록이 분리되어 있고, webhook 이 등록되지 않은 + 사이트에서는 발송은 되지만 이력에 결과가 남지 않습니다 — 오류가 아니라 **결과 미상**입니다. +3. **승인된 내용을 박제한다.** 알림톡 템플릿은 카카오 승인 시점의 내용을 로컬에 저장해 두고 + 발송하며, 발송할 때마다 카카오를 조회하지 않습니다. 그래서 승인 후 내용을 고치려면 승인을 + 먼저 취소해야 하고, 취소하는 순간 알림톡 발송이 멈춥니다. +4. **실패를 종류별로 다르게 다룬다.** 결과 코드를 성공 / 재시도(일시 오류) / 잔액 부족 / + 영구 실패 넷으로 분류합니다. 잔액 부족은 재시도해도 소용없으므로 즉시 실패 처리하고 + **관리자에게 알립니다** — 다만 그 알림이 같은 채널로 나가면 함께 실패하므로 쿨다운을 두고 + 다른 채널 병행을 권합니다. + +**의도적으로 하지 않는 것**: 알림 정의·수신자 해석·발송 대상 판정. 그 셋은 코어 +`GenericNotification` 과 각 도메인의 일이며, 이 플러그인은 채널 구현체입니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-message_bizppurio --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-message_bizppurio --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-message_bizppurio --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-message_bizppurio --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**발송**: 코어·모듈이 알림 발화 → 코어 알림 시스템이 켜져 있는 채널별로 발송 작업을 큐잉 → +이 플러그인의 채널 구현이 그 작업을 받음 → 알림톡이면 승인된 템플릿 내용으로, 문자면 본문 +길이에 따라 SMS/LMS 를 골라 비즈뿌리오 발송 API 호출 → 발송 기록(`bizppurio_dispatches`) +적재. 알림톡 발송이 불가하거나 실패하면 설정에 따라 대체 SMS 로 내려갑니다. + +**결과 수신**: 비즈뿌리오가 `POST /webhook` 으로 결과 통보 → +`BizppurioWebhookIpWhitelist` 미들웨어가 발신 IP 를 검사 → `WebhookReportService` 가 결과 +코드를 넷으로 분류해 발송 기록을 갱신. 잔액 부족(`9070`/`9071`/`7436`)이면 +`sirsoft-message_bizppurio.balance.low` 액션을 발행하고 관리자 알림을 보냅니다 — 대량 실패 +시 반복을 막기 위해 채널별 쿨다운(기본 3600초) 안에서는 **최초 1회만** 실행됩니다. + +**템플릿 수명주기**: 관리자 화면에서 작성(`POST templates`) → 검수 신청 +(`POST templates/{id}/request`) → 카카오 검수 → 승인/반려. 결과는 +`bizppurio:sync-template-status` 스케줄이 **30분마다** 확인하고, 화면의 [새로고침] +(`POST templates/{id}/sync`)으로 즉시 확인할 수도 있습니다. 승인된 템플릿을 고치려면 +`POST templates/{id}/cancel-approval` 로 승인을 먼저 취소합니다. + +**알림 이력 연결**: 코어가 알림 발송 성공·실패를 기록하면 +(`core.notification_log.after_log_sent` / `after_log_failed`) `LinkNotificationLogListener` +가 그 로그와 이 플러그인의 발송 기록을 연결합니다. 관리자 "알림 발송 이력" 화면에서 문자· +알림톡 결과가 함께 보이는 것이 이 연결 덕분입니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 1개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 6개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 7개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 7개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 1개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 1개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +발행 훅은 하나뿐입니다. + +**`sirsoft-message_bizppurio.balance.low`** — 잔액 부족·후불 한도 초과로 발송이 실패했을 때 +(쿨다운 내 최초 1회) 발화합니다. 인수는 `string $resultCode, string $channel` 이며, +`$resultCode` 는 `9070`(문자 잔액 부족) · `7436`(알림톡 지갑 잔액 부족) · `9071`(후불 한도 +초과) 중 하나입니다. + +```php +use App\Extension\HookManager; + +HookManager::addAction( + 'sirsoft-message_bizppurio.balance.low', + function (string $resultCode, string $channel) { + // 예: 잔액 부족 시 Slack 으로도 별도 알림 + SlackNotifier::send("비즈뿌리오 잔액 부족: 채널={$channel}, 코드={$resultCode}"); + }, + priority: 10 +); +``` + +이 훅이 관리자 자체 알림(잔액부족·후불한도초과 안내)을 발화하는 지점과 **동일**합니다. 잔액 +부족 알림을 문자·알림톡이 아닌 다른 경로로 받고 싶을 때 여기에 붙입니다 — 같은 채널로 보내면 +잔액이 없으므로 그 알림도 함께 실패합니다. + +**구독 6종이 이 플러그인의 배선 전부**입니다. 코어 알림 로그 2종(발송 기록 연결) · 코어 설정 +2종(저장 시 토큰 무효화 · 운영 모드 전환 시 필수값 검증) · 이커머스 1종(비회원 연락처 주입) · +자기 훅 1종(잔액부족 알림 데이터). `sirsoft-ecommerce.notification.extract_data` 는 manifest +의존이 아니라 훅 구독이므로, 이커머스가 없으면 비회원 문자 발송만 비고 나머지는 정상 +동작합니다. + +레이아웃 조각 7개가 UI 대부분입니다 — 알림 설정 화면의 비즈뿌리오 탭(코어·게시판·이커머스 +각각), 알림 목록 행 하단 요약, 알림 템플릿 편집 창의 알림톡·문자 섹션과 하단 버튼, 발송 이력 +화면의 결과 열. 대상 화면이 그 자리를 없애면 조각은 **오류 없이 사라집니다.** + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-message_bizppurio --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-message_bizppurio` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] webhook 라우트를 추가·변경했다면 `getMiddleware()` 의 `targets` 에 그 라우트 이름을 함께 추가 (누락 시 인증 없는 공개 경로가 된다) +- [ ] 결과 코드 분류(성공/재시도/잔액부족/영구실패)를 바꿨다면 `lang/{ko,en}/result_codes.php` 의 사유 문구도 함께 갱신 +- [ ] 코어 알림 시스템(채널 등록·발송 로그 훅)이 바뀌면 이 플러그인의 구독 4종이 조용히 끊기므로 함께 확인 +- [ ] 레이아웃 조각 7개는 대상 화면(알림 설정·알림 템플릿 편집·발송 이력)의 자리가 사라지면 오류 없이 빠진다 — 코어·게시판·이커머스 업그레이드 후 노출 확인 +- [ ] 크리덴셜 설정을 추가한다면 `frontend_schema` 에 `expose: false` + `sensitive: true` 를 함께 선언 +- [ ] `dist/` 는 커밋되는 배포 산출물 — TS 를 고쳤으면 `--production` 재빌드 후 커밋 (`sourceMappingURL` 잔존 금지) +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어 `bizppurioCategories` · `bizppurioProfiles` · `bizppurio_templates_list` · `report_url` · `templates_readiness` 가 편집기 캔버스에서 빈 화면으로 보인다. `data_source` 를 더 늘리면 그 자리도 같은 상태가 된다 + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 이 플러그인에서 알림 자체(수신자·발화 조건)를 정의 | 채널 등록까지만 — 알림은 코어·모듈이 정의 | 채널 구현이 알림을 소유하면 플러그인을 바꿀 때 알림 이력까지 사라진다 | +| 잔액 부족 알림을 문자·알림톡 채널로만 보내도록 두기 | 사이트 내 알림·메일 등 다른 채널 병행 | 잔액이 없어서 실패한 상황인데 통지도 같은 수단이면 함께 실패한다 | +| 잔액 부족 알림에 쿨다운 없이 매 실패마다 발송 | 채널별 쿨다운(기본 3600초) 안에서 최초 1회 | 대량 발송이 한꺼번에 실패하면 통지가 수백 건 쏟아진다 | +| 발송할 때마다 카카오에 템플릿을 조회 | 승인 시점 내용을 로컬에 박제해 발송 | 외부 조회가 발송 경로에 들어가면 그 서비스 지연이 곧 발송 지연이 된다 | +| 승인된 템플릿을 그대로 수정 | 승인 취소 → 수정 → 재신청 | 카카오 승인 대상은 특정 내용이다. 승인 후 내용이 바뀌면 승인과 발송물이 어긋난다 | +| webhook 라우트를 IP 화이트리스트 없이 공개 | `BizppurioWebhookIpWhitelist` 부착 유지 | 인증 없는 공개 엔드포인트다 — 위조 통보로 발송 결과를 조작할 수 있다 | +| webhook 라우트를 추가·변경하면서 미들웨어 `targets` 선언을 그대로 두기 | 라우트 이름을 `getMiddleware()` 선언에도 추가 | 이름이 어긋나면 미들웨어가 붙지 않는데, 정상 응답이 나가므로 오류도 로그도 남지 않는다 | +| 크리덴셜(비밀번호·API 키·발신프로필 키)을 프론트에 노출 | `expose: false` + `sensitive: true` 유지 | 발송 권한이 곧 비용이다 | +| 일시 오류와 영구 실패를 같은 방식으로 재시도 | 결과 코드 4분류를 따른다 | 영구 실패를 재시도하면 비용만 늘고, 일시 오류를 포기하면 발송이 누락된다 | +| 문자 본문이 없는 언어에 기본 언어 본문도 없이 발송 시도 | 두 단계 폴백 후 그 알림의 문자 발송을 건너뛴다 | 빈 본문 발송은 비용이 나가면서 수신자에게 아무 정보도 주지 않는다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 37개 | `plugins/_bundled/sirsoft-message_bizppurio/tests` | +| Vitest | 7개 | `vitest.config.ts` | +| Playwright | 2개 | `tests/Playwright` | +| 시나리오 매니페스트 | 6개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-message_bizppurio/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-message_bizppurio && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 8b062986..b9e811ba 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ## [1.0.0] - 2026-08-24 ### Added diff --git a/plugins/_bundled/sirsoft-message_bizppurio/README.md b/plugins/_bundled/sirsoft-message_bizppurio/README.md index 17f8a1b9..c7ad2ffa 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/README.md @@ -1,132 +1,201 @@ -# Bizppurio Messaging Plugin for G7 +# 비즈뿌리오 메시지 발송 +**그누보드7 플러그인 · sirsoft-message_bizppurio** +비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다. + +

- Version - G7 - PHP - License + version 1.0.1 + type 플러그인 + 그누보드7 >=7.0.6 + license MIT

- -비즈뿌리오(Bizppurio)를 연동해 문자(SMS/LMS)와 카카오 알림톡을 발송하는 G7 플러그인입니다. - -G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다. - -[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 관리](#알림톡-템플릿-관리) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트) + --- +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +비즈뿌리오(Bizppurio)를 연동해 **문자(SMS/LMS)와 카카오 알림톡**을 발송하는 플러그인입니다. + +그누보드7 코어 알림 시스템에 문자·알림톡 채널을 추가하므로, 회원가입·주문 완료처럼 코어와 모듈이 +이미 발화하는 알림을 문자와 알림톡으로도 자동 발송할 수 있습니다. 알림을 새로 만들 필요 없이 +**기존 알림의 채널만 켜면** 됩니다. + +발송 결과는 비즈뿌리오가 보내는 통보(webhook)로 받아 성공·실패와 실패 사유를 발송 이력에 +기록합니다. 그래서 webhook 등록을 하지 않으면 발송은 되지만 결과를 확인할 수 없습니다. + +카카오 알림톡 템플릿의 **작성·검수 신청·승인 취소·삭제를 관리자 화면에서 직접** 수행합니다 — +비즈뿌리오 콘솔을 따로 열 필요가 없습니다. + +의도적으로 하지 않는 것: 알림 자체를 만들지 않습니다. 어떤 사건에 알림을 보낼지는 코어와 +각 모듈이 정하고, 이 플러그인은 그 알림을 **어느 수단으로 내보낼지**만 담당합니다. + + ## 주요 기능 -- 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택 -- 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영 -- 알림톡 미승인·발송 불가 시 문자로 자동 대체발송(옵션) -- 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용) -- 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록 -- 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증 -- 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용) -- 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시 -- 알림별 카카오 알림톡 템플릿을 관리자 화면에서 직접 작성 → 검수 신청 → 승인 후 자동 발송 -- 검수 결과(승인·반려)를 30분 주기로 자동 확인하고, 반려 사유를 화면에서 확인해 수정 후 재신청 + +| 영역 | 설명 | +|---|---| +| 문자 발송 | 본문 길이에 따라 SMS/LMS 자동 선택 | +| 알림톡 발송 | 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영 | +| 대체발송 | 알림톡 미승인·발송 불가 시 문자로 자동 대체(옵션), 알림톡 없이 문자만 보내는 SMS 단독 모드 | +| 템플릿 관리 | 알림별 알림톡 템플릿을 관리자 화면에서 작성 → 검수 신청 → 승인 후 자동 발송 | +| 검수 상태 추적 | 승인·반려를 30분 주기로 자동 확인, 반려 사유를 화면에서 확인해 수정 후 재신청 | +| 비회원 발송 | 주문 시 입력한 연락처로 비회원에게도 발송 | +| 다국어 문자 | 문자 본문을 언어별로 입력, 회원 언어에 맞춰 발송 | +| 결과 기록 | 비즈뿌리오 통보로 성공/실패/사유를 발송 이력에 자동 기록 | +| 검수(테스트) 모드 | 실제 발송 없이 화면·흐름 검증, 이력에 "검수" 라벨로 구분 표시 | +| 잔액 경고 | 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운) | +| 이력 통합 | 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과를 함께 표시 | + ---- +## 동작 방식 + + +```mermaid +flowchart TD + E[코어·모듈이 알림 발화
회원가입·주문완료 등] --> Q[문자·알림톡 채널 발송 작업] + Q --> A{알림톡 템플릿 승인?} + A -->|승인| K[알림톡 발송] + A -->|미승인·발송불가| S[대체 SMS] + K -->|실패| S + Q -->|SMS 단독| S + K --> B[비즈뿌리오 발송 API] + S --> B + B -->|결과 통보 webhook| R[(발송 이력에 결과 기록)] +``` + +알림을 만드는 것은 코어와 모듈이고, 이 플러그인은 그 알림을 문자·알림톡으로 내보냅니다. +발송 결과는 비즈뿌리오가 나중에 통보하므로, **webhook URL 을 비즈뿌리오 콘솔에 등록하지 +않으면 이력에 결과가 남지 않습니다.** + +일시적 오류(카카오 시스템 오류·처리 지연·게이트웨이 오류)는 자동으로 재시도합니다. 지갑 잔액 +부족과 후불 한도 초과는 재시도 대상이 아니며, 즉시 실패 처리하고 관리자에게 알립니다. + +```mermaid +flowchart LR + W[미작성] --> D[작성중] + D -->|검수 신청| I[검수중] + I -->|승인| OK[승인 · 발송 가능] + I -->|반려| RJ[반려] + RJ -->|수정 후 재신청| I + OK -->|수정하려면| CX[승인 취소] + CX --> D +``` + +알림톡 템플릿은 위 상태를 거칩니다. **승인 상태에서만 알림톡이 발송**되며, 승인을 취소하면 +그 즉시 알림톡 발송이 멈춥니다(대체 SMS 는 설정에 따라 계속 발송). + ## 요구 사항 -| 구분 | 항목 | 내용 | -|------|------|------| -| 플랫폼 | G7 | `>= 7.0.6` | -| 플랫폼 | PHP | `^8.2` | -| 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 | -| 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) | -| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 (템플릿은 이 플러그인 화면에서 작성·검수 신청) | - -> 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다. - ---- + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.6` | +| PHP | `^8.2` | + ## 설치 -플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다. - -```text -plugins/sirsoft-message_bizppurio -``` - -프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다. - + ```bash -npm install -npm run build +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-message_bizppurio + +# 활성화 +php artisan plugin:activate sirsoft-message_bizppurio + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-message_bizppurio --force ``` -그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널이 알림 설정에 등록됩니다. - ---- +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio + ## 관리자 설정 -관리자 플러그인 설정 화면에서 비즈뿌리오 계정 정보를 입력합니다. + +| 키 | 의미 | 기본값 | +|---|---|---| +| `is_test_mode` | 검수 모드 | `true` | +| `bizppurio_id` | 비즈뿌리오 아이디 | - | +| `password` | 비밀번호 | - | +| `api_key` | API 키 | - | +| `sender_number` | 발신번호 | - | +| `sender_key` | 알림톡 발신프로필 키 | - | -| 설정 | 필수 여부 | 설명 | -|------|:---:|------| -| 검수 모드 | - | 활성화 시 실제 발송 없이 검수용으로만 동작. 발송 이력에 "검수" 라벨로 표시되어 실제 장애와 구분됨 | -| 비즈뿌리오 아이디 / 비밀번호 | 운영 시 필수 | 비즈뿌리오 계정 로그인 정보 | -| API 키 | 운영 시 필수 | 비즈뿌리오 API 인증에 사용 | -| 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 | -| 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 | -| 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 | +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + -> 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다. + +설정은 관리자의 플러그인 목록에서 이 플러그인의 설정으로 들어가 조정합니다. ---- +| 항목 | 필수 | 언제 바꾸는가 | 바꾸면 달라지는 것 | +|---|:---:|---|---| +| 검수 모드 | - | 도입 초기·흐름 점검 시 | 켜면 실제 발송 없이 동작하고 이력에 "검수" 라벨이 붙습니다. **끄는 순간부터 실제 발송과 비용이 발생**합니다 | +| 비즈뿌리오 아이디 / 비밀번호 | 운영 시 | 계정 발급 후 | 비즈뿌리오 API 인증 | +| API 키 | 운영 시 | 계정 발급 후 | 비즈뿌리오 API 인증 | +| 발신번호 | 운영 시 | 발신번호를 등록·변경했을 때 | 문자 발송에 쓰는 번호. **비즈뿌리오 콘솔에 사전 등록된 번호만** 사용할 수 있습니다 | +| 알림톡 발신프로필 키 | 알림톡 사용 시 | 카카오 발신프로필 발급 후 | 알림톡 발송 주체 | -## webhook(발송 결과 리포트) 등록 +비밀번호·API 키·발신프로필 키는 **프론트엔드로 노출되지 않습니다**(민감값으로 선언되어 있어 +설정 화면의 서버 조회로만 다뤄집니다). -비즈뿌리오가 발송 결과를 통보할 URL을 비즈뿌리오 콘솔에 등록해야 발송 결과(성공/실패/사유)가 발송 이력에 자동 기록됩니다. +설정 화면에 없는 값이 하나 있습니다 — 잔액 부족 알림의 재발송 최소 간격 +(`balance_low_notify_cooldown`, 기본 3600초)은 설정 **파일**의 값입니다. 대량 실패 시 관리자 +알림이 반복되는 것을 막는 장치이며, 조정하려면 설치된 플러그인의 +`config/settings/defaults.json` 을 고칩니다. -> **이 등록을 하지 않으면 발송 자체는 되지만 성공/실패 여부를 확인할 수 없습니다.** +**webhook 등록이 별도로 필요합니다.** 비즈뿌리오 콘솔에 아래 주소를 발송 결과 통보 URL 로 +등록해야 성공/실패가 이력에 기록됩니다. 정확한 주소는 설정 화면의 환경설정 탭에서 복사할 수 +있습니다. ```text https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook ``` + -정확한 URL은 관리자 플러그인 설정 화면의 환경설정 탭에서도 복사할 수 있습니다. +## 사용 방법 ---- + +**도입 준비**: 비즈뿌리오에 가입해 API 사용 승인을 받고, 문자 발송용 **발신번호를 콘솔에 사전 +등록**합니다. 알림톡을 쓰려면 카카오 **발신프로필**도 등록합니다(템플릿 자체는 이 플러그인 +화면에서 작성합니다). 그다음 플러그인을 활성화하면 회원 대상 알림에 문자·알림톡 채널이 알림 +설정에 등록됩니다. 검수와 운영은 **별도의 비즈뿌리오 계정**으로 운영하는 것을 권장합니다. -## 발송 흐름 +**알림톡 템플릿 작성 → 발송까지**: 작성 위치는 두 곳입니다. -```text -코어/모듈이 알림 발화 (예: 회원가입, 주문 완료) -→ 알림 설정에서 켜 둔 문자·알림톡 채널로 발송 작업 큐잉 -→ 알림톡: 승인된 템플릿의 저장 내용으로 발송, 발송 불가 시 문자로 대체발송(옵션) -→ 문자: 본문 길이에 따라 SMS/LMS 자동 선택 -→ 비즈뿌리오 발송 API 호출 -→ 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보 -→ 발송 이력에 결과 기록, 실패 시 사유·결과코드 함께 기록 -``` +- **알림 설정 > 비즈뿌리오 탭** — 알림 항목의 **[편집]** 을 누르면 편집 창 안에 **알림톡 템플릿** + 섹션과 **문자(SMS)** 섹션이 함께 열립니다. 알림톡 본문·유형·버튼, 대체 SMS/SMS 단독 여부와 + 문자 본문, 수신자 규칙을 한 창에서 고치고 하단 **[저장]** 한 번으로 모두 저장합니다(알림톡 + 검수는 **[저장 후 검수 신청]** — 작성 폼 하단의 **검수자 전달 의견**에 변수 예시값 등을 + 적으면 신청과 함께 카카오 검수자에게 전달됩니다). 이 채널에서는 코어의 제목/본문 입력이 + 숨겨지고 알림톡 본문이 그 자리를 대신합니다. 알림 목록의 각 행 하단에는 승인 여부 + (**승인됨** / **미승인 (세부 상태)**)와 문자 설정 요약이 표시됩니다. 게시판·이커머스의 알림 + 설정 화면에서도 동일하게 동작합니다. +- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색· + 필터링합니다(자체 작성/SMS 본문 모달). -일시적 오류(카카오 시스템 오류, 처리 지연, 게이트웨이 오류 등)로 실패한 경우 자동으로 재시도합니다. 지갑 잔액 부족·후불 한도 초과는 재시도 대상이 아니며, 즉시 실패 처리와 함께 관리자에게 알림이 발송됩니다. +기본 흐름은 **작성 → 검수 신청 → (카카오 승인) → 자동 발송** 입니다. 승인된 시점의 내용이 +발송 기준으로 저장되며, 발송할 때마다 카카오를 조회하지 않습니다. ---- +메시지 유형(기본형·부가정보형·채널추가형·복합형)과 강조 유형(없음·강조표기·이미지·아이템 +리스트), 버튼(최대 5개)·바로연결(최대 10개)을 지원합니다. 본문에는 `#{변수}` 형식으로 알림 +변수를 넣을 수 있습니다. -## 알림톡 템플릿 관리 - -카카오 알림톡 템플릿의 **작성·검수 신청·취소·삭제를 관리자 화면에서 직접** 수행합니다. 비즈뿌리오 콘솔을 따로 열 필요가 없습니다. - -작성 위치는 두 곳입니다. - -- **알림 설정 > 비즈뿌리오 탭** — 알림 항목의 **[편집]** 을 누르면 알림 템플릿 편집 창 안에 **알림톡 템플릿** 섹션과 **문자(SMS)** 섹션이 함께 열립니다. 알림톡 본문·유형·버튼, 대체 SMS/SMS 단독 여부와 문자 본문, 수신자 규칙을 한 창에서 고치고 하단 **[저장]** 한 번으로 모두 저장합니다(알림톡 검수는 **[저장 후 검수 신청]** — 작성 폼 하단의 **검수자 전달 의견**에 변수 예시값 등을 적으면 신청과 함께 카카오 검수자에게 전달됩니다). 이 채널에서는 코어의 제목/본문 입력이 숨겨지고 알림톡 본문이 그 자리를 대신합니다. 알림 목록의 각 행 하단에는 승인 여부(**승인됨** / **미승인 (세부 상태)**)와 문자 설정 요약이 표시됩니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다. -- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다(자체 작성/SMS 본문 모달). - -기본 흐름은 **작성 → 검수 신청 → (카카오 승인) → 자동 발송** 입니다. 승인된 시점의 내용이 발송 기준으로 저장되며, 발송할 때마다 카카오를 조회하지 않습니다. - -메시지 유형(기본형·부가정보형·채널추가형·복합형)과 강조 유형(없음·강조표기·이미지·아이템리스트), 버튼(최대 5개)·바로연결(최대 10개)을 지원합니다. 본문에는 `#{변수}` 형식으로 알림 변수를 넣을 수 있습니다. - -이미지 강조 유형에 쓸 이미지는 화면에서 직접 업로드하며, 카카오 규격에 따라 **jpg/png · 500KB 이하 · 가로 500px 이상 · 가로:세로 2:1** 비율이어야 합니다. 규격에 맞지 않으면 업로드 단계에서 사유와 함께 거부됩니다. +이미지 강조 유형에 쓸 이미지는 화면에서 직접 업로드하며, 카카오 규격에 따라 **jpg/png · +500KB 이하 · 가로 500px 이상 · 가로:세로 2:1** 비율이어야 합니다. 규격에 맞지 않으면 업로드 +단계에서 사유와 함께 거부됩니다. | 상태 | 발송 가능 | 설명 | -|:---:|:---:|------| +|:---:|:---:|---| | 미작성 | ❌ | 템플릿 내용을 아직 작성하지 않은 상태 | | 작성중 | ❌ | 저장했으나 검수를 신청하기 전 상태 (내용 수정 가능) | | 검수중 | ❌ | 카카오 검수 진행 중. 내용을 고치려면 먼저 [신청 취소] | @@ -138,25 +207,81 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook 검수 결과는 30분 주기로 자동 확인하며, 편집 창의 [새로고침]으로 즉시 확인할 수도 있습니다. -검수중·승인된 템플릿은 편집 창에서 내용이 잠기고 요약만 표시됩니다. 검수중이면 [신청 취소] 후, 승인된 템플릿은 [수정 (승인 취소)] 를 눌러 승인을 먼저 취소해야 고칠 수 있습니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 같은 창의 확인 박스에서 확인 후 진행합니다. +검수중·승인된 템플릿은 편집 창에서 내용이 잠기고 요약만 표시됩니다. 검수중이면 [신청 취소] +후, 승인된 템플릿은 [수정 (승인 취소)] 를 눌러 승인을 먼저 취소해야 고칠 수 있습니다. +**승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 +발송), 같은 창의 확인 박스에서 확인 후 진행합니다. -### 문자(SMS) 본문 - -알림마다 문자 본문을 따로 입력합니다(편집 창의 문자(SMS) 섹션). 쓰임은 두 가지이고 본문은 하나를 공유하며, 카카오 검수 대상이 아니므로 [저장] 즉시 반영됩니다. +**문자(SMS) 본문**: 알림마다 문자 본문을 따로 입력합니다(편집 창의 문자(SMS) 섹션). 쓰임은 +두 가지이고 본문은 하나를 공유하며, 카카오 검수 대상이 아니므로 [저장] 즉시 반영됩니다. - **대체 SMS** — 알림톡 발송이 실패했을 때(수신 거부·미가입 등) 같은 번호로 문자를 대신 보냅니다. -- **SMS 단독** — 알림톡을 쓰지 않고 문자로만 보냅니다. 이 항목을 켜면 템플릿이 승인 상태여도 알림톡을 보내지 않습니다. +- **SMS 단독** — 알림톡을 쓰지 않고 문자로만 보냅니다. 이 항목을 켜면 템플릿이 승인 상태여도 + 알림톡을 보내지 않습니다. -문자 본문은 **언어별로 입력**하며, 회원이 사용하는 언어의 본문으로 발송됩니다. 해당 언어의 본문이 비어 있으면 기본 언어 본문으로 발송하고, 기본 언어도 비어 있으면 그 알림의 문자 발송을 건너뜁니다. 알림톡 본문은 카카오가 승인한 원문 그대로만 발송할 수 있어 언어 구분이 없습니다. +문자 본문은 **언어별로 입력**하며, 회원이 사용하는 언어의 본문으로 발송됩니다. 해당 언어의 +본문이 비어 있으면 기본 언어 본문으로 발송하고, 기본 언어도 비어 있으면 그 알림의 문자 발송을 +건너뜁니다. 알림톡 본문은 카카오가 승인한 원문 그대로만 발송할 수 있어 언어 구분이 없습니다. ---- +**운영 시 확인할 것** -## 발송 결과 코드 +- 운영 모드 전환 전 검수 모드에서 문자·알림톡 발송 흐름을 먼저 확인합니다. 운영 모드는 실제 + 발송과 비용이 발생합니다. +- 비밀번호·API 키·발신프로필 키를 외부에 노출하지 않습니다. +- 지갑 잔액·후불 한도를 주기적으로 확인합니다. 부족 시 관리자 알림이 발송되지만, **그 알림도 + 같은 채널(문자·알림톡)을 쓰면 함께 실패**하므로 사이트 내 알림·메일 같은 다른 채널로도 받도록 + 설정하는 것을 권장합니다. +- 플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. + 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 다시 설치하면 문자·알림톡 + 채널이 자동으로 복원됩니다. + -비즈뿌리오/카카오가 반환하는 결과 코드는 4가지로 분류되어 처리됩니다. +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 발송은 되는데 이력에 성공/실패가 남지 않음 | 비즈뿌리오 콘솔에 결과 통보(webhook) URL 이 등록되지 않음 | 설정 화면 환경설정 탭에서 URL 을 복사해 비즈뿌리오 콘솔에 등록합니다 | +| 알림톡이 안 가고 문자만 감 | 그 알림의 템플릿이 승인 상태가 아니거나 SMS 단독이 켜져 있음 | 템플릿 상태를 확인해 검수 신청·재신청하고, SMS 단독 설정을 확인합니다 | +| 알림톡·문자 모두 발송되지 않음 | 검수 모드가 켜져 있거나 계정 정보·발신번호가 비어 있음 | 검수 모드를 끄고 아이디·비밀번호·API 키·발신번호가 모두 입력되었는지 확인합니다 | +| 템플릿을 고칠 수 없음 (입력이 잠김) | 검수중 또는 승인 상태 | 검수중이면 [신청 취소], 승인 상태면 [수정 (승인 취소)] 후 편집합니다. 승인 취소 즉시 알림톡 발송이 멈춥니다 | +| 이미지 업로드가 거부됨 | 카카오 규격 미달 | jpg/png · 500KB 이하 · 가로 500px 이상 · 가로:세로 2:1 로 맞춥니다 | +| 특정 언어 회원에게 문자가 가지 않음 | 그 언어와 기본 언어의 문자 본문이 모두 비어 있음 | 편집 창의 문자(SMS) 섹션에서 기본 언어 본문을 채웁니다 | +| 잔액 부족 알림이 계속 오지 않음 | 재발송 쿨다운(기본 3600초) 안에서는 한 번만 발송 | 정상 동작입니다. 간격을 바꾸려면 설정 파일의 `balance_low_notify_cooldown` 을 조정합니다 | +| 결과 코드만 표시되고 사유가 없음 | 다국어 파일에 없는 코드 | 코드 자체가 비즈뿌리오·카카오의 응답값입니다. 자주 나오는 코드라면 `lang/{ko,en}/result_codes.php` 에 추가합니다 | + +**발송 결과 코드**는 4가지로 분류되어 처리됩니다. | 분류 | 처리 방침 | -|:---:|------| +|:---:|---| | 성공 | 발송 완료 | | 재시도 (일시 오류) | 자동 재시도 대상 (예: 카카오 시스템 오류, 처리 지연, 게이트웨이 오류) | | 잔액 부족 | 즉시 실패 처리 + 관리자 자체 알림 | @@ -165,7 +290,7 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook 주요 코드 예시: | 코드 | 분류 | 사유 | -|:---:|------|------| +|:---:|---|---| | `1000` `4100` `6600` `7000` | 성공 | 발송/리포트 성공 | | `9070` | 잔액 부족 | 잔액 부족(문자) | | `9071` | 잔액 부족 | 후불 한도 초과 | @@ -173,95 +298,15 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook | `4400` | 영구 실패 | 음영 지역 | | `7103` | 영구 실패 | 발신 프로필 키 무효 | -발송 이력 화면에는 `사유 (코드)` 형식(예: "음영 지역 (4400)")으로 표시됩니다. 전체 코드 목록은 `lang/ko/result_codes.php` / `lang/en/result_codes.php`에 정의되어 있으며, lang에 없는 코드는 코드만 표시됩니다. +발송 이력 화면에는 `사유 (코드)` 형식(예: "음영 지역 (4400)")으로 표시됩니다. 전체 코드 +목록은 `lang/ko/result_codes.php` · `lang/en/result_codes.php` 에 정의되어 있으며, 목록에 없는 +코드는 코드만 표시됩니다. + ---- +## 변경 이력 -## 가용 훅 (Hook) - -다른 모듈이나 플러그인에서 아래 훅에 연결해 잔액부족 상황을 확장 처리할 수 있습니다. - -### 액션 훅 - -| 훅 이름 | 시점 | 인수 | -|------|------|------| -| `sirsoft-message_bizppurio.balance.low` | 잔액 부족·한도 초과로 발송 실패 시 (쿨다운 내 최초 1회) | `string $resultCode, string $channel` | - -### 훅 등록 예시 - -```php -use App\Extension\HookManager; - -HookManager::addAction( - 'sirsoft-message_bizppurio.balance.low', - function (string $resultCode, string $channel) { - // 예: 잔액 부족 시 Slack으로도 별도 알림 - SlackNotifier::send("비즈뿌리오 잔액 부족: 채널={$channel}, 코드={$resultCode}"); - }, - priority: 10 -); -``` - -`$resultCode`는 잔액 부족(`9070` 문자 / `7436` 알림톡) 또는 후불 한도 초과(`9071`) 코드입니다. 이 훅은 관리자 자체 알림(잔액부족/후불한도초과 안내)을 발화하는 지점과 동일하며, 채널별 쿨다운(기본 3600초) 동안 한 번만 실행됩니다. - ---- - -## API - -전체 엔드포인트 레퍼런스는 [docs/api/README.md](docs/api/README.md)를 참고하세요. - -| 문서 | 내용 | -|------|------| -| [webhook.md](docs/api/webhook.md) | 비즈뿌리오 발송 결과 리포트 수신 | -| [templates.md](docs/api/templates.md) | 알림톡 템플릿 라이프사이클 관리 (작성·검수 신청·상태 동기화·발송 설정) | -| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 카테고리·발신프로필 조회 | -| [token.md](docs/api/token.md) | 비즈뿌리오 인증 토큰 | -| [dispatch-results.md](docs/api/dispatch-results.md) | 발송 결과 조회 | -| [report.md](docs/api/report.md) | 발송 결과 리포트 URL 조회 | - -### 권한 - -| 권한 | 설명 | -|------|------| -| `sirsoft-message_bizppurio.messaging.view` | 발송 이력, 알림톡 템플릿, 발송 결과 조회 | -| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 작성·검수 신청·신청/승인 취소·상태 동기화·삭제, 대체 SMS 설정 등 관리 작업 | - ---- - -## 삭제 시 동작 - -플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다. - ---- - -## 보안 및 운영 참고 - -- 비즈뿌리오 비밀번호, API 키, 알림톡 발신프로필 키는 외부에 노출하지 마세요. 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다. -- 운영 모드 전환 전 검수 모드에서 문자·알림톡 발송 흐름을 먼저 확인하세요. 운영 모드는 실제 발송과 비용이 발생합니다. -- webhook URL을 비즈뿌리오 콘솔에 등록하지 않으면 발송 결과 확인이 불가능합니다. -- 검수와 운영은 별도의 비즈뿌리오 계정 사용을 권장합니다. -- 지갑 잔액/후불 한도를 주기적으로 확인하세요. 부족 시 관리자 알림이 발송되지만, 알림 자체도 같은 채널(문자/알림톡)을 사용하지 않는 별도 채널(예: 사이트 내 알림, 메일)로 함께 받는 것을 권장합니다. - ---- - -## 테스트 - -플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다. - -```bash -php artisan test plugins/sirsoft-message_bizppurio/tests -``` - -프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다. - -```bash -npm install -npm run test:run -npm run build -``` - ---- +[CHANGELOG.md](CHANGELOG.md) ## 라이선스 -MIT \ No newline at end of file +MIT diff --git a/plugins/_bundled/sirsoft-message_bizppurio/composer.json b/plugins/_bundled/sirsoft-message_bizppurio/composer.json index 22992fd2..6c265d21 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/composer.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/composer.json @@ -2,7 +2,7 @@ "name": "plugins/sirsoft-message_bizppurio", "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin for G7 platform by sirsoft", "type": "library", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "authors": [ { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/README.md new file mode 100644 index 00000000..297a8e10 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/README.md @@ -0,0 +1,23 @@ +# 비즈뿌리오 메시지 발송 개발자 문서 + +> plugins/_bundled/sirsoft-message_bizppurio · 플러그인 + + +**훅 수**: 1 · **구독 훅 수**: 6 · **라우트 수**: 21 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 2 · **레이아웃 수**: 1 · **핸들러 수**: 2 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md index 2e390b3d..d903b631 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md @@ -33,7 +33,7 @@ | PHONE | body | string | 아니오 | max 20 | 수신 전화번호 | | MEDIA | body | string | 아니오 | max 10 | 실제 발송된 매체 유형(대체발송 시 요청 유형과 다를 수 있음) | | RESULT | body | string | 예 | max 10 | 발송 결과 코드 — ResultCodeResolver 가 성공/실패와 사유로 해석 | -| REFKEY | body | string | 예 | max 32 | 발송 시 G7 이 부여한 참조 키 — 이 값으로 bizppurio_dispatches 행을 찾는다 | +| REFKEY | body | string | 예 | max 32 | 발송 시 그누보드7 이 부여한 참조 키 — 이 값으로 bizppurio_dispatches 행을 찾는다 | | TELRES | body | string | 아니오 | max 10 | 대체발송(문자) 결과 코드 | | KAORES | body | string | 아니오 | max 10 | 알림톡 발송 결과 코드 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/architecture.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/architecture.md new file mode 100644 index 00000000..68227f1e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/architecture.md @@ -0,0 +1,97 @@ +# 비즈뿌리오 메시지 발송 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +이 플러그인은 **채널 구현체**입니다 — 코어 알림 시스템이 정의한 "알림을 어떤 수단으로 +내보내는가" 의 자리를 채웁니다. 알림 자체(무엇을 언제 누구에게)는 코어와 각 도메인 모듈이 +소유하며, 이 경계 덕분에 플러그인을 삭제해도 알림과 그 이력은 남습니다. + +그 위에 이 도메인 고유의 제약 셋이 설계를 결정했습니다. + +- **결과가 비동기로 돌아온다.** 비즈뿌리오는 발송 API 응답이 아니라 webhook 통보로 최종 + 결과를 줍니다. 그래서 발송 기록(`bizppurio_dispatches`)이 먼저 생기고 결과가 나중에 채워지는 + 2단 구조이며, webhook 이 등록되지 않은 사이트에서는 그 칸이 영영 비어 있습니다 — 오류가 + 아니라 **결과 미상**이라는 상태입니다. +- **외부 승인이 발송 조건이다.** 알림톡은 카카오가 승인한 템플릿으로만 보낼 수 있습니다. + 승인 시점의 내용을 로컬(`bizppurio_templates`)에 박제해 두고 발송하며, 발송 경로에서 외부를 + 조회하지 않습니다 — 조회를 넣으면 그 서비스의 지연이 곧 발송 지연이 됩니다. 대신 승인 상태 + 변화를 30분 주기 스케줄로 따라잡습니다. +- **실패에 종류가 있다.** 결과 코드를 성공 / 재시도(일시 오류) / 잔액 부족 / 영구 실패 넷으로 + 분류합니다. 잔액 부족은 재시도해도 소용없고 **운영자 개입이 필요한** 유일한 분류라, 이것만 + 관리자 알림과 확장 훅(`balance.low`)을 갖습니다. + +**의도적으로 하지 않는 것**: 알림 정의·수신자 해석·자체 관리 메뉴. UI 는 다른 화면에 끼워 넣는 +조각 7개와 플러그인 설정 화면 하나뿐입니다 — 문자·알림톡 설정이 알림 설정 화면 **안에** 있어야 +운영자가 한자리에서 알림을 완성할 수 있기 때문입니다. + + +## 계층 지도 + + +``` +[등록] RegisterNotificationChannelsListener → 코어 알림 시스템에 문자·알림톡 채널 등록 + SeedChannelTemplatesListener → 그 채널의 템플릿 자리 시드 + +[발송] 코어 알림 발화 → 채널 발송 작업 + │ + ▼ + Services (발송 조립 · 알림톡/문자 선택 · 대체발송 판정) + │ 비즈뿌리오 인증 토큰 (설정 저장 시 InvalidateTokenOnSettingsSaveListener 가 무효화) + ▼ + 비즈뿌리오 발송 API → bizppurio_dispatches 적재 + +[결과] POST /webhook + │ BizppurioWebhookIpWhitelist (발신 IP 검사 — 인증이 없는 공개 경로다) + ▼ + WebhookReportService + │ 결과 코드 4분류 → 발송 기록 갱신 + │ 잔액 부족이면 balance.low 발행 + 관리자 알림 (채널별 쿨다운) + ▼ + LinkNotificationLogListener → 코어 알림 로그와 발송 기록 연결 + +[템플릿] Admin API (작성·검수신청·취소·승인취소·동기화·삭제) + │ + ▼ + bizppurio_templates ← bizppurio:sync-template-status (30분) +``` + +리스너 7종이 각각 다른 이음매를 맡습니다 — 채널 등록 2 · 결과 연결 1 · 설정 반응 2(토큰 +무효화 · 운영 모드 필수값 검증) · 데이터 주입 2(비회원 연락처 · 잔액부족 알림 데이터). 하나가 +빠지면 그 이음매만 조용히 끊기고 나머지는 정상 동작하므로, 리스너를 지우거나 이름을 바꿀 때는 +그 이음매가 무엇이었는지 먼저 확인합니다. + +`ValidateBizppurioSettingsListener` 만 성격이 다릅니다 — `core.plugin_settings.update_rules` +필터로 **운영 모드로 전환할 때만** 필수값(아이디·비밀번호·API 키·발신번호) 검증을 추가합니다. +검수 모드에서는 그 값들이 비어 있어도 저장되어야 하기 때문입니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-message_bizppurio --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-message_bizppurio --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-message_bizppurio --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-message_bizppurio --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/data-model.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/data-model.md new file mode 100644 index 00000000..1f587252 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/data-model.md @@ -0,0 +1,134 @@ +# 비즈뿌리오 메시지 발송 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `BizppurioDispatch` | `bizppurio_dispatches` | 20 | user→User | - | +| `BizppurioTemplate` | `bizppurio_templates` | 15 | - | - | + + + +둘의 역할이 **발송 기록**과 **템플릿 상태**로 갈립니다. + +- **`BizppurioDispatch`** (`bizppurio_dispatches`) — 발송 한 건의 기록입니다. fillable 이 20개로 + 큰 이유는 **발송 시점의 상태를 그대로 남기기** 때문입니다: 대상·채널·본문·비즈뿌리오 응답· + webhook 으로 나중에 채워지는 결과 코드와 사유. 발송과 결과가 시점이 다르므로 한 행이 두 + 번에 걸쳐 완성됩니다. +- **`BizppurioTemplate`** (`bizppurio_templates`) — 알림별 알림톡 템플릿과 그 검수 상태입니다. + **카카오 승인 시점의 내용이 여기 박제**되며, 발송할 때마다 카카오를 조회하지 않습니다. + 문자(SMS) 본문도 같은 행에 함께 있습니다 — 대체 SMS 와 SMS 단독이 같은 본문을 공유하기 + 때문입니다. + +`BizppurioTemplate` 에 관계가 없는 것은 **알림 종류를 문자열 키로 참조**하기 때문입니다. 코어와 +게시판·이커머스가 각자 알림을 정의하므로 외래키로 묶을 단일 테이블이 없고, 알림이 사라지면 그 +템플릿 행은 참조 대상 없이 남습니다. + +`BizppurioDispatch` 의 `user→User` 관계는 회원 발송에만 채워집니다 — **비회원 발송은 +`user_id` 가 없고** 주문 시 입력한 연락처만 남습니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `bizppurio_dispatches` | `BizppurioDispatch` | +| `bizppurio_templates` | `BizppurioTemplate` | + + + +둘 다 `bizppurio_` 접두사를 갖습니다. + +`bizppurio_dispatches` 는 **계속 쌓이는 로그성 테이블**입니다. 발송량에 비례해 늘어나므로 조회는 +반드시 페이지네이션을 거쳐야 하고, 목록에 본문 같은 큰 컬럼을 싣지 않습니다. + +`bizppurio_templates` 는 알림 종류 수만큼만 존재하는 **설정성 테이블**입니다. 알림 하나에 행 +하나이므로 크기가 데이터 증가에 비례하지 않습니다. + +발송 기록은 코어 알림 로그(`notification_logs`)와 **별개**이며, +`LinkNotificationLogListener` 가 둘을 연결합니다. 두 로그가 따로 있는 이유는 코어 로그가 +"알림이 발송 요청되었다" 를, 이쪽이 "그 요청이 비즈뿌리오에서 어떻게 끝났다" 를 담기 때문입니다. + + +## 마이그레이션 + + +마이그레이션 2개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_07_13_000001_create_bizppurio_dispatches_table.php` | `bizppurio_dispatches` | `bizppurio_dispatches` | ✅ | +| `2026_07_13_000002_create_bizppurio_templates_table.php` | `bizppurio_templates` | `bizppurio_templates` | ✅ | + + + +2개이며 둘 다 초기 테이블 생성입니다 — 아직 스키마 변경 이력이 없는 젊은 확장입니다. + +발송 기록 테이블에 컬럼을 더할 때는 **로그성 테이블이라는 점**을 염두에 둡니다. 이미 쌓인 행에 +기본값을 채우는 백필은 행 수에 비례하므로, `chunkById()` 로 순회하고 상한 없는 `get()` 을 +쓰지 않습니다(`chunk()` 계열은 OFFSET 기반이라 갱신된 행이 필터에서 이탈한 만큼 커서가 밀려 +미처리 행을 조용히 건너뜁니다). + +새 컬럼을 더할 때 초기 `create_*` 파일을 고치지 않습니다 — 이미 설치된 사이트는 그 파일을 +다시 실행하지 않으므로 반영되지 않으며, 기존 행을 손봐야 하는 변경은 `upgrades/` 의 업그레이드 +스텝 백필이 함께 필요합니다. 한국어 `comment` 와 `down()` 은 필수입니다. + + +## Enum + + +| Enum | backing | case 수 | case | +|---|---|---|---| +| `BizppurioTemplateStatus` | `string` | 7 | `draft`, `requested`, `approved`, `rejected`, `stopped`, `blocked`, `dormant` | +| `DispatchChannel` | `string` | 3 | `sms`, `lms`, `alimtalk` | +| `DispatchSource` | `string` | 3 | `auto`, `manual`, `bulk` | +| `DispatchStatus` | `string` | 4 | `pending`, `sent`, `success`, `failed` | +| `ResultCategory` | `string` | 4 | `success`, `retry`, `permanent_failure`, `balance_low` | + + + +PHP Enum 은 없지만 **닫힌 어휘가 셋** 있습니다. + +| 어휘 | 값 | SSoT | +|---|---|---| +| 템플릿 상태 | 미작성·작성중·검수중·승인·반려·중지·차단·휴면 | 비즈뿌리오/카카오가 정의 — 이쪽에서 만들 수 없습니다 | +| 결과 코드 분류 | 성공 / 재시도 / 잔액 부족 / 영구 실패 | 이 플러그인이 정의 | +| 결과 코드 자체 | `1000` `9070` `7436` `4400` … | 비즈뿌리오/카카오가 정의, 사유 문구는 `lang/{ko,en}/result_codes.php` | + +앞의 둘은 **외부가 정하는 어휘**라 Enum 으로 굳히면 상대가 값을 추가할 때마다 이쪽이 깨집니다. +그래서 문자열로 다루고, 모르는 값이 오면 코드만 그대로 표시합니다 — "알 수 없는 코드" 로 +뭉뚱그리면 운영자가 비즈뿌리오에 문의할 근거를 잃습니다. + +가운데 분류만 이 플러그인이 정하므로 **여기가 확장 지점**입니다. 새 코드가 어느 분류에 +들어가는지 판정이 바뀌면 재시도 여부와 관리자 알림 발화가 함께 바뀝니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `BizppurioDispatchRepository` | 구현 | 비즈뿌리오 발송 이력 Repository 구현체. | +| `BizppurioDispatchRepositoryInterface` | 인터페이스 | 비즈뿌리오 발송 이력 Repository 계약. | +| `BizppurioTemplateRepository` | 구현 | 비즈뿌리오 알림 템플릿 Repository 구현체 (#597). | +| `BizppurioTemplateRepositoryInterface` | 인터페이스 | 비즈뿌리오 알림 템플릿 Repository 인터페이스 (#597). | + + + +발송 기록과 템플릿 각각에 Repository 가 있고, 서비스는 **인터페이스만 주입**받습니다(구체 +클래스 타입힌트 금지). + +발송 기록 Repository 에서 특히 걸리는 것 둘: + +- **목록 조회는 페이지네이션과 컬럼 프루닝.** 로그성 테이블이라 발송량에 비례해 늘어나고, + 본문 컬럼이 큽니다. 목록이 실제로 그리는 것만 싣습니다. +- **결과 갱신은 발송 기록을 특정해서.** webhook 통보는 비즈뿌리오가 준 식별자로 해당 발송을 + 찾아 갱신합니다 — 찾지 못한 통보를 조용히 버리지 않고 그 사실이 남아야 합니다. + +리스너는 Repository 를 거쳐 데이터에 접근합니다. `Model::query()` · `DB::table()` · +`$row->save()` 를 직접 부르지 않습니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/editor-spec.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/editor-spec.md new file mode 100644 index 00000000..7fbd5ce8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/editor-spec.md @@ -0,0 +1,83 @@ +# 비즈뿌리오 메시지 발송 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._ + + + +문자·알림톡 채널 플러그인은 라우트 21개에 관리자 화면도 여럿 갖지만 편집기 스펙이 +없습니다. 이것은 설계가 아니라 **아직 만들지 않은 상태**입니다 — 아래 미커버 목록이 그 +결과를 보여 줍니다. + + +## 선언 블록 + + +_선언된 편집기 스펙 블록이 없습니다._ + + + +선언한 블록이 없습니다. 이 플러그인의 관리자 화면은 연동 설정·알림톡 템플릿 관리 등 +여러 갈래이고 각각이 자기 도메인 데이터를 읽으므로, 공용 ID 만으로는 덮이지 않습니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다. +편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도 +`componentPalette` 는 여전히 비어 있을 것입니다. + + +## 샘플 데이터와 페이지 상태 + + +_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._ + +**프리뷰 샘플이 없는 `data_source` 5개** — 편집기 캔버스에서 이 자리만 빈 화면이 됩니다. 실제 화면은 정상이라 오류도 경고도 남지 않습니다. + +`bizppurioCategories` · `bizppurioProfiles` · `bizppurio_templates_list` · `report_url` · `templates_readiness` + + + +미커버가 5종으로 저장소에서 가장 많습니다 — `bizppurioCategories` · +`bizppurioProfiles` · `bizppurio_templates_list` · `report_url` · `templates_readiness`. + +알림톡 템플릿 관리 화면 전체가 편집기 캔버스에서 빈 채로 보인다는 뜻입니다. 이 플러그인의 +관리자 화면을 편집기로 손보려는 운영자는 지금 목록도 상태도 볼 수 없습니다. + +연동 설정 쪽 `settings` 만 공용 ID 라 템플릿 스펙이 채웁니다. + + +## 수정 시 동반 의무 + + +_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._ + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + + + +편집기 스펙을 신설해야 하는 시점은 하나입니다 — **이 확장이 소유한 레이아웃에 +이 확장만 쓰는 `data_source` 가 생겼을 때**. 그 순간부터 편집기 캔버스의 그 영역은 +빈 화면이 되고, 실제 화면은 정상 동작하므로 오류도 경고도 남지 않습니다. + +신설 절차는 확장 루트에 `editor-spec.json` 을 만들고 `sampleData.byDataSourceId` 에 +그 ID 를 넣는 것으로 시작합니다. 팔레트·컨트롤은 템플릿의 일이므로 넣지 않습니다. +파일을 만든 뒤 update 커맨드로 활성 디렉토리에 반영해야 편집기가 읽습니다 — +`_bundled` 폴백이 없습니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/extension-points.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/extension-points.md new file mode 100644 index 00000000..2d404152 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/extension-points.md @@ -0,0 +1,228 @@ +# 비즈뿌리오 메시지 발송 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 1종 / 호출 지점 1곳. 이 중 1종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-message_bizppurio.balance.low` | action | — | `src/Services/WebhookReportService.php:125` | + + + +하나뿐이고, 그 하나가 **운영자 개입이 필요한 유일한 실패**를 알립니다. + +`sirsoft-message_bizppurio.balance.low` 는 잔액 부족·후불 한도 초과로 발송이 실패했을 때 +발화합니다. 인수는 `string $resultCode, string $channel` 이며 `$resultCode` 는 `9070`(문자 +잔액 부족) · `7436`(알림톡 지갑 잔액 부족) · `9071`(후불 한도 초과) 중 하나입니다. + +```php +use App\Extension\HookManager; + +HookManager::addAction( + 'sirsoft-message_bizppurio.balance.low', + function (string $resultCode, string $channel) { + SlackNotifier::send("비즈뿌리오 잔액 부족: 채널={$channel}, 코드={$resultCode}"); + }, + priority: 10 +); +``` + +이 훅은 관리자 자체 알림을 발화하는 지점과 **동일**하며, 채널별 쿨다운(기본 3600초) 안에서는 +한 번만 실행됩니다. 대량 발송이 한꺼번에 실패해도 통지가 쏟아지지 않게 하는 장치입니다. + +잔액 부족 통지를 **문자·알림톡이 아닌 경로로도** 받고 싶을 때 여기에 붙입니다 — 잔액이 없어서 +실패한 상황이므로 같은 채널로 보내는 통지는 함께 실패합니다. + +다른 실패(일시 오류·영구 실패)에는 훅이 없습니다. 일시 오류는 재시도가 해결하고, 영구 실패는 +발송 이력에 사유가 남아 운영자가 확인할 수 있기 때문입니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.notification_log.after_log_failed` | action (미선언) | `LinkNotificationLogListener` | `linkLog` | 15 | +| `core.notification_log.after_log_sent` | action (미선언) | `LinkNotificationLogListener` | `linkLog` | 15 | +| `core.plugin_settings.after_save` | action | `InvalidateTokenOnSettingsSaveListener` | `invalidateToken` | 10 | +| `core.plugin_settings.update_rules` | filter | `ValidateBizppurioSettingsListener` | `addLiveModeRules` | 10 | +| `sirsoft-ecommerce.notification.extract_data` | filter | `GuestPhoneExtractListener` | `injectGuestPhone` | 20 | +| `sirsoft-message_bizppurio.notification.extract_data` | filter | `BalanceLowNotificationDataListener` | `injectBalanceLowData` | 10 | + + + +6개가 각각 다른 이음매입니다. 하나가 빠지면 그 이음매만 조용히 끊기고 나머지는 정상 +동작하므로, 리스너를 지우거나 이름을 바꿀 때는 그것이 무엇을 잇고 있었는지 먼저 확인합니다. + +| 훅 | 무엇을 잇는가 | +|---|---| +| `core.notification_log.after_log_sent` · `after_log_failed` | 코어 알림 로그와 이 플러그인의 발송 기록을 연결 — 관리자 "알림 발송 이력" 에 문자·알림톡 결과가 함께 보이는 이유 | +| `core.plugin_settings.after_save` | 설정을 저장하면 비즈뿌리오 인증 토큰을 무효화 — 계정 정보를 바꿨는데 옛 토큰으로 계속 발송하는 것을 막습니다 | +| `core.plugin_settings.update_rules` | **운영 모드로 전환할 때만** 필수값(아이디·비밀번호·API 키·발신번호) 검증을 추가. 검수 모드에서는 비어 있어도 저장되어야 합니다 | +| `sirsoft-ecommerce.notification.extract_data` | 비회원 주문 알림에 주문 시 입력한 연락처를 주입 | +| `sirsoft-message_bizppurio.notification.extract_data` | 잔액부족 관리자 알림의 본문 변수 채우기 (자기 훅) | + +`sirsoft-ecommerce` 는 **manifest 의존이 아닙니다.** 훅 구독은 상대가 없으면 발화하지 않으므로 +이커머스가 없어도 이 플러그인은 정상 동작하고 비회원 문자 발송만 비어 있습니다. 대신 이커머스가 +그 훅 이름이나 페이로드를 바꾸면 예외 없이 조용히 끊기고, 증상은 "비회원 주문 문자가 안 간다" +로만 나타납니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `BalanceLowNotificationDataListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/BalanceLowNotificationDataListener.php` | +| `GuestPhoneExtractListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GuestPhoneExtractListener.php` | +| `InvalidateTokenOnSettingsSaveListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/InvalidateTokenOnSettingsSaveListener.php` | +| `LinkNotificationLogListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/LinkNotificationLogListener.php` | +| `RegisterNotificationChannelsListener` | 0개 | 명시 등록 | ✅ | `src/Listeners/RegisterNotificationChannelsListener.php` | +| `SeedChannelTemplatesListener` | 0개 | 명시 등록 | ✅ | `src/Listeners/SeedChannelTemplatesListener.php` | +| `ValidateBizppurioSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateBizppurioSettingsListener.php` | + + + +7개 전부 `HookListenerInterface` 를 구현하고 `getSubscribedHooks()` 로 자기 구독을 선언합니다. + +| 리스너 | 역할 | +|---|---| +| `RegisterNotificationChannelsListener` | 코어 알림 시스템에 문자·알림톡 채널 등록 | +| `SeedChannelTemplatesListener` | 그 채널의 템플릿 자리 시드 | +| `LinkNotificationLogListener` | 코어 알림 로그 ↔ 발송 기록 연결 | +| `InvalidateTokenOnSettingsSaveListener` | 설정 저장 시 인증 토큰 무효화 | +| `ValidateBizppurioSettingsListener` | 운영 모드 전환 시 필수값 검증 규칙 추가 | +| `GuestPhoneExtractListener` | 비회원 주문 알림에 연락처 주입 | +| `BalanceLowNotificationDataListener` | 잔액부족 알림 본문 변수 채우기 | + +구독 수가 0인 둘(`RegisterNotificationChannelsListener` · `SeedChannelTemplatesListener`)은 +훅이 아니라 **수명주기 시점**(설치·활성화)에 호출되는 리스너라 정적 훅 수집에 잡히지 않습니다. +이 둘이 빠지면 채널 자체가 알림 설정 화면에 나타나지 않습니다. + +리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 를 직접 부르지 않습니다 — 데이터 +접근은 Repository 인터페이스 주입으로만 합니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/notification_log_result.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/notification_row_footer.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/notification_tab_board.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/notification_tab_core.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/notification_tab_ecommerce.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/notification_template_form_footer_actions.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/notification_template_form_sections.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +조각 7개가 이 플러그인의 **운영 UI 대부분**입니다. 자체 레이아웃은 플러그인 설정 화면 하나 +뿐입니다. + +| 조각 | 들어가는 자리 | +|---|---| +| `notification_tab_core.json` · `notification_tab_board.json` · `notification_tab_ecommerce.json` | 코어·게시판·이커머스 알림 설정 화면의 "비즈뿌리오" 탭 | +| `notification_row_footer.json` | 알림 목록 각 행 하단의 승인 여부·문자 설정 요약 | +| `notification_template_form_sections.json` | 알림 템플릿 편집 창의 알림톡·문자(SMS) 섹션 | +| `notification_template_form_footer_actions.json` | 그 편집 창 하단의 [저장] · [저장 후 검수 신청] 등 버튼 | +| `notification_log_result.json` | 발송 이력 화면의 결과 열 | + +문자·알림톡 설정을 **알림 설정 화면 안에** 두는 것이 이 설계의 핵심입니다. 별도 화면으로 +분리하면 운영자가 알림 하나를 완성하는 데 두 화면을 오가야 하고, 어느 쪽을 저장했는지 헷갈리게 +됩니다. + +같은 이유로 탭 조각이 셋(코어·게시판·이커머스)입니다 — 알림 설정 화면이 도메인마다 따로 +있으므로 각각에 붙어야 합니다. **새 도메인이 알림 설정 화면을 갖게 되면 조각이 하나 더 +필요합니다.** + +대상 화면을 소유한 쪽이 그 자리를 없애면 조각은 오류 없이 사라집니다 — 코어·게시판·이커머스를 +업그레이드한 뒤에는 일곱 자리를 눈으로 확인합니다. + + +## 미들웨어 + + +| 미들웨어 | 부착 대상(targets) | 우선순위 | +|---|---|---| +| `BizppurioWebhookIpWhitelist` | `api.plugins.sirsoft-message_bizppurio.webhook` | - | + + + +하나뿐이고, 그것이 이 플러그인의 **유일한 보안 경계**입니다. + +`BizppurioWebhookIpWhitelist` 는 `api.plugins.sirsoft-message_bizppurio.webhook` 라우트에만 +붙어 발신 IP 를 검사합니다. webhook 은 외부 서비스가 부르는 경로라 로그인 인증을 쓸 수 없고, +그래서 발신자 검증이 IP 화이트리스트뿐입니다. + +**webhook 라우트를 추가하거나 이름을 바꾸면 이 선언의 `targets` 도 함께 고쳐야 합니다.** +이름이 어긋나면 미들웨어가 붙지 않는데 정상 응답이 나가므로 오류도 로그도 남지 않습니다 — +위조된 통보로 발송 결과를 조작할 수 있는 상태가 조용히 만들어집니다. + +미들웨어는 `getMiddleware()` 로 부착 대상을 스스로 선언합니다(self-gate). 커널 미들웨어 그룹을 +직접 조작하거나 라우트 파일에 FQCN 을 붙이지 않습니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +없습니다. 발송은 서버가 외부 API 를 부르는 일이고, 결과는 나중에 webhook 으로 돌아와 이력에 +기록됩니다 — 화면이 실시간으로 따라가야 할 사건이 아닙니다. + +발송 진행 상황을 실시간으로 보여줘야 한다면 `balance.low` 같은 훅을 구독하는 쪽에서 자기 +채널로 내보냅니다. + + +## 스케줄 + + +| 스케줄 | 주기 | 설명 | +|---|---|---| +| `bizppurio:sync-template-status` | `everyThirtyMinutes` | 비즈뿌리오 알림톡 템플릿 검수 상태 동기화 | + + + +하나뿐입니다. `bizppurio:sync-template-status` 가 **30분마다** 카카오 알림톡 템플릿의 검수 +상태(승인·반려·중지·차단·휴면)를 비즈뿌리오에서 가져와 로컬(`bizppurio_templates`)에 +반영합니다. + +이 스케줄이 필요한 이유는 **검수 결과가 비동기이기 때문**입니다. 카카오가 승인을 알려 주는 +통보 경로가 없어 주기적으로 물어봐야 하고, 승인 여부가 곧 발송 가능 여부이므로 이 동기화가 +멈추면 승인이 났는데도 발송되지 않는 상태가 지속됩니다. + +즉시 확인이 필요하면 관리자 화면의 [새로고침](`POST templates/{id}/sync`)으로 단건 조회할 수 +있습니다. 스케줄러가 등록되지 않은 설치에서는 이 수동 경로가 유일한 갱신 수단입니다. + + +## 알림 정의 + + +| 알림 키 | 채널 | +|---|---| +| `bizppurio_balance_low` | `mail`, `database` | + + + +하나뿐입니다. `bizppurio_balance_low` 는 지갑 잔액 부족·후불 한도 초과로 발송이 실패했을 때 +**운영자에게** 보내는 알림입니다. + +이 알림에는 순환 위험이 있습니다 — 잔액이 없어서 실패한 상황인데 이 통지를 문자·알림톡으로 +보내면 그것도 함께 실패합니다. 그래서 채널은 `mail` + `database` 이며, 운영자에게 **다른 +채널 병행**을 권합니다. + +반복 발송을 막기 위해 채널별 쿨다운(기본 3600초)이 걸려 있습니다. 대량 발송이 한꺼번에 +실패하면 실패 건마다 통지가 나가는 것을 막는 장치이며, 간격은 설정 파일의 +`balance_low_notify_cooldown` 이 정합니다(설정 화면에는 노출되지 않습니다). + +알림 본문의 변수는 `BalanceLowNotificationDataListener` 가 자기 훅 +(`sirsoft-message_bizppurio.notification.extract_data`)으로 채웁니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/frontend.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/frontend.md new file mode 100644 index 00000000..7098f63c --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/frontend.md @@ -0,0 +1,103 @@ +# 비즈뿌리오 메시지 발송 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +플러그인 설정 화면(`plugin_settings`) 하나뿐입니다. **이 플러그인의 운영 UI 대부분은 레이아웃이 +아니라 확장 조각 7개**이며, 알림 설정·알림 템플릿 편집·발송 이력 화면 안에 들어갑니다. + +설정 화면 자체가 두 역할을 합니다 — 환경설정(크리덴셜·검수 모드·webhook URL 복사)과 "알림 +템플릿 관리"(전체 알림의 템플릿 상태 조회·검색·필터). 후자를 별도 메뉴로 빼지 않은 것은 알림별 +편집이 이미 알림 설정 화면 안에 있어, 여기서는 **전체를 훑어보는 용도**만 필요하기 때문입니다. + +`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를 +찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면이 사라집니다. + +레이아웃·조각 JSON 만 고쳤다면 빌드는 필요 없고 +`php artisan plugin:update sirsoft-message_bizppurio --force` 로 반영합니다. + + +## 액션 핸들러 + + +핸들러 2개 (정의: `resources/js/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `insertVariable` | `sirsoft-message_bizppurio.insertVariable` | +| `uploadTemplateImage` | `sirsoft-message_bizppurio.uploadTemplateImage` | + + + +둘뿐이며 **알림톡 템플릿 작성 폼**을 위한 것입니다. + +| 핸들러 | 하는 일 | +|---|---| +| `insertVariable` | 본문 입력 커서 위치에 `#{변수}` 형식의 알림 변수를 삽입 | +| `uploadTemplateImage` | 강조 유형이 "이미지" 일 때 쓸 이미지를 업로드 | + +`uploadTemplateImage` 는 **카카오 규격 검증**이 붙는 자리입니다 — jpg/png · 500KB 이하 · +가로 500px 이상 · 가로:세로 2:1. 규격 위반은 업로드 단계에서 사유와 함께 거부해야 합니다. +통과시키면 검수 신청 후 카카오가 반려하는데, 그때는 이미 며칠이 지나 있습니다. + +핸들러 TS 를 고치면 빌드가 필요합니다 — `php artisan plugin:build` 후 +`plugin:update --force`. 프론트엔드 변경은 Playwright spec 을 함께 갱신·실행합니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftMessageBizppurio` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftMessageBizppurio.initPlugin()` 이 재등록 진입점입니다. 로케일을 전환하면 +코어가 이 함수를 다시 불러 핸들러를 재등록하는데, 없거나 이름이 다르면 **로케일 전환 직후 +템플릿 작성 폼의 변수 삽입·이미지 업로드가 무반응**이 됩니다 — 오류도 토스트도 남지 않습니다. + +진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +커밋되는 산출물은 `dist/js/plugin.iife.js` 하나이며 동봉 제3자 자산은 없습니다 — 이 플러그인의 +프론트엔드는 폼 보조 기능 둘뿐이라 외부 라이브러리가 필요하지 않습니다. + +`dist/` 는 **배포 산출물**입니다. 소스(`resources/js/**`)를 고치면 `--production` 으로 다시 +굽고 그 결과를 함께 커밋합니다. 새 소스 리터럴이 `dist/` 에 없으면 stale 빌드이며, 브라우저가 +받는 것은 커밋된 `dist/` 이므로 소스만 고친 변경은 사이트에 반영되지 않습니다. +`sourceMappingURL` 참조를 남기지 않습니다(`.map` 은 커밋 대상이 아니라 404 가 됩니다). + +나중에 제3자 자산이 필요해지면 외부 CDN 이 아니라 `dist/vendor/{lib}/{version}/` 에 동봉하고 +same-origin 으로 서빙합니다 — CDN 도달 실패는 예외도 서버 로그도 남기지 않고 화면 기능만 +조용히 사라집니다. 자산 URL 은 문자열로 조립하지 않고 `G7Core.asset.plugin` 을 씁니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/settings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/settings.md new file mode 100644 index 00000000..0d1d4664 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/settings.md @@ -0,0 +1,151 @@ +# 비즈뿌리오 메시지 발송 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `is_test_mode` | `boolean` | `true` | 검수 모드 | +| `bizppurio_id` | `string` | - | 비즈뿌리오 아이디 | +| `password` | `string` | - | 비밀번호 | +| `api_key` | `string` | - | API 키 | +| `sender_number` | `string` | - | 발신번호 | +| `sender_key` | `string` | - | 알림톡 발신프로필 키 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +6개 항목이 두 무리입니다 — **검수/운영 전환 토글** 하나와 **비즈뿌리오 크리덴셜** 다섯. + +`is_test_mode` 의 기본값이 `true` 인 것이 이 플러그인의 안전장치입니다. 설치 직후에는 실제 +발송이 일어나지 않고, 운영자가 명시적으로 끄는 순간부터 **발송과 비용이 발생**합니다. 그 +전환 시점에만 필수값 검증이 걸리는 것도 같은 이유입니다 — +`ValidateBizppurioSettingsListener` 가 `core.plugin_settings.update_rules` 로 운영 모드일 +때만 아이디·비밀번호·API 키·발신번호를 요구합니다. 검수 모드에서는 비어 있어도 저장되어야 +합니다. + +크리덴셜 셋(`password` · `api_key` · `sender_key`)은 `frontend_schema` 에서 `expose: false` ++ `sensitive: true` 로 선언되어 **프론트엔드로 나가지 않습니다.** 관리자 설정 화면은 코어의 +`/api/admin/plugins/{id}/settings` 로 서버에서 직접 조회하므로 `window.G7Config` 노출이 +필요 없고, 그래서 전 필드가 `expose: false` 입니다. + +**설정 화면에 없는 값이 하나 있습니다.** `balance_low_notify_cooldown`(기본 3600초)은 +`defaults` 에만 있고 `getSettingsSchema()` 에도 `frontend_schema` 에도 없어 화면에서 편집할 수 +없습니다. 잔액부족 알림의 반복을 막는 값이며, 조정하려면 설치본의 설정 파일을 고칩니다. 화면 +입력을 추가하려면 스키마와 `frontend_schema` 양쪽에 함께 선언해야 합니다. + +설정을 저장하면 `InvalidateTokenOnSettingsSaveListener` 가 비즈뿌리오 인증 토큰을 무효화 +합니다 — 계정 정보를 바꿨는데 옛 토큰으로 계속 발송하는 것을 막는 장치입니다. + + +## 권한 + + +| 카테고리 | 이름 | 액션 | 라우트 키 | +|---|---|---|---| +| `messaging` | 메시지 발송 | `view`, `manage` | - | + + + +`messaging` 하나에 `view` / `manage` 두 액션입니다. + +| 권한 | 무엇을 할 수 있는가 | +|---|---| +| `sirsoft-message_bizppurio.messaging.view` | 발송 이력·알림톡 템플릿·발송 결과 조회 | +| `sirsoft-message_bizppurio.messaging.manage` | 템플릿 작성·검수 신청·신청/승인 취소·상태 동기화·삭제, 대체 SMS 설정 | + +라우트 키가 없는 것은 이 플러그인의 관리 API 가 `admin` 미들웨어와 개별 권한 지정으로 보호 +되기 때문입니다. + +`manage` 는 **비용과 발송 중단에 직결**됩니다 — 승인 취소는 그 즉시 알림톡 발송을 멈추고, +템플릿 삭제는 되돌릴 수 없습니다. 넓게 부여하지 않습니다. + +설정 변경(크리덴셜·검수 모드)은 이 권한이 아니라 코어의 플러그인 설정 권한이 관장합니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +등록하지 않습니다. 이 플러그인의 운영 UI 가 **다른 화면 안에** 있기 때문입니다 — 알림 설정 +화면의 비즈뿌리오 탭, 알림 템플릿 편집 창, 발송 이력 화면. + +문자·알림톡 설정을 별도 메뉴로 분리하면 운영자가 알림 하나를 완성하는 데 두 화면을 오가야 +하고, 어느 쪽을 저장했는지 헷갈리게 됩니다. + +전체 템플릿 상태를 한눈에 보는 화면은 **플러그인 설정 안**의 "알림 템플릿 관리" 탭에 +있습니다. 설정 화면은 코어의 플러그인 목록에서 들어가는 공통 경로를 씁니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-message_bizppurio/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +21개가 두 무리로 갈리며, **성격 차이가 큽니다.** + +| 무리 | 경로 | 보호 | +|---|---|---| +| webhook | `POST /webhook` | **인증 없음** — 외부 서비스가 부르므로 `BizppurioWebhookIpWhitelist` 가 유일한 경계 | +| 관리자 | `admin/*` (템플릿 수명주기 · 알림톡 카테고리/발신프로필 조회 · 발송 결과 조회 · 리포트 URL · 토큰 점검 · 템플릿 준비 상태) | `auth:sanctum` + `admin` + 개별 권한 | + +**webhook 라우트가 이 플러그인의 유일한 공개 경로**입니다. 라우트를 추가하거나 이름을 바꾸면 +`getMiddleware()` 의 `targets` 도 함께 고쳐야 합니다 — 이름이 어긋나면 미들웨어가 붙지 않는데 +정상 응답이 나가므로 오류도 로그도 남지 않고, 위조된 통보로 발송 결과를 조작할 수 있는 상태가 +조용히 만들어집니다. + +템플릿 수명주기 라우트가 많은 것은 상태 전이가 많기 때문입니다 — 작성·수정·검수 신청·신청 +취소·승인 취소·해제·동기화·삭제가 각각 별도 엔드포인트입니다. 상태를 하나의 `PATCH` 로 +합치지 않은 것은 각 전이의 권한·검증·부작용이 다르기 때문입니다(승인 취소는 발송을 즉시 +멈춥니다). + +라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 확장 라우트는 활성 상태인 확장의 것만 +등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 404 가 됩니다 — webhook 이 404 가 되면 +발송 결과가 영영 기록되지 않습니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +manifest 상 양방향 모두 비어 있습니다. 코어만으로 동작하고, 이 플러그인을 요구하는 확장도 +없습니다. + +**실제로 맞물리는 확장이 셋** 있습니다 — 전부 훅 구독 또는 레이아웃 조각이라 manifest 의존이 +아닙니다: + +| 확장 | 무엇으로 | 없으면 | +|---|---|---| +| `sirsoft-ecommerce` | `notification.extract_data` 훅 구독 + 알림 설정 탭 조각 | 비회원 주문 문자 발송만 비고 나머지는 정상 | +| `sirsoft-board` | 알림 설정 탭 조각 | 게시판 알림에 비즈뿌리오 탭만 안 보임 | +| 코어 알림 시스템 | 채널 등록 · 알림 로그 훅 2종 · 설정 훅 2종 | 이것이 없으면 플러그인 자체가 성립하지 않음 | + +의존으로 올리지 않은 판단은 맞습니다 — 이커머스나 게시판이 없어도 코어 알림을 문자로 보내는 +기능은 그대로 동작합니다. 대신 그 대가로 **상대가 훅 이름이나 확장점을 바꾸면 예외 없이 +조용히 끊깁니다.** + +**새 도메인이 알림 설정 화면을 갖게 되면** 그 화면용 탭 조각이 하나 더 필요합니다. 조각이 +없으면 그 도메인의 알림은 비즈뿌리오 채널을 설정할 수 없는데, 화면에 탭이 없을 뿐이라 오류가 +나지 않습니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/package.json b/plugins/_bundled/sirsoft-message_bizppurio/package.json index 3a8d1cf9..7a49647a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/package.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json @@ -1,6 +1,6 @@ { "name": "@plugins/sirsoft-message_bizppurio", - "version": "1.0.0", + "version": "1.0.1", "private": true, "type": "module", "scripts": { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json index 1e163be5..2944f058 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json @@ -5,7 +5,7 @@ "ko": "비즈뿌리오 메시지 발송", "en": "Bizppurio Messaging" }, - "version": "1.0.0", + "version": "1.0.1", "description": { "ko": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.", "en": "Bizppurio SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook." diff --git a/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md b/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md new file mode 100644 index 00000000..d4987b49 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/AGENTS.md @@ -0,0 +1,175 @@ +# KG 이니시스 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-pay_kginicis) — KG 이니시스 PG 연동(PC/모바일/가상계좌/에스크로/일본 CBT). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유 +2. 확장 방식: `RegisterPgProviderListener`/`RegisterCashReceiptProviderListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다 +3. 건드리면 안 되는 것: `authUrl`/`P_REQ_URL`/`netCancelUrl` 화이트리스트 검증 생략, 콜백 재처리 방지 로직 우회, IP 화이트리스트 미들웨어(`InicisNotifyIpWhitelist`) 미부착 +4. 작업 위치: `plugins/_bundled/sirsoft-pay_kginicis` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-pay_kginicis --force` +``` + +## 1. 이 확장은 무엇인가 + + +KG 이니시스 PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 결제수단마다 +프로토콜이 다릅니다 — PC 는 브라우저 결제창 + 서버 승인 API, 모바일은 폼 POST 이동 + 별도 +승인 API, 일본 CBT 는 완전히 다른 인증/승인 체계(JPPG)를 씁니다. 이 플러그인의 역할은 그 +세 가지 서로 다른 프로토콜을 전부 흡수해 이커머스 쪽에는 "결제 성공/실패/취소"라는 하나의 +결과만 넘기는 것입니다. + +**설계 원칙**: 이 플러그인은 상태를 소유하지 않습니다(§data-model.md — 모델·테이블 0개). +주문·결제 상태는 전부 `sirsoft-ecommerce`의 테이블에 있고, 이 플러그인은 PG API 와 그 상태를 +동기화하는 역할만 합니다. 등록도 코드 결합이 아니라 훅 기반입니다 +(`sirsoft-ecommerce.payment.registered_pg_providers` 필터) — 이커머스 모듈은 이 플러그인의 +존재를 컴파일 타임에 몰라도 됩니다. + +**의도적으로 하지 않는 것**: 결제 실패 시 자동으로 다른 PG 로 재시도하지 않습니다 — PG 마다 +가맹점 계약·결제수단이 다르므로 자동 전환은 이중 결제·과금 위험을 만듭니다. 또한 일본 결제 +설정이 불완전할 때 한국 표준결제로 조용히 대체하지 않고 결제 자체를 중단합니다 — 설정 실수를 +"어쨌든 결제는 된다"로 감추면 잘못된 통화·수수료로 승인될 수 있습니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_kginicis --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_kginicis --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_kginicis --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_kginicis --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_kginicis --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**PC 결제 승인**: `PaymentCallbackController`(KG 이니시스가 POST 하는 authToken/authUrl 수신) +→ `authUrl` 화이트리스트 검증 → `sirsoft-pay_kginicis.payment.before_authorize` 훅 → +`KgInicisApiService` 가 승인 API 호출 → `sirsoft-pay_kginicis.payment.after_authorize` 훅 → +이커머스 주문 결제 완료 처리. 승인 후 로컬 처리 실패 시 `netCancelUrl` 로 망취소를 시도합니다 +— 이 지점이 실패하면 "PG 는 승인, 우리는 실패"인 가장 위험한 상태이므로 반드시 오류 로그를 +남깁니다. + +**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가 +`sirsoft-ecommerce.payment.refund` 필터 발화 → 이 플러그인의 `PaymentRefundListener` +(우선순위 10)가 먼저 KG 이니시스 취소 API 호출 → `CancelActivityLogListener`(우선순위 20)가 +그 결과(PG 응답 시각·취소 TID)를 활동 로그에 별도 기록. 우선순위 순서가 중요합니다 — 취소가 +실제로 성공한 뒤에야 로그를 남겨야 "로그는 있는데 실제 취소는 실패"가 생기지 않습니다. + +**일본 CBT 승인**: `/payment/cbt/hash-data` 로 해시 생성(타임스탬프 신선도 검증) → +CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달 → `cbtapprove` API 호출 → +카드/PayPay 는 즉시 완료, 편의점은 입금대기로 저장 후 별도 NOTI 수신 시 완료. 로컬 후속 +처리가 실패하면 CBT 전용 취소 API 로 자동 취소를 시도하고, 그마저 실패하면 수동 취소가 +필요하다는 오류 로그를 남깁니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 6개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 14개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 11개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 4개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +`before_authorize`/`before_cancel`/`before_cbt_refund` 는 PG 호출 **전** 개입 지점입니다 — +예를 들어 고액 결제에 본인인증을 추가로 요구하고 싶은 확장이 `before_cancel` 을 잡아 조건 +미충족 시 예외를 던지면 KG 이니시스 API 호출 자체가 일어나지 않습니다(`before_cancel` 의 +용도로 이미 "본인인증 등 확장 지점"이라 발행 위치에 명시돼 있습니다). `after_*` 훅은 PG 응답을 +받은 뒤 부가효과(추가 로그, 알림 등)를 붙이는 자리입니다. 구독 훅 14개 중 다수가 +`core.layout_extension.after_apply` 인 이유는 관리자 주문 목록/상세 화면에 "테스트 모드 +배지"·"거래 조회 UI"를 레이아웃 확장으로 주입하기 때문입니다(§레이아웃 확장). + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_kginicis --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_kginicis` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다 +- [ ] IP 화이트리스트(`InicisNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신 +- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백/통보 URL 등록")에도 반영 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_kginicis --force` + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| PG 콜백의 `authUrl`/`P_REQ_URL`/`netCancelUrl`을 화이트리스트 없이 그대로 호출 | KG 이니시스 허용 URL 목록과 대조 후에만 호출 | 콜백 파라미터를 신뢰하면 공격자가 임의 URL로 서버발 요청을 유도할 수 있다(SSRF) | +| 동일 거래번호 콜백을 매번 재처리 | 콜백 재처리 방지 검사를 거친 뒤 처리 | 재처리를 막지 않으면 같은 결제가 중복 완료 처리되거나 중복 환불될 수 있다 | +| 결제창 서명/모바일 해시/CBT 해시 요청에 타임스탬프 검증 생략 | 타임스탬프 신선도 검증 유지 | 오래된 서명 재사용(replay)으로 위조 결제 요청이 통과할 수 있다 | +| 일본 결제 설정 미완료 시 한국 표준결제로 조용히 대체 | 설정 미완료면 결제 자체를 중단 | 통화·수수료·정산 구조가 다른 결제가 잘못된 흐름으로 승인될 수 있다 | +| 라이브 키(사인키·INIAPI 키/IV·해시키)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 서명을 위조할 수 있다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 35개 | `plugins/_bundled/sirsoft-pay_kginicis/tests` | +| Vitest | 12개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 2개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_kginicis/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-pay_kginicis && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md index d416b9d6..9abf5077 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.3] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ## [1.1.2] - 2026-08-22 ### Changed diff --git a/plugins/_bundled/sirsoft-pay_kginicis/README.md b/plugins/_bundled/sirsoft-pay_kginicis/README.md index efe9a60c..43ce9204 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/README.md +++ b/plugins/_bundled/sirsoft-pay_kginicis/README.md @@ -1,267 +1,275 @@ -# KG Inicis Plugin for G7 +# KG 이니시스 -KG 이니시스 표준결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. +**그누보드7 플러그인 · sirsoft-pay_kginicis** +KG 이니시스 표준결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인 -PC 결제는 KG 이니시스 `INIStdPay.js` 표준결제창을 사용하고, 모바일 결제는 KG 이니시스 모바일 표준결제창으로 이동한 뒤 서버 승인 API로 최종 승인합니다. 일본 엔(JPY) 결제는 KG 이니시스 CBT(JPPG) 흐름을 사용합니다. + +

+ version 1.1.3 + type 플러그인 + 그누보드7 >=7.0.10 + license MIT + requires sirsoft-ecommerce +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +KG 이니시스 표준결제를 그누보드7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. PC 결제는 +`INIStdPay.js` 표준결제창을, 모바일 결제는 모바일 표준결제창으로 이동한 뒤 서버 승인 API로 +최종 승인하는 흐름을 씁니다. 일본 엔(JPY) 결제는 별도의 KG 이니시스 CBT(JPPG) 흐름을 씁니다. + +이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를 소유하지 않습니다 — 그 상태는 +`sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를 KG 이니시스 API 와 +어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유 테이블/모델이 하나도 없습니다 +(§data-model.md). + ## 주요 기능 -- 신용카드, 계좌이체, 가상계좌, 휴대폰결제 지원 -- PC 표준결제창 연동 -- 모바일 표준결제창 연동 및 `P_CHKFAKE` 위변조 방지 해시 생성 -- 삼성페이, L.pay, 카카오페이 간편결제 버튼 주입 -- 가상계좌 발급, PC/모바일 입금통보 처리 -- 에스크로 결제, 배송 등록, 구매결정, 구매거절확인 연동 -- 결제 취소 및 부분취소 연동 -- PG 측 결제 취소 확인 시점의 활동 로그 별도 기록 (PG 응답 시각·취소 TID 사후 추적) -- 주문 완료/마이페이지 영수증 버튼 주입 -- 관리자 주문 상세의 거래 조회, 에스크로 처리 UI 확장 -- 현금영수증 발급/취소 (이커머스 모듈의 공용 현금영수증 프로바이더로 등록 — PG 결제사와 독립 선택 가능) -- 일본 결제 CBT(JPPG) 인증/승인, 테스트 상품 생성, 연결 진단 -- 승인 URL 화이트리스트, 콜백 재처리 방지, 타임스탬프 신선도 검증 + +| 영역 | 설명 | +|---|---| +| 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 | +| 간편결제 | 삼성페이, L.pay, 카카오페이 버튼 주입 (다른 PG가 기본이어도 노출 가능) | +| 가상계좌 | 발급 + PC/모바일 입금통보 처리 | +| 에스크로 | 결제, 배송 등록, 구매결정, 구매거절확인 연동 | +| 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 TID) | +| 영수증 | 주문 완료/마이페이지 영수증 버튼, 현금영수증 발급/취소(이커머스 공용 프로바이더) | +| 관리자 확장 | 주문 상세 거래 조회, 에스크로 처리 UI | +| 일본 결제(CBT) | 인증/승인, 테스트 상품 생성, 연결 진단 | +| 보안 | 승인 URL 화이트리스트, 콜백 재처리 방지, 타임스탬프 신선도 검증 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[체크아웃 주문 생성] -->|PC| B["/payment/signature 호출 → INIStdPay.js 결제창"] + A -->|모바일| C["/payment/mobile/signature → 모바일 표준결제창"] + A -->|JPY| D["/payment/cbt/hash-data → CBT 인증 URL"] + B --> E["/payment/callback (authToken·authUrl)"] + C --> F["/payment/mobile/callback (P_TID·P_REQ_URL)"] + D --> G["/payment/cbt/callback (sid)"] + E --> H[서버가 authUrl 화이트리스트 검증 후 승인 API 호출] + F --> H + G --> I[cbtapprove API 호출] + H --> J[주문 결제 완료 처리] + I --> J + J --> K[성공 URL 리다이렉트] +``` + +승인 후 로컬 처리가 실패하면 PC/모바일은 각각 netCancel/취소 API로, CBT는 CBT 전용 취소 +API로 자동 취소를 시도합니다(자동 취소까지 실패하면 수동 취소가 필요하다는 오류 로그를 +남깁니다) — "PG 는 승인됐는데 우리 시스템은 실패"라는 상태가 남지 않도록 하기 위함입니다. + +가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, KG 이니시스가 입금통보 +URL로 결과를 POST 하면 거래번호 재처리·금액 검증 후 결제 완료 처리됩니다. PC/모바일 입금통보는 +URL이 다르지만 같은 IP 화이트리스트 미들웨어를 거칩니다. + +일본 CBT 는 카드/PayPay는 즉시 승인 후 완료 처리되고, 편의점(CVS) 결제는 입금대기로 저장된 뒤 +`/payment/cbt/cvs-notify` 수신 시 완료 처리됩니다. JPY 주문은 일본 결제 설정이 완료된 경우에만 +CBT 결제창으로 진입하며, 설정이 부족하면 한국 표준결제로 대체하지 않고 결제 자체를 중단합니다. + ## 요구 사항 -| 항목 | 내용 | -|------|------| -| G7 | `>= 7.0.0-beta.2` | -| 의존 모듈 | `sirsoft-ecommerce >= 1.0.0-beta.4` | + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | | PHP | `^8.2` | +| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` | + + + +| 항목 | 필요한 것 | +|---|---| | 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KG 이니시스 가맹점 계약 정보 | | PC 결제 | MID, signKey | | 모바일 결제 | MID, 모바일 hash key | | 취소/거래조회/현금영수증 | INIAPI key, INIAPI IV | | 일본 CBT | 별도 일본 결제 MID, CBT hash key | -서버에서 KG 이니시스 결제/INIAPI/CBT 호스트로 HTTPS outbound 요청이 가능해야 합니다. CBT 테스트 환경 `devcbt.inicis.com`은 KG 이니시스 측에 서버 egress IP 등록이 필요할 수 있습니다. +서버에서 KG 이니시스 결제/INIAPI/CBT 호스트로 HTTPS outbound 요청이 가능해야 합니다. CBT +테스트 환경 `devcbt.inicis.com`은 KG 이니시스 측에 서버 egress IP 등록이 필요할 수 있습니다. + ## 설치 -플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다. - -```text -plugins/sirsoft-pay_kginicis -``` - -프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다. - + ```bash -npm install -npm run build +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-pay_kginicis + +# 활성화 +php artisan plugin:activate sirsoft-pay_kginicis + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-pay_kginicis --force ``` -그다음 G7 관리자에서 플러그인을 활성화하고, 이커머스 결제 설정에서 PG 제공자를 `KG 이니시스`로 선택합니다. +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis + + +설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "KG 이니시스"로 선택해야 실제로 결제 +흐름에 연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다. ## 관리자 설정 -관리자 플러그인 설정 화면에서 KG 이니시스 계약 정보를 입력합니다. + +| 키 | 의미 | 기본값 | +|---|---|---| +| `is_test_mode` | 테스트 모드 | `true` | +| `test_mid` | 테스트 가맹점 ID (MID) | `INIpayTest` | +| `test_sign_key` | 테스트 사인키 | `SU5JTElURV9UUklQTEVERVNfS0VZU1RS` | +| `test_iniapi_key` | 테스트 INIAPI 키 | `ItEQKi3rY7uvDS8l` | +| `test_iniapi_iv` | 테스트 INIAPI IV | `HYb3yQ4f65QL89==` | +| `live_mid` | 라이브 가맹점 ID (MID) | - | +| `live_sign_key` | 라이브 사인키 | - | +| `live_iniapi_key` | 라이브 INIAPI 키 | - | +| `live_iniapi_iv` | 라이브 INIAPI IV | - | +| `test_mobile_hash_key` | 테스트 모바일 해시키 | `3CB8183A4BE283555ACC8363C0360223` | +| `live_mobile_hash_key` | 라이브 모바일 해시키 | - | +| `use_escrow` | 에스크로 결제 활성화 | `false` | +| `japan_enabled` | 일본 결제 활성화 | `false` | +| `japan_restrict_jpy_payment_methods` | JPY 주문 결제수단 제한 | `false` | +| `test_japan_sign_key` | 테스트 일본 CBT 해시키 | `5AL5Djb1Ipualn0F` | +| `live_japan_mid` | 라이브 일본 MID | - | +| `live_japan_sign_key` | 라이브 일본 CBT 해시키 | - | +| `japan_merchant_name` | 일본 결제 가맹점명 | `サンプルストア` | +| `japan_merchant_name_kana` | 일본 결제 가맹점명 Kana | `サンプルストア` | +| `japan_merchant_name_alphabet` | 일본 결제 가맹점명 영문 | `Sample Store` | +| `japan_merchant_name_short` | 일본 결제 가맹점 약칭 | `サンプル` | +| `japan_contact_name` | 일본 결제 문의처명 | `サポート窓口` | +| `japan_contact_email` | 일본 결제 문의 이메일 | `support@example.com` | +| `japan_contact_phone` | 일본 결제 문의 전화번호 | `0120-123-456` | +| `japan_contact_opening_hours` | 일본 결제 문의 영업시간 | `10:00-18:00` | +| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` | +| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` | +| `easy_pay_allow_with_other_pg` | 타 PG와 사용가능함 | `false` | +| `easy_pay_samsung_pay` | KG이니시스 삼성페이 사용 | `false` | +| `easy_pay_naverpay` | KG이니시스 네이버페이 사용 | `false` | +| `easy_pay_show_brand_button` | 간편결제 브랜드 버튼 표시 | `false` | +| `easy_pay_lpay` | KG이니시스 L.pay 사용 | `false` | +| `easy_pay_kakaopay` | KG이니시스 카카오페이 사용 | `false` | +| `use_credit_point` | 신용카드 포인트 사용 | `false` | -| 설정 | 설명 | -|------|------| -| 테스트 모드 | 활성화 시 KG 이니시스 테스트 환경을 사용합니다. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일 23:00~23:50 사이 자동 취소될 수 있습니다. | -| 테스트 MID | 기본값은 `INIpayTest`입니다. 에스크로 테스트 사용 시 내부적으로 `iniescrow0`을 사용합니다. | -| 테스트 사인키 | PC 결제창 서명 생성에 사용합니다. | -| 테스트 INIAPI 키/IV | 취소, 거래조회, 현금영수증, 에스크로 API 인증에 사용합니다. | -| 테스트 모바일 해시키 | 모바일 `P_CHKFAKE` 생성에 사용합니다. | -| 라이브 MID | 운영 MID입니다. `SIR` prefix 없이 입력해도 플러그인이 자동 보정합니다. | -| 라이브 사인키 | 운영 결제창 서명 생성에 사용합니다. 외부에 노출하지 마세요. | -| 라이브 INIAPI 키/IV | 운영 취소, 거래조회, 현금영수증, 에스크로 API 인증에 사용합니다. | -| 라이브 모바일 해시키 | 운영 모바일 `P_CHKFAKE` 생성에 사용합니다. | -| 에스크로 결제 활성화 | PC는 `acceptmethod`에 `useescrow`, 모바일은 `P_RESERVED`에 `useescrow=Y`를 추가합니다. | -| 일본 결제 활성화 | JPY 주문에서 KG 이니시스 CBT(JPPG) 결제 흐름을 사용합니다. | -| 테스트 일본 CBT 해시키 | CBT 테스트 해시 생성에 사용합니다. 테스트 MID는 `CBTTEST001` 고정값을 사용합니다. | -| 라이브 일본 MID/해시키 | 운영 CBT 결제에 사용합니다. | -| JPPG 결제창 표시 정보 | 일본 결제창 `extraData`에 포함되는 가맹점명, 가나명, 영문명, 문의처 정보를 설정합니다. 운영 전 실제 계약 정보로 교체하세요. | -| 결제 성공 URL | 기본값은 `/shop/orders/{orderId}/complete`입니다. | -| 결제 실패 URL | 기본값은 `/shop/checkout`입니다. | -| 간편결제 | KG 이니시스 계약이 완료된 간편결제만 활성화하세요. | -| 타 PG와 사용가능함 | 다른 PG가 기본값이어도 KG 이니시스 간편결제 버튼을 체크아웃 화면에 표시합니다. | -| 신용카드 포인트 사용 | PC 카드 결제 `acceptmethod`에 신용카드 포인트 사용 옵션을 추가합니다. | +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + -테스트 모드 주문은 실제 배송하지 마세요. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일 23:00~23:50 사이 자동 취소될 수 있습니다. + +테스트 모드에서는 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일 +23:00~23:50 사이 자동 취소될 수 있습니다 — **테스트 모드 주문을 실제로 배송하지 마세요.** +운영 키(라이브 사인키·INIAPI 키/IV·모바일/CBT 해시키)는 외부에 노출하지 말고, 배포 전 +테스트 모드가 의도한 값인지 반드시 확인하세요. -운영 키와 해시키는 외부에 노출하지 말고, 배포 전 테스트 모드가 의도한 값인지 확인하세요. +라이브 MID는 `SIR` 접두사 없이 입력해도 플러그인이 자동 보정합니다. 에스크로는 PC의 +`acceptmethod`에 `useescrow`를, 모바일은 `P_RESERVED`에 `useescrow=Y`를 추가하는 방식으로 +켜집니다. 일본 결제를 운영 모드로 켜려면 라이브 일본 MID/CBT 해시키와 실제 JPPG 가맹점 +표시 정보가 필요합니다 — 기본 샘플값이 남아 있으면 설정 저장 단계에서 차단됩니다. -## 콜백 및 통보 URL - -KG 이니시스 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제 운영 도메인으로 바꿔 입력하세요. +**콜백/통보 URL 등록** — KG 이니시스 가맹점 관리자에 아래 URL을 실제 운영 도메인으로 등록합니다. | 용도 | URL | -|------|-----| -| PC 결제 결과 Return URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/callback` | -| PC 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/vbank-notify` | -| 모바일 결제 결과 `P_NEXT_URL` | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/mobile/callback` | -| 모바일 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify` | -| CBT 콜백 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/cbt/callback` | -| CBT 편의점 입금 NOTI URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` | -| 에스크로 구매결정 화면 | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/{orderNumber}` | +|---|---| +| PC 결제 결과 Return URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/callback` | +| PC 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/vbank-notify` | +| 모바일 결제 결과 `P_NEXT_URL` | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/mobile/callback` | +| 모바일 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify` | +| CBT 콜백 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/cbt/callback` | +| CBT 편의점 입금 NOTI URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` | +| 에스크로 구매결정 화면 | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/{orderNumber}` | -PC 결제 결과 Return URL과 모바일 `P_NEXT_URL`은 사용자 브라우저를 통해 호출됩니다. 가상계좌 입금통보 URL은 KG 이니시스 서버가 직접 호출하므로 운영 환경에서 IP 화이트리스트가 적용됩니다. +가상계좌 입금통보 URL은 KG 이니시스 서버가 직접 호출하므로 운영 환경에서 IP 화이트리스트가 +적용됩니다(`203.238.37.15`, `39.115.212.9`, `118.129.210.25`, `183.109.71.153` — 운영 전 +KG 이니시스 최신 연동 가이드로 다시 확인하세요). `local`/`testing` 환경에서는 개발·테스트를 +위해 이 제한을 우회합니다. + -KG 이니시스 PC 에스크로 매뉴얼 기준 별도 webhook 통보 채널은 사용하지 않습니다. 에스크로 배송등록, 구매결정, 구매거절확인은 플러그인이 제공하는 화면과 API를 통해 처리합니다. +## 사용 방법 -## IP 화이트리스트 + +**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가 +`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, 이 플러그인의 `PaymentRefundListener` +가 KG 이니시스 취소/부분취소 API를 호출합니다(전액취소는 `cancelPrice=null` + +`totalAmount=null`, 부분취소는 취소 금액 + 원래 결제금액). 배송비가 포함된 주문은 전체취소 시 +배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후)이 PG +취소 금액으로 전달됩니다. 부분취소로 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 +코어가 취소 자체를 거부(422)해 PG 호출이 아예 발생하지 않습니다. KG 이니시스 API 호출이 +실패하면 주문 상태 변경이 롤백됩니다. -운영 환경에서는 아래 IP에서 들어온 KG 이니시스 가상계좌 입금통보만 허용합니다. `local`, `testing` 환경에서는 개발과 테스트를 위해 제한을 우회합니다. +**에스크로 처리**: 에스크로 결제 완료 후 관리자 주문 상세에서 배송 등록을 호출할 수 있고, +사용자는 에스크로 구매결정 화면에서 구매확인을 진행합니다. 구매거절이 발생한 주문은 관리자 +주문 상세에서 구매거절확인을 호출할 수 있습니다. -| IP | -|----| -| `203.238.37.15` | -| `39.115.212.9` | -| `118.129.210.25` | -| `183.109.71.153` | +**CBT 연결 진단**: 일본 결제 테스트가 실패하면 관리자 CBT 연결 진단(§API)에서 서버 egress +IP와 `devcbt.inicis.com` 443 연결 상태를 먼저 확인합니다. -운영 전 KG 이니시스 가맹점 관리자와 최신 연동 가이드의 통보 서버 IP를 다시 확인하세요. +전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은 +[docs/extension-points.md](docs/extension-points.md) 를 참고하세요. + -## 결제 흐름 +## 다른 확장과의 연동 -### PC 결제 + +**이 확장이 의존하는 확장** -```text -체크아웃 주문 생성 -→ 프론트엔드 핸들러가 /payment/signature 호출 -→ INIStdPay.js 결제창 실행 -→ KG 이니시스가 /payment/callback 으로 authToken, authUrl POST -→ 서버가 authUrl 화이트리스트 검증 후 승인 API 호출 -→ 주문 결제 완료 처리 -→ 성공 URL로 리다이렉트 -``` +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | -승인 후 주문 처리에 실패하면 KG 이니시스 netCancel URL로 망취소를 시도합니다. +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) -### 모바일 결제 +없음. + -```text -체크아웃 주문 생성 -→ /payment/mobile/signature 호출 -→ 모바일 표준결제창으로 form POST -→ KG 이니시스가 /payment/mobile/callback 으로 P_TID, P_REQ_URL 전달 -→ 서버가 P_REQ_URL 화이트리스트 검증 후 모바일 승인 API 호출 -→ 주문 결제 완료 처리 -→ 성공 URL로 리다이렉트 -``` + +`RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에, `RegisterCashReceiptProviderListener` +가 현금영수증 프로바이더 레지스트리에 각각 등록합니다 — PG 결제사 선택과 현금영수증 발급사 +선택은 서로 독립적이라, 다른 PG를 쓰면서도 KG 이니시스로 현금영수증만 발급하는 조합이 +가능합니다. + -모바일 승인 후 주문 처리에 실패하면 취소 API로 자동 취소를 시도합니다. +## 문서 -### 가상계좌 + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + -```text -결제창에서 가상계좌 발급 -→ 주문 결제 정보에 은행, 계좌번호, 예금주, 만료일 저장 -→ 주문은 입금대기 상태 유지 -→ KG 이니시스가 입금통보 URL로 입금 결과 POST -→ 거래번호 재처리와 금액 검증 후 주문 결제 완료 처리 -``` +## 트러블슈팅 -PC와 모바일 입금통보는 서로 다른 URL을 사용하지만, 모두 같은 IP 화이트리스트 미들웨어를 통과해야 합니다. + +| 증상 | 원인 | 조치 | +|---|---|---| +| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 KG 이니시스 통보 서버 IP가 없음 | 최신 연동 가이드의 통보 서버 IP로 화이트리스트를 갱신 | +| 결제 승인 후 주문이 실패 상태로 남음 | 로컬 후속 처리 실패 후 자동 취소(망취소/취소 API)까지 실패 | 오류 로그의 안내대로 수동 취소 진행 — PG 승인은 이미 됐을 수 있음 | +| 일본 결제창이 안 열리고 결제가 중단됨 | 일본 결제 설정(라이브 MID/해시키/가맹점 정보) 미완료 | 설정을 완료하거나, 완료 전까지는 JPY 주문을 받지 않음 — 한국 표준결제로 자동 대체되지 않음 | +| CBT 테스트가 계속 실패함 | 서버 egress IP 미등록 또는 방화벽으로 443 포트 차단 | 관리자 CBT 연결 진단 실행 후 KG 이니시스에 서버 IP 등록 요청 | +| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | KG 이니시스 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 | + -### 일본 CBT +## 변경 이력 -```text -JPY 주문 생성 -→ /payment/cbt/hash-data 호출 -→ CBT 인증 URL로 form POST -→ KG 이니시스가 /payment/cbt/callback 으로 sid 전달 -→ 서버가 cbtapprove API 호출 -→ 카드/PayPay는 주문 결제 완료 처리 -→ 편의점(CVS)은 입금대기 저장 후 /payment/cbt/cvs-notify 입금 NOTI 수신 시 결제 완료 처리 -``` - -CBT 승인 이후 로컬 후속 처리에 실패하면 CBT 전용 취소 API로 자동 취소를 시도합니다. 자동 취소까지 실패한 경우에는 운영자 수동 취소가 필요하다는 오류 로그를 남깁니다. - -일본 엔(JPY) 주문은 일본 결제 설정이 완료된 경우에만 CBT 결제창으로 진입합니다. 설정이 부족하면 한국 표준결제 흐름으로 대체하지 않고 결제를 중단합니다. - -현재 CBT 결제창은 선택한 결제수단에 맞춰 지불수단을 제한합니다. 신용카드는 `CARD`만, PayPay는 `PAYpay`만, 일본 편의점결제는 `CVS`만 열립니다. - -운영 모드에서 일본 결제를 활성화하려면 라이브 일본 MID/CBT 해시키와 실제 JPPG 가맹점 표시 정보가 필요합니다. 기본 샘플값이 남아 있으면 설정 저장 단계에서 차단됩니다. - -### 에스크로 - -에스크로를 활성화하면 결제 요청에 에스크로 옵션을 전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 배송 등록을 호출할 수 있고, 사용자는 에스크로 구매결정 화면에서 구매확인을 진행할 수 있습니다. - -구매거절이 발생한 주문은 관리자 주문 상세에서 구매거절확인을 호출할 수 있습니다. - -### 결제 취소 / 부분취소 - -```text -관리자 주문 취소 요청 (cancel_pg=true) -→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화 -→ PaymentRefundListener 가 KG 이니시스 취소/부분취소 API 호출 - · 전액취소: cancelPrice=null, totalAmount=null - · 부분취소: cancelPrice=취소 금액, totalAmount=원래 결제금액 -→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원 -→ CancelActivityLogListener 가 PG 응답 시각·취소 TID를 활동 로그에 기록 -``` - -배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG 취소 금액으로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. KG 이니시스 API 호출이 실패하면 주문 상태 변경이 롤백됩니다. - -## API - -### 사용자 API - -| Method | Path | 설명 | -|--------|------|------| -| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/signature` | PC 결제창 서명 생성 | -| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/mobile/signature` | 모바일 `P_CHKFAKE` 생성 | -| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data` | CBT hashData 생성 | -| `GET` | `/api/plugins/sirsoft-pay_kginicis/user/orders/{orderNumber}/receipt` | KG 이니시스 영수증 URL 조회 | - -### 관리자 API - -| Method | Path | 설명 | -|--------|------|------| -| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/vbank-notify-url` | PC/모바일 가상계좌 입금통보 URL 조회 | -| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/test-mode-map` | 주문목록 테스트 모드 배지용 맵 조회 | -| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/transaction/query` | TID로 KG 이니시스 거래 조회 | -| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/transaction-status` | 주문번호로 거래 상태 조회 | -| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery` | 에스크로 배송 등록 폼 데이터 조회 | -| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery` | KG 이니시스 에스크로 배송 등록 | -| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-deny-confirm` | 에스크로 구매거절확인 | -| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/cbt-test-product` | CBT 테스트용 JPY 상품 생성 | -| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/cbt-connectivity-check` | CBT 테스트 호스트 연결 진단 | - -## 훅 - -다른 모듈이나 플러그인에서 아래 훅에 연결해 결제 흐름을 확장할 수 있습니다. - -| 훅 | 타입 | 시점 | -|----|------|------| -| `sirsoft-pay_kginicis.payment.before_authorize` | action | KG 이니시스 서버 승인 API 호출 전 | -| `sirsoft-pay_kginicis.payment.after_authorize` | action | KG 이니시스 서버 승인 완료 후 | -| `sirsoft-pay_kginicis.payment.before_cancel` | action | KG 이니시스 결제 취소 API 호출 전 | -| `sirsoft-pay_kginicis.payment.after_cancel` | action | KG 이니시스 결제 취소 완료 후 | - -`sirsoft-ecommerce.payment.refund` 필터를 통해 이커머스 환불 요청을 KG 이니시스 취소/부분취소 API로 연결합니다. - -## 보안 및 운영 참고 - -- 운영 도메인의 `APP_URL`을 HTTPS 절대 URL로 정확히 설정하세요. -- 운영 signKey, INIAPI key, INIAPI IV, 모바일 hash key, CBT hash key는 외부에 노출하지 마세요. -- 결제창 서명, 모바일 해시, CBT 해시 생성 요청은 타임스탬프 신선도를 검증합니다. -- CBT 해시 생성 요청은 주문자 이메일/연락처와 서버 주문 정보를 대조하고, IP/주문번호 단위 요청 횟수를 제한합니다. -- CBT 환불은 결제 당시 저장된 테스트/운영 모드와 일본 MID 기준으로 처리합니다. -- PC `authUrl`, 모바일 `P_REQ_URL`, PC `netCancelUrl`은 KG 이니시스 허용 URL만 사용합니다. -- 동일 거래번호 콜백은 중복 처리하지 않도록 방어합니다. -- 운영 환경에서는 가상계좌 입금통보 IP 화이트리스트가 적용됩니다. -- 가상계좌 입금통보 URL은 KG 이니시스 가맹점 관리자에 반드시 등록해야 합니다. -- KG 이니시스 계약이 없는 결제수단이나 간편결제를 활성화하면 결제창 오류가 발생할 수 있습니다. -- CBT 테스트가 실패하면 관리자 CBT 연결 진단에서 서버 egress IP와 `devcbt.inicis.com` 443 연결 상태를 먼저 확인하세요. - -## 테스트 - -플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다. - -```bash -php artisan test plugins/sirsoft-pay_kginicis/tests -``` - -프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다. - -```bash -npm install -npm run test:run -npm run build -``` +[CHANGELOG.md](CHANGELOG.md) ## 라이선스 diff --git a/plugins/_bundled/sirsoft-pay_kginicis/composer.json b/plugins/_bundled/sirsoft-pay_kginicis/composer.json index 1f62194c..2116a34e 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/composer.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-pay_kginicis", "description": "KG Inicis PG Plugin for G7 platform", - "version": "1.1.2", + "version": "1.1.3", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/README.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/README.md new file mode 100644 index 00000000..4d4ba29b --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/README.md @@ -0,0 +1,23 @@ +# KG 이니시스 개발자 문서 + +> plugins/_bundled/sirsoft-pay_kginicis · 플러그인 + + +**훅 수**: 6 · **구독 훅 수**: 14 · **라우트 수**: 35 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 1 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/architecture.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/architecture.md new file mode 100644 index 00000000..51add76a --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/architecture.md @@ -0,0 +1,65 @@ +# KG 이니시스 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +결제 프로토콜(PC/모바일/CBT)마다 별도 컨트롤러·서비스 경로를 두면서도, 이커머스 쪽에는 +`sirsoft-ecommerce.payment.registered_pg_providers`/`registered_cash_receipt_providers` +필터로 등록하는 하나의 진입점만 노출합니다. 이 경계 덕분에 KG 이니시스가 프로토콜을 바꾸거나 +새 결제수단을 추가해도 이커머스 모듈 코드는 건드리지 않습니다 — 변경은 이 플러그인 안에서만 +일어납니다. 반대로 이 플러그인이 소유 테이블을 두지 않는 것도 같은 경계 원칙입니다: 결제 +"사실"(성공/실패/금액/취소)은 이커머스가 소유하고, 이 플러그인은 그 사실을 만드는 절차만 +소유합니다. + + +## 계층 지도 + + +``` +Controllers (PaymentCallbackController 등 — PG 콜백 수신, 화이트리스트·재처리 방지 검증) + │ + ▼ +Services (KgInicisApiService — 승인/취소/CBT API 호출, 서명·해시 생성) + │ + ├──▶ Repositories (CbtCvsOperationsRepository/CbtReconciliationRepository + │ — sirsoft-ecommerce 의 Order 모델을 조회, 자체 테이블 없음) + │ + └──▶ 훅 발행 (before/after_authorize·cancel·cbt_refund) ──▶ 다른 확장 리스너 + +Listeners (RegisterPgProviderListener 등 — 이커머스 레지스트리 등록, + 레이아웃 확장 주입, 설정 검증) +``` + +미들웨어(`InicisNotifyIpWhitelist`)는 이 흐름과 별도 레인에서 가상계좌/CBT 편의점 입금통보 +라우트 앞단을 지킵니다 — Service 계층이 아니라 라우팅 계층에서 걸러야 신뢰할 수 없는 발신자의 +요청이 애초에 비즈니스 로직에 닿지 않습니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_kginicis --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_kginicis --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_kginicis --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_kginicis --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_kginicis --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/data-model.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/data-model.md new file mode 100644 index 00000000..f10ff9cd --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/data-model.md @@ -0,0 +1,75 @@ +# KG 이니시스 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +_소유 모델이 없습니다._ + + + +결제 상태(주문·결제 성공/실패/취소/금액)는 전부 `sirsoft-ecommerce`의 `Order`/결제 모델에 +있습니다. 이 플러그인이 자체 모델을 두지 않는 것은 실수나 미완성이 아니라 설계입니다(§AGENTS.md +"이 확장은 무엇인가") — PG 마다 결제 기록 테이블을 따로 두면 "이 주문이 지금 실제로 어떤 +상태인가"를 물을 때 여러 테이블을 조인해야 하고, PG 를 교체하면 과거 주문의 결제 이력을 +조회할 방법이 갈라집니다. + + +## 소유 테이블 + + +_소유 테이블이 없습니다._ + + + +KG 이니시스 고유 정보(MID·서명키 등)는 코어 `PluginSettingsService`(설정 스키마)에, 가상계좌 +계좌정보·CBT 승인 정보 같은 거래별 데이터는 이커머스 주문/결제 레코드의 JSON 컬럼 또는 +연관 필드에 함께 저장됩니다 — 이 플러그인이 그 값을 "소유"하지 않고 이커머스 테이블에 +"기록"만 남기는 형태입니다. + + +## 마이그레이션 + + +_마이그레이션이 없습니다._ + + + +소유 테이블이 없으므로 마이그레이션도 없습니다. 이 플러그인이 설정 스키마를 바꿀 때는 +마이그레이션이 아니라 `config/settings/defaults.json`(§settings.md)과 필요 시 업그레이드 +스텝(과거 설정값 정정용)을 씁니다. + + +## Enum + + +_Enum 이 없습니다._ + + + +결제수단·상태 분류(카드/계좌이체/가상계좌/휴대폰 등)는 이 플러그인이 아니라 이커머스가 소유한 +결제수단 Enum 을 그대로 따릅니다 — PG 마다 결제수단 이름을 다시 정의하면 이커머스가 PG 를 +교체 가능한 형태로 다룰 수 없습니다. KG 이니시스 API 고유의 코드값(예: `acceptmethod` 문자열 +조합)은 Enum 이 아니라 KG 이니시스 API 스펙에 맞춘 문자열 상수로만 존재합니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `CbtCvsOperationsRepository` | 구현 | - | +| `CbtCvsOperationsRepositoryInterface` | 인터페이스 | - | +| `CbtReconciliationRepository` | 구현 | - | +| `CbtReconciliationRepositoryInterface` | 인터페이스 | - | + + + +두 Repository 모두 자체 테이블이 아니라 `sirsoft-ecommerce`의 `Order` 모델을 조회합니다 +(`CbtCvsOperationsRepository::findOrderWithPayment()`가 대표적 예). 소유 데이터가 없는데도 +Repository 인터페이스를 쓰는 이유는 "이 플러그인이 이커머스 데이터에 접근하는 지점"을 +Service 안에 흩어진 쿼리가 아니라 한 곳으로 모아, 나중에 이커머스의 주문 조회 방식이 바뀌어도 +이 두 클래스만 고치면 되게 하기 위해서입니다 — 일반적인 "내 테이블 CRUD" Repository 와는 +쓰임이 다릅니다. + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/editor-spec.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/editor-spec.md new file mode 100644 index 00000000..16dcd15a --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/editor-spec.md @@ -0,0 +1,117 @@ +# KG 이니시스 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `plugins/_bundled/sirsoft-pay_kginicis/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 1 · 페이지 상태 1 + + + +KG이니시스 결제 플러그인이 소유한 화면은 관리자 설정 하나뿐입니다. 결제 자체는 PG 결제창에서 +일어나고 그 창은 이 확장이 그리는 화면이 아니므로, 편집기가 다룰 표면이 설정 화면으로 +좁혀집니다. + +`스타일 시스템`·`다크 모드 전략` 이 비어 있는 것은 정상입니다 — 화면을 그리는 규칙은 +템플릿이 정하고, 결제 플러그인은 그 위에 자기 설정 항목만 얹습니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `editor-spec.json (인라인)` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 1 | `editor-spec.json (인라인)` | + + + +선언한 것은 `vbank_info` 하나, 그리고 설정 화면의 상태 변종 하나입니다. 다른 설정 항목 +(`settings` 등)은 여러 확장이 공유하는 공용 ID 라 admin 템플릿 스펙이 채웁니다 — +여기에 다시 선언하면 같은 ID 의 샘플이 두 곳에 생기고, 둘이 갈라져도 오류가 나지 +않습니다. + +가상계좌 안내(`vbank_info`)를 굳이 넣은 것은 그 영역이 **결제 수단에 따라 나타났다 +사라지는** 자리라, 샘플이 없으면 편집기에서 그 영역 자체를 볼 수 없기 때문입니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `vbank_info` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 1 | `*/admin/plugins/sirsoft-pay_kginicis/settings` | + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +상태 변종의 범위가 `*/admin/plugins/sirsoft-pay_kginicis/settings` 하나인 것은 이 플러그인이 자기 +설정 화면만 소유하기 때문입니다. 주문·결제 화면은 `sirsoft-ecommerce` 가 소유하므로 +그 화면의 프리뷰는 이커머스 스펙이 그립니다. + +결제 흐름을 편집기에서 확인하려던 것이라면 이 문서가 아니라 이커머스 모듈의 편집기 스펙 +문서를 봅니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan plugin:update sirsoft-pay_kginicis --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +이 플러그인의 결제 수단이 이커머스 관리자 화면에 어떻게 보이는지는 편집기 스펙이 아니라 +결제수단 카탈로그 선언(`needs_pg`·`pg_provider`·`pg_locked`)이 정합니다. 배지가 이상하게 +보인다면 스펙이 아니라 그 선언을 봅니다. + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md new file mode 100644 index 00000000..4c74751f --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md @@ -0,0 +1,178 @@ +# KG 이니시스 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 6종 / 호출 지점 6곳. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-pay_kginicis.payment.after_authorize` | action | KG 이니시스 서버 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:247` | +| `sirsoft-pay_kginicis.payment.after_cancel` | action | KG 이니시스 결제 취소 완료 후 | `src/Services/KgInicisApiService.php:780` | +| `sirsoft-pay_kginicis.payment.after_cbt_refund` | action | KG 이니시스 일본 CBT 결제 취소 완료 후 | `src/Services/KgInicisApiService.php:592` | +| `sirsoft-pay_kginicis.payment.before_authorize` | action | KG 이니시스 서버 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:243` | +| `sirsoft-pay_kginicis.payment.before_cancel` | action | KG 이니시스 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/KgInicisApiService.php:758` | +| `sirsoft-pay_kginicis.payment.before_cbt_refund` | action | KG 이니시스 일본 CBT 결제 취소 API 호출 전 | `src/Services/KgInicisApiService.php:564` | + + + +일반 결제(승인/취소)와 CBT(일본)가 각각 별도 `before/after_cbt_refund` 훅 쌍을 갖는 이유는 +두 흐름이 서로 다른 API·통화·해시 체계를 쓰기 때문입니다 — 하나로 합치면 구독자가 매번 +"이게 CBT 인지 일반인지"를 페이로드로 분기해야 합니다. `before_cancel`은 발행 위치 설명에 +"본인인증 등 확장 지점"이라고 명시돼 있습니다 — 고액 취소에 관리자 재인증을 강제하고 싶은 +확장은 이 훅에서 예외를 던져 PG 호출 자체를 막을 수 있습니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 20 | +| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 | +| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailTestModeLayoutListener` | `ensureTestModeLayout` | 65 | +| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 | +| `core.plugin_settings.before_save` | action (미선언) | `ValidateCbtSettingsListener` | `validateBeforeSave` | 10 | +| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 | +| `sirsoft-ecommerce.cash_receipt.cancel` | filter | `RegisterCashReceiptProviderListener` | `cancel` | 10 | +| `sirsoft-ecommerce.cash_receipt.issue` | filter | `RegisterCashReceiptProviderListener` | `issue` | 10 | +| `sirsoft-ecommerce.cash_receipt.registered_providers` | filter | `RegisterCashReceiptProviderListener` | `registerProvider` | 10 | +| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 | +| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 | +| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 | +| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 | +| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 20 | + + + +`core.layout_extension.after_apply`를 구독하는 3개 리스너(`Ensure*LayoutListener`)가 서로 +다른 우선순위(60/65/66)를 갖는 것은 우연이 아닙니다 — 관리자 주문 목록/상세 레이아웃에 여러 +확장이 조각을 주입할 수 있어, 이 플러그인의 조각들이 서로 겹치지 않는 순서로 배치되도록 +번호를 나눠 씁니다. `sirsoft-ecommerce.payment.refund`를 구독하는 두 리스너의 우선순위 +(`PaymentRefundListener`=10, `CancelActivityLogListener`=20)는 §AGENTS.md 핵심 흐름에서 +설명한 대로 "취소 성공 후에만 로그 기록"을 강제하기 위한 순서입니다 — 뒤바뀌면 실패한 취소도 +로그에 성공처럼 남을 수 있습니다. + + +## 활동 로그 훅 + +> 이 확장이 코어 활동 로그(`activity_logs`)에 기록을 남기기 위해 구독하는 훅 1개입니다. +> 위 「구독 훅」 절이 이 확장의 구독 전량을 싣고, 이 절은 그중 **기록을 남기는 것**만 추립니다. +> 코어 `docs/backend/activity-log-hooks.md` 에는 총계와 이 문서로의 링크만 남습니다(#601). + +> 새 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문, +> 그리고 번들 일본어 팩까지 함께 정의해야 합니다 — **플러그인 lang 파일에 넣으면 action 라벨이 +> 해석되지 않습니다.** (description 본문은 이 확장의 `lang/{ko,en}/activity_log.php` 소유입니다.) + +### 결제 취소 훅 (CancelActivityLogListener) + +**파일**: `plugins/_bundled/sirsoft-pay_kginicis/src/Listeners/CancelActivityLogListener.php` +**총 1훅** + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.payment.refund` | `logCancelConfirmed` | `payment.cancel` | `ResolvesActivityLogType` 로 해석 | Order | + +> 이 훅은 `filter` 이고 우선순위 **20** 입니다. 같은 훅을 구독하는 `PaymentRefundListener`(10)가 +> 먼저 실행돼 실제 취소가 성공한 뒤에야 기록이 남습니다 — 순서가 뒤바뀌면 실패한 취소도 +> 성공처럼 로그에 남습니다. + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` | +| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` | +| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` | +| `EnsureAdminOrderDetailTestModeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailTestModeLayoutListener.php` | +| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` | +| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` | +| `RegisterCashReceiptProviderListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/RegisterCashReceiptProviderListener.php` | +| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` | +| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` | +| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` | +| `ValidateCbtSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateCbtSettingsListener.php` | + + + +`RegisterPgProviderListener`·`RegisterCashReceiptProviderListener`·`RegisterEasyPayMethodsListener` +가 이 플러그인의 "등록" 축입니다 — 이 셋이 없으면 플러그인을 활성화해도 이커머스 화면에서 +KG 이니시스가 보이지 않습니다. 나머지는 전부 부가 UI(`Ensure*LayoutListener`)나 정합성 +검증(`ValidateCbtSettingsListener`)입니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/checkout_payment_error.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +`checkout_payment_error.json`은 체크아웃 화면에 KG 이니시스 특유의 오류 메시지(예: 계약되지 +않은 결제수단 선택 시 안내)를 끼워 넣고, `user_order_show.json`은 주문 상세에 영수증 버튼을 +추가합니다. 관리자 쪽 두 조각(`admin_order_list_test_badge`/`admin_order_payment_query`)은 +"이 주문이 테스트 모드로 결제됐는가"와 "PG 거래 상태를 다시 조회"를 관리자 화면에서 바로 +확인하게 하는 운영 편의 기능입니다 — 실제 결제 로직과는 분리돼 있어 이 조각만 비활성화해도 +결제 자체는 영향받지 않습니다. + + +## 미들웨어 + + +| 미들웨어 | 부착 대상(targets) | 우선순위 | +|---|---|---| +| `InicisNotifyIpWhitelist` | `web.plugins.sirsoft-pay_kginicis.payment.cbt.cvs-notify`, `web.plugins.sirsoft-pay_kginicis.payment.vbank-notify`, `web.plugins.sirsoft-pay_kginicis.payment.mobile.vbank-notify` | - | + + + +가상계좌/CBT 편의점 입금통보 3개 라우트에만 부착되고 결제 승인/취소 콜백 라우트에는 +부착되지 않습니다 — 입금통보는 KG 이니시스 서버가 발신자 인증 수단 없이 단순 POST 로 +호출하므로 IP 로 걸러야 하지만, 승인/취소 콜백은 `authUrl`/`authToken` 자체가 위조 방지 +수단(§AGENTS.md 금지 패턴)이라 별도 IP 제한이 없어도 안전합니다. `local`/`testing` 환경에서는 +이 제한이 우회됩니다 — 개발 중에는 실제 KG 이니시스 IP 대역에서 요청이 오지 않기 때문입니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +결제 진행 상황(승인 대기 등)을 실시간으로 밀어줄 필요가 없습니다 — PC/모바일 결제는 결제창이 +닫히고 콜백이 오는 시점에 화면이 이미 그 페이지에 있고, 가상계좌 입금통보는 방문자가 화면을 +보고 있지 않은 시점에 도착하므로 알림(§알림 정의)이나 다음 방문 시 조회가 더 적절합니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +가상계좌 만료 처리나 CBT 정산 대사(reconciliation) 같은 주기적 점검이 있을 법하지만 +(`CbtReconciliationRepository` 참고), 현재는 관리자가 필요할 때 수동으로 조회·확인하는 +구조입니다 — 자동 스케줄로 상태를 바꾸면 결제 상태 변경 시점을 운영자가 놓칠 수 있습니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +결제 완료/실패 알림은 이 플러그인이 아니라 `sirsoft-ecommerce`의 주문 상태 알림 정의가 +담당합니다 — PG 가 여러 개일 수 있는데 PG 마다 "결제 완료 알림"을 각자 만들면 같은 이벤트에 +대해 서로 다른 알림 정의가 난립합니다. 이 플러그인은 "그 결제가 KG 이니시스를 통했다"는 +사실만 이커머스에 전달하고, 알림 발송은 이커머스가 단일하게 책임집니다. + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/frontend.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/frontend.md new file mode 100644 index 00000000..9e81ca7f --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/frontend.md @@ -0,0 +1,81 @@ +# KG 이니시스 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면 하나뿐입니다 — 체크아웃·주문상세의 +결제 UI는 이 플러그인 소유가 아니라 §레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 +조각)으로 존재합니다. "화면"과 "레이아웃 확장 조각"을 헷갈리면 체크아웃 결제 버튼을 찾으러 +`resources/layouts/`를 뒤지게 되는데, 실제로는 `resources/extensions/`에 있습니다. + + +## 액션 핸들러 + + +핸들러 1개 (정의: `resources/js/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `requestPayment` | `sirsoft-pay_kginicis.requestPayment` | + + + +`requestPayment` 하나로 PC/모바일/CBT 3가지 프로토콜을 전부 처리합니다 — 체크아웃 버튼은 +결제수단·통화가 무엇이든 이 핸들러 하나만 호출하고, PC 결제창을 열지 모바일 폼을 제출할지 +CBT 인증 URL로 이동할지는 핸들러 내부에서 서버 응답(§API `/payment/*/signature`, +`/payment/cbt/hash-data`)에 따라 분기합니다. 레이아웃 JSON 작성자가 결제수단별로 다른 +핸들러를 호출할 필요가 없다는 뜻입니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftKginicis` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftKginicis` 로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록 +진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). `initPlugin()`이 KG 이니시스 결제창 +스크립트(`INIStdPay.js`) 자체를 미리 로드하지 않는 것도 의도입니다 — 그 스크립트는 +`requestPayment` 핸들러가 실제 결제 시도 시점에만 동적으로 로드합니다(모든 방문자가 결제 +페이지에 오는 것은 아니므로 전역 부팅에서 미리 불러올 필요가 없습니다). + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +KG 이니시스가 제공하는 `INIStdPay.js`/모바일 결제창 스크립트는 이 목록에 없습니다 — 그 +스크립트들은 KG 이니시스 CDN 에서 결제 시도 시점에 동적으로 로드되는 제3자 자산이라, 이 +플러그인이 빌드 시 번들링하는 `dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은 +결제창 자체는 KG 이니시스가 그리고, 이 플러그인은 결제 버튼 같은 최소한의 UI만 코어 +컴포넌트로 구성하기 때문입니다. + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/settings.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/settings.md new file mode 100644 index 00000000..ccd4f50c --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/settings.md @@ -0,0 +1,119 @@ +# KG 이니시스 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `is_test_mode` | `boolean` | `true` | 테스트 모드 | +| `test_mid` | `string` | `INIpayTest` | 테스트 가맹점 ID (MID) | +| `test_sign_key` | `string` | `SU5JTElURV9UUklQTEVERVNfS0VZU1RS` | 테스트 사인키 | +| `test_iniapi_key` | `string` | `ItEQKi3rY7uvDS8l` | 테스트 INIAPI 키 | +| `test_iniapi_iv` | `string` | `HYb3yQ4f65QL89==` | 테스트 INIAPI IV | +| `live_mid` | `string` | - | 라이브 가맹점 ID (MID) | +| `live_sign_key` | `string` | - | 라이브 사인키 | +| `live_iniapi_key` | `string` | - | 라이브 INIAPI 키 | +| `live_iniapi_iv` | `string` | - | 라이브 INIAPI IV | +| `test_mobile_hash_key` | `string` | `3CB8183A4BE283555ACC8363C0360223` | 테스트 모바일 해시키 | +| `live_mobile_hash_key` | `string` | - | 라이브 모바일 해시키 | +| `use_escrow` | `boolean` | `false` | 에스크로 결제 활성화 | +| `japan_enabled` | `boolean` | `false` | 일본 결제 활성화 | +| `japan_restrict_jpy_payment_methods` | `boolean` | `false` | JPY 주문 결제수단 제한 | +| `test_japan_sign_key` | `string` | `5AL5Djb1Ipualn0F` | 테스트 일본 CBT 해시키 | +| `live_japan_mid` | `string` | - | 라이브 일본 MID | +| `live_japan_sign_key` | `string` | - | 라이브 일본 CBT 해시키 | +| `japan_merchant_name` | `string` | `サンプルストア` | 일본 결제 가맹점명 | +| `japan_merchant_name_kana` | `string` | `サンプルストア` | 일본 결제 가맹점명 Kana | +| `japan_merchant_name_alphabet` | `string` | `Sample Store` | 일본 결제 가맹점명 영문 | +| `japan_merchant_name_short` | `string` | `サンプル` | 일본 결제 가맹점 약칭 | +| `japan_contact_name` | `string` | `サポート窓口` | 일본 결제 문의처명 | +| `japan_contact_email` | `string` | `support@example.com` | 일본 결제 문의 이메일 | +| `japan_contact_phone` | `string` | `0120-123-456` | 일본 결제 문의 전화번호 | +| `japan_contact_opening_hours` | `string` | `10:00-18:00` | 일본 결제 문의 영업시간 | +| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL | +| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL | +| `easy_pay_allow_with_other_pg` | `boolean` | `false` | 타 PG와 사용가능함 | +| `easy_pay_samsung_pay` | `boolean` | `false` | KG이니시스 삼성페이 사용 | +| `easy_pay_naverpay` | `boolean` | `false` | KG이니시스 네이버페이 사용 | +| `easy_pay_show_brand_button` | `boolean` | `false` | 간편결제 브랜드 버튼 표시 | +| `easy_pay_lpay` | `boolean` | `false` | KG이니시스 L.pay 사용 | +| `easy_pay_kakaopay` | `boolean` | `false` | KG이니시스 카카오페이 사용 | +| `use_credit_point` | `boolean` | `false` | 신용카드 포인트 사용 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +`test_*`/`live_*` 접두어 쌍이 반복되는 것이 이 스키마의 핵심 구조입니다 — 테스트 모드와 +운영 모드가 완전히 다른 자격증명 집합을 쓰기 때문에, 하나의 키를 두고 모드에 따라 값을 +바꾸는 대신 애초에 별도 키로 분리했습니다. 이 덕분에 `is_test_mode` 를 껐다 켰다 해도 각 +모드의 자격증명은 서로 덮어쓰이지 않습니다. 일본(`japan_*`) 설정군이 특히 많은 이유는 +KG 이니시스 CBT 결제창의 `extraData`(가맹점 표시 정보)가 한국 표준결제와 별개의 계약·심사 +단위이기 때문입니다. + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +결제 설정은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — "결제 설정을 볼 수 있는 사람"은 +PG 마다 다시 정의할 이유가 없는 하나의 개념이라, 이 플러그인이 별도 권한을 선언하지 않고 +이커머스의 결제/설정 권한에 얹혀 갑니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해 +접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가 +난립합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_kginicis/...` | +| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_kginicis/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +`api`(Bearer 토큰 인증, 결제창 서명·해시 생성처럼 로그인 사용자가 브라우저에서 직접 호출하는 +엔드포인트)와 `web`(콜백·입금통보처럼 KG 이니시스 서버나 리다이렉트로 도달하는 엔드포인트)이 +분리된 이유는 인증 방식이 다르기 때문입니다 — KG 이니시스는 우리 서비스의 Bearer 토큰을 모르므로 +콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다. 새 KG 이니시스 콜백을 추가할 때는 `web` +쪽에, 프론트엔드가 로그인 상태로 직접 호출하는 기능은 `api` 쪽에 둡니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는 이커머스가 +소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이 플러그인이 다룰 +주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅(`registered_pg_providers` 등)이나 +`Order` 모델 구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화"). + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json b/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json index 067a0feb..89887f19 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-pay_kginicis", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-pay_kginicis", - "version": "1.1.2", + "version": "1.1.3", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-pay_kginicis/package.json b/plugins/_bundled/sirsoft-pay_kginicis/package.json index 90dab81f..1698b9ca 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/package.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-pay_kginicis", - "version": "1.1.2", + "version": "1.1.3", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json index c4301b92..e0627977 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json +++ b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json @@ -5,7 +5,7 @@ "ko": "KG 이니시스", "en": "KG Inicis" }, - "version": "1.1.2", + "version": "1.1.3", "license": "MIT", "github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis", "github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis/blob/main/CHANGELOG.md", diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md b/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md new file mode 100644 index 00000000..beeb9dd9 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/AGENTS.md @@ -0,0 +1,179 @@ +# NHN KCP — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-pay_nhnkcp) — NHN KCP PG 연동(PC CLI 승인/모바일 SOAP/가상계좌/에스크로). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유 +2. 확장 방식: `RegisterPgProviderListener`/`RegisterEasyPayMethodsListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다 +3. 건드리면 안 되는 것: KCP CLI(`bin/pp_cli*`) 호출 인자 사전검증(`assertSafeCliValue`) 생략, 동일 거래번호 콜백 재처리 방지 우회, IP 화이트리스트 미들웨어(`RestrictKcpIp`) 미부착 +4. 작업 위치: `plugins/_bundled/sirsoft-pay_nhnkcp` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-pay_nhnkcp --force` +``` + +## 1. 이 확장은 무엇인가 + + +NHN KCP PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. PC 결제는 KCP CLI +바이너리(`bin/pp_cli*`)를 서버에서 직접 실행해 승인 응답을 받고, 모바일 결제는 SOAP +승인키 발급 후 모바일 결제창으로 폼 이동합니다. 두 프로토콜 모두 `sirsoft-pay_kginicis`(HTTP +API 호출)와 달리 **로컬 프로세스 실행**을 최종 승인 수단으로 쓴다는 점이 이 플러그인 고유의 +설계 축입니다. + +**설계 원칙**: 이 플러그인도 `sirsoft-pay_kginicis`와 마찬가지로 상태를 소유하지 않습니다 +(§data-model.md — 모델·테이블·Repository 0개, kginicis 의 CBT 정산 Repository 2개조차 없음: +이 플러그인은 일본/CBT 결제를 아예 구현하지 않기 때문입니다). 등록은 훅 기반입니다 +(`sirsoft-ecommerce.payment.registered_pg_providers` 필터) — 이커머스 모듈은 이 플러그인의 +존재를 컴파일 타임에 몰라도 됩니다. + +**의도적으로 하지 않는 것**: CLI 실행 권한이 사라진 경우(예: `plugin:update` 가 `_bundled` 의 +0664 권한을 활성 디렉토리로 그대로 복사) 조용히 실패하지 않고 결제 hot path 에서 +`ensureCliExecutable()` 로 0755 자가 복구를 시도합니다 — "결제 버튼을 눌렀는데 원인 불명으로 +9502 오류"라는 상태를 막기 위함입니다. 또한 CLI 인자에 위험 문자·제어문자가 섞이면 그 값을 +정제해서 통과시키지 않고 `NhnKcpApiException` 으로 즉시 거부합니다 — 부분 정제는 안전하다는 +착각을 주면서 실제로는 우회 경로를 남길 수 있습니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nhnkcp --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nhnkcp --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nhnkcp --force` | +| `bin/` | 확장이 실행하는 외부 바이너리·인증서 | 교체 시 OS별 파일과 권한을 함께 확인 (비면 해당 기능 정지) | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**PC 결제 승인**: `PaymentCallbackController`(KCP 결제창이 POST 하는 `enc_data`/`enc_info` +수신) → `sirsoft-pay_nhnkcp.payment.before_confirm` 훅 → `NhnKcpApiService::executeCli()` 가 +OS 판별 후 `executeCliWindows()`/`executeCliLinux()` 로 분기 → CLI 인자 전량 +`assertSafeCliValue()` 사전검증 → `escapeshellarg()` 이중 quoting 후 `exec()` 실행 → +`sirsoft-pay_nhnkcp.payment.after_confirm` 훅 → 이커머스 주문 결제 완료 처리. +`PreventsReplayCallback` 트레이트가 콜백 진입 시점에 동일 `transaction_id` 가 이미 `paid` +상태인지 먼저 확인해 재처리를 조기 차단합니다. + +**모바일 결제 승인**: `/mobile/approval-key` API 호출 → KCP SOAP `approve` 로 승인키·`pay_url` +획득 → 브라우저가 `pay_url` 로 폼 POST → KCP가 `/payment/callback` 으로 결과 POST(PC와 동일 +콜백 엔드포인트 공유) → 이후는 PC 흐름과 합류. + +**가상계좌 입금통보 / 에스크로 공통통보**: KCP 서버가 `/payment/vbank-notify` 또는 +`/payment/escrow-common-notify` 를 직접 호출 → `RestrictKcpIp` 미들웨어가 운영 모드에서 +발신 IP 를 화이트리스트와 대조 → `EscrowCommonNotifyController` 가 `tx_cd`/`cl_status` 조합으로 +4가지 훅(`escrow.purchase_confirmed`/`purchase_cancelled`/`denial_confirmed`/ +`delivery_started`) 중 하나를 분기 발화. 결제 취소는 `PaymentRefundListener`(우선순위 10)가 +먼저 KCP 취소 API 를 호출한 뒤 `CancelActivityLogListener`(우선순위 20)가 그 결과를 활동 +로그에 별도 기록합니다 — `sirsoft-pay_kginicis` 와 동일한 순서 원칙입니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 8개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 8개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +`before_confirm`/`before_cancel` 은 PG(CLI/SOAP) 호출 **전** 개입 지점입니다 — 예를 들어 +`before_cancel` 을 잡아 조건 미충족 시 예외를 던지면 KCP 취소 API 자체가 호출되지 않습니다. +`after_*` 훅은 응답을 받은 뒤 부가효과를 붙이는 자리입니다. 에스크로 훅 4종은 KCP 공통통보의 +`tx_cd`/`cl_status` 조합을 이미 해석해 발화하므로, 구독하는 확장은 원시 통보 파라미터를 +다시 파싱할 필요가 없습니다. 구독 훅의 `core.layout_extension.after_apply` 3건은 관리자 +주문 목록/상세에 "테스트 모드 배지"·"거래 조회 UI"를 레이아웃 확장으로 주입하기 때문입니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_nhnkcp --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_nhnkcp` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] CLI 인자 조립부를 고칠 때 `assertSafeCliValue()` 검증을 모든 인자에 유지 — 인자 하나만 빠져도 그 필드가 injection 통로가 된다 +- [ ] `bin/` 바이너리(OS별 CLI·`pub.key`·WSDL) 교체 시 실행 권한(0755)과 파일 존재를 관리자 설정 화면의 시스템 점검(§API)으로 확인 +- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다 +- [ ] IP 화이트리스트(`RestrictKcpIp`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신 +- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백 및 통보 URL")에도 반영 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_nhnkcp --force` + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| KCP CLI 인자(`site_cd`/`tx_cd`/`enc_data` 등)를 검증 없이 문자열 결합해 `exec()` 에 전달 | `assertSafeCliValue()` 로 위험 문자·제어문자 사전 거부 후 `escapeshellarg()` 로 quoting | 검증을 생략하면 서버가 받은 KCP 응답 값이 그대로 셸 명령 인자가 되어 명령 삽입(command injection)으로 이어질 수 있다 | +| CLI 실행 권한 오류(9502)를 그대로 사용자에게 노출하고 자가 복구를 생략 | `ensureCliExecutable()` 로 결제 hot path 진입 시 0755 자가 복구 시도 | `plugin:update` 가 파일 권한을 0664 로 되돌리는 것은 배포 절차의 부작용이지 운영자 실수가 아니다 — 매 결제 실패로 드러나게 두면 안 된다 | +| 동일 `transaction_id` 콜백을 매번 재처리 | `PreventsReplayCallback::wasAlreadyPaid()` 로 이미 `paid` 상태면 멱등 응답 | KCP 서버의 재전송·사용자의 새로고침으로 같은 콜백이 두 번 오면 결제완료 알림·마일리지가 중복 적립될 수 있다 | +| 에스크로 공통통보의 `tx_cd`/`cl_status` 매핑을 컨트롤러 밖(리스너 등)에서 다시 판정 | `EscrowCommonNotifyController` 의 매핑표(§핵심 흐름)를 SSoT 로 유지 | 판정 로직이 두 곳에 있으면 KCP 가 새 `cl_status` 값을 보낼 때 한쪽만 갱신되어 조용히 어긋난다 | +| 라이브 사이트 키(`live_site_key`)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 요청을 위조할 수 있다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 28개 | `plugins/_bundled/sirsoft-pay_nhnkcp/tests` | +| Vitest | 8개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 1개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_nhnkcp/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-pay_nhnkcp && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index d4b3c23d..ca9a86be 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ## [1.0.3] - 2026-08-22 ### Security diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/README.md b/plugins/_bundled/sirsoft-pay_nhnkcp/README.md index e71edf19..179b5272 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/README.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/README.md @@ -1,30 +1,96 @@ -# NHN KCP Plugin for G7 +# NHN KCP -NHN KCP Standard Pay 결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. +**그누보드7 플러그인 · sirsoft-pay_nhnkcp** +NHN KCP Standard Pay 결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인 -PC 결제는 KCP `payplus_web.jsp` 결제창과 KCP CLI 승인 모듈을 사용하고, 모바일 결제는 SmartPhone Pay SOAP 승인키를 받은 뒤 KCP 모바일 결제창으로 이동합니다. + +

+ version 1.0.4 + type 플러그인 + 그누보드7 >=7.0.10 + license MIT + requires sirsoft-ecommerce +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +NHN KCP Standard Pay 결제를 그누보드7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. PC +결제는 `payplus_web.jsp` 결제창 + 서버의 KCP CLI 승인 모듈을, 모바일 결제는 SmartPhone Pay +SOAP 승인키 발급 + 모바일 결제창을 씁니다. + +`sirsoft-pay_kginicis`와 마찬가지로 이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를 +소유하지 않습니다 — 그 상태는 `sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 +"그 상태를 KCP CLI/SOAP API 와 어떻게 주고받는가"만 책임집니다(§data-model.md). 다른 PG +플러그인과 구별되는 이 플러그인만의 특징은 PC 결제 최종 승인이 HTTP API 호출이 아니라 +**서버에서 실행하는 CLI 바이너리**라는 점입니다 — KCP 가 표준결제 승인 로직을 컴파일된 +실행파일로만 배포하기 때문입니다. + ## 주요 기능 -- 신용카드, 계좌이체, 가상계좌, 휴대폰결제 지원 -- PC Standard Pay 결제창 연동 -- 모바일 SmartPhone Pay 승인키 발급 및 모바일 결제창 연동 -- PAYCO, 네이버페이, 네이버페이 포인트, 카카오페이, Apple Pay 간편결제 버튼 주입 -- 가상계좌 발급, 입금통보, 테스트 모드 모의입금 처리 -- 에스크로 결제, 에스크로 배송 등록, 공통통보 처리 -- 결제 취소 및 부분취소 연동 -- PG 측 결제 취소 확인 시점의 활동 로그 별도 기록 (PG 응답 시각·취소 거래번호 사후 추적) -- 주문 완료/마이페이지 영수증, 현금영수증 조회 버튼 주입 -- 관리자 주문 상세의 KCP 거래 정보 표시 -- 관리자 설정 화면의 KCP 실행 환경 점검 + +| 영역 | 설명 | +|---|---| +| 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 | +| 간편결제 | PAYCO, 네이버페이, 네이버페이 포인트, 카카오페이, Apple Pay 버튼 주입 | +| PC 결제 | `payplus_web.jsp` 표준결제창 + 서버 KCP CLI 승인 | +| 모바일 결제 | SmartPhone Pay SOAP 승인키 발급 + 모바일 결제창 | +| 가상계좌 | 발급, 입금통보, 테스트 모드 모의입금 | +| 에스크로 | 결제, 배송 등록, 공통통보(구매확인/구매취소/구매취소확인/배송시작) | +| 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 거래번호) | +| 영수증 | 주문 완료/마이페이지 영수증, 현금영수증 조회 버튼 | +| 관리자 확장 | 주문 상세 KCP 거래 정보 표시, KCP 실행 환경(CLI/SOAP) 점검 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[체크아웃 주문 생성] -->|PC| B["payplus_web.jsp 결제창 iframe 로드"] + A -->|모바일| C["/mobile/approval-key 호출 → SOAP 승인키 발급"] + B --> D["/payment/callback (enc_data·enc_info)"] + C --> E["모바일 결제창 → /payment/callback"] + D --> F[서버가 KCP CLI 실행해 승인 확인] + E --> F + F --> G[주문 결제 완료 처리] + G --> H[성공 URL 리다이렉트] +``` + +PC 결제 승인은 `NhnKcpApiService`가 OS 를 판별해 `pp_cli`/`pp_cli_x64`/`pp_cli_exe.exe` 중 +하나를 `exec()`로 실행하는 방식입니다. 모든 CLI 인자는 `assertSafeCliValue()`로 위험 +문자·제어문자를 사전 거부한 뒤 `escapeshellarg()`로 quoting 합니다 — KCP 응답값을 검증 없이 +셸 명령에 넣으면 명령 삽입(command injection) 통로가 됩니다. `PreventsReplayCallback` +트레이트가 콜백 진입 시점에 동일 거래번호가 이미 결제완료 상태인지 확인해 중복 처리를 +막습니다. + +가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, KCP 가 입금통보 URL로 +결과를 POST 하면 금액 검증 후 결제 완료 처리됩니다. 에스크로는 KCP 공통통보의 `tx_cd`/ +`cl_status` 조합을 해석해 구매확인/구매취소/구매취소확인/배송시작 4가지 훅으로 분기 +발화합니다. + ## 요구 사항 -| 항목 | 내용 | -|------|------| -| G7 | `>= 7.0.0-beta.2` | -| 의존 모듈 | `sirsoft-ecommerce >= 1.0.0-beta.5` | + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | | PHP | `^8.2` | +| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` | + + + +| 항목 | 필요한 것 | +|---|---| | PC 결제 | PHP `exec()` 사용 가능, KCP CLI 바이너리, `pub.key` | | 모바일 결제 | PHP SOAP 확장, KCP WSDL 파일 | | 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KCP 가맹점 계약 정보 | @@ -47,61 +113,70 @@ chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli_x64 ``` -관리자 설정 화면의 시스템 점검 API가 실행 권한을 자동 복구할 수 있지만, 서버 권한 정책에 따라 직접 조치가 필요할 수 있습니다. +관리자 설정 화면의 시스템 점검 API 와 결제 hot path 의 자가 복구(`ensureCliExecutable()`)가 +실행 권한을 자동 복구할 수 있지만, 서버 권한 정책에 따라 직접 조치가 필요할 수 있습니다. + ## 설치 -플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다. - -```text -plugins/sirsoft-pay_nhnkcp -``` - -프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다. - + ```bash -npm install -npm run build +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-pay_nhnkcp + +# 활성화 +php artisan plugin:activate sirsoft-pay_nhnkcp + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-pay_nhnkcp --force ``` -그다음 G7 관리자에서 플러그인을 활성화하고, 이커머스 결제 설정에서 PG 제공자를 `NHN KCP`로 선택합니다. +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp + + +설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "NHN KCP"로 선택해야 실제로 결제 흐름에 +연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다. ## 관리자 설정 -관리자 플러그인 설정 화면에서 KCP 계약 정보를 입력합니다. + +| 키 | 의미 | 기본값 | +|---|---|---| +| `is_test_mode` | 테스트 모드 | `true` | +| `test_site_cd` | 테스트 사이트 코드 (site_cd) | `T0000` | +| `test_site_key` | 테스트 사이트 키 (site_key) | - | +| `live_site_cd` | 라이브 사이트 코드 (site_cd) | - | +| `live_site_key` | 라이브 사이트 키 (site_key) | - | +| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` | +| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` | +| `use_escrow` | 에스크로 결제 활성화 | `false` | +| `escrow_test_site_cd` | 테스트 에스크로 사이트 코드 | - | +| `vbank_expire_days` | 가상계좌 입금 만료(일) | `3` | +| `easy_pay_allow_with_other_pg` | - | `false` | +| `easy_pay_payco` | - | `false` | +| `easy_pay_naverpay` | - | `false` | +| `easy_pay_naverpay_point` | - | `false` | +| `easy_pay_kakaopay` | - | `false` | +| `easy_pay_applepay` | - | `false` | -| 설정 | 설명 | -|------|------| -| 테스트 모드 | 활성화 시 KCP 테스트 환경을 사용합니다. | -| 테스트 사이트 코드 | 기본값은 `T0000`입니다. | -| 테스트 사이트 키 | KCP 테스트 site key입니다. | -| 라이브 사이트 코드 | 운영 site code입니다. `SR` prefix 없이 입력해도 플러그인이 자동 보정합니다. | -| 라이브 사이트 키 | 운영 site key입니다. 외부에 노출하지 마세요. | -| 결제 성공 URL | 기본값은 `/shop/orders/{orderId}/complete`입니다. | -| 결제 실패 URL | 기본값은 `/shop/checkout`입니다. | -| 가상계좌 입금 만료일 | 가상계좌 발급 후 입금 가능 기간입니다. | -| 에스크로 결제 사용 | 활성화 시 KCP 에스크로 결제 파라미터를 함께 전달합니다. | -| 에스크로 테스트 사이트 코드 | 테스트 에스크로 site code입니다. 기본 fallback은 `T0007`입니다. | -| 간편결제 | KCP 계약이 완료된 간편결제만 활성화하세요. | -| 타 PG와 사용가능함 | 다른 PG가 기본값이어도 KCP 간편결제 버튼을 체크아웃 화면에 표시합니다. | +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + -PAYCO 테스트 결제는 내부 기본값으로 간편결제 테스트 site code `S6729`를 사용합니다. + +라이브 사이트 키는 외부에 노출하지 마세요. 배포 전 테스트 모드가 의도한 값인지 반드시 +확인하세요. -## 콜백 및 통보 URL - -KCP 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제 운영 도메인으로 바꿔 입력하세요. +**콜백 및 통보 URL 등록** — KCP 가맹점 관리자에 아래 URL을 실제 운영 도메인으로 등록합니다. | 용도 | URL | -|------|-----| -| 결제 결과 Return URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/callback` | -| 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify` | -| 에스크로 공통통보 URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify` | +|---|---| +| 결제 결과 Return URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/callback` | +| 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify` | +| 에스크로 공통통보 URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify` | -결제 결과 Return URL은 브라우저가 POST하는 경로이므로 IP 제한을 적용하지 않습니다. 가상계좌 입금통보와 에스크로 공통통보는 KCP 서버가 직접 호출하므로 운영 모드에서 IP 화이트리스트를 적용합니다. - -## IP 화이트리스트 - -운영 모드에서는 아래 IP에서 들어온 KCP 서버 통보만 허용합니다. 테스트 모드에서는 개발과 KCP testadmin 모의입금을 위해 IP 제한을 우회합니다. +결제 결과 Return URL은 브라우저가 POST하는 경로이므로 IP 제한을 적용하지 않습니다. 가상계좌 +입금통보와 에스크로 공통통보는 KCP 서버가 직접 호출하므로 운영 모드에서 아래 IP +화이트리스트를 적용합니다(테스트 모드에서는 개발·KCP testadmin 모의입금을 위해 우회). | IP | |----| @@ -116,49 +191,22 @@ KCP 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제 | `210.122.72.173` | 운영 전 KCP 가맹점 관리자와 최신 연동 가이드의 통보 서버 IP를 다시 확인하세요. + -## 결제 흐름 +## 사용 방법 -### PC 결제 + +**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가 +`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, 이 플러그인의 `PaymentRefundListener` +가 KCP 취소 API를 호출합니다(전액취소는 `isPartial=false`, 부분취소는 `isPartial=true` + +원래 결제금액). 배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, +쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후)이 PG `cancelAmt`로 전달됩니다. 부분취소로 +쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부(422)해 PG 호출이 +아예 발생하지 않습니다. KCP API 호출이 실패하면 주문 상태 변경이 롤백됩니다. -```text -체크아웃 주문 생성 -→ 프론트엔드 핸들러가 payplus_web.jsp 결제창 실행 -→ KCP가 /payment/callback 으로 enc_data, enc_info POST -→ 서버가 KCP CLI로 승인 확인 -→ 주문 결제 완료 처리 -→ 성공 URL로 리다이렉트 -``` - -### 모바일 결제 - -```text -체크아웃 주문 생성 -→ /api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key 호출 -→ 서버가 KCP SOAP approve 로 approval_key, pay_url 획득 -→ 브라우저가 pay_url 로 form POST -→ KCP가 /payment/callback 으로 결과 POST -→ 주문 결제 완료 처리 -→ 성공 URL로 리다이렉트 -``` - -### 가상계좌 - -```text -결제창에서 가상계좌 발급 -→ 주문 결제 정보에 은행, 계좌번호, 예금주, 만료일 저장 -→ 주문은 입금대기 상태 유지 -→ KCP가 /payment/vbank-notify 로 입금통보 POST -→ 입금 금액 검증 후 주문 결제 완료 처리 -``` - -테스트 모드에서는 마이페이지 주문 상세에 KCP testadmin 모의입금 폼이 표시될 수 있습니다. - -### 에스크로 - -에스크로를 활성화하면 결제 요청에 `escw_used=Y`, `pay_mod=O`를 전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 운송장번호와 택배사를 입력해 KCP 배송 등록을 호출할 수 있습니다. - -KCP 공통통보는 아래 이벤트를 처리합니다. +**에스크로 처리**: 에스크로를 활성화하면 결제 요청에 `escw_used=Y`, `pay_mod=O`를 +전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 운송장번호와 택배사를 입력해 KCP +배송 등록을 호출할 수 있습니다. KCP 공통통보는 아래 이벤트를 처리합니다. | tx_cd | 조건 | 처리 | |-------|------|------| @@ -167,82 +215,65 @@ KCP 공통통보는 아래 이벤트를 처리합니다. | `TX02` | `cl_status=3` | 구매취소 확인 훅 실행 | | `TX03` | - | 배송시작 훅 실행 | -### 결제 취소 / 부분취소 +**가상계좌 모의입금**: 테스트 모드에서는 마이페이지 주문 상세에 KCP testadmin 모의입금 +폼이 표시될 수 있습니다. -```text -관리자 주문 취소 요청 (cancel_pg=true) -→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화 -→ PaymentRefundListener 가 KCP cancelPayment API 호출 - · 전액취소: isPartial=false - · 부분취소: isPartial=true, totalAmt=원래 결제금액 -→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원 -→ CancelActivityLogListener 가 PG 응답 시각·취소 거래번호를 활동 로그에 기록 -``` +전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은 +[docs/extension-points.md](docs/extension-points.md) 를 참고하세요. + -배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG cancelAmt 로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. KCP API 호출이 실패하면 주문 상태 변경이 롤백됩니다. +## 다른 확장과의 연동 -## API + +**이 확장이 의존하는 확장** -### 사용자 API +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | -| Method | Path | 설명 | -|--------|------|------| -| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/receipt` | KCP 영수증, 현금영수증 URL 조회 | -| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/vbank-mock-deposit-info` | 테스트 모드 가상계좌 모의입금 정보 조회 | -| `POST` | `/api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key` | 모바일 결제 승인키 발급 | +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) -### 관리자 API +없음. + -| Method | Path | 설명 | -|--------|------|------| -| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/vbank-notify-url` | 가상계좌/에스크로 통보 URL 조회 | -| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/test-mode-map` | 주문목록 테스트 모드 배지용 맵 조회 | -| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status` | 저장된 KCP 거래 정보 조회 | -| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | 에스크로 배송 등록 폼 데이터 조회 | -| `POST` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | KCP 에스크로 배송 등록 | -| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/health` | KCP 실행 환경 점검 | + +`RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에, +`RegisterEasyPayMethodsListener`가 간편결제 결제수단 레지스트리에 각각 등록합니다 — PG +결제사 선택과 간편결제 노출은 서로 독립적이라, 다른 PG가 기본값이어도 KCP 간편결제 버튼을 +체크아웃 화면에 노출하는 조합이 가능합니다(`easy_pay_allow_with_other_pg`). + -## 훅 +## 문서 -다른 모듈이나 플러그인에서 아래 훅에 연결해 결제 흐름을 확장할 수 있습니다. + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + -| 훅 | 타입 | 시점 | -|----|------|------| -| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP CLI 승인 확인 전 | -| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP CLI 승인 확인 후 | -| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 취소 API 호출 전 | -| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 취소 API 호출 후 | -| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | 에스크로 구매확인 통보 수신 | -| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | 에스크로 구매취소 통보 수신 | -| `sirsoft-pay_nhnkcp.escrow.denial_confirmed` | action | 에스크로 구매취소 확인 통보 수신 | -| `sirsoft-pay_nhnkcp.escrow.delivery_started` | action | 에스크로 배송시작 통보 수신 | +## 트러블슈팅 -## 보안 및 운영 참고 + +| 증상 | 원인 | 조치 | +|---|---|---| +| 결제 승인 시 res_cd=9502 오류 | `plugin:update` 가 CLI 바이너리 실행 권한을 0664 로 되돌림 | 관리자 설정 화면의 시스템 점검을 실행하거나 `chmod 755` 로 직접 복구 | +| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 KCP 통보 서버 IP가 없음 | 최신 연동 가이드의 통보 서버 IP로 화이트리스트를 갱신 | +| 결제 요청이 CLI 인자 오류로 거부됨 | 주문번호·인코딩 데이터 등에 위험 문자/제어문자 포함 | `assertSafeCliValue()`가 의도적으로 거부한 것 — 원인 값을 정제하지 말고 왜 그런 값이 만들어졌는지 상위 데이터를 확인 | +| 모바일 결제 승인키 발급 실패 | PHP SOAP 확장 미설치 또는 WSDL 파일 누락 | `php -m`으로 soap 확장 확인, `bin/*.wsdl` 존재 확인 | +| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | KCP 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 | + -- 운영 도메인의 `APP_URL`을 HTTPS 절대 URL로 정확히 설정하세요. -- 운영 site key는 외부에 노출하지 마세요. -- 운영 모드에서는 KCP 서버 통보 IP 화이트리스트가 적용됩니다. -- 결제 승인 후 서버 후속 처리에 실패하면 PG 잔존 승인을 자동 취소합니다. -- 동일 거래번호 콜백은 중복 처리하지 않도록 방어합니다. -- KCP CLI 호출 인자는 위험 문자와 제어문자를 사전에 거부합니다. -- 가상계좌와 에스크로 통보 URL은 KCP 가맹점 관리자에 반드시 등록해야 합니다. -- KCP 계약이 없는 결제수단이나 간편결제를 활성화하면 KCP 오류가 발생할 수 있습니다. +## 변경 이력 -## 테스트 - -플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다. - -```bash -php artisan test plugins/sirsoft-pay_nhnkcp/tests -``` - -프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다. - -```bash -npm install -npm run test:run -npm run build -``` +[CHANGELOG.md](CHANGELOG.md) ## 라이선스 diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json b/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json index d866151a..861610a0 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-pay_nhnkcp", "description": "NHN KCP PG Plugin for G7 platform", - "version": "1.0.3", + "version": "1.0.4", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md new file mode 100644 index 00000000..d682a3ed --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/README.md @@ -0,0 +1,23 @@ +# NHN KCP 개발자 문서 + +> plugins/_bundled/sirsoft-pay_nhnkcp · 플러그인 + + +**훅 수**: 8 · **구독 훅 수**: 9 · **라우트 수**: 16 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 3 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/architecture.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/architecture.md new file mode 100644 index 00000000..c11875ba --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/architecture.md @@ -0,0 +1,63 @@ +# NHN KCP — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +NHN KCP 표준결제(Standard Pay)를 `sirsoft-ecommerce` 에 연결하는 어댑터입니다. 다른 PG +플러그인(`sirsoft-pay_kginicis`, `sirsoft-tosspayments` 등)이 전부 HTTP API 로 승인을 +받는 것과 달리, 이 플러그인의 PC 결제 승인은 **서버에서 KCP CLI 바이너리를 실행**하는 +방식입니다 — KCP 가 표준결제 승인 로직을 컴파일된 실행파일로만 배포하기 때문입니다. 이 +차이가 데이터 모델(§data-model.md — Repository 조차 없는 이유), 확장점(CLI 실행 권한 +점검 API), 금지 패턴(CLI 인자 injection 방어) 전체에 스며 있습니다. + +이 플러그인도 결제 상태 자체는 소유하지 않습니다 — 주문·결제 테이블은 `sirsoft-ecommerce` +소유이고, 이 플러그인은 "그 상태를 KCP CLI/SOAP API 와 어떻게 주고받는가"만 책임집니다. + + +## 계층 지도 + + +```text +Controller (PaymentCallbackController / EscrowCommonNotifyController / MobileApprovalController) + → NhnKcpApiService (CLI 실행 · SOAP 호출 · 인자 사전검증) + → sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음) + +Listener (RegisterPgProviderListener 등) + → sirsoft-ecommerce 의 필터 훅에 등록 (컴파일 타임 결합 없음) +``` + +이 플러그인에는 FormRequest 계층이 얕습니다 — 결제 승인·통보 콜백은 사용자가 채운 폼이 +아니라 KCP 가 보내는 고정 스키마이므로, 검증의 대부분은 `NhnKcpApiService`의 +`assertSafeCliValue()`(CLI 인자 안전성)와 `PreventsReplayCallback`(중복 콜백 방지)이 +담당합니다. 일반 CRUD 플러그인의 "Controller → FormRequest → Service → Repository → Model" +5단 계층과 다른 이유가 여기 있습니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nhnkcp --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nhnkcp --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nhnkcp --force` | +| `bin/` | 확장이 실행하는 외부 바이너리·인증서 | 교체 시 OS별 파일과 권한을 함께 확인 (비면 해당 기능 정지) | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/data-model.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/data-model.md new file mode 100644 index 00000000..8af8563c --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/data-model.md @@ -0,0 +1,69 @@ +# NHN KCP — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +_소유 모델이 없습니다._ + + + +결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이 +소유합니다(§AGENTS.md "설계 원칙"). 이 플러그인은 그 모델을 직접 참조해 읽고 쓸 뿐, 자기 +Repository 조차 두지 않았습니다(§Repository) — `sirsoft-pay_kginicis`가 CBT(일본) 정산용 +Repository 2개를 갖는 것과 달리, 이 플러그인은 일본/CBT 결제를 아예 구현하지 않으므로 +그 계층이 존재할 이유가 없습니다. + + +## 소유 테이블 + + +_소유 테이블이 없습니다._ + + + +가상계좌 발급 정보(은행·계좌번호·예금주·만료일)와 KCP 거래 정보(`tno` 등)는 이커머스 +`OrderPayment` 테이블의 기존 컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면 +관리자 주문 상세가 PG 종류에 따라 다른 테이블을 조인해야 해 화면 로직이 PG 개수만큼 +분기합니다. + + +## 마이그레이션 + + +_마이그레이션이 없습니다._ + + + +소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 이 플러그인의 +설정 스키마 변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB +마이그레이션 대상이 아닙니다. + + +## Enum + + +_Enum 이 없습니다._ + + + +KCP 공통통보의 `tx_cd`/`cl_status` 값은 Enum 대신 `EscrowCommonNotifyController`(§extension-points.md +"핵심 흐름"의 매핑표)의 조건 분기로 직접 처리합니다 — 이 값들은 이 플러그인 코드 어디에도 +재사용되지 않는 KCP 고유 프로토콜 상수라, Enum 으로 승격해도 얻는 타입 안전성 대비 간접 +계층만 늘어납니다. + + +## Repository + + +_Repository 가 없습니다._ + + + +이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너·`Concerns` +트레이트)은 모두 이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유 +테이블이 없는 상태에서 남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는 +빈 계층입니다. `sirsoft-pay_kginicis`의 CBT Repository 2개는 이 플러그인에는 없는 +일본 결제 전용 정산 데이터(자체 소유 테이블)를 다루기 위한 것이라 대칭이 아닙니다. + diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/editor-spec.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/editor-spec.md new file mode 100644 index 00000000..4dcd35c5 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/editor-spec.md @@ -0,0 +1,117 @@ +# NHN KCP — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `plugins/_bundled/sirsoft-pay_nhnkcp/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 2 · 페이지 상태 1 + + + +NHN KCP 결제 플러그인이 소유한 화면은 관리자 설정 하나뿐입니다. 결제 자체는 PG 결제창에서 +일어나고 그 창은 이 확장이 그리는 화면이 아니므로, 편집기가 다룰 표면이 설정 화면으로 +좁혀집니다. + +`스타일 시스템`·`다크 모드 전략` 이 비어 있는 것은 정상입니다 — 화면을 그리는 규칙은 +템플릿이 정하고, 결제 플러그인은 그 위에 자기 설정 항목만 얹습니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 2 | `editor-spec.json (인라인)` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 1 | `editor-spec.json (인라인)` | + + + +선언한 것은 `vbank_info` 와 연동 점검용 `health` 둘, 그리고 설정 화면의 상태 변종 하나입니다. 다른 설정 항목 +(`settings` 등)은 여러 확장이 공유하는 공용 ID 라 admin 템플릿 스펙이 채웁니다 — +여기에 다시 선언하면 같은 ID 의 샘플이 두 곳에 생기고, 둘이 갈라져도 오류가 나지 +않습니다. + +가상계좌 안내(`vbank_info`)를 굳이 넣은 것은 그 영역이 **결제 수단에 따라 나타났다 +사라지는** 자리라, 샘플이 없으면 편집기에서 그 영역 자체를 볼 수 없기 때문입니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 2 | `vbank_info` · `health` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 1 | `*/admin/plugins/sirsoft-pay_nhnkcp/settings` | + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +상태 변종의 범위가 `*/admin/plugins/sirsoft-pay_nhnkcp/settings` 하나인 것은 이 플러그인이 자기 +설정 화면만 소유하기 때문입니다. 주문·결제 화면은 `sirsoft-ecommerce` 가 소유하므로 +그 화면의 프리뷰는 이커머스 스펙이 그립니다. + +결제 흐름을 편집기에서 확인하려던 것이라면 이 문서가 아니라 이커머스 모듈의 편집기 스펙 +문서를 봅니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan plugin:update sirsoft-pay_nhnkcp --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +이 플러그인의 결제 수단이 이커머스 관리자 화면에 어떻게 보이는지는 편집기 스펙이 아니라 +결제수단 카탈로그 선언(`needs_pg`·`pg_provider`·`pg_locked`)이 정합니다. 배지가 이상하게 +보인다면 스펙이 아니라 그 선언을 봅니다. + diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md new file mode 100644 index 00000000..a5d98e68 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md @@ -0,0 +1,171 @@ +# NHN KCP — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 8종 / 호출 지점 8곳. 이 중 4종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-pay_nhnkcp.escrow.delivery_started` | action | — | `src/Controllers/EscrowCommonNotifyController.php:101` | +| `sirsoft-pay_nhnkcp.escrow.denial_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:98` | +| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | — | `src/Controllers/EscrowCommonNotifyController.php:97` | +| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:96` | +| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 결제 취소 완료 후 | `src/Services/NhnKcpApiService.php:250` | +| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:279` | +| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NhnKcpApiService.php:229` | +| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:274` | + + + +`escrow.*` 4종에 `유형`/`설명`이 비어 있는 것은 실수가 아니라 선언 누락입니다 — 소스에서 +자동 감지된 훅이라 `getHooks()`에 등록하면 이름 그대로도 의미가 분명해 설명을 생략했습니다. +`before_confirm`/`before_cancel`은 KCP API 호출 **전** 개입 지점이라 여기서 예외를 던지면 +실제 KCP 호출 자체가 일어나지 않습니다(예: 고액 결제에 추가 인증을 요구하고 싶은 확장이 +`before_cancel`에서 조건 미충족 시 예외). `after_*`는 응답을 받은 뒤 부가효과(로그, 알림)를 +붙이는 자리입니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 30 | +| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 | +| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 | +| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 | +| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 | +| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 | +| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 | +| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 | +| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 30 | + + + +`RegisterPgProviderListener` 가 우선순위 10 으로 두 훅(`get_client_config`/ +`registered_pg_providers`)을 모두 구독하는 이유는 "이 PG 가 존재한다는 사실"과 "체크아웃 +화면이 필요로 하는 클라이언트 설정값"이 같은 리스너의 책임이기 때문입니다 — 등록과 설정 +노출이 다른 리스너로 갈라지면 한쪽만 갱신되는 사각이 생깁니다. `PaymentRefundListener`(10) +가 `CancelActivityLogListener`(20)보다 먼저 실행되도록 우선순위를 명시한 것은 실제 취소가 +성공한 뒤에야 활동 로그를 남기기 위함입니다 — 순서가 뒤바뀌면 "로그는 있는데 취소는 실패"가 +생깁니다. + + +## 활동 로그 훅 + +> 이 확장이 코어 활동 로그(`activity_logs`)에 기록을 남기기 위해 구독하는 훅 1개입니다. +> 위 「구독 훅」 절이 이 확장의 구독 전량을 싣고, 이 절은 그중 **기록을 남기는 것**만 추립니다. +> 코어 `docs/backend/activity-log-hooks.md` 에는 총계와 이 문서로의 링크만 남습니다(#601). + +> 새 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문, +> 그리고 번들 일본어 팩까지 함께 정의해야 합니다 — **플러그인 lang 파일에 넣으면 action 라벨이 +> 해석되지 않습니다.** (description 본문은 이 확장의 `lang/{ko,en}/activity_log.php` 소유입니다.) + +### 결제 취소 훅 (CancelActivityLogListener) + +**파일**: `plugins/_bundled/sirsoft-pay_nhnkcp/src/Listeners/CancelActivityLogListener.php` +**총 1훅** + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.payment.refund` | `logCancelConfirmed` | `payment.cancel` | `ResolvesActivityLogType` 로 해석 | Order | + +> 이 훅은 `filter` 이고 우선순위 **20** 입니다. 같은 훅을 구독하는 `PaymentRefundListener`(10)가 +> 먼저 실행돼 실제 취소가 성공한 뒤에야 기록이 남습니다 — 순서가 뒤바뀌면 실패한 취소도 +> 성공처럼 로그에 남습니다. + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` | +| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` | +| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` | +| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` | +| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` | +| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` | +| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` | +| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` | + + + +`RestoreLayoutExtensionsAfterUpdateListener`가 존재하는 이유는 `plugin:update`가 레이아웃 +확장 조각(§레이아웃 확장)의 활성/비활성 상태를 초기화할 수 있어서입니다 — 운영자가 특정 +화면(예: 테스트배지)을 꺼둔 상태로 플러그인을 업데이트해도 그 선택이 사라지지 않도록 +업데이트 직후 복원합니다. 8개 리스너 전부가 `HookListenerInterface`를 구현하는 것은 +auto-discovery 대상이라는 뜻이 아니라 이 저장소의 전 리스너 공통 계약입니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/checkout_easy_pay.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user_order_complete_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +5개 조각은 각각 독립적인 화면 관심사입니다 — 관리자 주문 목록의 테스트배지, 관리자 주문 +상세의 거래조회 UI, 체크아웃의 간편결제 버튼, 주문완료/마이페이지의 영수증 버튼이 서로 +다른 화면·다른 컴포넌트 트리에 주입되므로 하나의 조각으로 합치지 않았습니다. 새 KCP 기능이 +필요로 하는 화면이 이 5개 중 하나에 해당하면 새 조각을 만들지 말고 기존 조각을 확장합니다. + + +## 미들웨어 + + +| 미들웨어 | 부착 대상(targets) | 우선순위 | +|---|---|---| +| `RestrictKcpIp` | `web.plugins.sirsoft-pay_nhnkcp.payment.vbank-notify`, `web.plugins.sirsoft-pay_nhnkcp.payment.escrow-common-notify` | - | + + + +결제 결과 Return URL(`/payment/callback`)에는 이 미들웨어가 붙지 않습니다 — 그 경로는 +브라우저가 POST 하는 경로라 발신 IP 가 사용자마다 다르기 때문입니다. IP 화이트리스트가 +의미 있는 것은 KCP 서버가 직접 호출하는 두 통보 경로(가상계좌 입금통보·에스크로 공통통보) +뿐입니다. 테스트 모드에서는 개발 편의와 KCP testadmin 모의입금을 위해 이 제한을 우회합니다 +— 운영 모드로 전환할 때 이 우회가 함께 꺼지는지 확인해야 합니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +결제 승인·통보는 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가 +필요한 지점이 없습니다 — 가상계좌 입금통보조차 KCP 서버의 POST 요청 하나로 완결됩니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +가상계좌 만료 처리(§settings.md `vbank_expire_days`)는 이 플러그인이 크론으로 직접 만료 +스캔을 하지 않고, 만료 이후 도착하는 KCP 입금통보를 거부하는 방식으로 처리됩니다 — 별도 +스케줄 작업이 필요 없습니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미 +포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트(결제완료)에 대해 PG 수만큼 중복 +알림 정의가 생깁니다. + diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/frontend.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/frontend.md new file mode 100644 index 00000000..c7072db4 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/frontend.md @@ -0,0 +1,85 @@ +# NHN KCP — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +`sirsoft-pay_kginicis`와 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면 +하나뿐입니다 — 체크아웃·주문상세·마이페이지의 결제 UI는 이 플러그인 소유가 아니라 +§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다. + + +## 액션 핸들러 + + +핸들러 3개 (정의: `resources/js/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `requestPayment` | `sirsoft-pay_nhnkcp.requestPayment` | +| `setPaymentMethod` | `sirsoft-pay_nhnkcp.setPaymentMethod` | +| `copyToClipboard` | `sirsoft-pay_nhnkcp.copyToClipboard` | + + + +`sirsoft-pay_kginicis`가 핸들러 1개(`requestPayment`)로 끝나는 것과 달리 이 플러그인은 +3개입니다. `setPaymentMethod`가 별도로 필요한 이유는 KCP 간편결제 버튼(PAYCO/네이버페이/ +카카오페이/Apple Pay)이 레이아웃 컴포넌트가 아니라 KCP 가 제공하는 DOM 을 그대로 쓰기 +때문입니다 — React 상태로 선택 하이라이트를 그리는 대신 DOM 을 직접 조작해 선택된 버튼에 +테두리를 입힙니다(`updateEasyPayButtonStyles`). Apple Pay 는 iOS 모바일이 아니면 여기서 +바로 오류 모달을 띄우고 요청 자체를 막습니다 — KCP 서버까지 보냈다가 거부당하면 사용자가 +결제 실패 이유를 알 수 없기 때문입니다. `copyToClipboard`는 가상계좌 계좌번호 복사 +버튼처럼 결제와 무관한 범용 유틸리티라 KCP 고유 로직이 없습니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftNhnkcp` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftNhnkcp`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록 +진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). KCP 결제창 스크립트 자체는 이 +진입점이 미리 로드하지 않습니다 — 모든 방문자가 결제 페이지에 오는 것은 아니므로 전역 +부팅에서 미리 불러올 필요가 없습니다(`requestPayment` 핸들러가 실제 결제 시도 시점에만 +동적으로 로드). + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +KCP 가 제공하는 `payplus_web.jsp` SDK 는 이 목록에 없습니다 — `requestPayment` 핸들러가 +결제 시도 시점에 iframe 안으로 동기 로드하는 제3자 자산이라, 이 플러그인이 빌드 시 +번들링하는 `dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은 결제창 자체는 KCP 가 +그리고, 이 플러그인은 간편결제 버튼·복사 버튼 같은 최소한의 UI만 코어 컴포넌트로 +구성하기 때문입니다. + diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/settings.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/settings.md new file mode 100644 index 00000000..db69f342 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/settings.md @@ -0,0 +1,101 @@ +# NHN KCP — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `is_test_mode` | `boolean` | `true` | 테스트 모드 | +| `test_site_cd` | `string` | `T0000` | 테스트 사이트 코드 (site_cd) | +| `test_site_key` | `string` | - | 테스트 사이트 키 (site_key) | +| `live_site_cd` | `string` | - | 라이브 사이트 코드 (site_cd) | +| `live_site_key` | `string` | - | 라이브 사이트 키 (site_key) | +| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL | +| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL | +| `use_escrow` | `boolean` | `false` | 에스크로 결제 활성화 | +| `escrow_test_site_cd` | `string` | - | 테스트 에스크로 사이트 코드 | +| `vbank_expire_days` | `integer` | `3` | 가상계좌 입금 만료(일) | +| `easy_pay_allow_with_other_pg` | `boolean` | `false` | - | +| `easy_pay_payco` | `boolean` | `false` | - | +| `easy_pay_naverpay` | `boolean` | `false` | - | +| `easy_pay_naverpay_point` | `boolean` | `false` | - | +| `easy_pay_kakaopay` | `boolean` | `false` | - | +| `easy_pay_applepay` | `boolean` | `false` | - | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +`test_*`/`live_*` 쌍 구조는 `sirsoft-pay_kginicis`와 동일한 이유입니다 — 테스트 모드와 운영 +모드가 완전히 다른 자격증명 집합을 쓰므로 `is_test_mode`를 켜고 꺼도 서로의 값을 덮어쓰지 +않습니다. kginicis 와 달리 `japan_*` 설정군이 전혀 없는 것은 이 플러그인이 KCP 의 일본/CBT +결제 상품을 구현하지 않기 때문입니다(§data-model.md, §architecture.md) — 이 플러그인에 +일본 결제를 요구하는 요청이 오면 새 설정 키를 추가하는 대신 별도 플러그인 여부를 먼저 +검토해야 합니다. + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을 +선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이 +플러그인 수만큼 중복 정의됩니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해 +접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가 +난립합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_nhnkcp/...` | +| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_nhnkcp/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +`api`(Bearer 토큰 인증, 모바일 승인키 발급처럼 로그인 사용자가 브라우저에서 직접 호출하는 +엔드포인트)와 `web`(콜백·입금통보·공통통보처럼 KCP 서버나 리다이렉트로 도달하는 +엔드포인트)이 분리된 이유는 인증 방식이 다르기 때문입니다 — KCP 는 우리 서비스의 Bearer +토큰을 모르므로 콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다. 새 KCP 콜백을 추가할 +때는 `web` 쪽에 둡니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는 +이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이 +플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델 +구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화"). + diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json b/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json index fd265ec4..82f2e350 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-pay_nhnkcp", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-pay_nhnkcp", - "version": "1.0.3", + "version": "1.0.4", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/package.json b/plugins/_bundled/sirsoft-pay_nhnkcp/package.json index 18a5d567..15b4b615 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/package.json +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-pay_nhnkcp", - "version": "1.0.3", + "version": "1.0.4", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json index 2fe05e96..54edcbc7 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json @@ -5,7 +5,7 @@ "ko": "NHN KCP", "en": "NHN KCP" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp", "github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp/blob/main/CHANGELOG.md", diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md b/plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md new file mode 100644 index 00000000..e2db3de2 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/AGENTS.md @@ -0,0 +1,171 @@ +# 나이스페이먼츠 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-pay_nicepayments) — 나이스페이먼츠 PG 연동(인증+승인 2단계/가상계좌/에스크로/간편결제 8종). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유 +2. 확장 방식: `RegisterPgProviderListener`/`RegisterEasyPayMethodsListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다 +3. 건드리면 안 되는 것: 결제창 인증 실패(`AuthResultCode != '0000'`)를 승인 API 호출 전인데도 hard failure 로 취급, 가상계좌 입금통보 IP 화이트리스트(`VbankNotifyIpWhitelist`) 미부착 +4. 작업 위치: `plugins/_bundled/sirsoft-pay_nicepayments` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-pay_nicepayments --force` +``` + +## 1. 이 확장은 무엇인가 + + +나이스페이먼츠 PG를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 결제 승인은 **인증→승인 +2단계**입니다 — 결제창(`goPay` iframe 팝업/모바일 폼)이 먼저 인증 결과(`AuthResultCode`)를 +`/payment/callback`으로 POST 하고, 서버가 그 결과를 받아 `NextAppURL`로 다시 승인 API를 +호출해야 최종 완료됩니다. `sirsoft-pay_kginicis`(결제창 후 단일 승인 API)와 달리 인증 +단계에서 실패하는 것과 승인 단계에서 실패하는 것을 서로 다르게 취급해야 합니다(§금지 패턴). + +**설계 원칙**: 이 플러그인도 상태를 소유하지 않습니다(§data-model.md — 모델·테이블·Repository +0개). 주문·결제 상태는 `sirsoft-ecommerce`에 있고, 이 플러그인은 그 상태를 나이스페이먼츠 +API 와 동기화하는 역할만 합니다. 등록은 훅 기반입니다 +(`sirsoft-ecommerce.payment.registered_pg_providers` 필터). + +**의도적으로 하지 않는 것**: 사용자가 결제창에서 취소하거나 PG 가 인증을 거부한 경우 +(`AuthResultCode != '0000'`)는 아직 승인 API 호출 전이므로 일반 오류 메시지를 띄우지 않고 +체크아웃으로 조용히 리다이렉트합니다 — 이 시점은 "결제 시도 자체를 안 한 것"과 사실상 +같아서, 사용자에게 오류로 보이면 혼란만 커집니다. 운영 가시성은 로그(`auth_result_code`/ +`auth_result_msg`)로만 보존합니다. 반면 2단계(승인) 이후의 실패(서명·MID·금액 불일치)는 +"돈이 오갔을 수 있는" 실패라 `?error=` 쿼리로 명시적으로 안내합니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nicepayments --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nicepayments --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nicepayments --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nicepayments --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nicepayments --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**PC/모바일 결제 승인**: 결제창(iframe 팝업)이 `/payment/callback`으로 인증 결과 POST → +`AuthResultCode == '0000'` 확인(아니면 §1 "의도적으로 하지 않는 것"의 silent redirect) → +`sirsoft-pay_nicepayments.payment.before_authorize` 훅 → 서버가 `NextAppURL`로 승인 API +호출 → `sirsoft-pay_nicepayments.payment.after_authorize` 훅 → 이커머스 주문 결제 완료 +처리. 결제 요청 시점에 주문의 `total_tax_amount`/`total_vat_amount`/`total_tax_free_amount` +가 모두 0이 아니면 과세 필드를 폼에 포함합니다(§4 "과세 처리"). + +**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가 +`sirsoft-ecommerce.payment.refund` 필터 발화 → `PaymentRefundListener`(우선순위 10)가 먼저 +나이스페이먼츠 취소 API 호출(전액취소 `isPartial=0`/부분취소 `isPartial=1`) → +`CancelActivityLogListener`(우선순위 20)가 결과를 활동 로그에 별도 기록. 가상계좌 입금 +완료 건은 환불 계좌 정보가 필요해 일반 취소 API가 아니라 별도 어드민 환불 계좌 API 경로로 +처리됩니다. 취소 API 호출이 실패하면 `refund_failed` 훅이 발화합니다. + +**에스크로 배송 등록**: 관리자 주문 상세에서 운송장번호·택배사를 입력 → +`AdminEscrowController::registerDelivery()` → 나이스페이먼츠 배송 등록 API 호출. +`EscrowDeliveryRegisterRequest`가 입력을 검증합니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 5개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 8개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +`before_authorize`/`before_cancel`은 API 호출 **전** 개입 지점이라 여기서 예외를 던지면 +실제 나이스페이먼츠 호출이 일어나지 않습니다. `refund_failed`는 취소 API 호출이 실패했을 +때만 발화하는 별도 훅입니다 — `after_cancel`(성공 응답 후)과 구분해서 구독해야 합니다. +운영자 알림(예: Slack) 을 붙이고 싶은 확장은 `after_*`가 아니라 `refund_failed`를 잡습니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_nicepayments --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_nicepayments` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다 +- [ ] 인증 실패(1단계)와 승인 실패(2단계)의 사용자 안내 방식(silent redirect vs `?error=`)을 구분 유지 — §1 "의도적으로 하지 않는 것" 참고 +- [ ] IP 화이트리스트(`VbankNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신 +- [ ] 새 간편결제 수단을 추가하면 그 결제수단의 계약 상태를 관리자 안내에도 반영 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-pay_nicepayments --force` + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 결제창 인증 실패(`AuthResultCode != '0000'`)를 승인 실패와 동일하게 `?error=` 로 안내 | 승인 API 호출 전 실패는 체크아웃으로 silent redirect, 로그로만 기록 | 아직 결제 시도 자체가 성립하지 않은 단계인데 오류 메시지를 띄우면 사용자가 "돈이 빠져나갔나" 불필요하게 불안해한다 | +| 가상계좌 입금통보(`vbank-notify`)에 IP 화이트리스트 미부착 | `VbankNotifyIpWhitelist` 미들웨어 유지 | 통보 엔드포인트는 나이스페이먼츠 서버만 호출해야 하며, 화이트리스트가 없으면 제3자가 위조 입금통보를 보내 결제 상태를 조작할 수 있다 | +| 부분취소인데 가상계좌 입금 완료 건을 일반 취소 API로 처리 | 환불 계좌 정보가 필요한 가상계좌 건은 별도 어드민 환불 계좌 API 경로로 처리 | 가상계좌는 카드와 달리 PG가 자동으로 환불할 계좌를 모르므로 일반 취소 API를 호출하면 실패하거나 환불이 누락된다 | +| 라이브 가맹점 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제 요청을 위조할 수 있다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 23개 | `plugins/_bundled/sirsoft-pay_nicepayments/tests` | +| Vitest | 7개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 1개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_nicepayments/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-pay_nicepayments && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md index 1fce5f39..995ce18a 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ## [1.0.2] - 2026-08-19 ### Security diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/README.md b/plugins/_bundled/sirsoft-pay_nicepayments/README.md index 74e4d83e..ee14a231 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/README.md +++ b/plugins/_bundled/sirsoft-pay_nicepayments/README.md @@ -1,146 +1,237 @@ -# NicePayments Plugin for G7 +# NicePayments -나이스페이먼츠(NicePayments) PG 연동 플러그인입니다. G7 플랫폼의 sirsoft-ecommerce 모듈과 함께 동작합니다. +**그누보드7 플러그인 · sirsoft-pay_nicepayments** +나이스페이먼츠 결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인 -## 지원 결제 수단 + +

+ version 1.0.3 + type 플러그인 + 그누보드7 >=7.0.10 + license MIT + requires sirsoft-ecommerce +

+ -| 결제 수단 | PayMethod | -|-----------|-----------| -| 신용카드 | CARD | -| 가상계좌 | VBANK | -| 계좌이체 | BANK | -| 휴대폰결제 | CELLPHONE | +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +나이스페이먼츠(NicePayments) 표준결제를 그누보드7 `sirsoft-ecommerce` 모듈에 연결하는 결제 +플러그인입니다. 결제 승인은 **인증→승인 2단계**로 나뉩니다 — 결제창(`goPay` iframe +팝업/모바일 폼)이 먼저 인증 결과를 서버로 보내고, 서버가 그 결과를 받아 별도 승인 API를 +호출해야 최종 완료됩니다. + +이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를 소유하지 않습니다 — 그 상태는 +`sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를 나이스페이먼츠 +API 와 어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유 테이블/모델이 하나도 +없습니다(§data-model.md). + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 | +| 간편결제 | 네이버페이, 카카오페이, 삼성페이, 애플페이, PAYCO, 11pay, SSG페이, L.pay 버튼 주입 | +| 승인 방식 | 결제창 인증 + 서버 승인 API 2단계 | +| 가상계좌 | 발급 + 입금통보 처리 | +| 에스크로 | 배송 등록, 거래 조회 | +| 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 TID), 실패 시 `refund_failed` 훅 | +| 영수증 | 주문 완료/마이페이지 영수증 버튼 | +| 관리자 확장 | 주문 상세 거래 조회, 에스크로 배송 등록 UI | +| 과세 처리 | 주문의 세금/부가세/면세 금액 자동 반영 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[체크아웃 주문 생성] --> B["결제창(goPay iframe) 로드"] + B --> C["/payment/callback (AuthResultCode)"] + C -->|실패| D[체크아웃으로 silent redirect] + C -->|성공| E["NextAppURL 로 승인 API 호출"] + E --> F[주문 결제 완료 처리] + F --> G[성공 URL 리다이렉트] +``` + +결제창에서 사용자가 취소하거나 PG 가 인증을 거부하면(`AuthResultCode != '0000'`) 아직 +승인 API 호출 전이므로 일반 오류 메시지를 띄우지 않고 체크아웃으로 조용히 리다이렉트합니다 +— 운영 가시성은 로그(`auth_result_code`/`auth_result_msg`)로 보존합니다. 2단계(승인) 이후의 +실패(서명/MID/금액 불일치)는 `?error=` 쿼리로 명시적으로 안내합니다. + +가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, 나이스페이먼츠가 입금통보 +URL로 결과를 POST 하면 결제 완료 처리됩니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | +| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` | + + + +| 항목 | 필요한 것 | +|---|---| +| 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, 나이스페이먼츠 가맹점 계약 정보 | +| PC/모바일 결제 | MID, 가맹점 키 | + +서버에서 나이스페이먼츠 API 호스트로 HTTPS outbound 요청이 가능해야 합니다. + ## 설치 + ```bash -# 플러그인 디렉토리에 배치 후 -composer install -npm install && npm run build +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-pay_nicepayments + +# 활성화 +php artisan plugin:activate sirsoft-pay_nicepayments + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-pay_nicepayments --force ``` -## 설정 +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_nicepayments + -관리자 → 플러그인 → NicePayments 설정에서 구성합니다. +설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "나이스페이먼츠"로 선택해야 실제로 결제 +흐름에 연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다. -| 항목 | 설명 | -|------|------| -| 테스트 모드 | 활성화 시 나이스페이먼츠 공용 테스트 MID를 사용합니다. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일 23:30경 일괄 자동 취소됩니다. | -| 테스트 MID | 테스트 가맹점 ID (`nicepay00m` 기본값) | -| 테스트 가맹점 키 | 나이스페이 공용 테스트 키 | -| 라이브 MID | 실서비스 가맹점 ID | -| 라이브 가맹점 키 | 실서비스 가맹점 키 (외부 노출 금지) | -| 결제 성공 URL | 결제 완료 후 리다이렉트 경로 (`{orderId}` 치환 지원) | -| 결제 실패 URL | 결제 실패 후 리다이렉트 경로 | +## 관리자 설정 -테스트 모드 주문은 실제 배송하지 마세요. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일 23:30경 일괄 자동 취소됩니다. + +| 키 | 의미 | 기본값 | +|---|---|---| +| `is_test_mode` | 테스트 모드 | `true` | +| `test_mid` | 테스트 가맹점 ID (MID) | `nicepay00m` | +| `test_merchant_key` | 테스트 가맹점 키 | `EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==` | +| `live_mid` | 라이브 가맹점 ID (MID) | - | +| `live_merchant_key` | 라이브 가맹점 키 | - | +| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` | +| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` | +| `use_escrow` | 에스크로 결제 사용 | `false` | +| `easy_pay_allow_with_other_pg` | 타 PG와 사용가능함 | `false` | +| `easy_pay_naverpay` | 네이버페이 간편결제 | `false` | +| `easy_pay_kakaopay` | 카카오페이 간편결제 | `false` | +| `easy_pay_samsungpay` | 삼성페이 간편결제 | `false` | +| `easy_pay_applepay` | 애플페이 간편결제 | `false` | +| `easy_pay_payco` | PAYCO 간편결제 | `false` | +| `easy_pay_skpay` | 11pay (SK페이) 간편결제 | `false` | +| `easy_pay_ssgpay` | SSG페이 간편결제 | `false` | +| `easy_pay_lpay` | L.pay 간편결제 | `false` | -## 웹훅 (가상계좌 입금 통보) +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + -나이스페이먼츠 관리자에서 가상계좌 입금 통보 URL을 아래로 설정하세요: + +테스트 모드에서는 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일 +23:30경 일괄 자동 취소될 수 있습니다 — **테스트 모드 주문을 실제로 배송하지 마세요.** 라이브 +가맹점 키는 외부에 노출하지 마세요. -``` +**웹훅(가상계좌 입금 통보)** — 나이스페이먼츠 관리자에 아래 URL을 실제 운영 도메인으로 +등록합니다. + +```text https://your-domain.com/plugins/sirsoft-pay_nicepayments/payment/vbank-notify ``` -### IP 화이트리스트 - -나이스페이먼츠 서버 IP만 허용됩니다. 로컬/테스트 환경에서는 자동으로 우회됩니다. +가상계좌 입금 통보는 나이스페이먼츠 서버가 직접 호출하므로 아래 IP 화이트리스트가 +적용됩니다(로컬/테스트 환경에서는 자동 우회). | IP | |----| -| 121.133.126.10 | -| 121.133.126.11 | -| 211.33.136.39 | +| `121.133.126.10` | +| `121.133.126.11` | +| `211.33.136.39` | + -## 결제 흐름 +## 사용 방법 -### PC / 모바일 결제 (인증 + 승인 2단계) + +**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가 +`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, `PaymentRefundListener`가 +나이스페이먼츠 취소 API를 호출합니다(전액취소 `isPartial=0`/부분취소 `isPartial=1`). +배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 +주문은 실결제금액(쿠폰 차감 후)이 PG `cancelAmt`로 전달됩니다. 부분취소로 쿠폰 최소 +주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부(422)해 PG 호출이 아예 +발생하지 않습니다. 가상계좌 입금 완료 건은 환불 계좌 정보가 필요해 일반 취소 API가 아닌 +별도 어드민 환불 계좌 API 경로로 처리됩니다. -``` -브라우저 → goPay(form) / 모바일 결제창 form POST -결제창 → POST /payment/callback → authCallback() (1단계 인증) -서버 → POST NextAppURL → 승인 API 호출 (2단계) -승인 완료 → completePayment() → 성공 페이지 리다이렉트 -``` +**에스크로 배송 등록**: 관리자 주문 상세에서 운송장번호·택배사를 입력해 나이스페이먼츠 +배송 등록을 호출할 수 있습니다. -### 결제창 취소 / 인증 실패 +**거래 단건 조회**: `NicePaymentsApiService::queryTransaction(string $tid): array`로 거래 +상태를 조회할 수 있습니다. -모바일 결제창에서 사용자가 취소버튼을 누르거나 PG 가 인증을 거부하면 (AuthResultCode != '0000') 결제 승인 (NextAppURL 호출) 이전이므로 사용자에게 generic 오류 메시지를 띄우지 않고 체크아웃으로 silent redirect 합니다. 운영 가시성은 로그(`auth_result_code` / `auth_result_msg`) 로 보존됩니다. 2단계 이후 hard failure (signature / mid / amount / authorize) 는 종전대로 `?error=` 쿼리 부착하여 안내합니다. +전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은 +[docs/extension-points.md](docs/extension-points.md) 를 참고하세요. + -### 결제 취소 / 부분취소 +## 다른 확장과의 연동 -```text -관리자 주문 취소 요청 (cancel_pg=true) -→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화 -→ PaymentRefundListener 가 NicePayments cancelPayment API 호출 - · 전액취소: isPartial=0 - · 부분취소: isPartial=1 -→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원 -→ CancelActivityLogListener 가 PG 응답 시각·취소 TID를 활동 로그에 기록 -``` + +**이 확장이 의존하는 확장** -배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG cancelAmt 로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. 가상계좌 입금 완료 건은 환불 계좌 정보가 필요해 일반 취소 API 가 아닌 별도 어드민 환불 계좌 API 경로로 처리됩니다. +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | -## 가용 훅 (Hook) +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) -다른 플러그인이나 리스너에서 아래 훅에 연결할 수 있습니다. +없음. + -### 액션 훅 + +`RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에, +`RegisterEasyPayMethodsListener`가 간편결제 결제수단 레지스트리에 각각 등록합니다 — PG +결제사 선택과 간편결제 노출은 서로 독립적이라, 다른 PG가 기본값이어도 나이스페이먼츠 +간편결제 버튼을 체크아웃 화면에 노출하는 조합이 가능합니다(`easy_pay_allow_with_other_pg`). + -| 훅 이름 | 시점 | 인수 | -|---------|------|------| -| `sirsoft-pay_nicepayments.payment.before_authorize` | 서버 승인 API 호출 직전 | `Order $order, array $pgParams` | -| `sirsoft-pay_nicepayments.payment.after_authorize` | 서버 승인 API 응답 직후 | `Order $order, array $pgResponse` | -| `sirsoft-pay_nicepayments.payment.before_cancel` | NicePayments 취소 API 호출 직전 | `Order $order, OrderPayment $payment, float $refundAmount` | -| `sirsoft-pay_nicepayments.payment.after_cancel` | NicePayments 취소 API 호출 직후 | `Order $order, OrderPayment $payment, array $pgResponse` | -| `sirsoft-pay_nicepayments.payment.refund_failed` | 환불 API 호출 실패 시 | `Order $order, OrderPayment $payment, array $context` | +## 문서 -#### `refund_failed` context 구조 + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + -```php -[ - 'tid' => string, // 나이스페이 거래번호 - 'cancel_amt' => int, // 환불 시도 금액 (원) - 'error' => string, // 오류 메시지 -] -``` +## 트러블슈팅 -### 훅 등록 예시 + +| 증상 | 원인 | 조치 | +|---|---|---| +| 결제창에서 취소했는데 오류 화면이 뜸 | `AuthResultCode != '0000'` 분기가 silent redirect 대신 오류를 노출하도록 잘못 수정됨 | §동작 방식의 의도된 동작(조용히 체크아웃 복귀)으로 되돌리고 로그로만 확인 | +| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 나이스페이먼츠 통보 서버 IP가 없음 | 위 IP 목록으로 화이트리스트를 갱신 | +| 결제 승인 후 주문이 실패 상태로 남음 | 인증은 성공했지만 승인 API 호출 또는 로컬 후속 처리 실패 | 오류 로그 확인 — PG 승인은 이미 됐을 수 있으므로 수동 확인 필요 | +| 부분취소가 실패하고 422 응답 | 부분취소로 쿠폰 최소 주문금액 조건 미충족 | 코어가 의도적으로 거부한 것 — 쿠폰 조건을 다시 충족하거나 전액취소 | +| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | 나이스페이먼츠 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 | + -```php -use App\Extension\HookManager; +## 변경 이력 -HookManager::addAction( - 'sirsoft-pay_nicepayments.payment.refund_failed', - function (Order $order, OrderPayment $payment, array $context) { - // 예: Slack 알림 발송 - SlackNotifier::send("환불 실패: 주문 #{$order->order_number}, 오류: {$context['error']}"); - }, - priority: 10 -); -``` - -## API 단건 조회 - -`NicePaymentsApiService::queryTransaction(string $tid): array` 메서드로 거래 상태를 조회할 수 있습니다. - -```php -$apiService = app(\Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService::class); -$result = $apiService->queryTransaction('NICE_TID_12345'); -// $result['ResultCode'], $result['Amt'], ... -``` - -## 과세 처리 - -결제 요청 시 주문의 `total_tax_amount`, `total_vat_amount`, `total_tax_free_amount` 값을 자동으로 나이스페이 폼에 포함합니다. 세 값이 모두 0이면 과세 필드를 생략합니다. - -## 테스트 실행 - -```bash -cd c:/g7 -php artisan test --filter=Nicepayments -``` +[CHANGELOG.md](CHANGELOG.md) ## 라이선스 diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/composer.json b/plugins/_bundled/sirsoft-pay_nicepayments/composer.json index 6e0090a6..86cc9b76 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/composer.json +++ b/plugins/_bundled/sirsoft-pay_nicepayments/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-pay_nicepayments", "description": "NicePayments PG Plugin for G7 platform", - "version": "1.0.2", + "version": "1.0.3", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md new file mode 100644 index 00000000..1a5b6da2 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/README.md @@ -0,0 +1,23 @@ +# 나이스페이먼츠 개발자 문서 + +> plugins/_bundled/sirsoft-pay_nicepayments · 플러그인 + + +**훅 수**: 5 · **구독 훅 수**: 9 · **라우트 수**: 15 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 2 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/architecture.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/architecture.md new file mode 100644 index 00000000..4b0aa54a --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/architecture.md @@ -0,0 +1,60 @@ +# 나이스페이먼츠 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +나이스페이먼츠 표준결제를 `sirsoft-ecommerce` 에 연결하는 어댑터입니다. 이 플러그인의 +고유한 설계 축은 승인이 **인증→승인 2단계**로 나뉜다는 점입니다 — 결제창이 먼저 인증 +결과만 보내고, 서버가 그 결과를 별도 승인 API 호출로 확정합니다. `sirsoft-pay_kginicis`처럼 +결제창 콜백 하나로 승인까지 끝나는 구조와 달리, 이 플러그인은 "인증은 됐지만 아직 승인 +전"이라는 중간 상태를 다뤄야 합니다(§AGENTS.md "의도적으로 하지 않는 것"). + +이 플러그인도 결제 상태 자체는 소유하지 않습니다 — 주문·결제 테이블은 `sirsoft-ecommerce` +소유이고, 이 플러그인은 그 상태를 나이스페이먼츠 API 와 어떻게 주고받는가만 책임집니다. + + +## 계층 지도 + + +```text +Controller (PaymentCallbackController / AdminEscrowController / AdminTransactionController) + → NicePaymentsApiService (인증 검증 · 승인/취소/단건조회 API 호출) + → sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음) + +Listener (RegisterPgProviderListener 등) + → sirsoft-ecommerce 의 필터 훅에 등록 (컴파일 타임 결합 없음) +``` + +`PaymentCallbackController`가 인증 결과(`AuthResultCode`)를 먼저 판정하고 실패 시 승인 +API 호출 없이 조기 반환하는 것이 이 플러그인 계층 구조의 특징입니다 — 일반적인 "Controller +→ FormRequest → Service" 흐름과 달리, 이 판정 자체가 Controller 안에 있는 이유는 그 +판정 결과에 따라 아예 다른 응답(silent redirect vs `?error=`)을 골라야 하기 때문입니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nicepayments --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nicepayments --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nicepayments --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nicepayments --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nicepayments --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/data-model.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/data-model.md new file mode 100644 index 00000000..62de628d --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/data-model.md @@ -0,0 +1,65 @@ +# 나이스페이먼츠 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +_소유 모델이 없습니다._ + + + +결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이 +소유합니다(§AGENTS.md "설계 원칙"). 이 플러그인은 그 모델을 직접 참조해 읽고 쓸 뿐, 자기 +Repository 조차 두지 않았습니다(§Repository) — 인증→승인 2단계 흐름의 중간 상태도 별도 +테이블에 저장하지 않고, 인증 결과를 받은 요청 컨텍스트 안에서만 다루다가 승인이 확정되는 +순간 이커머스 테이블에 반영합니다. + + +## 소유 테이블 + + +_소유 테이블이 없습니다._ + + + +가상계좌 발급 정보와 나이스페이먼츠 거래번호(TID)는 이커머스 `OrderPayment` 테이블의 기존 +컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면 관리자 주문 상세가 PG +종류에 따라 다른 테이블을 조인해야 합니다. + + +## 마이그레이션 + + +_마이그레이션이 없습니다._ + + + +소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 설정 스키마 +변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB 마이그레이션 +대상이 아닙니다. + + +## Enum + + +_Enum 이 없습니다._ + + + +`AuthResultCode`(`'0000'` = 성공)와 PG 응답 코드는 Enum 대신 컨트롤러의 조건 분기로 직접 +판정합니다 — 나이스페이먼츠 고유 프로토콜 상수라 이 플러그인 코드 어디에도 재사용되지 +않으며, Enum 으로 승격해도 얻는 타입 안전성 대비 간접 계층만 늘어납니다. + + +## Repository + + +_Repository 가 없습니다._ + + + +이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너)은 모두 +이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유 테이블이 없는 상태에서 +남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는 빈 계층입니다. + diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/editor-spec.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/editor-spec.md new file mode 100644 index 00000000..e5fb830a --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/editor-spec.md @@ -0,0 +1,117 @@ +# 나이스페이먼츠 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `plugins/_bundled/sirsoft-pay_nicepayments/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 1 · 페이지 상태 1 + + + +나이스페이먼츠 결제 플러그인이 소유한 화면은 관리자 설정 하나뿐입니다. 결제 자체는 PG 결제창에서 +일어나고 그 창은 이 확장이 그리는 화면이 아니므로, 편집기가 다룰 표면이 설정 화면으로 +좁혀집니다. + +`스타일 시스템`·`다크 모드 전략` 이 비어 있는 것은 정상입니다 — 화면을 그리는 규칙은 +템플릿이 정하고, 결제 플러그인은 그 위에 자기 설정 항목만 얹습니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `editor-spec.json (인라인)` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 1 | `editor-spec.json (인라인)` | + + + +선언한 것은 `vbank_info` 하나, 그리고 설정 화면의 상태 변종 하나입니다. 다른 설정 항목 +(`settings` 등)은 여러 확장이 공유하는 공용 ID 라 admin 템플릿 스펙이 채웁니다 — +여기에 다시 선언하면 같은 ID 의 샘플이 두 곳에 생기고, 둘이 갈라져도 오류가 나지 +않습니다. + +가상계좌 안내(`vbank_info`)를 굳이 넣은 것은 그 영역이 **결제 수단에 따라 나타났다 +사라지는** 자리라, 샘플이 없으면 편집기에서 그 영역 자체를 볼 수 없기 때문입니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `vbank_info` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 1 | `*/admin/plugins/sirsoft-pay_nicepayments/settings` | + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +상태 변종의 범위가 `*/admin/plugins/sirsoft-pay_nicepayments/settings` 하나인 것은 이 플러그인이 자기 +설정 화면만 소유하기 때문입니다. 주문·결제 화면은 `sirsoft-ecommerce` 가 소유하므로 +그 화면의 프리뷰는 이커머스 스펙이 그립니다. + +결제 흐름을 편집기에서 확인하려던 것이라면 이 문서가 아니라 이커머스 모듈의 편집기 스펙 +문서를 봅니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan plugin:update sirsoft-pay_nicepayments --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +이 플러그인의 결제 수단이 이커머스 관리자 화면에 어떻게 보이는지는 편집기 스펙이 아니라 +결제수단 카탈로그 선언(`needs_pg`·`pg_provider`·`pg_locked`)이 정합니다. 배지가 이상하게 +보인다면 스펙이 아니라 그 선언을 봅니다. + diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md new file mode 100644 index 00000000..4395ff52 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md @@ -0,0 +1,164 @@ +# 나이스페이먼츠 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 5종 / 호출 지점 5곳. 이 중 1종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-pay_nicepayments.payment.after_authorize` | action | 나이스페이먼츠 서버 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:257` | +| `sirsoft-pay_nicepayments.payment.after_cancel` | action | 나이스페이먼츠 결제 취소 완료 후 | `src/Services/NicePaymentsApiService.php:308` | +| `sirsoft-pay_nicepayments.payment.before_authorize` | action | 나이스페이먼츠 서버 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:252` | +| `sirsoft-pay_nicepayments.payment.before_cancel` | action | 나이스페이먼츠 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NicePaymentsApiService.php:285` | +| `sirsoft-pay_nicepayments.payment.refund_failed` | action | — | `src/Listeners/PaymentRefundListener.php:131` | + + + +`before_authorize`/`before_cancel`은 API 호출 **전** 개입 지점입니다 — 예외를 던지면 실제 +나이스페이먼츠 호출 자체가 일어나지 않습니다. `refund_failed`는 `getHooks()` 선언 없이 +소스에서 자동 감지된 훅으로, `after_cancel`(성공 응답)과 달리 취소 API 호출이 **실패**했을 +때만 발화합니다 — 운영 알림을 붙이려는 확장은 이 훅을 구독해야 합니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 40 | +| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 | +| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 | +| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 | +| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 | +| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 | +| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 | +| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 | +| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 40 | + + + +`PaymentRefundListener`(10)가 `CancelActivityLogListener`(20)보다 먼저 실행되도록 우선순위를 +명시한 것은 실제 취소가 성공한 뒤에야 활동 로그를 남기기 위함입니다 — 순서가 뒤바뀌면 +"로그는 있는데 취소는 실패"가 생깁니다. `RegisterEasyPayMethodsListener`가 8종의 간편결제 +방식을 하나의 필터 훅에서 한 번에 주입하는 이유는 §settings.md 의 설정 스키마와 대칭을 +맞추기 위함입니다 — 설정 키가 8개인데 훅 등록이 여러 곳으로 흩어지면 한쪽만 갱신되는 +사각이 생깁니다. + + +## 활동 로그 훅 + +> 이 확장이 코어 활동 로그(`activity_logs`)에 기록을 남기기 위해 구독하는 훅 1개입니다. +> 위 「구독 훅」 절이 이 확장의 구독 전량을 싣고, 이 절은 그중 **기록을 남기는 것**만 추립니다. +> 코어 `docs/backend/activity-log-hooks.md` 에는 총계와 이 문서로의 링크만 남습니다(#601). + +> 새 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문, +> 그리고 번들 일본어 팩까지 함께 정의해야 합니다 — **플러그인 lang 파일에 넣으면 action 라벨이 +> 해석되지 않습니다.** (description 본문은 이 확장의 `lang/{ko,en}/activity_log.php` 소유입니다.) + +### 결제 취소 훅 (CancelActivityLogListener) + +**파일**: `plugins/_bundled/sirsoft-pay_nicepayments/src/Listeners/CancelActivityLogListener.php` +**총 1훅** + +| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable | +|---------|----------------|-------------|---------|----------| +| `sirsoft-ecommerce.payment.refund` | `logCancelConfirmed` | `payment.cancel` | `ResolvesActivityLogType` 로 해석 | Order | + +> 이 훅은 `filter` 이고 우선순위 **20** 입니다. 같은 훅을 구독하는 `PaymentRefundListener`(10)가 +> 먼저 실행돼 실제 취소가 성공한 뒤에야 기록이 남습니다 — 순서가 뒤바뀌면 실패한 취소도 +> 성공처럼 로그에 남습니다. + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` | +| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` | +| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` | +| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` | +| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` | +| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` | +| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` | +| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` | + + + +`RestoreLayoutExtensionsAfterUpdateListener`는 `plugin:update`가 레이아웃 확장 조각(§레이아웃 +확장)의 활성/비활성 상태를 초기화할 수 있어서 존재합니다 — 운영자가 특정 화면을 꺼둔 상태로 +업데이트해도 그 선택이 사라지지 않도록 복원합니다. 8개 리스너 전부가 +`HookListenerInterface`를 구현하는 것은 이 저장소의 전 리스너 공통 계약입니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/checkout_easy_pay.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user_mypage_order_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user_order_complete_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +5개 조각은 각각 독립적인 화면 관심사입니다 — 관리자 주문 목록의 테스트배지, 관리자 주문 +상세의 거래조회 UI, 체크아웃의 간편결제 버튼, 마이페이지·주문완료의 영수증 버튼이 서로 +다른 화면에 주입되므로 하나로 합치지 않았습니다. `user_mypage_order_receipt.json`과 +`user_order_complete_receipt.json`이 별도 파일인 것은 두 화면의 컴포넌트 트리와 데이터 +소스가 다르기 때문입니다(§AGENTS.md "설계 원칙" — 상태는 이커머스 소유이므로 화면마다 +필요한 조회 방식이 다를 수 있습니다). + + +## 미들웨어 + + +| 미들웨어 | 부착 대상(targets) | 우선순위 | +|---|---|---| +| `VbankNotifyIpWhitelist` | `web.plugins.sirsoft-pay_nicepayments.payment.vbank-notify` | - | + + + +결제 결과 콜백(`/payment/callback`)에는 이 미들웨어가 붙지 않습니다 — 그 경로는 브라우저가 +POST 하는 경로라 발신 IP 가 사용자마다 다르기 때문입니다. IP 화이트리스트가 의미 있는 것은 +나이스페이먼츠 서버가 직접 호출하는 가상계좌 입금통보뿐입니다. 로컬/테스트 환경에서는 +이 제한을 자동 우회합니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +결제 승인·통보는 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가 +필요한 지점이 없습니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +가상계좌 만료는 이 플러그인이 크론으로 스캔하지 않고, 만료 이후 도착하는 나이스페이먼츠 +입금통보를 거부하는 방식으로 처리됩니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미 +포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트에 대해 PG 수만큼 중복 정의가 +생깁니다. 운영자에게 실패를 알리고 싶은 확장은 §발행 훅의 `refund_failed` 를 구독합니다. + diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/frontend.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/frontend.md new file mode 100644 index 00000000..f97fceb5 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/frontend.md @@ -0,0 +1,79 @@ +# 나이스페이먼츠 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +다른 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면 +하나뿐입니다 — 체크아웃·주문상세·마이페이지의 결제 UI는 이 플러그인 소유가 아니라 +§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다. + + +## 액션 핸들러 + + +핸들러 2개 (정의: `resources/js/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `requestPayment` | `sirsoft-pay_nicepayments.requestPayment` | +| `setPaymentMethod` | `sirsoft-pay_nicepayments.setPaymentMethod` | + + + +`setPaymentMethod`가 별도로 필요한 이유는 나이스페이먼츠 간편결제 버튼(네이버페이/카카오페이/ +삼성페이/애플페이/PAYCO/11pay/SSG페이/L.pay 8종)이 레이아웃 컴포넌트가 아니라 PG가 +제공하는 DOM을 그대로 쓰기 때문입니다 — React 상태로 선택 하이라이트를 그리는 대신 DOM을 +직접 조작해 선택된 버튼에 테두리를 입힙니다(`updateEasyPayButtonStyles`). `requestPayment` +하나로 PC/모바일 2가지 프로토콜(§docs/architecture.md "인증→승인 2단계")을 모두 처리합니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftNicepayments` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftNicepayments`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록 +진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 나이스페이먼츠 결제창 스크립트 +자체는 이 진입점이 미리 로드하지 않습니다 — `requestPayment` 핸들러가 결제 시도 시점에 +동적으로 스크립트를 삽입한 뒤 `goPay()`를 호출합니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +나이스페이먼츠가 제공하는 결제창 SDK는 이 목록에 없습니다 — `requestPayment` 핸들러가 +결제 시도 시점에 동적으로 로드하는 제3자 자산이라, 이 플러그인이 빌드 시 번들링하는 +`dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은 결제창 자체는 PG 가 그리고, 이 +플러그인은 간편결제 버튼 같은 최소한의 UI만 코어 컴포넌트로 구성하기 때문입니다. + diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/settings.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/settings.md new file mode 100644 index 00000000..d2c590c1 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/settings.md @@ -0,0 +1,102 @@ +# 나이스페이먼츠 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `is_test_mode` | `boolean` | `true` | 테스트 모드 | +| `test_mid` | `string` | `nicepay00m` | 테스트 가맹점 ID (MID) | +| `test_merchant_key` | `string` | `EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==` | 테스트 가맹점 키 | +| `live_mid` | `string` | - | 라이브 가맹점 ID (MID) | +| `live_merchant_key` | `string` | - | 라이브 가맹점 키 | +| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL | +| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL | +| `use_escrow` | `boolean` | `false` | 에스크로 결제 사용 | +| `easy_pay_allow_with_other_pg` | `boolean` | `false` | 타 PG와 사용가능함 | +| `easy_pay_naverpay` | `boolean` | `false` | 네이버페이 간편결제 | +| `easy_pay_kakaopay` | `boolean` | `false` | 카카오페이 간편결제 | +| `easy_pay_samsungpay` | `boolean` | `false` | 삼성페이 간편결제 | +| `easy_pay_applepay` | `boolean` | `false` | 애플페이 간편결제 | +| `easy_pay_payco` | `boolean` | `false` | PAYCO 간편결제 | +| `easy_pay_skpay` | `boolean` | `false` | 11pay (SK페이) 간편결제 | +| `easy_pay_ssgpay` | `boolean` | `false` | SSG페이 간편결제 | +| `easy_pay_lpay` | `boolean` | `false` | L.pay 간편결제 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +`test_*`/`live_*` 쌍 구조는 다른 PG 플러그인과 동일한 이유입니다 — 테스트 모드와 운영 +모드가 완전히 다른 자격증명을 쓰므로 `is_test_mode`를 켜고 꺼도 서로의 값을 덮어쓰지 +않습니다. 간편결제 플래그가 8개(네이버페이/카카오페이/삼성페이/애플페이/PAYCO/11pay/ +SSG페이/L.pay)로 다른 PG 플러그인보다 많은 것은 나이스페이먼츠가 실제로 이 8종 전부를 +중계하기 때문입니다 — 계약이 없는 수단을 켜면 결제 시도 시점에 PG 오류로 드러납니다 +(§AGENTS.md "금지 패턴"과 유사하게, 계약 여부는 이 플러그인이 검증하지 않고 PG 응답에 +위임합니다). + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을 +선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이 +플러그인 수만큼 중복 정의됩니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해 +접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가 +난립합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_nicepayments/...` | +| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_nicepayments/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +`api`(Bearer 토큰 인증, 관리자 거래조회·에스크로 배송등록처럼 로그인 사용자가 직접 +호출하는 엔드포인트)와 `web`(결제 콜백·가상계좌 입금통보처럼 결제창이나 PG 서버가 +도달하는 엔드포인트)이 분리된 이유는 인증 방식이 다르기 때문입니다 — 나이스페이먼츠는 +우리 서비스의 Bearer 토큰을 모르므로 콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는 +이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이 +플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델 +구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화"). + diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json b/plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json index fee0d3b2..641c8424 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json +++ b/plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-pay_nicepayments", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-pay_nicepayments", - "version": "1.0.2", + "version": "1.0.3", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/package.json b/plugins/_bundled/sirsoft-pay_nicepayments/package.json index 65d607bd..b8f03e51 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/package.json +++ b/plugins/_bundled/sirsoft-pay_nicepayments/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-pay_nicepayments", - "version": "1.0.2", + "version": "1.0.3", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json b/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json index 7cac67bd..626f55de 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json +++ b/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json @@ -5,7 +5,7 @@ "ko": "나이스페이먼츠", "en": "NicePayments" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nicepayments", "github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nicepayments/blob/main/CHANGELOG.md", diff --git a/plugins/_bundled/sirsoft-tosspayments/AGENTS.md b/plugins/_bundled/sirsoft-tosspayments/AGENTS.md new file mode 100644 index 00000000..b3b9eb9f --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/AGENTS.md @@ -0,0 +1,180 @@ +# 토스페이먼츠 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-tosspayments) — 토스페이먼츠 PG 연동(통합결제창 SDK/가상계좌 웹훅 secret 대조/에스크로 3-상태). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유 +2. 확장 방식: `RegisterPgProviderListener`/`RegisterTossPaymentMethodsListener`/`RegisterCashReceiptProviderListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다 +3. 건드리면 안 되는 것: 결제 승인 확인 시 서버가 재계산한 금액과 PG 콜백 금액 대조(amount mismatch 검사) 생략, 가상계좌 웹훅의 secret 대조(`webhook_secret_verify`) 우회 — 토스는 notify IP 목록·서명을 제공하지 않아 secret 대조가 유일한 위조 방지 수단 +4. 작업 위치: `plugins/_bundled/sirsoft-tosspayments` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-tosspayments --force` +``` + +## 1. 이 확장은 무엇인가 + + +토스페이먼츠 PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 승인은 +브라우저 리다이렉트 기반입니다 — 통합결제창 SDK(`js.tosspayments.com/v2/standard`)가 +결제를 처리한 뒤 브라우저를 `?paymentKey&orderId&amount`가 붙은 콜백 URL로 리다이렉트하고, +서버가 그 파라미터로 승인 확인 API를 호출합니다. 다른 PG 플러그인(iframe 팝업/CLI/SOAP)과 +달리 프론트엔드 계층이 SDK 호출 하나로 끝나고 프로토콜 복잡도가 대부분 서버 쪽(확인·웹훅)에 +있습니다. + +이 플러그인은 **결제창형**과 **주문서형**(`order_sheet_mode`) 두 UI 모드를 지원합니다. +결제창형은 통합결제창이 결제수단을 전부 처리하는 카드 하나로 노출되고, 주문서형은 +`method_*` 설정으로 활성화한 개별 토스 결제수단(카드/가상계좌/계좌이체/휴대폰/토스페이/ +카카오페이/네이버페이/페이코/삼성페이)이 체크아웃 화면에 개별 버튼으로 뜹니다 — 어느 +쪽이든 최종 처리는 토스 결제창 하나로 귀결되므로 각 수단은 `pg_provider` 를 이 플러그인 +자신으로 고정(`pg_locked`)합니다(§AGENTS.md "4. 확장점"). + +**설계 원칙**: 이 플러그인도 상태를 소유하지 않습니다(§data-model.md — 모델·테이블 0개). +가상계좌 웹훅 검증은 토스가 notify IP 목록이나 서명을 제공하지 않는다는 제약에서 +비롯됩니다 — 승인 확인 응답에만 실리는 `secret` 값을 `payment_meta`에 저장해 두었다가 +웹훅이 도착하면 대조하는 것이 토스 공식 문서가 제시하는 유일한 위조 방지 수단입니다. + +**의도적으로 하지 않는 것**: 승인 확인 시 PG가 돌려준 금액과 서버가 재계산한 주문 금액이 +다르면(`amount_mismatch`) 결제를 완료 처리하지 않고 실패로 되돌립니다 — 콜백 URL의 +`amount` 쿼리 파라미터는 브라우저를 거치므로 신뢰할 수 없는 입력이기 때문입니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-tosspayments --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-tosspayments --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-tosspayments --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-tosspayments --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**결제 승인**: 통합결제창이 브라우저를 `/payment/callback?paymentKey&orderId&amount`로 +리다이렉트 → `PaymentCallbackController`가 주문 조회 → +`sirsoft-tosspayments.payment.before_confirm` 훅 → `TossPaymentsApiService::confirmPayment()` +가 토스 승인 확인 API 호출 → 응답 금액과 서버 재계산 금액 대조(불일치 시 실패 처리) → +`sirsoft-tosspayments.payment.after_confirm` 훅 → 이커머스 주문 결제 완료 처리. 가상계좌가 +발급된 경우 응답에 실린 `secret`을 `payment_meta.toss_secret`에 저장해 이후 웹훅 대조에 +씁니다. + +**가상계좌 입금 웹훅**: 토스가 `/webhook/deposit`으로 POST → 저장된 `toss_secret`과 웹훅 +본문의 `secret`을 대조(`webhook_secret_verify` 설정이 꺼져 있지 않은 한 강제) → 일치하면 +결제 완료 처리, 불일치하면 경고 로그만 남기고 처리하지 않습니다. + +**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가 +`sirsoft-ecommerce.payment.refund` 필터 발화 → `PaymentRefundListener`가 토스 취소 API 호출 +→ `before_cancel`/`after_cancel` 훅 발화. + +**설정 저장 검증**: 관리자가 플러그인 설정을 저장 → `core.plugin_settings.before_save` +(동기 훅, `sync: true`) → `ValidateTossSettingsListener`가 `vbank_valid_hours`(1~2160시간)와 +`use_escrow`(`off`/`on`/`buyer_choice` 3-상태) 범위를 검증 → 위반 시 `ValidationException`으로 +저장 자체를 막습니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 4개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 6개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 3개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +`before_confirm`/`before_cancel`은 API 호출 **전** 개입 지점이라 예외를 던지면 실제 +토스 호출이 일어나지 않습니다. `core.plugin_settings.before_save`를 `ValidateTossSettingsListener` +가 구독하는 것은 다른 PG 플러그인에는 없는 패턴입니다 — 이 훅은 코어 `PluginSettingsService` +가 발행하며, `sync: true`가 없으면 큐로 비동기 디스패치되어 `ValidationException`이 워커 +안에서 죽고 저장은 그대로 진행됩니다(§CLAUDE.md "Listener 데이터 접근 규정" 의 sync 훅 +규칙과 동일한 이유). + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-tosspayments --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-tosspayments` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 승인 확인에서 금액 대조(`amount_mismatch`) 로직을 우회하거나 완화하지 않는다 +- [ ] 가상계좌 웹훅의 secret 대조(`webhook_secret_verify`)를 기본값 `true` 이외로 바꾸지 않는다 — 끄면 토스 노티 위조를 막을 수단이 사라진다 +- [ ] `order_sheet_mode` 관련 로직을 고칠 때 `RegisterPgProviderListener`(enabled_methods)와 `RegisterTossPaymentMethodsListener`(builtin 결제수단 주입) 양쪽을 함께 갱신 — 한쪽만 고치면 설정과 노출 목록이 어긋난다 +- [ ] `ValidateTossSettingsListener`에 새 범위 검증을 추가하면 `core.plugin_settings.before_save` 의 `sync: true`를 유지 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어도 되는 상태(공용 ID 만 사용)다. 이 확장만 쓰는 `data_source` 를 새로 붙이는 순간 `editor-spec.json` 신설이 필요해진다 + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 콜백 URL의 `amount` 쿼리 파라미터를 그대로 신뢰해 결제 완료 처리 | 서버가 주문 금액을 재계산해 PG 응답 금액과 대조, 불일치 시 실패 처리 | 콜백은 브라우저를 거치므로 사용자가 쿼리 파라미터를 조작해 실제 결제 금액보다 낮은 금액으로 완료 처리를 유도할 수 있다 | +| 가상계좌 웹훅의 secret 대조를 생략하거나 항상 통과 | `payment_meta.toss_secret`과 웹훅 본문의 secret을 항상 대조 | 토스는 notify IP 목록·서명을 제공하지 않아 secret 대조가 유일한 위조 방지 수단이다 — 생략하면 제3자가 임의 주문에 대해 위조 입금통보를 보낼 수 있다 | +| `core.plugin_settings.before_save` 리스너에 `sync: true` 없이 등록 | 저장을 막아야 하는 검증 훅은 반드시 `sync: true` | 기본값(비동기 큐)이면 `ValidationException`이 워커 안에서 죽고 저장이 그대로 진행되어 검증이 무력화된다 | +| 라이브 시크릿 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 서버측 API를 위조 호출할 수 있다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 12개 | `plugins/_bundled/sirsoft-tosspayments/tests` | +| Vitest | 5개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 3개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-tosspayments/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-tosspayments && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md index bd44dd40..e648579a 100644 --- a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md @@ -4,6 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ## [1.0.2] - 2026-08-19 ### Fixed diff --git a/plugins/_bundled/sirsoft-tosspayments/README.md b/plugins/_bundled/sirsoft-tosspayments/README.md new file mode 100644 index 00000000..c3bc959f --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/README.md @@ -0,0 +1,214 @@ +# 토스페이먼츠 + +**그누보드7 플러그인 · sirsoft-tosspayments** +토스페이먼츠 결제 게이트웨이 (통합결제창 연동) + + +

+ version 1.0.3 + type 플러그인 + 그누보드7 >=7.0.10 + license MIT + requires sirsoft-ecommerce +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +토스페이먼츠 통합결제창 결제를 그누보드7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. +승인은 브라우저 리다이렉트 기반입니다 — 결제창(SDK)이 결제를 처리한 뒤 브라우저를 콜백 +URL로 돌려보내고, 서버가 그 파라미터로 승인 확인 API를 호출합니다. + +이 플러그인은 결제창형(통합결제창 카드 하나)과 주문서형(개별 토스 결제수단 버튼) 두 UI +모드를 지원합니다. 결제 자체의 상태(주문·결제 성공/실패/취소)는 소유하지 않습니다 — 그 +상태는 `sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를 +토스페이먼츠 API 와 어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유 +테이블/모델이 하나도 없습니다(§data-model.md). + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 승인 방식 | 통합결제창 SDK + 브라우저 리다이렉트 콜백 + 서버 승인 확인 | +| UI 모드 | 결제창형(단일 카드) / 주문서형(개별 결제수단 버튼) 전환 | +| 결제수단 | 카드, 가상계좌, 계좌이체, 휴대폰, 토스페이, 카카오페이, 네이버페이, 페이코, 삼성페이 | +| 가상계좌 | 발급 + 웹훅 입금통보(secret 대조로 위조 방지) | +| 에스크로 | 3-상태(끔/켬/구매자선택) | +| 현금영수증 | 카드/계좌이체 발급·취소 프로바이더 등록 | +| 결제 취소 | 전액/부분취소, 실패 시 별도 훅 | +| 설정 저장 검증 | 가상계좌 유효시간·에스크로 값 서버측 범위 강제 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[체크아웃 주문 생성] --> B["통합결제창 SDK 호출"] + B --> C["/payment/callback (paymentKey·orderId·amount)"] + C --> D[서버가 금액 재계산 후 대조] + D -->|일치| E["승인 확인 API 호출"] + D -->|불일치| F[결제 실패 처리] + E --> G[주문 결제 완료 처리] + G --> H[성공 URL 리다이렉트] +``` + +가상계좌가 발급되면 승인 확인 응답에만 실리는 secret 값을 저장해 두었다가, 토스가 입금 +웹훅을 보내면 그 secret 을 대조해 위조를 막습니다 — 토스는 notify IP 목록이나 서명을 +제공하지 않아 이 방식이 공식적으로 제시되는 유일한 검증 수단입니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | +| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-tosspayments + +# 활성화 +php artisan plugin:activate sirsoft-tosspayments + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-tosspayments --force +``` + +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments + + +## 관리자 설정 + + +| 키 | 의미 | 기본값 | +|---|---|---| +| `is_test_mode` | 테스트 모드 | `true` | +| `test_client_key` | 테스트 클라이언트 키 | - | +| `test_secret_key` | 테스트 시크릿 키 | - | +| `live_client_key` | 라이브 클라이언트 키 | - | +| `live_secret_key` | 라이브 시크릿 키 | - | +| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` | +| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` | +| `order_sheet_mode` | 주문서형 결제 | `false` | +| `method_card` | 카드 | `true` | +| `method_virtual_account` | 가상계좌 | `false` | +| `method_transfer` | 계좌이체 | `false` | +| `method_mobile_phone` | 휴대폰 | `false` | +| `method_tosspay` | 토스페이 | `false` | +| `method_kakaopay` | 카카오페이 | `false` | +| `method_naverpay` | 네이버페이 | `false` | +| `method_payco` | 페이코 | `false` | +| `method_samsungpay` | 삼성페이 | `false` | +| `vbank_valid_hours` | 가상계좌 입금기한(시간) | `24` | +| `vbank_cash_receipt_type` | 가상계좌 현금영수증 유형 | - | +| `use_escrow` | 에스크로 사용 | `off` | +| `webhook_secret_verify` | 웹훅 secret 검증 | `true` | + +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + + + +`order_sheet_mode`를 켜야 `method_*` 개별 결제수단 플래그가 체크아웃 화면에 실제로 +반영됩니다 — 꺼둔 상태(기본값)에서는 `method_*` 값을 바꿔도 통합결제창이 결제수단을 +전부 처리하므로 화면에 변화가 없습니다. 라이브 키(클라이언트 키·시크릿 키)는 외부에 +노출하지 마세요. + +**웹훅 URL 등록** — 토스페이먼츠 개발자센터에 아래 URL을 실제 운영 도메인으로 등록합니다. + +```text +https://your-domain.com/plugins/sirsoft-tosspayments/webhook/deposit +``` + +`webhook_secret_verify`(기본값 켜짐)를 끄면 이 웹훅의 위조 방지 수단이 사라지므로 특별한 +이유가 없는 한 켜둡니다. + + +## 사용 방법 + + +**결제창형으로 시작하기**: 별도 설정 없이 활성화만 하면 통합결제창 카드 하나로 카드·계좌이체· +가상계좌·휴대폰결제가 전부 처리됩니다. 간편결제(토스페이/카카오페이/네이버페이 등)를 +개별 버튼으로 노출하고 싶다면 `order_sheet_mode`를 켜고 해당 `method_*` 플래그를 +활성화하세요. + +**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가 +`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, `PaymentRefundListener`가 +토스페이먼츠 취소 API를 호출합니다. `after_cancel`은 취소 API 가 **성공했을 때만** +발화합니다 — 실패하면 `TossPaymentsApiException`이 던져지고 코어가 이를 받아 취소 요청 +자체를 실패로 응답합니다(이 플러그인에는 나이스페이먼츠의 `refund_failed` 같은 별도 +실패 훅이 없습니다). + +**가상계좌 확인**: 테스트 모드에서 가상계좌 웹훅이 도착하지 않으면 §웹훅 URL 등록이 +완료됐는지, `webhook_secret_verify`가 켜져 있다면 저장된 secret 이 정상 발급됐는지 +확인합니다. + +전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은 +[docs/extension-points.md](docs/extension-points.md) 를 참고하세요. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 결제는 성공했는데 체크아웃으로 실패 리다이렉트됨 | 콜백 `amount`가 서버 재계산 금액과 불일치 | 의도된 안전장치 — 쿠폰/재고 변경 등으로 주문 금액이 결제 시점과 달라졌는지 확인 | +| 가상계좌 입금통보가 반영되지 않음 | 웹훅 URL 미등록, 또는 secret 불일치로 조용히 무시됨 | §웹훅 URL 등록 확인 + 로그의 `deposit webhook secret mismatch` 경고 확인 | +| 주문서형으로 켰는데 개별 결제수단 버튼이 안 보임 | `order_sheet_mode`는 켰지만 해당 `method_*` 플래그 비활성 | 노출하려는 결제수단의 `method_*`를 개별로 활성화 | +| 설정 저장 시 422 오류 | `vbank_valid_hours` 범위(1~2160) 또는 `use_escrow` 값(`off`/`on`/`buyer_choice`) 위반 | `ValidateTossSettingsListener`가 의도적으로 차단한 것 — 값을 허용 범위로 수정 | +| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | 토스페이먼츠 계약이 없는 결제수단을 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/plugins/_bundled/sirsoft-tosspayments/composer.json b/plugins/_bundled/sirsoft-tosspayments/composer.json index 15e87a6b..cf3a759c 100644 --- a/plugins/_bundled/sirsoft-tosspayments/composer.json +++ b/plugins/_bundled/sirsoft-tosspayments/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-tosspayments", "description": "TossPayments PG Plugin for G7 platform", - "version": "1.0.2", + "version": "1.0.3", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/README.md b/plugins/_bundled/sirsoft-tosspayments/docs/README.md new file mode 100644 index 00000000..708c7737 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/README.md @@ -0,0 +1,23 @@ +# 토스페이먼츠 개발자 문서 + +> plugins/_bundled/sirsoft-tosspayments · 플러그인 + + +**훅 수**: 4 · **구독 훅 수**: 9 · **라우트 수**: 4 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 1 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/api/payment.md b/plugins/_bundled/sirsoft-tosspayments/docs/api/payment.md index dc5a9ca2..8dbdea9d 100644 --- a/plugins/_bundled/sirsoft-tosspayments/docs/api/payment.md +++ b/plugins/_bundled/sirsoft-tosspayments/docs/api/payment.md @@ -86,7 +86,7 @@ Location: /shop/checkout?error=PAY_PROCESS_CANCELED&orderId=20260711-000001 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | paymentKey | query | string | 예 | — | 토스가 발급한 결제 키. Confirm API 호출에 사용한다. | -| orderId | query | string | 예 | — | 주문번호 (G7 `orders.order_number`). SDK 호출 시 넘긴 값이 그대로 돌아온다. | +| orderId | query | string | 예 | — | 주문번호 (그누보드7 `orders.order_number`). SDK 호출 시 넘긴 값이 그대로 돌아온다. | | amount | query | integer | 예 | min 1 | 결제 금액. 주문의 결제요청 금액과 대조하며, 불일치 시 결제를 승인하지 않는다. | **요청 예시** diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md b/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md index dc539b60..6b315991 100644 --- a/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md +++ b/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md @@ -27,7 +27,7 @@ | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| orderId | body | string | 예 | max 100 | 주문번호 (G7 `orders.order_number`). 이 값으로 주문·결제 레코드를 조회한다. | +| orderId | body | string | 예 | max 100 | 주문번호 (그누보드7 `orders.order_number`). 이 값으로 주문·결제 레코드를 조회한다. | | status | body | string | 예 | `DONE`, `CANCELED` | 입금 결과. `DONE`=입금완료 → 결제완료 처리, `CANCELED`=입금취소 → 결제실패 처리. | | secret | body | string | 아니오 | max 255 | 결제 승인 응답에서 발급받아 `payment_meta.toss_secret` 에 저장해 둔 값. 위조 방지 대조에 사용. | | transactionKey | body | string | 아니오 | max 255 | 토스 거래 키. 결제완료 처리 시 `transaction_id` 로 기록한다 (없으면 기존 값 유지). | @@ -111,7 +111,7 @@ OK | eventType | body | string | 아니오 | max 64 | 토스 이벤트 종류 (예: `PAYMENT_STATUS_CHANGED`). | | createdAt | body | string | 아니오 | max 64 | 토스가 이벤트를 생성한 시각. | | data | body | array | 예 | — | 결제 정보 객체. `data.orderId`(주문번호)와 `data.status`(토스 결제상태)를 읽어 로컬 상태와 대조한다. | -| data.orderId | body | string | 예 | max 100 | 주문번호 (G7 `orders.order_number`). 이 값으로 주문·결제 레코드를 조회해 로컬 결제상태를 확인한다. | +| data.orderId | body | string | 예 | max 100 | 주문번호 (그누보드7 `orders.order_number`). 이 값으로 주문·결제 레코드를 조회해 로컬 결제상태를 확인한다. | | data.status | body | string | 예 | max 40 | 토스 측 결제상태 (예: `DONE`, `CANCELED`, `WAITING_FOR_DEPOSIT`). 로컬 `payments.payment_status` 와 함께 로그에 기록해 불일치를 추적한다. | | data.paymentKey | body | string | 아니오 | max 255 | 토스 결제 키. 수신만 하며 이 엔드포인트에서 상태 전이에 사용하지 않는다. | diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/architecture.md b/plugins/_bundled/sirsoft-tosspayments/docs/architecture.md new file mode 100644 index 00000000..1ee4a9f7 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/architecture.md @@ -0,0 +1,58 @@ +# 토스페이먼츠 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +토스페이먼츠 통합결제창을 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 다른 PG +플러그인(iframe 팝업, CLI 실행, SOAP)과 달리 이 플러그인은 순수 리다이렉트 기반입니다 — +결제창이 브라우저를 콜백 URL로 돌려보내고, 서버는 그 쿼리 파라미터로 승인을 확인합니다. +이 단순함의 대가로 콜백 파라미터(특히 `amount`)를 신뢰하지 않고 서버가 재검증해야 +합니다(§AGENTS.md "의도적으로 하지 않는 것"). + +가상계좌 웹훅 검증도 다른 PG 와 다릅니다 — IP 화이트리스트가 아니라 결제 승인 응답에만 +실리는 secret 값 대조입니다. 토스가 notify IP 목록이나 서명을 제공하지 않기 때문입니다. + + +## 계층 지도 + + +```text +Controller (PaymentCallbackController / WebhookController) + → TossPaymentsApiService (승인 확인 · 취소 API 호출) + → sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음) + +Listener (RegisterPgProviderListener / RegisterTossPaymentMethodsListener / ValidateTossSettingsListener 등) + → sirsoft-ecommerce 의 필터 훅 + 코어 설정 저장 훅에 등록 (컴파일 타임 결합 없음) +``` + +`ValidateTossSettingsListener`가 `core.plugin_settings.before_save`(코어 설정 저장 훅)를 +구독하는 것은 이 플러그인만의 계층 특징입니다 — 결제 도메인 훅(`sirsoft-ecommerce.payment.*`) +뿐 아니라 코어 설정 저장 경로에도 개입해 저장 시점에 값을 검증합니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-tosspayments --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-tosspayments --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-tosspayments --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-tosspayments --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/data-model.md b/plugins/_bundled/sirsoft-tosspayments/docs/data-model.md new file mode 100644 index 00000000..139f3397 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/data-model.md @@ -0,0 +1,64 @@ +# 토스페이먼츠 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +_소유 모델이 없습니다._ + + + +결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이 +소유합니다(§AGENTS.md "설계 원칙"). 가상계좌 웹훅 검증에 쓰는 secret 조차 별도 테이블이 +아니라 `OrderPayment.payment_meta`(JSON 컬럼)의 `toss_secret` 키에 저장됩니다 — 이 값은 +그 주문 하나에만 의미가 있어 독립 테이블을 둘 이유가 없습니다. + + +## 소유 테이블 + + +_소유 테이블이 없습니다._ + + + +가상계좌 발급 정보와 토스 거래키(`paymentKey`)는 이커머스 `OrderPayment` 테이블의 기존 +컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면 관리자 주문 상세가 PG +종류에 따라 다른 테이블을 조인해야 합니다. + + +## 마이그레이션 + + +_마이그레이션이 없습니다._ + + + +소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 설정 스키마 +변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB 마이그레이션 +대상이 아닙니다. + + +## Enum + + +_Enum 이 없습니다._ + + + +`use_escrow`의 3-상태(`off`/`on`/`buyer_choice`)는 Enum이 아니라 +`ValidateTossSettingsListener::USE_ESCROW_VALUES` 상수 배열로 검증합니다 — 설정값 하나에만 +쓰이는 닫힌 어휘라 Enum 승격의 이득(여러 곳에서 타입으로 재사용)이 없습니다. + + +## Repository + + +_Repository 가 없습니다._ + + + +이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너)은 모두 +이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유 테이블이 없는 상태에서 +남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는 빈 계층입니다. + diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/editor-spec.md b/plugins/_bundled/sirsoft-tosspayments/docs/editor-spec.md new file mode 100644 index 00000000..7bfa9b9b --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/editor-spec.md @@ -0,0 +1,83 @@ +# 토스페이먼츠 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._ + + + +토스페이먼츠 결제 플러그인은 다른 결제 3종과 달리 편집기 스펙을 두지 않았습니다. +그럼에도 미커버가 없는 것은 이 플러그인의 설정 화면이 공용 ID 만 읽기 때문입니다 — +다른 결제 플러그인이 선언한 `vbank_info` 에 해당하는 영역을 이 플러그인은 설정 화면에서 +같은 방식으로 다루지 않습니다. + +즉 지금은 스펙 없이도 편집기에서 화면이 온전히 보입니다. + + +## 선언 블록 + + +_선언된 편집기 스펙 블록이 없습니다._ + + + +선언한 블록이 없습니다. 형제 결제 플러그인(`sirsoft-pay_kginicis` 등)은 스펙을 갖고 +있으므로, 이 플러그인에 가상계좌 안내 같은 도메인 영역을 추가할 때는 그쪽 스펙을 선례로 +봅니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다. +편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도 +`componentPalette` 는 여전히 비어 있을 것입니다. + + +## 샘플 데이터와 페이지 상태 + + +_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._ + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +미커버가 없습니다. 설정 화면이 읽는 `settings` 는 공용 ID 라 admin 템플릿 스펙이 +채우므로, 편집기에서 설정 화면이 값이 채워진 상태로 보입니다. + +결제 흐름 화면(주문·결제·완료)은 `sirsoft-ecommerce` 가 소유하므로 그 프리뷰는 이커머스 +스펙이 그립니다. + + +## 수정 시 동반 의무 + + +_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._ + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + + + +편집기 스펙을 신설해야 하는 시점은 하나입니다 — **이 확장이 소유한 레이아웃에 +이 확장만 쓰는 `data_source` 가 생겼을 때**. 그 순간부터 편집기 캔버스의 그 영역은 +빈 화면이 되고, 실제 화면은 정상 동작하므로 오류도 경고도 남지 않습니다. + +신설 절차는 확장 루트에 `editor-spec.json` 을 만들고 `sampleData.byDataSourceId` 에 +그 ID 를 넣는 것으로 시작합니다. 팔레트·컨트롤은 템플릿의 일이므로 넣지 않습니다. +파일을 만든 뒤 update 커맨드로 활성 디렉토리에 반영해야 편집기가 읽습니다 — +`_bundled` 폴백이 없습니다. + diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/extension-points.md b/plugins/_bundled/sirsoft-tosspayments/docs/extension-points.md new file mode 100644 index 00000000..8ee88279 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/extension-points.md @@ -0,0 +1,135 @@ +# 토스페이먼츠 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 4종 / 호출 지점 4곳. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `sirsoft-tosspayments.payment.after_cancel` | action | 토스페이먼츠 결제 취소 완료 후 | `src/Services/TossPaymentsApiService.php:102` | +| `sirsoft-tosspayments.payment.after_confirm` | action | 토스페이먼츠 결제 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:98` | +| `sirsoft-tosspayments.payment.before_cancel` | action | 토스페이먼츠 결제 취소 API 호출 전 | `src/Services/TossPaymentsApiService.php:98` | +| `sirsoft-tosspayments.payment.before_confirm` | action | 토스페이먼츠 결제 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:94` | + + + +`before_confirm`/`before_cancel`은 API 호출 **전** 개입 지점이라 예외를 던지면 실제 토스 +호출이 일어나지 않습니다. `after_confirm`은 승인 확인 응답을 받은 뒤(§AGENTS.md "핵심 흐름") +발화하므로, 이 시점에는 아직 금액 대조가 끝나지 않았을 수 있습니다 — 구독하는 확장은 +이커머스가 최종 완료 처리를 마쳤는지 별도로 확인해야 합니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.plugin_settings.before_save` | action (미선언) | `ValidateTossSettingsListener` | `validateBeforeSave` | 10 | +| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 | +| `sirsoft-ecommerce.cash_receipt.cancel` | filter | `RegisterCashReceiptProviderListener` | `cancel` | 10 | +| `sirsoft-ecommerce.cash_receipt.issue` | filter | `RegisterCashReceiptProviderListener` | `issue` | 10 | +| `sirsoft-ecommerce.cash_receipt.registered_providers` | filter | `RegisterCashReceiptProviderListener` | `registerProvider` | 10 | +| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 | +| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 | +| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 | +| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterTossPaymentMethodsListener` | `injectTossMethods` | 20 | + + + +`core.plugin_settings.before_save`는 다른 PG 플러그인에는 없는 구독입니다 — 이 플러그인만 +설정 저장 시점에 서버측 범위 검증(`vbank_valid_hours`, `use_escrow`)을 강제합니다 +(§AGENTS.md "핵심 흐름"). `RegisterTossPaymentMethodsListener`가 `order_sheet_mode`가 +꺼져 있으면 아무것도 주입하지 않는 것은 결제창형에서는 토스 결제수단 전부가 통합결제창 +카드 하나로 처리되기 때문입니다 — 개별 버튼을 만들 필요가 없습니다. 3종의 현금영수증 훅 +(`cash_receipt.*`)은 카드/계좌이체 발급, 취소, 프로바이더 등록을 각각 담당하며 모두 +`RegisterCashReceiptProviderListener` 하나가 처리합니다 — PG 선택과 현금영수증 발급사 +선택이 이커머스에서 독립적인 개념이라 별도 등록입니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` | +| `RegisterCashReceiptProviderListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/RegisterCashReceiptProviderListener.php` | +| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` | +| `RegisterTossPaymentMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterTossPaymentMethodsListener.php` | +| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` | +| `ValidateTossSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateTossSettingsListener.php` | + + + +`ValidateTossSettingsListener`는 구독 훅이 `sirsoft-ecommerce.*`가 아니라 +`core.plugin_settings.before_save`라는 점에서 이 표의 다른 5개 리스너와 성격이 다릅니다 — +결제 도메인이 아니라 코어 설정 저장 파이프라인에 개입합니다. `getSubscribedHooks()`에서 +`sync: true`를 선언하는 것도 이 리스너뿐입니다(§AGENTS.md "금지 패턴"). + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/admin_order_payment.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/checkout-payment-error.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +`checkout-payment-error.json`은 다른 PG 플러그인에는 없는 조각입니다 — 승인 확인 실패 +(`amount_mismatch`, 서명 오류 등)가 `?error=` 쿼리로 체크아웃에 되돌아왔을 때 그 오류를 +사용자에게 보여주는 전용 UI입니다. 다른 PG는 오류 안내를 체크아웃 레이아웃이 이미 가진 +공용 오류 처리에 맡기지만, 이 플러그인은 리다이렉트 기반 승인이라 오류 사유가 다양해 +전용 조각을 둡니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +다른 PG 플러그인은 가상계좌 입금통보에 IP 화이트리스트 미들웨어를 부착하지만, 이 +플러그인은 미들웨어가 없습니다 — 토스가 notify IP 목록을 제공하지 않아 IP 기반 검증 +자체가 불가능하기 때문입니다. 대신 §핵심 흐름의 secret 대조(`WebhookController` 안의 +애플리케이션 레벨 검증)가 그 역할을 대신합니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +결제 승인·웹훅은 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가 +필요한 지점이 없습니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +가상계좌 만료는 이 플러그인이 크론으로 스캔하지 않고, 만료 이후 도착하는 토스 입금 웹훅을 +거부하는 방식으로 처리됩니다. + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미 +포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트에 대해 PG 수만큼 중복 정의가 +생깁니다. + diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/frontend.md b/plugins/_bundled/sirsoft-tosspayments/docs/frontend.md new file mode 100644 index 00000000..e626408d --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/frontend.md @@ -0,0 +1,79 @@ +# 토스페이먼츠 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +다른 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면 +하나뿐입니다 — 체크아웃·주문상세의 결제 UI는 이 플러그인 소유가 아니라 §레이아웃 확장 +(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다. + + +## 액션 핸들러 + + +핸들러 1개 (정의: `resources/js/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `requestPayment` | `sirsoft-tosspayments.requestPayment` | + + + +핸들러가 이것 하나뿐인 이유는 이 플러그인이 통합결제창 SDK 호출 이후를 전부 브라우저 +리다이렉트에 위임하기 때문입니다(§AGENTS.md "1. 이 확장은 무엇인가") — 다른 PG처럼 결제 +수단 선택 UI를 DOM으로 직접 조작할 필요가 없습니다. `order_sheet_mode`에 따라 통합결제창 +하나(카드 한 장)를 열지, 사용자가 고른 개별 토스 결제수단(`params.paymentMethodId`)을 +지정해 열지가 갈리지만(`params.paymentMethod`, 미지정 시 `_local.paymentMethod` 참조) 그 +분기도 이 핸들러 하나 안에서 처리합니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftTosspayments` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftTosspayments`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록 +진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 토스 SDK(`js.tosspayments.com/v2/standard`) +자체는 이 진입점이 미리 로드하지 않습니다 — `requestPayment` 핸들러가 결제 시도 시점에 +동적으로 로드합니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +토스 SDK 자체는 이 목록에 없습니다 — `requestPayment` 핸들러가 결제 시도 시점에 동적으로 +로드하는 제3자 자산이라, 이 플러그인이 빌드 시 번들링하는 `dist/` 산출물과는 다른 층입니다. +다른 PG 플러그인과 달리 `editor-spec.json`이 없는 것은 이 플러그인이 레이아웃 편집기에서 +커스터마이즈 가능한 전용 컴포넌트를 노출하지 않기 때문입니다 — 결제 UI는 코어 기본 +컴포넌트와 §레이아웃 확장 조각만으로 구성됩니다. + diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/settings.md b/plugins/_bundled/sirsoft-tosspayments/docs/settings.md new file mode 100644 index 00000000..6a79bd8a --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/settings.md @@ -0,0 +1,105 @@ +# 토스페이먼츠 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `is_test_mode` | `boolean` | `true` | 테스트 모드 | +| `test_client_key` | `string` | - | 테스트 클라이언트 키 | +| `test_secret_key` | `string` | - | 테스트 시크릿 키 | +| `live_client_key` | `string` | - | 라이브 클라이언트 키 | +| `live_secret_key` | `string` | - | 라이브 시크릿 키 | +| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL | +| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL | +| `order_sheet_mode` | `boolean` | `false` | 주문서형 결제 | +| `method_card` | `boolean` | `true` | 카드 | +| `method_virtual_account` | `boolean` | `false` | 가상계좌 | +| `method_transfer` | `boolean` | `false` | 계좌이체 | +| `method_mobile_phone` | `boolean` | `false` | 휴대폰 | +| `method_tosspay` | `boolean` | `false` | 토스페이 | +| `method_kakaopay` | `boolean` | `false` | 카카오페이 | +| `method_naverpay` | `boolean` | `false` | 네이버페이 | +| `method_payco` | `boolean` | `false` | 페이코 | +| `method_samsungpay` | `boolean` | `false` | 삼성페이 | +| `vbank_valid_hours` | `integer` | `24` | 가상계좌 입금기한(시간) | +| `vbank_cash_receipt_type` | `string` | - | 가상계좌 현금영수증 유형 | +| `use_escrow` | `string` | `off` | 에스크로 사용 | +| `webhook_secret_verify` | `boolean` | `true` | 웹훅 secret 검증 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +`order_sheet_mode`가 이 스키마의 분기점입니다 — `false`(결제창형)면 `method_*` 8개 플래그는 +읽히지 않고 통합결제창이 결제수단 선택을 전담합니다. `true`(주문서형)로 켜야 `method_*` +플래그가 실제로 체크아웃 화면의 개별 버튼 노출 여부를 결정합니다(§AGENTS.md "이 확장은 +무엇인가"). `vbank_valid_hours`(1~2160시간)와 `use_escrow`(`off`/`on`/`buyer_choice`)는 +`ValidateTossSettingsListener`가 저장 시점에 범위를 강제합니다 — UI 의 input 힌트만으로는 +관리자 설정 저장 API 직접 호출을 막을 수 없기 때문입니다. `webhook_secret_verify`를 끄면 +가상계좌 웹훅의 유일한 위조 방지 수단이 사라지므로 기본값 `true`를 유지해야 합니다. + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을 +선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이 +플러그인 수만큼 중복 정의됩니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해 +접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가 +난립합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `web` | `src/routes/web.php` | `/plugins/sirsoft-tosspayments/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +다른 PG 플러그인과 달리 `api` 라우트 파일이 없습니다 — 이 플러그인은 로그인 사용자가 +Bearer 토큰으로 직접 호출하는 엔드포인트(예: 관리자 거래조회)를 두지 않습니다. 승인 +콜백·가상계좌 웹훅 모두 결제창 리다이렉트나 토스 서버가 도달하는 경로라 `web`에만 +있습니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는 +이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이 +플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델 +구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화"). + diff --git a/plugins/_bundled/sirsoft-tosspayments/package-lock.json b/plugins/_bundled/sirsoft-tosspayments/package-lock.json index a509cf26..fa8c7165 100644 --- a/plugins/_bundled/sirsoft-tosspayments/package-lock.json +++ b/plugins/_bundled/sirsoft-tosspayments/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-tosspayments", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-tosspayments", - "version": "1.0.2", + "version": "1.0.3", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-tosspayments/package.json b/plugins/_bundled/sirsoft-tosspayments/package.json index 549cc877..bc081869 100644 --- a/plugins/_bundled/sirsoft-tosspayments/package.json +++ b/plugins/_bundled/sirsoft-tosspayments/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-tosspayments", - "version": "1.0.2", + "version": "1.0.3", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-tosspayments/plugin.json b/plugins/_bundled/sirsoft-tosspayments/plugin.json index d3cd159b..d8c8232f 100644 --- a/plugins/_bundled/sirsoft-tosspayments/plugin.json +++ b/plugins/_bundled/sirsoft-tosspayments/plugin.json @@ -5,7 +5,7 @@ "ko": "토스페이먼츠", "en": "TossPayments" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "description": { "ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)", diff --git a/plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md b/plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md new file mode 100644 index 00000000..73ae0ca9 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/AGENTS.md @@ -0,0 +1,189 @@ +# KG이니시스 본인인증 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-verification_kginicis) — KG이니시스 본인확인(reqSvcCd=03) IDV Provider. 코어 `IdentityVerificationInterface` 12메서드 구현, PII 레코드 소유 (payment 플러그인과 달리 소유 테이블 있음) +2. 확장 방식: `RegisterInicisProviderListener` 로 코어 `core.identity.registered_providers` 필터에 등록 — 코어는 이 플러그인의 존재를 모른다 +3. 건드리면 안 되는 것: 비로그인 사용자 PII 캐시 stash(`inicis:pending_record:` 접두) 로직 우회, 라이브 MID `SRB` 프리픽스 정책값 상수(`LIVE_MID_PREFIX`) 미참조, 중복가입 차단(`AssertNoDuplicateInicisIdentity`) 우회 +4. 작업 위치: `plugins/_bundled/sirsoft-verification_kginicis` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-verification_kginicis --force` +``` + +## 1. 이 확장은 무엇인가 + + +KG이니시스 본인확인(휴대폰 인증, `reqSvcCd=03`)을 코어 본인인증(IDV) 체계에 연결하는 +Provider 입니다. 코어 `IdentityVerificationInterface`(표준 12메서드)를 구현해, 회원가입· +비밀번호 찾기·민감작업 등 코어가 정의한 모든 IDV 강제 지점에서 이메일 인증 대신 이니시스 +팝업이 대신 동작하게 합니다. `sirsoft-verification_nhnkcp`도 같은 인터페이스를 구현하며, +운영자는 둘 중 어느 것이든(또는 둘 다) 설치해 사용할 수 있습니다 — 코어는 등록된 +provider ID로만 구분하고 어느 PG사인지 모릅니다. + +**결제 PG 플러그인과의 결정적 차이**: 이 플러그인은 실제 PII(개인식별정보) 레코드를 +소유합니다(§data-model.md — `inicis_identity_records` 테이블, `InicisIdentityRecord` +모델). 결제 플러그인들이 "상태는 남의 것, 절차만 내 것"이었던 것과 달리, 본인확인은 그 +확인 결과(이름·생년월일·성별·CI/DI 등)를 이 플러그인이 직접 보관해야 이후 재확인 없이 +"본인확인 완료 여부"를 판단할 수 있습니다. + +**의도적으로 하지 않는 것**: 비로그인 사용자(예: 회원가입 도중)의 PII는 확인 즉시 +DB 에 쓰지 않고 Cache 에 임시 저장(`inicis:pending_record:` 접두)했다가, 가입이 실제로 +완료된 뒤(`core.auth.after_register` 훅)에야 레코드로 흡수합니다 — 가입을 완료하지 않은 +방문자의 PII 를 DB 에 영구 저장하지 않기 위함입니다. 사용자가 탈퇴하거나 계정이 삭제되면 +`core.user.after_withdraw`/`core.user.before_delete` 훅에서 관련 레코드를 정리합니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.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()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_kginicis --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_kginicis --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_kginicis --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_kginicis --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_kginicis --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**본인확인 시작~완료**: 코어가 IDV 를 요구하는 지점(회원가입 등)에서 428 응답 → +프론트 `startAuth` 핸들러가 사용자 클릭 컨텍스트 안에서 `window.open`으로 빈 팝업 생성 +(Chrome popup blocker 회피 — 자동 호출은 차단되지만 클릭 직후 호출은 통과) → 코어 +challenge 시작 응답의 `mid`/`mtxid`/`authHash`로 팝업에 이니시스 인증 폼 제출 → 이니시스 +인증 완료 후 `InicisChallengeMappingRepository`가 mTxId ↔ challenge_id 매핑을 저장 → +인증 결과는 postMessage 또는 팝업 종료 감지로 회수 → `InicisIdentityProvider::verify()`가 +SEED 복호화 후 결과를 반환. 성인인증(`inicis.adult_verification` purpose)으로 발행된 +challenge 는 만 19세 이상만 통과시킵니다. + +**비로그인 사용자(회원가입 도중) 처리**: `verify()` 시점에 로그인 사용자가 없으면 PII 를 +Cache 에 stash(`inicis:pending_record:{key}`) → 회원가입 완료 → `core.auth.after_register` +훅 → `CompleteInicisRecordAfterRegister`가 같은 캐시 키로 PII 를 회수해 +`InicisIdentityRecord`로 흡수. + +**중복가입 차단**: `core.auth.before_register` 훅 → `AssertNoDuplicateInicisIdentity`가 +`duplicate_block_enabled` 설정이 켜져 있으면 `duplicate_field`(DI 또는 CI) 기준으로 +`InicisIdentityLogQueryRepository`를 조회해 이미 가입된 동일인이 있는지 확인 → 있으면 +가입을 차단. + +**사용자 삭제/탈퇴 시 PII 정리**: `core.user.before_delete`/`core.user.after_withdraw` 훅 → +`CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`가 해당 사용자의 +`inicis_identity_records`를 정리. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 3개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 6개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 6개 | [훅 리스너](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#알림-정의) | + + + +`core.identity.registered_providers` 는 코어가 등록된 IDV provider 목록을 모으는 필터 +훅입니다 — 새 IDV PG 를 추가하려는 확장은 이 훅에 자기 provider 를 등록하면 됩니다 +(`sirsoft-verification_nhnkcp`가 동일 패턴). `core.plugin_settings.update_validation_rules`는 +`ValidateInicisSettingsListener`가 `is_test_mode=false`(라이브 모드) 진입 시 +`live_mid`/`live_api_key`에 `required` 규칙을 동적으로 부여하는 자리입니다 — 코어 +`UpdatePluginSettingsRequest`의 정적 스키마는 "테스트 모드일 땐 선택, 라이브 모드일 땐 +필수" 같은 조건부 검증을 표현할 수 없기 때문입니다. `live_mid`의 `SRB` 프리픽스는 이 +필터가 아니라 `InicisIdentityProvider::buildLiveMid()`가 그 값을 실제로 쓸 때(요청 조립 +시점) 동적으로 부착하므로 별도 형식 검증을 두지 않습니다 — DB 에는 운영자가 입력한 원본 +값이 그대로 저장됩니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-verification_kginicis --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-verification_kginicis` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] PII 컬럼(이름·생년월일·성별·CI/DI 등)을 다루는 코드 변경 시 GDPR 삭제/탈퇴 정리 리스너(`CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`)가 여전히 그 컬럼을 정리하는지 확인 +- [ ] 팝업 기반 인증 흐름(`startAuth`)을 고칠 때 `window.open`을 사용자 클릭 컨텍스트 밖으로 옮기지 않는다 — Chrome popup blocker 회피가 깨진다 +- [ ] `duplicate_field`/`duplicate_block_enabled` 로직을 고치면 `InicisDuplicateField` Enum 과 `AssertNoDuplicateInicisIdentity`를 함께 갱신 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-verification_kginicis --force` + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 비로그인 사용자의 PII 를 verify 즉시 DB 에 저장 | Cache 에 stash(`inicis:pending_record:` 접두) 후 가입 완료 시 흡수 | 가입을 완료하지 않은 방문자의 PII 를 DB 에 영구 저장하면 불필요한 개인정보 보유가 된다 | +| 사용자 삭제/탈퇴 리스너 없이 PII 컬럼 추가 | `CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`에 정리 로직 동반 | 정리 누락 시 탈퇴한 사용자의 PII 가 무기한 남는다 | +| `LIVE_MID_PREFIX` 상수를 참조하지 않고 `'SRB'`를 문자열로 재작성 | `InicisIdentityProvider::LIVE_MID_PREFIX` 참조 | 이니시스 프리픽스 정책이 바뀌면 상수 1곳만 갱신해야 런타임 로직 전체에 반영된다 — 문자열 재작성은 사각을 만든다 | +| 팝업을 사용자 클릭 이벤트 핸들러 밖(비동기 콜백 등)에서 `window.open` | 사용자 제스처 컨텍스트 안에서 직접 호출 | Chrome 등 브라우저는 사용자 제스처 없이 열리는 팝업을 자동 차단한다 | +| 라이브 API 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 본인확인 API 를 위조 호출할 수 있다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 22개 | `plugins/_bundled/sirsoft-verification_kginicis/tests` | +| Vitest | 8개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 8개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-verification_kginicis/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-verification_kginicis && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md index 98f38f78..cab377a0 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.5] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ## [1.0.4] - 2026-08-22 ### Changed diff --git a/plugins/_bundled/sirsoft-verification_kginicis/README.md b/plugins/_bundled/sirsoft-verification_kginicis/README.md new file mode 100644 index 00000000..f179c7f9 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/README.md @@ -0,0 +1,183 @@ +# KG이니시스 본인인증 + +**그누보드7 플러그인 · sirsoft-verification_kginicis** +KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 그누보드7 코어 IDV 인프라에 Provider 로 등록하는 플러그인 + + +

+ version 1.0.5 + type 플러그인 + 그누보드7 >=7.0.8 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +KG이니시스 본인확인(휴대폰 인증, reqSvcCd=03)을 그누보드7 코어의 본인인증(IDV) 체계에 연결하는 +플러그인입니다. 코어가 정의한 표준 인터페이스를 구현해, 회원가입·비밀번호 찾기·민감작업 +등 코어가 IDV 를 요구하는 모든 지점에서 이메일 인증 대신 이니시스 팝업이 동작하게 +합니다. + +결제 PG 플러그인들과 달리 이 플러그인은 실제 개인식별정보(PII — 이름·생년월일·성별·CI/DI +등)를 직접 보관합니다. 본인확인 결과를 저장해 두어야 이후 재확인 없이 "이 사용자가 +본인확인을 완료했는가"를 즉시 판단할 수 있기 때문입니다. `sirsoft-ecommerce`를 비롯한 +어떤 다른 확장에도 의존하지 않고 코어만으로 동작합니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 본인확인 | KG이니시스 통합인증(reqSvcCd=03) 팝업 기반 본인확인 | +| 성인인증 | 만 19세 이상 여부만 확인하는 별도 purpose | +| 중복가입 차단 | DI 또는 CI 기준으로 동일인 재가입 차단 (선택) | +| 게스트 처리 | 비로그인 사용자의 본인확인 결과를 임시 보관 후 가입 완료 시 흡수 | +| 개인정보 정리 | 사용자 탈퇴/삭제 시 보관 중인 PII 레코드 자동 파기 | +| 마이페이지 | 본인확인 완료 상태 카드 노출 | +| 관리자 설정 | 테스트/라이브 모드 전환, 중복가입 판정 기준 설정 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[코어가 IDV 요구 · 428] --> B[사용자 클릭 → 팝업 오픈] + B --> C[이니시스 인증 폼 제출] + C --> D[인증 완료 → 결과 회수] + D --> E[SEED 복호화 → PII 확보] + E -->|로그인 사용자| F[레코드 즉시 저장] + E -->|비로그인 사용자| G[Cache 임시 보관] + G --> H[가입 완료 시 레코드로 흡수] +``` + +팝업은 반드시 사용자 클릭 이벤트 안에서 열립니다 — 비동기 콜백 이후에 열면 브라우저 +팝업 차단기에 걸립니다. 비로그인 사용자(회원가입 도중)의 본인확인 결과는 가입이 실제로 +완료되기 전까지 DB 가 아니라 Cache 에만 임시 보관됩니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.8` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-verification_kginicis + +# 활성화 +php artisan plugin:activate sirsoft-verification_kginicis + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-verification_kginicis --force +``` + +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-verification_kginicis + + +## 관리자 설정 + + +| 키 | 의미 | 기본값 | +|---|---|---| +| `is_test_mode` | 테스트 모드 | `true` | +| `test_mid` | 테스트 MID | `INIiasTest` | +| `test_api_key` | 테스트 API 키 | `TGdxb2l3enJDWFRTbTgvREU3MGYwUT09` | +| `live_mid` | 라이브 MID | - | +| `live_api_key` | 라이브 API 키 | - | +| `duplicate_field` | 중복 판정 필드 | `di` | +| `duplicate_block_enabled` | 중복 가입 차단 | `true` | + +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + + + +`live_mid`는 실제 사용 시점(요청 조립 시)에 `SRB` 프리픽스가 자동으로 붙으므로 프리픽스 +없이 입력해도 됩니다. +`live_mid`/`live_api_key`는 `is_test_mode`를 끄는(라이브 모드) 순간부터 필수가 됩니다 — +테스트 모드에서는 비워둘 수 있습니다. `duplicate_field`(`di` 또는 `ci`)와 +`duplicate_block_enabled`는 다른 IDV provider(예: `sirsoft-verification_nhnkcp`)와 별개로 +이 provider 를 통해 확인한 사용자에게만 적용됩니다. 라이브 API 키는 외부에 노출하지 +마세요. + + +## 사용 방법 + + +**활성화하기**: 플러그인을 활성화하면 자동으로 코어 IDV provider 목록에 등록됩니다. +별도 화면 배치 작업 없이 코어가 이미 정의한 IDV 강제 지점(회원가입 등)에서 즉시 +동작합니다. 여러 IDV provider 를 동시에 설치했다면 코어 본인인증 정책 화면에서 어느 +provider 를 쓸지 선택합니다. + +**중복가입 차단 켜기**: 동일인이 여러 계정을 만드는 것을 막고 싶다면 +`duplicate_block_enabled`를 켜고 `duplicate_field`로 DI/CI 중 판정 기준을 고릅니다. + +**개인정보 보관 정책 확인**: 사용자가 탈퇴하거나 관리자가 계정을 삭제하면 보관 중인 +본인확인 PII 가 자동으로 파기됩니다 — 별도 운영 작업이 필요 없습니다. + +전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은 +[docs/extension-points.md](docs/extension-points.md) 를 참고하세요. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 인증 버튼을 눌러도 팝업이 안 뜸 | 브라우저 팝업 차단기 | `startAuth`가 클릭 이벤트 핸들러 안에서 직접 호출되는지 확인 — 비동기 콜백 뒤로 옮기면 차단됨 | +| 설정 저장 시 422 오류 | 라이브 모드인데 `live_mid`/`live_api_key` 미입력 | `is_test_mode`를 켜거나 라이브 자격증명을 입력 | +| 이미 가입된 사용자인데 중복 오류 없이 재가입됨 | `duplicate_block_enabled`가 꺼져 있거나 `duplicate_field` 기준이 실제 판정과 다름 | 관리자 설정에서 두 값을 확인 | +| 탈퇴한 사용자의 본인확인 정보가 남아있는 것으로 보임 | 정리 리스너 실행 여부를 별도로 확인하지 않음 | `CleanInicisRecordOnUserWithdraw`/`CleanInicisRecordOnUserDelete` 정상 등록 여부를 훅 캐시에서 확인 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/plugins/_bundled/sirsoft-verification_kginicis/composer.json b/plugins/_bundled/sirsoft-verification_kginicis/composer.json index edc52d24..802dca62 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/composer.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-verification_kginicis", "description": "KG Inicis Identity Verification provider for G7", - "version": "1.0.4", + "version": "1.0.5", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/README.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/README.md new file mode 100644 index 00000000..b6a592a5 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/README.md @@ -0,0 +1,23 @@ +# KG이니시스 본인인증 개발자 문서 + +> plugins/_bundled/sirsoft-verification_kginicis · 플러그인 + + +**훅 수**: 3 · **구독 훅 수**: 6 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 3 · **레이아웃 수**: 1 · **핸들러 수**: 1 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/architecture.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/architecture.md new file mode 100644 index 00000000..ddf01782 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/architecture.md @@ -0,0 +1,68 @@ +# KG이니시스 본인인증 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +KG이니시스 본인확인을 코어 IDV(본인인증) 체계에 연결하는 Provider 입니다. 코어 +`IdentityVerificationInterface`를 구현하는 것이 이 플러그인의 유일한 계약이며, 코어는 +`core.identity.registered_providers` 필터로 등록된 provider 목록만 알고 어느 PG사의 +구현인지는 모릅니다. + +결제 PG 플러그인들과 달리 이 플러그인은 실제 PII 를 소유합니다(§data-model.md). 본인확인 +결과(CI/DI 등)를 재확인 없이 판단하려면 그 결과를 어딘가 보관해야 하고, 그 보관 책임은 +Provider 자신에게 있습니다 — 코어는 "본인확인 완료 여부"만 알면 되고 원본 PII 를 알 필요가 +없습니다. + + +## 계층 지도 + + +```text +Controller (Http/Controllers) → FormRequest (Http/Requests) + → InicisIdentityProvider (IdentityVerificationInterface 구현 — verify/challenge 표준 진입점) + → InicisGatewayInterface (외부 통신 + SEED 복호화) + → InicisChallengeMappingRepositoryInterface (mTxId ↔ challenge_id) + → InicisIdentityRecordRepositoryInterface (PII record) + → CacheInterface (비로그인 verify PII 임시 stash) + +Listener (RegisterInicisProviderListener 등) + → 코어 identity/auth/user 훅에 등록 (컴파일 타임 결합 없음) +``` + +`InicisIdentityProvider`가 4개의 협력자(게이트웨이·Repository 2종·캐시)를 생성자 주입받는 +것은 그 각각이 서로 다른 관심사(외부 API 통신, DB 영속, 임시 캐시)이기 때문입니다 — 하나로 +합치면 단위 테스트에서 외부 API 를 모킹할 때 DB/캐시까지 함께 모킹해야 합니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.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()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_kginicis --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_kginicis --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_kginicis --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_kginicis --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_kginicis --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/data-model.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/data-model.md new file mode 100644 index 00000000..6c8be445 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/data-model.md @@ -0,0 +1,95 @@ +# KG이니시스 본인인증 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `InicisChallengeMapping` | `inicis_challenge_mappings` | 2 | challenge→IdentityVerificationLog | - | +| `InicisIdentityRecord` | `inicis_identity_records` | 17 | user→User | - | + + + +`InicisIdentityRecord`가 PII(이름·생년월일·성별·CI/DI 등)를 직접 보관하는 것이 결제 +플러그인들과의 근본적 차이입니다(§AGENTS.md "1. 이 확장은 무엇인가") — 이 레코드가 없으면 +"이 사용자가 본인확인을 완료했는가"를 매번 이니시스에 재조회해야 합니다. +`InicisChallengeMapping`은 별도 모델입니다 — 진행 중인 인증 시도(mTxId ↔ challenge_id)와 +완료된 확인 결과(PII record)는 생명주기가 다르기 때문입니다(전자는 인증 세션 하나, +후자는 사용자당 최신 1건). + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `inicis_challenge_mappings` | `InicisChallengeMapping` | +| `inicis_identity_records` | `InicisIdentityRecord` | + + + +`inicis_identity_records.user_id`는 `unique` 제약을 갖습니다 — 한 사용자는 본인확인 결과를 +1건만 보유하며, 재인증 시 기존 레코드를 갱신합니다(새 레코드를 추가하지 않습니다). 이 +설계 덕분에 "이 사용자가 본인확인을 완료했는가"는 단순 존재 조회 하나로 판정됩니다. + + +## 마이그레이션 + + +마이그레이션 3개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_05_08_000001_create_inicis_identity_records_table.php` | `inicis_identity_records` | `inicis_identity_records` | ✅ | +| `2026_05_08_000002_create_inicis_challenge_mappings_table.php` | `inicis_challenge_mappings` | `inicis_challenge_mappings` | ✅ | +| `2026_06_22_000001_make_inicis_record_aux_fields_nullable.php` | - | `inicis_identity_records` | ✅ | + + + +세 번째 마이그레이션이 `name`/`phone`/`birthday` 암호화 컬럼을 nullable 로 완화한 이유는 +누락 값을 `null`로 저장하기 위함입니다 — `Crypt::encryptString('')`로 "암호화된 빈 +문자열"을 저장하면 복호화 시 빈 칸이 나오는 오염 레코드가 됩니다. 정상 경로에서는 +`verify()` 가드(`INCOMPLETE_IDENTITY`)가 이 신원 핵심값의 누락을 이미 차단하므로 항상 +채워지며, 이 nullable 화는 가드를 우회한 비정상 입력이 암호화된 빈 문자열로 오염 +저장되는 것을 막는 방어적 통일입니다. + + +## Enum + + +| Enum | backing | case 수 | case | +|---|---|---|---| +| `InicisDuplicateField` | `string` | 2 | `di`, `ci` | + + + +`di`(연계정보)와 `ci`(연계정보의 상위 개념 — 사이트 간 동일인 식별용) 중 어느 필드로 중복 +가입을 판정할지는 운영자가 설정으로 고릅니다(§settings.md `duplicate_field`). Enum 으로 +닫힌 것은 이 값이 `AssertNoDuplicateInicisIdentity`의 조회 조건과 +`InicisIdentityLogQueryRepository`의 컬럼 화이트리스트 양쪽에서 타입 안전하게 재사용되기 +때문입니다 — 문자열 리터럴이었다면 오타가 조용히 "중복 없음"으로 판정될 위험이 있습니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `InicisChallengeMappingRepository` | 구현 | `inicis_challenge_mappings` 테이블 Repository 구현체. | +| `InicisChallengeMappingRepositoryInterface` | 인터페이스 | 이니시스 mTxId ↔ challenge_id 매핑 Repository 인터페이스. | +| `InicisIdentityLogQueryRepository` | 구현 | InicisIdentityLogQueryRepositoryInterface 구현체. | +| `InicisIdentityLogQueryRepositoryInterface` | 인터페이스 | 본 plugin 의 동일인 검증 listener 전용 IdentityVerificationLog 조회 Repository. | +| `InicisIdentityRecordRepository` | 구현 | `inicis_identity_records` 테이블 Repository 구현체. | +| `InicisIdentityRecordRepositoryInterface` | 인터페이스 | KG이니시스 본인확인 PII 레코드 Repository 인터페이스. | + + + +3쌍(인터페이스+구현체)으로 나뉜 것은 각자 다른 데이터를 다루기 때문입니다 — +`InicisChallengeMappingRepository`(진행 중인 인증 세션), `InicisIdentityRecordRepository` +(완료된 PII), `InicisIdentityLogQueryRepository`(동일인 검증 전용 `IdentityVerificationLog` +조회, 코어 로그 테이블을 이 플러그인 관점으로 좁혀 읽는 어댑터). 결제 플러그인들이 +Repository 를 하나도 두지 않는 것과 대조적으로, 이 플러그인은 실제 PII 를 소유하므로 +Repository 인터페이스 주입 원칙(§CLAUDE.md "Service-Repository 패턴")이 그대로 적용됩니다. + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/editor-spec.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/editor-spec.md new file mode 100644 index 00000000..6aeee96f --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/editor-spec.md @@ -0,0 +1,113 @@ +# KG이니시스 본인인증 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `plugins/_bundled/sirsoft-verification_kginicis/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 1 · 페이지 상태 2 + + + +KG이니시스 통합인증 Provider 플러그인은 코어 IDV 인프라에 자기 Provider 를 등록하는 것이 본체이고, +화면은 관리자 설정과 **사용자 화면에 뜨는 인증 창**입니다. 그래서 스펙이 담는 것도 그 +두 자리뿐입니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `editor-spec.json (인라인)` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 2 | `editor-spec.json (인라인)` | + + + +`byDataSourceId` 는 `inicisRecord` 하나입니다 — 인증 결과 레코드를 화면에 보여 주는 자리입니다. +인증 정책·목적·메시지는 코어 IDV 가 소유하고 admin 템플릿 스펙이 그 샘플을 채우므로 +여기서 다시 선언하지 않습니다. + +`states.groups` 가 2종인 것이 이 플러그인의 특징입니다. 인증은 **여러 화면에 걸쳐 +나타나는 기능**이라 설정 화면 하나로 끝나지 않습니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `inicisRecord` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 2 | `*/admin/plugins/sirsoft-verification_kginicis/settings` · `_user_base` | + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +상태 범위는 `*/admin/plugins/sirsoft-verification_kginicis/settings` 와 `_user_base` 입니다. `_user_base` 가 들어 있는 것이 핵심입니다 — 본인인증 +요구는 특정 라우트가 아니라 **어느 화면에서든 428 응답으로 발생**할 수 있고, 그때 뜨는 +인증 창은 베이스 레이아웃 위에 얹힙니다. + +편집기 캔버스는 실제 428 응답을 받지 않으므로, 그 상태를 변종으로 주입해 두지 않으면 +인증 창이 화면에 나타나지 않아 **편집할 방법이 없습니다.** + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan plugin:update sirsoft-verification_kginicis --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +인증 창의 모양을 바꿨다면 이 스펙만으로는 끝나지 않습니다. 인증 창을 여는 주체는 +템플릿 부트스트랩이 등록한 launcher 이므로, launcher 가 여는 화면과 여기 상태 변종이 +가리키는 화면이 같은지 함께 확인합니다. + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/extension-points.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/extension-points.md new file mode 100644 index 00000000..f1dc27fb --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/extension-points.md @@ -0,0 +1,130 @@ +# KG이니시스 본인인증 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 3종 / 호출 지점 3곳. 이 중 3종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `core.auth.after_register` | action | — | `src/Listeners/CompleteInicisRecordAfterRegister.php:64` | +| `core.user.after_withdraw` | action | — | `src/Listeners/CleanInicisRecordOnUserWithdraw.php:18` | +| `core.user.before_delete` | action | — | `src/Listeners/CleanInicisRecordOnUserDelete.php:23` | + + + +이 3종은 이 플러그인이 실제로 발행하는 훅이 아닙니다 — 세 리스너 파일 모두 코어가 그 +훅을 어떻게 호출하는지 보여주는 **docblock 예시**(`HookManager::doAction('core.user.before_delete', $user);` +형태의 주석)를 갖고 있는데, 소스 자동 감지가 그 주석 텍스트를 실제 발행 호출로 오인해 +잡아낸 결과입니다. 실제 발행 주체는 코어이며, 이 플러그인은 §구독 훅에서 같은 이름으로 +**구독**만 합니다. 이 확장의 리스너에 이런 docblock 예시를 새로 추가할 때는 이 표에 +가짜 항목이 늘어난다는 점을 감안합니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.auth.after_register` | action (미선언) | `CompleteInicisRecordAfterRegister` | `handle` | 50 | +| `core.auth.before_register` | action (미선언) | `AssertNoDuplicateInicisIdentity` | `handle` | 20 | +| `core.identity.registered_providers` | filter | `RegisterInicisProviderListener` | `register` | 20 | +| `core.plugin_settings.update_validation_rules` | filter | `ValidateInicisSettingsListener` | `addLiveModeRules` | 10 | +| `core.user.after_withdraw` | action (미선언) | `CleanInicisRecordOnUserWithdraw` | `handle` | 50 | +| `core.user.before_delete` | action (미선언) | `CleanInicisRecordOnUserDelete` | `handle` | 50 | + + + +`AssertNoDuplicateInicisIdentity`가 `core.auth.before_register`(가입 **전**)을 구독하는 +것은 중복 가입을 막으려면 가입 트랜잭션이 커밋되기 전에 차단해야 하기 때문입니다 — +`after_register`에서 잡으면 이미 중복 계정이 생성된 뒤라 롤백이 더 복잡해집니다. +`core.user.before_delete`는 `sync: true`로 동기 실행됩니다 — 이 시점 정리가 실패하면 +FK 제약(1451)으로 사용자 삭제 자체가 실패해야 하므로, 비동기 큐로 미뤄지면 삭제가 먼저 +끝나버릴 수 있습니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `AssertNoDuplicateInicisIdentity` | 1개 | 명시 등록 | ✅ | `src/Listeners/AssertNoDuplicateInicisIdentity.php` | +| `CleanInicisRecordOnUserDelete` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanInicisRecordOnUserDelete.php` | +| `CleanInicisRecordOnUserWithdraw` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanInicisRecordOnUserWithdraw.php` | +| `CompleteInicisRecordAfterRegister` | 1개 | 명시 등록 | ✅ | `src/Listeners/CompleteInicisRecordAfterRegister.php` | +| `RegisterInicisProviderListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterInicisProviderListener.php` | +| `ValidateInicisSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateInicisSettingsListener.php` | + + + +6개 리스너 중 3개(`CompleteInicisRecordAfterRegister`, `CleanInicisRecordOnUserWithdraw`, +`CleanInicisRecordOnUserDelete`)는 사용자 생명주기 각 단계(가입 완료·탈퇴·삭제)에 맞춰 +PII 레코드를 흡수하거나 정리하는 대칭 구조입니다 — 하나를 고칠 때 나머지 둘도 같은 PII +필드를 다루고 있는지 확인해야 합니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/identity_provider_inicis.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/mypage_identity_card.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +`identity_provider_inicis.json`은 코어 IDV 팝업(§CLAUDE.md "본인인증(IDV) 공통 UI 가이드")이 +provider 별로 다른 안내 문구·로고를 보여줘야 할 때 이 플러그인이 자기 몫을 주입하는 +조각입니다. `mypage_identity_card.json`은 마이페이지에 "본인확인 완료" 상태 카드를 +보여주는 조각으로, `sirsoft-verification_nhnkcp`도 동일한 명명 규칙의 자기 조각을 갖습니다 +— 여러 IDV provider 가 동시에 설치돼도 각자 자기 카드만 주입하므로 충돌하지 않습니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +결제 플러그인들과 달리 이 플러그인은 PG 서버가 직접 호출하는 웹훅/통보 엔드포인트가 +없습니다 — 본인확인 결과는 팝업 콜백(사용자 브라우저 경유)으로만 도달하므로 IP +화이트리스트 같은 서버간 통신 검증이 필요 없습니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +인증 결과는 팝업의 postMessage 또는 팝업 종료 감지로 프론트가 직접 회수합니다(§AGENTS.md +"핵심 흐름") — 서버가 다른 클라이언트에 실시간으로 알려야 할 상태 변화가 없어 브로드캐스트 +채널이 필요 없습니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +mTxId ↔ challenge_id 매핑(`inicis_challenge_mappings`)이나 pending PII 캐시는 만료된 +항목을 별도 배치로 청소하지 않습니다 — 캐시는 TTL 로 자연 소멸하고, 매핑 테이블의 정리는 +사용자 삭제/탈퇴 시점 리스너가 담당합니다(§훅 리스너). + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +본인확인 성공/실패는 사용자가 팝업 화면에서 즉시 확인하는 동기적 상호작용이라, 별도 +알림(이메일/SMS 등)을 발송할 지점이 없습니다. + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/frontend.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/frontend.md new file mode 100644 index 00000000..0fc4631e --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/frontend.md @@ -0,0 +1,81 @@ +# KG이니시스 본인인증 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +결제 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면 +하나뿐입니다 — 회원가입·마이페이지의 본인확인 UI는 이 플러그인 소유가 아니라 +§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각) 및 코어 IDV 공통 팝업 UI로 +존재합니다. + + +## 액션 핸들러 + + +핸들러 1개 (정의: `resources/js/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `startAuth` | `sirsoft-verification_kginicis.startAuth` | + + + +`startAuth`는 반드시 사용자 클릭 이벤트 핸들러 안에서(동기적으로) 호출돼야 합니다 — +`window.open`으로 빈 팝업을 먼저 연 뒤 그 팝업에 이니시스 인증 폼을 제출하는 방식인데, +`window.open`이 사용자 제스처 컨텍스트 밖(예: API 응답을 기다린 뒤의 비동기 콜백)에서 +호출되면 Chrome 등 브라우저의 팝업 차단기에 걸립니다. 과거 `setLauncher`로 코어 IDV +런처를 덮어써 챌린지 발급 이후 시점에 팝업을 여는 방식을 썼다가 이 문제로 폐기됐습니다 +(소스 주석 "Phase E′-revert"). + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftVerificationKginicis` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftVerificationKginicis`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 +재등록 진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 이 플러그인은 결제 PG +플러그인들과 달리 결제창 SDK 를 동적 로드하지 않습니다 — 이니시스 인증 폼은 팝업 +안에서 서버가 렌더링한 페이지로 제출되므로 프론트가 별도 스크립트를 불러올 필요가 +없습니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +제3자 SDK 스크립트가 목록에 없는 것은 §전역 진입점에서 설명한 대로 이 플러그인이 그런 +자산을 동적 로드하지 않기 때문입니다 — 인증 폼 자체가 서버 렌더링 페이지라 프론트는 +팝업을 열고 결과를 회수하는 역할만 합니다. CSS 산출물이 없는 것은 이 플러그인의 UI가 +버튼·상태 카드 같은 최소한의 코어 컴포넌트로만 구성되기 때문입니다. + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/settings.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/settings.md new file mode 100644 index 00000000..5947bf7e --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/settings.md @@ -0,0 +1,93 @@ +# KG이니시스 본인인증 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `is_test_mode` | `boolean` | `true` | 테스트 모드 | +| `test_mid` | `string` | `INIiasTest` | 테스트 MID | +| `test_api_key` | `string` | `TGdxb2l3enJDWFRTbTgvREU3MGYwUT09` | 테스트 API 키 | +| `live_mid` | `string` | - | 라이브 MID | +| `live_api_key` | `string` | - | 라이브 API 키 | +| `duplicate_field` | `enum` | `di` | 중복 판정 필드 | +| `duplicate_block_enabled` | `boolean` | `true` | 중복 가입 차단 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +결제 PG 플러그인들의 `test_*`/`live_*` 쌍과 같은 구조이지만 필드는 단 2개(`test_mid`/ +`test_api_key`, `live_mid`/`live_api_key`)뿐입니다 — 본인확인 API 는 결제와 달리 사인키· +INIAPI 키·모바일 해시키처럼 기능별로 분리된 자격증명이 필요 없습니다. `live_mid`는 +`InicisIdentityProvider::buildLiveMid()`가 그 값을 요청 조립 시점에 쓸 때 `SRB` 프리픽스를 +동적으로 부착하므로(DB 저장값은 원본 그대로) 운영자가 프리픽스를 직접 입력할 필요가 +없습니다. `live_mid`/`live_api_key`는 `is_test_mode=false`일 때만 +`ValidateInicisSettingsListener`가 `required`로 강제합니다(§AGENTS.md "핵심 흐름"). +`duplicate_field`/`duplicate_block_enabled`는 `sirsoft-ecommerce`와 무관한 코어 레벨 +회원가입 규칙입니다 — 이 플러그인은 이커머스에 의존하지 않습니다(§의존 관계). + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +본인인증 설정 접근 권한은 코어의 관리자 권한 체계 안에서 다뤄집니다 — IDV provider 마다 +별도 권한을 선언하면 provider 를 여러 개 설치했을 때 "본인인증 설정을 볼 수 있는 사람"이 +provider 수만큼 중복 정의됩니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해 +접근합니다 — IDV provider 마다 전용 사이드바 메뉴를 만들면 provider 를 여러 개 설치했을 +때 메뉴가 난립합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-verification_kginicis/...` | +| `web` | `src/routes/web.php` | `/plugins/sirsoft-verification_kginicis/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +`web` 라우트는 CSRF 검증을 명시적으로 제외합니다(`ValidateCsrfToken` 제외 그룹) — 이니시스 +결제창이 팝업 안에서 우리 서버로 폼을 직접 POST 하는데, 그 요청은 우리 CSRF 토큰을 모르기 +때문입니다. `popup-bridge`는 팝업과 opener 창 사이의 postMessage 중계용 정적 페이지라 +별도 인증이 필요 없습니다. `api`는 로그인 사용자가 Bearer 토큰으로 자기 본인확인 상태를 +조회하는 마이페이지 엔드포인트(`GET /me/identity/inicis`)입니다 — 챌린지 시작 자체는 +코어 `/api/identity/challenges`가 담당하므로 이 플러그인의 `api` 라우트에는 없습니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +결제 PG 플러그인들과 달리 이 플러그인은 `sirsoft-ecommerce`에 의존하지 않습니다 — 본인확인은 +결제와 무관하게 회원가입·비밀번호 찾기 등 코어 인증 흐름 전반에 쓰이는 기능이라, 이커머스가 +설치되지 않은 사이트(게시판만 운영하는 등)에서도 단독으로 동작해야 합니다. + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json b/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json index 4a822121..02f608b2 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-verification_kginicis", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-verification_kginicis", - "version": "1.0.4", + "version": "1.0.5", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-verification_kginicis/package.json b/plugins/_bundled/sirsoft-verification_kginicis/package.json index 29db9ea3..949e8210 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/package.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-verification_kginicis", - "version": "1.0.4", + "version": "1.0.5", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json index 05ba7270..07047f3b 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/plugin.json +++ b/plugins/_bundled/sirsoft-verification_kginicis/plugin.json @@ -5,7 +5,7 @@ "ko": "KG이니시스 본인인증", "en": "KG Inicis Identity Verification" }, - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "description": { "ko": "KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인", diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md b/plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md new file mode 100644 index 00000000..a638c7cd --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/AGENTS.md @@ -0,0 +1,191 @@ +# NHN KCP 휴대폰 본인확인 — 에이전트 가이드 + +> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 플러그인 (sirsoft-verification_nhnkcp) — NHN KCP 휴대폰 본인확인 IDV Provider. 코어 `IdentityVerificationInterface` 구현, PII 레코드 소유 (`sirsoft-verification_kginicis`와 같은 부류·다른 벤더) +2. 확장 방식: `RegisterKcpProviderListener` 로 코어 `core.identity.registered_providers` 필터에 등록 — 코어는 이 플러그인의 존재를 모른다 +3. 건드리면 안 되는 것: 데스크톱 팝업/모바일 리다이렉트 분기(`isMobileEnvironment()`) 우회, 라이브 사이트코드 `SM` 프리픽스 정책값 상수(`LIVE_SITE_CD_PREFIX`) 미참조, 중복가입 차단(`AssertNoDuplicateKcpIdentity`) 우회 +4. 작업 위치: `plugins/_bundled/sirsoft-verification_nhnkcp` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan plugin:update sirsoft-verification_nhnkcp --force` +``` + +## 1. 이 확장은 무엇인가 + + +NHN KCP 휴대폰 본인확인을 코어 본인인증(IDV) 체계에 연결하는 Provider 입니다. +`sirsoft-verification_kginicis`와 같은 부류(코어 `IdentityVerificationInterface` 12메서드 +구현)지만 다른 벤더이며, 운영자는 둘 중 하나 또는 둘 다 설치해 사용할 수 있습니다 — +코어는 등록된 provider ID로만 구분합니다. + +**이 플러그인만의 차이**: 인증 화면 진입 방식이 기기별로 갈립니다 — 데스크톱은 +`sirsoft-verification_kginicis`와 동일하게 사용자 클릭 컨텍스트 안에서 빈 팝업을 열고 +그 안에서 인증을 진행하지만, 모바일은 팝업 대신 **전체 페이지 리다이렉트**로 KCP 인증 +화면으로 이동한 뒤 콜백으로 복귀합니다(`isMobileEnvironment()` 분기) — 모바일 브라우저는 +팝업 UX 가 나쁘고 앱 전환(문자 인증 등)이 얽히면 팝업 컨텍스트 자체가 끊기기 쉽기 +때문입니다. 리다이렉트 복귀 시 원래 상태를 되살리기 위해 `sessionStorage`에 +`g7.identity.redirectStash` 키로 복귀 정보를 임시 저장합니다. + +**설계 원칙**: 이 플러그인도 실제 PII 를 소유합니다(§data-model.md — `kginicis`와 같은 +구조: 완료된 확인 결과 레코드 + 진행 중인 인증 거래 매핑을 별도 테이블로 분리). + +**의도적으로 하지 않는 것**: `kginicis`와 동일하게, 비로그인 사용자의 PII 는 확인 즉시 +DB 에 쓰지 않고 임시 보관했다가 가입 완료 시에만 레코드로 흡수합니다. 사용자 탈퇴/삭제 +시 관련 PII 를 자동 정리합니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.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()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_nhnkcp --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**본인확인 시작~완료(데스크톱)**: 코어 428 응답 → `startAuth`가 사용자 클릭 컨텍스트 +안에서 `window.open`으로 빈 팝업 생성 → KCP 인증 폼 제출 → 인증 완료 후 +`KcpCertTransactionRepository`가 거래 매핑을 저장 → 팝업 종료 감지로 결과 회수 → +`KcpIdentityProvider::verify()`가 결과를 반환. 성인인증(`nhnkcp.adult_verification` +purpose)으로 발행된 challenge 는 만 19세 이상만 통과시킵니다. + +**본인확인 시작~완료(모바일)**: `isMobileEnvironment()`가 참이면 팝업 대신 전체 페이지 +리다이렉트로 KCP 인증 화면으로 이동 → 복귀 정보를 `sessionStorage`(`g7.identity.redirectStash`)에 +저장 → 인증 완료 후 콜백 URL로 복귀 → 저장된 stash 로 원래 challenge 컨텍스트를 복원. + +**비로그인 사용자 처리 / 중복가입 차단 / 사용자 삭제·탈퇴 시 정리**: +`sirsoft-verification_kginicis`와 동일한 3단계 패턴입니다 — `verify()` 시 Cache +stash(`nhnkcp:pending_record:`) → `core.auth.after_register`에서 흡수, +`core.auth.before_register`에서 `AssertNoDuplicateKcpIdentity`가 중복 차단, +`core.user.before_delete`/`after_withdraw`에서 PII 정리. + +**설정 저장 시 검증 + 라이브 사이트코드 정규화**: 관리자가 설정 저장 요청 → FormRequest +검증 단계에서 `core.plugin_settings.update_validation_rules` 훅 → +`ValidateKcpSettingsListener::addLiveModeRules()`가 라이브 모드일 때 `live_site_cd`/ +`live_enc_key`를 필수로 강제(값이 비어있지 않은지만 확인 — 아직 프리픽스는 보지 않음) → +검증 통과 후 `PluginSettingsService`가 실제 저장하는 단계에서 +`core.plugin_settings.filter_save_data` 훅 → 같은 리스너의 `normalizeLiveSiteCd()`가 +`live_site_cd`에 `SM` 프리픽스가 없으면 자동으로 붙여 저장. 두 훅이 분리된 이유는 코어의 +FormRequest 검증과 Service 저장이 서로 다른 파이프라인 단계이기 때문입니다 — "필수값이 +비어있지 않은가"는 검증 단계에서, "저장될 값의 형식을 교정"은 저장 단계에서 처리합니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 0개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 7개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 6개 | [훅 리스너](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개인 것은 `sirsoft-verification_kginicis`와의 실제 차이입니다 — kginicis 쪽 +3건은 리스너 docblock 의 코어 호출 예시 주석을 소스 자동 감지가 오인한 결과였는데(§data-model.md +계열의 동일 패턴), 이 플러그인의 리스너 docblock 은 그런 예시 서술 방식을 쓰지 않아 +오탐이 없습니다. `core.identity.registered_providers`는 코어가 등록된 IDV provider 목록을 +모으는 필터 훅입니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-verification_nhnkcp --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝 +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-verification_nhnkcp` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] PII 컬럼을 다루는 코드 변경 시 GDPR 삭제/탈퇴 정리 리스너(`CleanKcpRecordOnUserDelete`/`CleanKcpRecordOnUserWithdraw`)가 여전히 그 컬럼을 정리하는지 확인 +- [ ] 데스크톱/모바일 분기(`isMobileEnvironment()`)를 고칠 때 양쪽 복귀 경로(팝업 종료 감지 / `redirectStash` 복원)를 함께 테스트 +- [ ] `duplicate_field`/`duplicate_block_enabled` 로직을 고치면 `KcpDuplicateField` Enum 과 `AssertNoDuplicateKcpIdentity`를 함께 갱신 +- [ ] `normalizeLiveSiteCd()`/`addLiveModeRules()`를 고칠 때 두 훅(`filter_save_data`/`update_validation_rules`)의 실행 순서(검증 먼저, 정규화는 저장 시점)를 유지 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec.json` 을 함께 갱신 — 샘플이 없는 `data_source` 는 편집기 캔버스에서만 빈 화면이 되고 실제 화면은 정상이라 오류도 경고도 남지 않는다. 반영은 `php artisan plugin:update sirsoft-verification_nhnkcp --force` + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 비로그인 사용자의 PII 를 verify 즉시 DB 에 저장 | Cache 에 stash(`nhnkcp:pending_record:` 접두) 후 가입 완료 시 흡수 | 가입을 완료하지 않은 방문자의 PII 를 DB 에 영구 저장하면 불필요한 개인정보 보유가 된다 | +| 사용자 삭제/탈퇴 리스너 없이 PII 컬럼 추가 | `CleanKcpRecordOnUserDelete`/`CleanKcpRecordOnUserWithdraw`에 정리 로직 동반 | 정리 누락 시 탈퇴한 사용자의 PII 가 무기한 남는다 | +| `LIVE_SITE_CD_PREFIX` 상수를 참조하지 않고 `'SM'`을 문자열로 재작성 | `KcpIdentityProvider::LIVE_SITE_CD_PREFIX` 참조 | KCP 사이트코드 정책이 바뀌면 상수 1곳만 갱신해야 런타임 로직 전체에 반영된다 | +| 모바일 리다이렉트 복귀 시 `redirectStash`를 검증 없이 신뢰 | 복귀 정보의 challenge 컨텍스트를 서버측 상태와 대조 후 사용 | `sessionStorage`는 클라이언트가 임의로 조작할 수 있는 저장소다 | +| 팝업을 사용자 클릭 이벤트 핸들러 밖에서 `window.open` | 사용자 제스처 컨텍스트 안에서 직접 호출 (데스크톱 경로) | Chrome 등 브라우저는 사용자 제스처 없이 열리는 팝업을 자동 차단한다 | +| 라이브 암호화 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 본인확인 API 를 위조 호출할 수 있다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 20개 | `plugins/_bundled/sirsoft-verification_nhnkcp/tests` | +| Vitest | 7개 | `vitest.config.ts` | +| Playwright | 2개 | `tests/Playwright` | +| 시나리오 매니페스트 | 14개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-verification_nhnkcp/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-verification_nhnkcp && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md index 97007adc..dbe7af2f 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.0.2] - 2026-08-31 + +### Added + +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. + ## [1.0.1] - 2026-08-19 ### Security diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/README.md b/plugins/_bundled/sirsoft-verification_nhnkcp/README.md new file mode 100644 index 00000000..2bc488e5 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/README.md @@ -0,0 +1,187 @@ +# NHN KCP 휴대폰 본인확인 + +**그누보드7 플러그인 · sirsoft-verification_nhnkcp** +NHN KCP 휴대폰 본인확인(V2 REST)을 그누보드7 코어 IDV 인프라에 Provider 로 등록하는 플러그인 + + +

+ version 1.0.2 + type 플러그인 + 그누보드7 >=7.0.6 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +NHN KCP 휴대폰 본인확인을 그누보드7 코어의 본인인증(IDV) 체계에 연결하는 플러그인입니다. +`sirsoft-verification_kginicis`와 같은 부류(코어 표준 인터페이스 구현)지만 다른 벤더이며, +운영자는 둘 중 하나 또는 둘 다 설치해 사용할 수 있습니다. + +이 플러그인만의 특징은 인증 화면 진입 방식이 기기별로 갈린다는 점입니다 — 데스크톱은 +팝업, 모바일은 전체 페이지 리다이렉트를 씁니다. 모바일 브라우저는 팝업 UX 가 나쁘고 +문자 인증 같은 앱 전환이 얽히면 팝업 컨텍스트가 끊기기 쉽기 때문입니다. + +kginicis 와 마찬가지로 이 플러그인은 실제 개인식별정보(PII)를 직접 보관하며, 결제 PG +플러그인들과 달리 `sirsoft-ecommerce`를 비롯한 어떤 확장에도 의존하지 않습니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 본인확인 | NHN KCP 휴대폰 본인확인 팝업(데스크톱)/리다이렉트(모바일) | +| 성인인증 | 만 19세 이상 여부만 확인하는 별도 purpose | +| 중복가입 차단 | DI 또는 CI 기준으로 동일인 재가입 차단 (선택) | +| 게스트 처리 | 비로그인 사용자의 본인확인 결과를 임시 보관 후 가입 완료 시 흡수 | +| 개인정보 정리 | 사용자 탈퇴/삭제 시 보관 중인 PII 레코드 자동 파기 | +| 마이페이지 | 본인확인 완료 상태 카드 노출 | +| 관리자 설정 | 테스트/라이브 모드 전환, 중복가입 판정 기준 설정 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[코어가 IDV 요구 · 428] --> B{기기 판별} + B -->|데스크톱| C[사용자 클릭 → 팝업 오픈] + B -->|모바일| D[복귀 정보 저장 → 페이지 리다이렉트] + C --> E[KCP 인증 완료 → 팝업 종료 감지] + D --> F[KCP 인증 완료 → 콜백 복귀] + E --> G[결과 회수 → PII 확보] + F --> G + G -->|로그인 사용자| H[레코드 즉시 저장] + G -->|비로그인 사용자| I[Cache 임시 보관 → 가입 완료 시 흡수] +``` + +데스크톱 팝업은 반드시 사용자 클릭 이벤트 안에서 열립니다 — 비동기 콜백 이후에 열면 +브라우저 팝업 차단기에 걸립니다. 모바일 리다이렉트는 이 제약이 없는 대신, 복귀 후 +원래 상태를 되살리기 위한 정보를 브라우저에 임시 저장합니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.6` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-verification_nhnkcp + +# 활성화 +php artisan plugin:activate sirsoft-verification_nhnkcp + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-verification_nhnkcp --force +``` + +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-verification_nhnkcp + + +## 관리자 설정 + + +| 키 | 의미 | 기본값 | +|---|---|---| +| `is_test_mode` | 테스트 모드 | `true` | +| `test_site_cd` | 테스트 사이트코드 | `AO7F3` | +| `test_enc_key` | 테스트 암호화 키 | `c2a22fa3ebe4698075bcac6b433d52e351c881b02fb83488d4283a43385b1f8e` | +| `live_site_cd` | 운영 사이트코드 | - | +| `live_enc_key` | 운영 암호화 키 | - | +| `web_siteid` | 웹사이트 ID | - | +| `duplicate_field` | 중복 판정 필드 | `di` | +| `duplicate_block_enabled` | 중복 가입 차단 | `true` | + +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + + + +`live_site_cd`는 저장 시 `SM` 프리픽스가 자동으로 붙으므로 프리픽스 없이 입력해도 됩니다. +`live_site_cd`/`live_enc_key`는 `is_test_mode`를 끄는(라이브 모드) 순간부터 필수가 +됩니다 — 테스트 모드에서는 비워둘 수 있습니다. `web_siteid`는 테스트/라이브 모드 공통으로 +쓰이는 웹사이트 식별자입니다. `duplicate_field`(`di` 또는 `ci`)와 +`duplicate_block_enabled`는 다른 IDV provider(예: `sirsoft-verification_kginicis`)와 +별개로 이 provider 를 통해 확인한 사용자에게만 적용됩니다. 라이브 암호화 키는 외부에 +노출하지 마세요. + + +## 사용 방법 + + +**활성화하기**: 플러그인을 활성화하면 자동으로 코어 IDV provider 목록에 등록됩니다. +별도 화면 배치 작업 없이 코어가 이미 정의한 IDV 강제 지점(회원가입 등)에서 즉시 +동작합니다. + +**중복가입 차단 켜기**: 동일인이 여러 계정을 만드는 것을 막고 싶다면 +`duplicate_block_enabled`를 켜고 `duplicate_field`로 DI/CI 중 판정 기준을 고릅니다. + +**모바일 동작 확인**: 데스크톱에서는 정상인데 모바일에서만 인증이 실패한다면 리다이렉트 +복귀 경로(`redirectStash`)가 원인일 가능성이 높습니다 — §트러블슈팅을 확인하세요. + +전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은 +[docs/extension-points.md](docs/extension-points.md) 를 참고하세요. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [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) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 데스크톱에서 인증 버튼을 눌러도 팝업이 안 뜸 | 브라우저 팝업 차단기 | `startAuth`가 클릭 이벤트 핸들러 안에서 직접 호출되는지 확인 | +| 모바일에서만 인증 완료 후 원래 화면으로 돌아오지 않음 | 리다이렉트 복귀 정보(`sessionStorage`의 `g7.identity.redirectStash`)가 유실됨 | 리다이렉트 도중 다른 탭/앱으로 완전히 전환되어 세션 스토리지가 초기화됐는지 확인 (동일 브라우저 탭 안에서 왕복해야 함) | +| 설정 저장 시 422 오류 | 라이브 모드인데 `live_site_cd`/`live_enc_key` 미입력 | `is_test_mode`를 켜거나 라이브 자격증명을 입력 | +| 이미 가입된 사용자인데 중복 오류 없이 재가입됨 | `duplicate_block_enabled`가 꺼져 있거나 `duplicate_field` 기준이 실제 판정과 다름 | 관리자 설정에서 두 값을 확인 | +| 탈퇴한 사용자의 본인확인 정보가 남아있는 것으로 보임 | 정리 리스너 실행 여부를 별도로 확인하지 않음 | `CleanKcpRecordOnUserWithdraw`/`CleanKcpRecordOnUserDelete` 정상 등록 여부를 훅 캐시에서 확인 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/composer.json b/plugins/_bundled/sirsoft-verification_nhnkcp/composer.json index 8d5d74e5..038cdab3 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/composer.json +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-verification_nhnkcp", "description": "NHN KCP mobile identity verification provider for G7", - "version": "1.0.1", + "version": "1.0.2", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md new file mode 100644 index 00000000..86888b4f --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md @@ -0,0 +1,23 @@ +# NHN KCP 휴대폰 본인확인 개발자 문서 + +> plugins/_bundled/sirsoft-verification_nhnkcp · 플러그인 + + +**훅 수**: 0 · **구독 훅 수**: 7 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 2 · **레이아웃 수**: 1 · **핸들러 수**: 1 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [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) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md new file mode 100644 index 00000000..9f838741 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md @@ -0,0 +1,70 @@ +# NHN KCP 휴대폰 본인확인 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +NHN KCP 휴대폰 본인확인을 코어 IDV 체계에 연결하는 Provider 입니다. +`sirsoft-verification_kginicis`와 계약(코어 `IdentityVerificationInterface`)·PII 소유 +구조는 같지만, 인증 화면 진입 방식이 기기별로 갈립니다 — 데스크톱은 팝업, 모바일은 전체 +페이지 리다이렉트입니다. 이 분기가 이 플러그인 아키텍처 전반(프론트 상태 복원, +`sessionStorage` 사용)에 스며 있습니다. + + +## 계층 지도 + + +```text +Controller (Http/Controllers) → FormRequest (Http/Requests) + → KcpIdentityProvider (IdentityVerificationInterface 구현 — verify/challenge 표준 진입점) + → KcpCertClientInterface (외부 통신 + 암호화/복호화) + → KcpCertTransactionRepositoryInterface (진행 중인 인증 거래) + → KcpIdentityRecordRepositoryInterface (완료된 PII record) + → IdentityVerificationLogRepositoryInterface (코어 IDV 로그 조회) + → KcpDuplicateIdentityChecker (중복가입 판정 로직) + → CacheInterface (비로그인 verify PII 임시 stash) + +Listener (RegisterKcpProviderListener 등) + → 코어 identity/auth/user/settings 훅에 등록 (컴파일 타임 결합 없음) +``` + +`KcpDuplicateIdentityChecker`가 별도 협력자로 분리된 것은 `sirsoft-verification_kginicis`와의 +작은 차이입니다 — kginicis 는 그 판정 로직을 `AssertNoDuplicateInicisIdentity` 리스너 +안에 두는 반면, 이 플러그인은 Provider 자신도 같은 판정 로직을 재사용할 수 있도록 별도 +클래스로 뽑았습니다. + +`sirsoft-verification_kginicis`와 계층 구조가 거의 동일한 것은 둘 다 같은 코어 계약을 +구현하기 때문입니다 — 새 IDV provider 를 추가할 때 이 두 플러그인을 참조 구현으로 삼을 수 +있습니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.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()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_nhnkcp --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md new file mode 100644 index 00000000..9f8dd713 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md @@ -0,0 +1,89 @@ +# NHN KCP 휴대폰 본인확인 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `KcpCertTransaction` | `nhnkcp_cert_transactions` | 5 | challenge→IdentityVerificationLog | - | +| `KcpIdentityRecord` | `nhnkcp_identity_records` | 15 | user→User | - | + + + +`sirsoft-verification_kginicis`의 `InicisIdentityRecord`/`InicisChallengeMapping`과 대칭인 +구조입니다 — `KcpIdentityRecord`가 PII 를 직접 보관하고(결제 플러그인들과의 근본적 차이), +`KcpCertTransaction`은 진행 중인 인증 시도만 다룹니다. 두 모델을 분리한 이유도 동일합니다 +— 인증 세션(거래 하나)과 확인 결과(사용자당 최신 1건)는 생명주기가 다릅니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `nhnkcp_cert_transactions` | `KcpCertTransaction` | +| `nhnkcp_identity_records` | `KcpIdentityRecord` | + + + +`nhnkcp_identity_records.user_id`는 `unique` 제약을 갖습니다 — 한 사용자는 본인확인 결과를 +1건만 보유하며, 재인증 시 기존 레코드를 갱신합니다. `sirsoft-verification_kginicis`와 +동일한 설계로, "이 사용자가 본인확인을 완료했는가"를 단순 존재 조회 하나로 판정합니다. + + +## 마이그레이션 + + +마이그레이션 2개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_07_28_000001_create_nhnkcp_identity_records_table.php` | `nhnkcp_identity_records` | `nhnkcp_identity_records` | ✅ | +| `2026_07_28_000002_create_nhnkcp_cert_transactions_table.php` | `nhnkcp_cert_transactions` | `nhnkcp_cert_transactions` | ✅ | + + + +`sirsoft-verification_kginicis`가 세 번째 마이그레이션으로 부가 필드를 nullable 완화한 +것과 달리, 이 플러그인은 두 테이블 생성만으로 끝났습니다 — 아직 같은 종류의 스키마 보정이 +필요했던 적이 없습니다. 향후 유사한 nullable 완화가 필요해지면 kginicis 의 사례(§CLAUDE.md +"소스 교정만으로는 기설치본이 낫지 않는다")를 참고합니다. + + +## Enum + + +| Enum | backing | case 수 | case | +|---|---|---|---| +| `KcpDuplicateField` | `string` | 2 | `di`, `ci` | + + + +`sirsoft-verification_kginicis`의 `InicisDuplicateField`와 case 구성이 동일합니다(`di`/`ci`) +— 두 벤더 모두 같은 개념(연계정보/연계정보 상위값)을 제공하기 때문입니다. `duplicate_field` +설정값과 `KcpDuplicateIdentityChecker`의 판정 컬럼 화이트리스트 양쪽에서 재사용되므로 Enum +으로 닫아 오타로 인한 "중복 없음" 오판정을 방지합니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `KcpCertTransactionRepository` | 구현 | `nhnkcp_cert_transactions` 테이블 Repository 구현체. | +| `KcpCertTransactionRepositoryInterface` | 인터페이스 | `nhnkcp_cert_transactions` 테이블 Repository 계약. | +| `KcpIdentityLogQueryRepository` | 구현 | KcpIdentityLogQueryRepositoryInterface 구현체. | +| `KcpIdentityLogQueryRepositoryInterface` | 인터페이스 | 본 plugin 이 발행한 코어 IDV 로그의 보조 조회/갱신 계약. | +| `KcpIdentityRecordRepository` | 구현 | `nhnkcp_identity_records` 테이블 Repository 구현체. | +| `KcpIdentityRecordRepositoryInterface` | 인터페이스 | `nhnkcp_identity_records` 테이블 Repository 계약. | + + + +3쌍으로 나뉜 것은 `sirsoft-verification_kginicis`와 같은 이유입니다 — +`KcpCertTransactionRepository`(진행 중인 인증 거래), `KcpIdentityRecordRepository`(완료된 +PII), `KcpIdentityLogQueryRepository`(코어 IDV 로그를 이 플러그인 관점으로 좁혀 읽는 +어댑터)가 각자 다른 데이터를 다룹니다. `KcpDuplicateIdentityChecker`(§architecture.md)는 +Repository 가 아니라 판정 로직 클래스입니다 — 두 Repository 를 조합해 판정만 하고 +데이터를 소유하지 않습니다. + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/editor-spec.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/editor-spec.md new file mode 100644 index 00000000..e200834d --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/editor-spec.md @@ -0,0 +1,113 @@ +# NHN KCP 휴대폰 본인확인 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `plugins/_bundled/sirsoft-verification_nhnkcp/editor-spec.json` | +| 형태 | 단일 파일 (인라인) | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | - | +| 다크 모드 전략 | - | + +> 단일 파일 · 프리뷰 샘플 1 · 페이지 상태 3 + + + +NHN KCP 휴대폰 본인확인 Provider 플러그인은 코어 IDV 인프라에 자기 Provider 를 등록하는 것이 본체이고, +화면은 관리자 설정과 **사용자 화면에 뜨는 인증 창**입니다. 그래서 스펙이 담는 것도 그 +두 자리뿐입니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `editor-spec.json (인라인)` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 3 | `editor-spec.json (인라인)` | + + + +`byDataSourceId` 는 `nhnkcpRecord` 하나입니다 — 인증 결과 레코드를 화면에 보여 주는 자리입니다. +인증 정책·목적·메시지는 코어 IDV 가 소유하고 admin 템플릿 스펙이 그 샘플을 채우므로 +여기서 다시 선언하지 않습니다. + +`states.groups` 가 3종인 것이 이 플러그인의 특징입니다. 인증은 **여러 화면에 걸쳐 +나타나는 기능**이라 설정 화면 하나로 끝나지 않습니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 `componentPalette` 를 선언하지 않습니다 — 편집기 팔레트에 추가되는 항목이 없습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일입니다. 모듈·플러그인은 레이아웃 JSON 에서 템플릿이 +제공하는 컴포넌트를 **쓰기만** 하므로, 편집기 팔레트에 새로 얹을 것이 없습니다. 그래서 이 +확장의 스펙은 `componentPalette`·`controls`·`componentCapabilities`·`nesting` 을 비우고 +**도메인 데이터**(`sampleData`·`states`)만 담습니다. + +팔레트에 무언가를 추가하고 싶다면 그것은 이 확장이 아니라 활성 템플릿 +(`sirsoft-admin_basic` / `sirsoft-basic`)의 스펙에 가야 합니다. 여기에 팔레트를 선언하면 +템플릿 선언과 같은 자리를 두고 다투게 되고, 어느 쪽이 이기는지가 합본 순서에 좌우됩니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 1 | `nhnkcpRecord` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 3 | `*/mypage/profile` · `*/admin/plugins/sirsoft-verification_nhnkcp/settings` · `_user_base` | + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +상태 범위는 `*/mypage/profile` · `*/admin/plugins/sirsoft-verification_nhnkcp/settings` · `_user_base` 입니다. `_user_base` 가 들어 있는 것이 핵심입니다 — 본인인증 +요구는 특정 라우트가 아니라 **어느 화면에서든 428 응답으로 발생**할 수 있고, 그때 뜨는 +인증 창은 베이스 레이아웃 위에 얹힙니다. + +편집기 캔버스는 실제 428 응답을 받지 않으므로, 그 상태를 변종으로 주입해 두지 않으면 +인증 창이 화면에 나타나지 않아 **편집할 방법이 없습니다.** + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan plugin:update sirsoft-verification_nhnkcp --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +인증 창의 모양을 바꿨다면 이 스펙만으로는 끝나지 않습니다. 인증 창을 여는 주체는 +템플릿 부트스트랩이 등록한 launcher 이므로, launcher 가 여는 화면과 여기 상태 변종이 +가리키는 화면이 같은지 함께 확인합니다. + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md new file mode 100644 index 00000000..576fee13 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md @@ -0,0 +1,123 @@ +# NHN KCP 휴대폰 본인확인 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +_이 확장은 훅을 발행하지 않습니다._ + + + +`sirsoft-verification_kginicis`는 같은 표에 3건이 잡히지만 실제로는 발행하는 훅이 +아닙니다(코어 호출부를 보여주는 docblock 예시를 소스 자동 감지가 오인한 결과) — 이 +플러그인은 그런 예시 서술 방식을 쓰지 않아 표가 정확히 비어 있습니다. 두 플러그인 모두 +실제로 발행하는 도메인 전용 훅은 없습니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.auth.after_register` | action (미선언) | `CompleteKcpRecordAfterRegister` | `handle` | 50 | +| `core.auth.before_register` | action (미선언) | `AssertNoDuplicateKcpIdentity` | `handle` | 20 | +| `core.identity.registered_providers` | filter | `RegisterKcpProviderListener` | `register` | 20 | +| `core.plugin_settings.filter_save_data` | filter | `ValidateKcpSettingsListener` | `normalizeLiveSiteCd` | 10 | +| `core.plugin_settings.update_validation_rules` | filter | `ValidateKcpSettingsListener` | `addLiveModeRules` | 10 | +| `core.user.after_withdraw` | action (미선언) | `CleanKcpRecordOnUserWithdraw` | `handle` | 50 | +| `core.user.before_delete` | action (미선언) | `CleanKcpRecordOnUserDelete` | `handle` | 50 | + + + +`ValidateKcpSettingsListener`가 2개 훅(`filter_save_data`/`update_validation_rules`)을 +동시에 구독하는 것은 `sirsoft-verification_kginicis`의 `ValidateInicisSettingsListener`(1개 +훅만 구독)와의 차이입니다 — kginicis 는 라이브 MID 프리픽스를 Provider 가 값을 쓸 때 +동적으로 붙이는 반면, 이 플러그인은 저장 시점에 정규화해 DB 에 프리픽스 붙은 값을 +남깁니다(§AGENTS.md "핵심 흐름"). `AssertNoDuplicateKcpIdentity`가 +`core.auth.before_register`(가입 **전**)을 구독하는 이유와 `core.user.before_delete`가 +`sync: true`인 이유는 kginicis 쪽과 동일합니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `AssertNoDuplicateKcpIdentity` | 1개 | 명시 등록 | ✅ | `src/Listeners/AssertNoDuplicateKcpIdentity.php` | +| `CleanKcpRecordOnUserDelete` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanKcpRecordOnUserDelete.php` | +| `CleanKcpRecordOnUserWithdraw` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanKcpRecordOnUserWithdraw.php` | +| `CompleteKcpRecordAfterRegister` | 1개 | 명시 등록 | ✅ | `src/Listeners/CompleteKcpRecordAfterRegister.php` | +| `RegisterKcpProviderListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterKcpProviderListener.php` | +| `ValidateKcpSettingsListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/ValidateKcpSettingsListener.php` | + + + +6개 리스너 중 3개(`CompleteKcpRecordAfterRegister`, `CleanKcpRecordOnUserWithdraw`, +`CleanKcpRecordOnUserDelete`)는 `sirsoft-verification_kginicis`와 동일하게 사용자 +생명주기 각 단계에 맞춰 PII 레코드를 흡수하거나 정리하는 대칭 구조입니다 — 하나를 고칠 +때 나머지 둘도 같은 PII 필드를 다루고 있는지 확인해야 합니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/identity_provider_nhnkcp.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/mypage_identity_card.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +`mypage_identity_card.json`은 `sirsoft-verification_kginicis`의 같은 이름 조각과 동일한 +역할(마이페이지 "본인확인 완료" 상태 카드)입니다 — 여러 IDV provider 가 동시에 설치돼도 +각자 자기 카드만 주입하므로 충돌하지 않습니다. `identity_provider_nhnkcp.json`은 코어 +IDV 팝업이 이 provider 고유의 안내 문구·로고를 보여줄 때 쓰는 조각입니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +결제 플러그인들과 달리 이 플러그인은 PG 서버가 직접 호출하는 웹훅/통보 엔드포인트가 +없습니다 — 본인확인 결과는 팝업 콜백 또는 모바일 리다이렉트 콜백(둘 다 사용자 브라우저 +경유)으로만 도달하므로 IP 화이트리스트 같은 서버간 통신 검증이 필요 없습니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +인증 결과는 팝업의 종료 감지 또는 모바일 리다이렉트 복귀로 프론트가 직접 회수합니다 +(§AGENTS.md "핵심 흐름") — 서버가 다른 클라이언트에 실시간으로 알려야 할 상태 변화가 +없어 브로드캐스트 채널이 필요 없습니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +`nhnkcp_cert_transactions`의 만료된 인증 거래나 `redirectStash`의 잔존 세션 데이터를 +별도 배치로 청소하지 않습니다 — `sessionStorage`는 브라우저 세션 종료로 자연 소멸하고, +서버측 거래 레코드 정리는 사용자 삭제/탈퇴 시점 리스너가 담당합니다(§훅 리스너). + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +본인확인 성공/실패는 사용자가 즉시 확인하는 동기적 상호작용이라, 별도 알림(이메일/SMS +등)을 발송할 지점이 없습니다. + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md new file mode 100644 index 00000000..b2e5cfe1 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md @@ -0,0 +1,80 @@ +# NHN KCP 휴대폰 본인확인 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +`sirsoft-verification_kginicis`와 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 +설정 화면 하나뿐입니다 — 회원가입·마이페이지의 본인확인 UI는 §레이아웃 확장 조각 및 코어 +IDV 공통 팝업 UI로 존재합니다. + + +## 액션 핸들러 + + +핸들러 1개 (정의: `resources/js/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `startAuth` | `sirsoft-verification_nhnkcp.startAuth` | + + + +`startAuth`는 `isMobileEnvironment()`로 기기를 판별해 데스크톱에서는 +`sirsoft-verification_kginicis`와 같은 팝업 패턴(사용자 클릭 컨텍스트 안에서 +`window.open`)을, 모바일에서는 `sessionStorage`(`g7.identity.redirectStash`)에 복귀 +정보를 저장한 뒤 전체 페이지 리다이렉트를 씁니다. 반드시 클릭 이벤트 핸들러 안에서 +호출돼야 팝업 차단을 피할 수 있다는 제약은 데스크톱 경로에만 해당합니다 — 모바일 +리다이렉트는 팝업 차단과 무관합니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftVerificationNhnkcp` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftVerificationNhnkcp`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 +재등록 진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). `sirsoft-verification_kginicis`와 +마찬가지로 이 플러그인은 결제창 SDK 를 동적 로드하지 않습니다 — 인증 화면 자체가 서버 +렌더링 페이지(팝업 안) 또는 리다이렉트 대상(모바일)이므로 프론트가 별도 스크립트를 불러올 +필요가 없습니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +제3자 SDK 스크립트가 목록에 없는 이유는 §전역 진입점과 동일합니다 — 인증 화면은 팝업 +안의 서버 렌더링 페이지이거나 모바일 리다이렉트 대상이라 프론트가 동적 로드할 자산이 +없습니다. CSS 산출물이 없는 것은 UI가 버튼·상태 카드 같은 최소한의 코어 컴포넌트로만 +구성되기 때문입니다. + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md new file mode 100644 index 00000000..7b3e68e2 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md @@ -0,0 +1,93 @@ +# NHN KCP 휴대폰 본인확인 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `is_test_mode` | `boolean` | `true` | 테스트 모드 | +| `test_site_cd` | `string` | `AO7F3` | 테스트 사이트코드 | +| `test_enc_key` | `string` | `c2a22fa3ebe4698075bcac6b433d52e351c881b02fb83488d4283a43385b1f8e` | 테스트 암호화 키 | +| `live_site_cd` | `string` | - | 운영 사이트코드 | +| `live_enc_key` | `string` | - | 운영 암호화 키 | +| `web_siteid` | `string` | - | 웹사이트 ID | +| `duplicate_field` | `enum` | `di` | 중복 판정 필드 | +| `duplicate_block_enabled` | `boolean` | `true` | 중복 가입 차단 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +`sirsoft-verification_kginicis`가 `mid`+`api_key` 2종 키 쌍을 쓰는 것과 달리 이 플러그인은 +`site_cd`+`enc_key`(대칭 암호화 키 1개)만 씁니다 — NHN KCP 본인확인 API 는 서명 검증용 +별도 API 키가 없고 site_cd/enc_key 조합만으로 인증합니다. `web_siteid`는 두 자격증명 +쌍(`test_*`/`live_*`)과 별개로 테스트·라이브 모드 공통으로 쓰이는 웹사이트 식별자입니다. +`live_site_cd`는 `is_test_mode=false`일 때만 `ValidateKcpSettingsListener`가 필수로 +강제하고, 저장 시점에 `SM` 프리픽스를 자동으로 붙입니다(§AGENTS.md "핵심 흐름" — kginicis +와 달리 DB 에 프리픽스가 붙은 값이 저장됩니다). `duplicate_field`/`duplicate_block_enabled`는 +kginicis 와 동일한 개념이지만 서로 다른 provider 를 통해 확인한 사용자에게 각자 독립 +적용됩니다. + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +본인인증 설정 접근 권한은 코어의 관리자 권한 체계 안에서 다뤄집니다 — IDV provider 마다 +별도 권한을 선언하면 provider 를 여러 개 설치했을 때 "본인인증 설정을 볼 수 있는 사람"이 +provider 수만큼 중복 정의됩니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해 +접근합니다 — IDV provider 마다 전용 사이드바 메뉴를 만들면 provider 를 여러 개 설치했을 +때 메뉴가 난립합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-verification_nhnkcp/...` | +| `web` | `src/routes/web.php` | `/plugins/sirsoft-verification_nhnkcp/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +`sirsoft-verification_kginicis`와 동일한 3-엔드포인트 구조입니다 — `web`의 콜백(CSRF 제외, +KCP 인증 화면이 직접 POST)은 데스크톱 팝업과 모바일 리다이렉트 양쪽 경로가 **같은 +엔드포인트로 수렴**합니다(§AGENTS.md "핵심 흐름" — 기기별 진입 방식만 다를 뿐 콜백 처리는 +공통). `bridge`는 팝업-opener 간 postMessage 중계 페이지, `api`는 로그인 사용자가 자기 +본인확인 상태를 조회하는 마이페이지 엔드포인트입니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +`sirsoft-verification_kginicis`와 마찬가지로 이 플러그인은 `sirsoft-ecommerce`를 비롯한 +어떤 확장에도 의존하지 않습니다 — 본인확인은 결제와 무관하게 회원가입 등 코어 인증 흐름 +전반에 쓰이는 기능이라, 이커머스가 설치되지 않은 사이트에서도 단독으로 동작해야 합니다. + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/package-lock.json b/plugins/_bundled/sirsoft-verification_nhnkcp/package-lock.json index 111cbc7d..979cc0c2 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/package-lock.json +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-verification_nhnkcp", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-verification_nhnkcp", - "version": "1.0.1", + "version": "1.0.2", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/package.json b/plugins/_bundled/sirsoft-verification_nhnkcp/package.json index 639638ef..d21496b7 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/package.json +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-verification_nhnkcp", - "version": "1.0.1", + "version": "1.0.2", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/plugin.json b/plugins/_bundled/sirsoft-verification_nhnkcp/plugin.json index 2dfa7be8..495f2e35 100644 --- a/plugins/_bundled/sirsoft-verification_nhnkcp/plugin.json +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/plugin.json @@ -5,7 +5,7 @@ "ko": "NHN KCP 휴대폰 본인확인", "en": "NHN KCP Mobile Identity Verification" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "description": { "ko": "NHN KCP 휴대폰 본인확인(V2 REST)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인", diff --git a/resources/views/dev-dashboard.blade.php b/resources/views/dev-dashboard.blade.php index 9b0adf6e..e1181a45 100644 --- a/resources/views/dev-dashboard.blade.php +++ b/resources/views/dev-dashboard.blade.php @@ -1052,6 +1052,28 @@ if (isset($_GET['ajax_action'])) { + +
+
+ 📗 + 확장 개발자 문서 +
+
+ + + +
+
+
@@ -1582,6 +1604,10 @@ if (isset($_GET['ajax_action'])) { */ const COMMAND_TIMEOUTS = { 'security:audit-dependencies': 300000, + // 확장 20개의 진입 클래스를 실제로 부팅해 선언형 getter 40종을 호출한다. + // 기본 60초를 넘기면 화면은 타임아웃을 띄우는데 서버는 계속 문서를 쓰므로, + // "실패했다" 와 "성공했는데 화면이 포기했다" 가 구분되지 않는다. + 'ext:docgen': 300000, }; /** diff --git a/templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md b/templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md new file mode 100644 index 00000000..7417390b --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md @@ -0,0 +1,167 @@ +# Hello Admin Template — 에이전트 가이드 + +> 이 문서는 이 템플릿을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 템플릿 (gnuboard7-hello_admin_template, type=admin) — 학습용 최소 Admin 템플릿. Basic 컴포넌트 8 + 베이스 1 + 대시보드 1 + **오류 6종**이 전부. `hidden: true` +2. 확장 방식: 훅 없음 — 확장점은 화면 구조다. 제공 컴포넌트 목록이 곧 계약이며, 모듈 조각은 그 안의 컴포넌트만 쓸 수 있다 +3. 건드리면 안 되는 것: 오류 레이아웃 6종 축소, 401 에서 직접 리다이렉트, 컴포넌트 등록 재시도 제거, 샘플에 실제 관리 화면 추가 +4. 작업 위치: `templates/_bundled/gnuboard7-hello_admin_template` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan template:update gnuboard7-hello_admin_template --force` +``` + +## 1. 이 확장은 무엇인가 + + +**학습용 최소 Admin 템플릿**입니다. 관리자 템플릿이 성립하기 위한 **최소 구성**만 담아 +"템플릿은 이 정도만 있으면 동작한다" 를 보이는 것이 목적입니다. + +담긴 것은 넷뿐입니다 — Basic 컴포넌트 8개(`Div` · `Button` · `H1` · `H2` · `H3` · `A` · +`Span` · `Img`), 베이스 레이아웃 `_admin_base`, 대시보드 화면 하나, 그리고 **오류 레이아웃 +6종**(401 · 403 · 404 · 500 · 503 · maintenance). + +**오류 6종이 필수인 것이 이 샘플의 핵심 학습 포인트**입니다. 코어는 오류 상황에서 활성 템플릿의 +해당 레이아웃을 부르는데, 없으면 그 오류가 화면에 나타나지 못합니다 — 사용자에게는 백지가 +됩니다. 화면이 하나뿐인 템플릿에도 이 6종은 있어야 합니다. + +`manifest.hidden = true` 라 관리자 UI 의 템플릿 목록에 나타나지 않습니다. artisan CLI 로는 +정상 설치·활성화됩니다. + +**의도적으로 하지 않는 것**: 실제 관리 화면·composite/layout 컴포넌트·핸들러·다국어 확장· +SEO 설정. `sirsoft-admin_basic` 이 컴포넌트 125개와 화면 145개를 갖는 것과 대비해 보면, 무엇이 +**필수**이고 무엇이 그 템플릿의 선택인지가 드러납니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update gnuboard7-hello_admin_template --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update gnuboard7-hello_admin_template --force` (빌드 불필요) | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update gnuboard7-hello_admin_template --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update gnuboard7-hello_admin_template --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +서버 코드가 없으므로 흐름은 **부트스트랩 → 컴포넌트 등록 → 라우트 → 레이아웃** 입니다. + +**부트스트랩**: `src/index.ts` 가 모듈 로드 시 `initTemplate()` 을 스스로 실행합니다. 그 안에서 +코어의 `ComponentRegistry` 를 찾아 Basic 8개를 등록하는데, **레지스트리가 아직 준비되지 않았을 +수 있으므로 100ms 간격으로 최대 50회 재시도**합니다(`window.load` 이후 시작). 이 재시도가 없으면 +로드 순서에 따라 컴포넌트가 등록되지 않고, 그러면 레이아웃이 참조하는 이름을 찾지 못해 화면이 +비게 됩니다. + +**화면 렌더**: `routes.json` 이 `*/admin` 을 `admin_dashboard` 에 대응 → 그 레이아웃이 +`_admin_base` 를 상속해 공통 뼈대를 얻고 콘텐츠를 채웁니다. + +**오류 렌더**: 코어가 401/403/404/500/503/maintenance 상황을 만나면 활성 템플릿의 해당 +레이아웃을 부릅니다. 여섯 모두 `_admin_base` 를 상속하므로 오류 화면에서도 공통 뼈대가 +유지됩니다. + +`401` 레이아웃에서 **로그인 리다이렉트를 직접 구현하지 않습니다** — 코어 +`TemplateApp.showRouteError` 가드가 처리하므로, 여기서 다시 이동시키면 이중 리다이렉트가 +됩니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 제공 컴포넌트 | 8개 | [제공 컴포넌트](docs/components.md#제공-컴포넌트) | +| 레이아웃 | 8개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) | +| 전용 핸들러 | 0개 | [템플릿 전용 핸들러](docs/handlers.md#템플릿-전용-핸들러) | +| 확장 오버라이드 | 0개 | [확장 오버라이드](docs/layouts.md#확장-오버라이드) | + + + +템플릿의 확장점은 훅이 아니라 **화면 구조**입니다. 이 샘플은 그 구조의 최소 형태를 보입니다. + +| 확장점 | 이 샘플에서 | +|---|---| +| 제공 컴포넌트 | Basic 8개. 다른 확장이 조각을 끼워 넣을 때 **여기 있는 것만** 쓸 수 있습니다 | +| 레이아웃 | 8개(베이스 1 + 화면 1 + 오류 6). 모듈이 `layout_extensions` 로 조각을 끼울 자리는 대시보드뿐입니다 | +| 확장 오버라이드 | 없음. `extensions/{확장}/` 에 같은 이름의 조각을 두면 그 확장이 제공한 원본을 대체합니다 | +| 전용 핸들러 | 없음 | + +**컴포넌트 목록이 곧 계약**이라는 점이 중요합니다. 모듈이 관리자 화면 조각을 만들 때 `DataGrid` +같은 컴포넌트를 쓰면, 그것을 제공하지 않는 이 템플릿에서는 **그 조각이 렌더되지 않습니다.** +모듈이 어느 템플릿에서든 동작하려면 코어가 정한 **필수 컴포넌트 집합**만 써야 하며, 그 목록의 +SSoT 는 코어 `config/template.php` 의 `required_admin_components` 입니다. + +이 샘플이 Basic 8개만 갖는 것은 그래서 실험이기도 합니다 — 이 템플릿으로 바꿨을 때 깨지는 +화면이 있다면, 그 화면이 템플릿 고유 컴포넌트에 의존하고 있다는 뜻입니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan template:update gnuboard7-hello_admin_template --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 오류 레이아웃 6종(401·403·404·500·503·maintenance)이 모두 있는지 확인 — 하나라도 없으면 그 상황에서 백지가 된다 +- [ ] 컴포넌트를 추가·삭제했다면 소스 · `template.json` 레지스트리 · `components.json` 을 함께 갱신 +- [ ] `manifest.hidden = true` 를 유지 (복제본에서만 제거) +- [ ] TSX 를 고쳤다면 `template:build --production` 후 `dist/` 동반 커밋 (`sourceMappingURL` 잔존 금지) +- [ ] `docs/extension/sample-extensions.md` 의 계층 표와 어긋나지 않는지 확인 +- [ ] 레이아웃 렌더링 테스트(`__tests__/layouts/*.test.tsx`)는 이 템플릿 디렉토리에 둔다 — 코어 디렉토리에 두지 않는다 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어도 되는 상태(공용 ID 만 사용)다. 이 확장만 쓰는 `data_source` 를 새로 붙이는 순간 `editor-spec.json` 신설이 필요해진다 + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 오류 레이아웃 6종 중 일부를 빼기 | 401 · 403 · 404 · 500 · 503 · maintenance 전부 유지 | 코어가 그 상황에서 활성 템플릿의 레이아웃을 부른다 — 없으면 사용자에게 백지가 된다 | +| `401` 레이아웃에서 로그인으로 직접 리다이렉트 | 코어 `TemplateApp.showRouteError` 가드에 위임 | 이중 리다이렉트가 되고, 돌아올 위치를 코어가 이미 관리한다 | +| 컴포넌트 등록에 재시도 없이 한 번만 시도 | `initTemplate()` 의 재시도 루프 유지 | 로드 순서에 따라 레지스트리가 아직 없을 수 있다 — 등록에 실패하면 레이아웃이 이름을 못 찾아 화면이 빈다 | +| `extends` 없는 독립 레이아웃에서 `toast`·`openModal` 사용 | `_admin_base` 를 상속하거나 호스트 컴포넌트를 직접 마운트 | 호스트가 없으면 핸들러는 성공으로 기록되는데 화면에는 아무것도 나타나지 않는다 | +| 이 샘플에 실제 관리 화면을 더해 "쓸모 있게" 만들기 | 짧게 유지하고, 필요한 화면은 별도 템플릿으로 | 샘플의 가치는 "최소 구성이 무엇인가" 를 보이는 것이다 | +| `manifest.hidden` 을 제거 | 그대로 둔다 (복제본에서만 제거) | 학습용 템플릿이 운영 사이트의 템플릿 목록에 섞인다 | +| 컴포넌트를 추가하면서 `template.json` 의 레지스트리를 갱신하지 않기 | 소스·레지스트리·`components.json` 을 함께 | 레이아웃이 참조하는 이름을 찾지 못해 그 컴포넌트만 조용히 렌더되지 않는다 | +| TSX 를 고치고 `dist/` 재빌드 없이 커밋 | `template:build --production` 후 `dist/` 동반 커밋 | 브라우저가 받는 것은 커밋된 `dist/` 다 — 소스 수정이 사문화된다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 0개 | — | +| Vitest | 2개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 0개 | — | + +```bash +# Vitest (확장 디렉토리에서) (PowerShell) +cd templates/_bundled/gnuboard7-hello_admin_template && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + diff --git a/templates/_bundled/gnuboard7-hello_admin_template/CHANGELOG.md b/templates/_bundled/gnuboard7-hello_admin_template/CHANGELOG.md index 69d007b9..3f28fd64 100644 --- a/templates/_bundled/gnuboard7-hello_admin_template/CHANGELOG.md +++ b/templates/_bundled/gnuboard7-hello_admin_template/CHANGELOG.md @@ -9,6 +9,9 @@ ### Added - 아이콘(Font Awesome)을 템플릿에 함께 담아 외부 CDN 없이 동작합니다. +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. ## [0.1.0] - 2026-07-01 diff --git a/templates/_bundled/gnuboard7-hello_admin_template/README.md b/templates/_bundled/gnuboard7-hello_admin_template/README.md new file mode 100644 index 00000000..dd1b4370 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_admin_template/README.md @@ -0,0 +1,181 @@ +# Hello Admin Template + +**그누보드7 템플릿 · gnuboard7-hello_admin_template** +그누보드7 학습용 최소 Admin 템플릿 스켈레톤 + + +

+ version 0.1.1 + type 템플릿 + 그누보드7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +그누보드7 **관리자 템플릿이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 운영에 쓰기 +위한 것이 아니라, "관리자 템플릿은 최소한 무엇을 갖춰야 하는가" 를 보이는 것이 목적입니다. + +담긴 것은 화면 부품 8개, 공통 뼈대 하나, 대시보드 한 장, 그리고 **오류 화면 6종**입니다. +오류 화면은 선택이 아니라 필수입니다 — 없으면 오류가 생겼을 때 방문자에게 아무것도 보이지 +않습니다. + +관리자 화면의 템플릿 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로 +설치·활성화할 수 있으며, 활성화하면 관리자 화면이 이 최소 템플릿으로 바뀝니다 — 기본 템플릿과 +나란히 비교해 보면 무엇이 필수이고 무엇이 그 템플릿의 선택인지 드러납니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 화면 부품 | 가장 기본적인 8개 (영역·버튼·제목 3종·링크·글자·이미지) | +| 공통 뼈대 | 모든 화면이 물려받는 관리자 기본 골격 | +| 대시보드 | 관리자 첫 화면 예시 한 장 | +| 오류 화면 | 401·403·404·500·503·점검 중 6종 | +| 다국어 | 한국어·영어 공통 문구 | +| 테스트 | 화면이 실제로 그려지는지 확인하는 렌더링 테스트 예시 | + + +## 동작 방식 + + +```mermaid +flowchart TD + B[_admin_base
관리자 공통 뼈대] --> D[admin_dashboard
대시보드] + B --> E401[401 권한 없음] + B --> E403[403 접근 거부] + B --> E404[404 없는 페이지] + B --> E500[500 서버 오류] + B --> E503[503 이용 불가] + B --> EM[maintenance 점검 중] +``` + +모든 화면이 하나의 공통 뼈대를 물려받습니다. 그래서 오류 화면에서도 관리자 화면의 골격이 +유지되고, 뼈대를 한 번 고치면 전 화면에 반영됩니다. + +오류 화면 6종은 오류가 났을 때 시스템이 직접 찾아 부르는 화면입니다. 하나라도 없으면 그 +상황에서 아무것도 보이지 않으므로, 화면이 한 장뿐인 템플릿에도 6종은 있어야 합니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan template:install gnuboard7-hello_admin_template + +# 활성화 +php artisan template:activate gnuboard7-hello_admin_template + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan template:update gnuboard7-hello_admin_template --force +``` + + +## 제공 컴포넌트 + + +컴포넌트 8개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 8개 | + + + +관리자 화면을 그리는 데 쓰는 **부품 8개**입니다. 기본 관리자 템플릿이 100개가 넘는 부품을 갖는 +것과 비교하면, 이 샘플이 얼마나 최소한만 담고 있는지 알 수 있습니다. + +부품 목록은 **모듈과의 계약**이기도 합니다. 모듈이 관리자 화면 조각을 만들 때 이 템플릿에 없는 +부품(예: 표 형태의 목록 부품)을 쓰면, 그 조각은 이 템플릿에서 그려지지 않습니다. 그래서 모듈이 +어느 템플릿에서든 동작하려면 정해진 **필수 부품**만 써야 합니다. + +이 템플릿으로 바꿔 보면 그 규칙을 지키지 않은 화면이 드러납니다 — 어떤 모듈 화면이 깨진다면 그 +화면이 특정 템플릿의 고유 부품에 기대고 있다는 뜻입니다. + + +## 사용 방법 + + +**설치해 보기**: 관리자 목록에 나타나지 않으므로 명령줄로 설치합니다. + +```bash +php artisan template:install gnuboard7-hello_admin_template +php artisan template:activate gnuboard7-hello_admin_template +``` + +활성화하면 관리자 화면이 이 최소 템플릿으로 바뀝니다. 원래 템플릿으로 되돌리려면 그 템플릿을 +다시 활성화하면 됩니다. + +**모듈 호환성 확인용으로 쓰기**: 만들고 있는 모듈의 관리자 화면이 특정 템플릿에만 의존하지 +않는지 확인할 때 유용합니다. 이 템플릿으로 바꿔서 화면이 깨진다면 그 화면이 필수 부품 밖의 +것을 쓰고 있다는 뜻입니다. + +**새 관리자 템플릿의 출발점으로 쓰기**: 이 디렉토리를 복제한 뒤 식별자를 바꾸고 학습용 표시를 +지우면 새 템플릿이 됩니다. 부품과 화면을 필요한 만큼 더해 나가면 됩니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 관리자 템플릿 목록에 이 템플릿이 없음 | 학습용이라 목록에서 제외됨 | 정상입니다. 명령줄로 설치·활성화합니다 | +| 활성화했더니 일부 모듈 화면이 비어 보임 | 그 화면이 이 템플릿에 없는 부품을 사용 | 정상입니다. 모듈이 필수 부품만 쓰도록 고치거나 원래 템플릿으로 되돌립니다 | +| 화면이 아무것도 안 그려짐 | 빌드 결과물이 없거나 오래됨 | 템플릿을 다시 빌드하고 반영합니다 | +| 오류가 났는데 아무 화면도 안 보임 | 해당 오류 화면이 없음 | 오류 화면 6종이 모두 있는지 확인합니다 | +| 복제해서 만든 템플릿이 목록에 안 보임 | 복제본에 학습용 표시가 남아 있음 | 복제본의 `hidden` 표시를 지웁니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/templates/_bundled/gnuboard7-hello_admin_template/docs/README.md b/templates/_bundled/gnuboard7-hello_admin_template/docs/README.md new file mode 100644 index 00000000..3e09d714 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_admin_template/docs/README.md @@ -0,0 +1,21 @@ +# Hello Admin Template 개발자 문서 + +> templates/_bundled/gnuboard7-hello_admin_template · 템플릿 + + +**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 1 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 8 · **핸들러 수**: 0 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | +| [components.md](components.md) | 템플릿이 제공하는 컴포넌트 | +| [layouts.md](layouts.md) | 레이아웃 목록과 라우트 매핑 | +| [handlers.md](handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | +| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | +| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 | +| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 | + diff --git a/templates/_bundled/gnuboard7-hello_admin_template/docs/architecture.md b/templates/_bundled/gnuboard7-hello_admin_template/docs/architecture.md new file mode 100644 index 00000000..0d3ed51d --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_admin_template/docs/architecture.md @@ -0,0 +1,73 @@ +# Hello Admin Template — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +"관리자 템플릿이 성립하기 위한 **최소 구성**은 무엇인가" 에 답하는 것이 목적입니다. 그래서 +담긴 것이 넷뿐입니다 — Basic 컴포넌트 8개, 베이스 레이아웃, 화면 하나, **오류 레이아웃 6종**. + +**오류 6종이 왜 최소 구성에 들어가는가**가 이 샘플의 핵심입니다. 코어는 오류 상황에서 활성 +템플릿의 해당 레이아웃을 부르는데, 없으면 그 오류를 화면에 표시할 수단이 없습니다 — 사용자에게는 +백지가 됩니다. 화면이 하나뿐인 템플릿에도 6종은 있어야 하며, 그래서 "선택" 이 아니라 "구성" 입니다. + +**컴포넌트를 8개로 제한한 것도 의도**입니다. 이 템플릿으로 바꿨을 때 깨지는 모듈 화면이 있다면, +그 화면이 템플릿 고유 컴포넌트에 의존하고 있다는 뜻입니다 — 모듈 호환성을 확인하는 실험 도구로 +쓸 수 있습니다. + +`manifest.hidden = true` 는 학습용이 운영 사이트의 템플릿 목록에 섞이지 않게 하면서도 CLI 로는 +실제로 설치·동작하게 하는 장치입니다. + +**의도적으로 하지 않는 것**: 실제 관리 화면 · composite/layout 컴포넌트 · 액션 핸들러 · +SEO 설정 · 확장 오버라이드. `sirsoft-admin_basic` 과 나란히 보면 무엇이 필수이고 무엇이 그 +템플릿의 선택인지가 드러납니다. + + +## 계층 지도 + + +``` +template.json manifest — type: admin · 컴포넌트 레지스트리 · error_config +routes.json `*/admin` → admin_dashboard + │ +layouts/_admin_base.json 관리자 공통 뼈대 (헤더 · 사이드바 · 콘텐츠 슬롯) + │ extends + ├─ layouts/admin_dashboard.json + └─ layouts/errors/{401,403,404,500,503,maintenance}.json ← 6종 필수 + │ +src/components/basic/ Div · Button · H1 · H2 · H3 · A · Span · Img + │ +src/index.ts initTemplate() — ComponentRegistry 에 8개 등록 (재시도 루프) + │ +dist/ 커밋되는 빌드 산출물 +__tests__/layouts/ createLayoutTest() 기반 렌더링 테스트 +``` + +**서버 코드가 없습니다.** 템플릿은 화면만 담당하며 데이터는 코어·모듈의 공개 API 에서 +옵니다 — 그래서 템플릿을 갈아 끼워도 데이터는 그대로입니다. + +`src/index.ts` 의 **재시도 루프**가 눈여겨볼 부분입니다. `initTemplate()` 이 모듈 로드 시 +스스로 실행되지만, 그 시점에 코어 `ComponentRegistry` 가 아직 없을 수 있어 100ms 간격으로 최대 +50회 다시 시도합니다(`window.load` 이후 시작). 재시도가 없으면 로드 순서에 따라 등록이 +건너뛰어지고, 레이아웃이 참조하는 컴포넌트 이름을 찾지 못해 화면이 조용히 빕니다. + +레이아웃 렌더링 테스트는 **이 템플릿 디렉토리에 둡니다**(`__tests__/layouts/`). 코어 +디렉토리에 두면 그 템플릿을 지울 때 테스트만 남습니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update gnuboard7-hello_admin_template --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update gnuboard7-hello_admin_template --force` (빌드 불필요) | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update gnuboard7-hello_admin_template --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update gnuboard7-hello_admin_template --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/templates/_bundled/gnuboard7-hello_admin_template/docs/components.md b/templates/_bundled/gnuboard7-hello_admin_template/docs/components.md new file mode 100644 index 00000000..8a4d5b0e --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_admin_template/docs/components.md @@ -0,0 +1,31 @@ +# Hello Admin Template — 컴포넌트 + +> 템플릿이 제공하는 컴포넌트 · 진입점: [AGENTS.md](../AGENTS.md) + +## 제공 컴포넌트 + + +컴포넌트 8개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 8개 | + + + +Basic 8개뿐이며 composite·layout 은 없습니다 — HTML 태그를 그대로 래핑한 최소 단위 +(`Div`→`
`)만으로 관리자 화면 골격을 그릴 수 있음을 보이는 구성입니다. + +**이 목록이 곧 모듈과의 계약**입니다. 모듈이 관리자 화면 조각에서 이 템플릿에 없는 컴포넌트 +(`DataGrid` · `MultilingualInput` 등)를 쓰면 그 조각은 여기서 **렌더되지 않습니다.** 모듈이 +어느 Admin 템플릿에서든 동작하려면 코어가 정한 필수 컴포넌트 집합만 써야 하며, 그 목록의 +SSoT 는 코어 `config/template.php` 의 `required_admin_components` 입니다. + +이 템플릿으로 바꿔 보면 그 규칙을 지키지 않은 화면이 드러납니다 — **모듈 호환성 확인용 +실험 도구**로 쓸 수 있습니다. + +컴포넌트를 추가할 때는 소스(`src/components/{분류}/`) · `template.json` 의 레지스트리 · +`components.json` 을 함께 갱신합니다. 하나라도 빠지면 레이아웃이 그 이름을 찾지 못해 그 +컴포넌트만 조용히 렌더되지 않습니다. TSX 를 고쳤으면 `template:build --production` 후 +`dist/` 를 함께 커밋합니다. + diff --git a/templates/_bundled/gnuboard7-hello_admin_template/docs/editor-spec.md b/templates/_bundled/gnuboard7-hello_admin_template/docs/editor-spec.md new file mode 100644 index 00000000..fb188458 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_admin_template/docs/editor-spec.md @@ -0,0 +1,85 @@ +# Hello Admin Template — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._ + + + +학습용 관리자 템플릿이라 편집기 스펙을 두지 않았습니다. 이 템플릿의 목적은 관리자 +템플릿의 최소 구조(레이아웃·라우트·베이스 레이아웃)를 보여 주는 것이고, 편집기 스펙은 +그 위에 얹히는 별개 축입니다. + +실제 관리자 템플릿이 스펙으로 무엇을 선언하는지는 `sirsoft-admin_basic` 의 같은 문서를 +봅니다 — 팔레트 79 · 컨트롤 303 · 역량 86 이 그 규모입니다. + + +## 선언 블록 + + +_선언된 편집기 스펙 블록이 없습니다._ + + + +선언한 블록이 없습니다. 팔레트·컨트롤·역량·중첩을 선언하지 않았으므로 이 템플릿을 +활성화한 상태에서는 편집기가 다룰 컴포넌트 어휘가 없습니다. + +이것이 "템플릿이 편집기 스펙을 갖는다" 는 규율의 의미입니다 — 스펙은 편집기의 부가 +기능이 아니라 **편집기가 그 템플릿에서 동작하기 위한 어휘 자체**입니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다. +편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도 +`componentPalette` 는 여전히 비어 있을 것입니다. + + +## 샘플 데이터와 페이지 상태 + + +_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._ + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +미커버가 없습니다. 이 템플릿의 레이아웃에는 `data_source` 자체가 없기 때문입니다 — +정적 화면만으로 구성된 학습용 골격이라 붙일 데이터가 없습니다. + +미커버 0 이 곧 "편집기에서 온전히 보인다" 를 뜻하지는 않습니다. 여기서는 그릴 데이터가 +애초에 없다는 뜻입니다. + + +## 수정 시 동반 의무 + + +_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._ + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + + + +이 템플릿을 실제 사용 템플릿으로 발전시킨다면 편집기 스펙 신설이 **가장 먼저** 필요한 +작업 중 하나입니다. 템플릿의 스펙은 모듈·플러그인과 달리 도메인 데이터가 아니라 +**컴포넌트 어휘**(팔레트·컨트롤·역량·중첩)를 담기 때문입니다. + +신설 순서는 `componentPalette` → `nesting` → `componentCapabilities` → `controls` +입니다. 앞의 둘이 없으면 편집기에서 컴포넌트를 놓을 수조차 없고, 뒤의 둘은 놓은 다음에 +속성을 바꾸기 위한 것입니다. `sirsoft-admin_basic/editor-spec/` 의 13개 블록 파일이 +완성된 형태의 선례입니다. + diff --git a/templates/_bundled/gnuboard7-hello_admin_template/docs/handlers.md b/templates/_bundled/gnuboard7-hello_admin_template/docs/handlers.md new file mode 100644 index 00000000..179a43b7 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_admin_template/docs/handlers.md @@ -0,0 +1,50 @@ +# Hello Admin Template — 핸들러 + +> 템플릿 전용 핸들러와 부트스트랩 · 진입점: [AGENTS.md](../AGENTS.md) + +## 템플릿 전용 핸들러 + + +_등록하는 액션 핸들러가 없습니다._ + + + +없습니다. 이 샘플의 화면은 코어 엔진의 기본 핸들러(`apiCall` · `navigate` · `setState` 등) +만으로 그려집니다. + +템플릿 전용 핸들러가 필요해지는 경우는 **그 템플릿의 화면 구조에만 있는 동작**을 다룰 때입니다 — +테마 전환, 장바구니 선택 상태, 브라우저 저장소 관리 같은 것들이며, +`sirsoft-basic` 이 32개를 갖는 것이 그 예입니다. + +핸들러를 도입할 때는 `initTemplate()` 안에서 `ActionDispatcher` 에 등록합니다. 등록도 +컴포넌트 등록과 같은 재시도 루프 안에 두어야 합니다 — 그 시점에 디스패처가 아직 없을 수 +있습니다. + + +## 부트스트랩 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `src/index.ts` | +| 전역 객체 | **미노출** | +| 재등록 진입점 | `initTemplate()` | + +재등록 진입점이 전역에 고정 이름으로 노출되지 않으면 로케일 전환 후 이 확장의 액션이 전부 무반응이 됩니다 (오류·토스트 없음). + + + +전역 객체가 **미노출**입니다. 모듈·플러그인은 `window.__[Name].initModule/initPlugin` 을 고정 +이름으로 노출해야 하지만(로케일 전환 후 코어가 그것을 다시 부릅니다), 템플릿은 코어가 부트스트랩 +경로를 직접 알고 있어 전역 노출이 필요하지 않습니다. + +`initTemplate()` 이 진입점이며 모듈 로드 시 스스로 실행됩니다. 하는 일은 하나 — 코어 +`ComponentRegistry` 에 Basic 8개를 등록하는 것입니다. + +**재시도 루프가 이 함수의 핵심**입니다. 실행 시점에 레지스트리가 아직 없을 수 있어 100ms 간격으로 +최대 50회 다시 시도하며, `window.load` 이후에 시작합니다. 재시도가 없으면 로드 순서에 따라 +등록이 건너뛰어지고, 레이아웃이 참조하는 컴포넌트 이름을 찾지 못해 **화면이 조용히 빕니다** — +오류도 경고도 남지 않습니다. + +최대 재시도를 넘기면 `logger.error` 로 사실을 남깁니다. 조용히 포기하지 않는 것이 규약입니다. + diff --git a/templates/_bundled/gnuboard7-hello_admin_template/docs/layouts.md b/templates/_bundled/gnuboard7-hello_admin_template/docs/layouts.md new file mode 100644 index 00000000..b9a358bd --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_admin_template/docs/layouts.md @@ -0,0 +1,84 @@ +# Hello Admin Template — 레이아웃 + +> 레이아웃 목록과 라우트 매핑 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 목록 + + +레이아웃 8개 (루트: `layouts`). + +| 그룹 | 개수 | +|---|---| +| `(root)` | 2개 | +| `errors` | 6개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `_admin_base` | `(root)` | partial | - | +| `admin_dashboard` | `(root)` | 화면 | `_admin_base` | +| `401` | `errors` | 화면 | `_admin_base` | +| `403` | `errors` | 화면 | `_admin_base` | +| `404` | `errors` | 화면 | `_admin_base` | +| `500` | `errors` | 화면 | `_admin_base` | +| `503` | `errors` | 화면 | `_admin_base` | +| `maintenance` | `errors` | 화면 | `_admin_base` | + + + +8개 중 **6개가 오류 레이아웃**입니다. 이 비율이 이 샘플이 말하려는 것 그 자체입니다 — 오류 +레이아웃 6종(401 · 403 · 404 · 500 · 503 · maintenance)은 선택이 아니라 **최소 구성**입니다. + +코어는 오류 상황에서 활성 템플릿의 해당 레이아웃을 부릅니다. 없으면 그 오류를 표시할 수단이 +없어 사용자에게는 백지가 되므로, 화면이 하나뿐인 템플릿에도 6종은 있어야 합니다. + +여섯 모두 `_admin_base` 를 상속합니다. 오류 화면에서도 관리자 골격이 유지되고, 뼈대를 한 번 +고치면 전 화면에 반영됩니다. **`extends` 없는 독립 레이아웃을 만들면** 그 화면에서는 +`toast`·`openModal` 이 성공으로 기록되지만 화면에는 아무것도 나타나지 않습니다(호스트 +컴포넌트가 마운트되지 않아서). + +`401` 레이아웃에서 **로그인 리다이렉트를 직접 구현하지 않습니다** — 코어 +`TemplateApp.showRouteError` 가드가 처리하므로, 여기서 다시 이동시키면 이중 리다이렉트가 되고 +돌아올 위치도 어긋납니다. + +레이아웃 JSON 만 고쳤다면 빌드 없이 +`php artisan template:update gnuboard7-hello_admin_template --force` 로 반영합니다. + + +## 라우트 매핑 + + +| 경로 | 레이아웃 | 이름 | +|---|---|---| +| `*/admin` | `admin_dashboard` | - | + + + +하나뿐입니다 — `*/admin` → `admin_dashboard`. + +앞의 `*` 는 로케일 접두 등 가변 구간을 받는 자리입니다. Admin 템플릿의 라우트는 이 형태로 +`*/admin/...` 네임스페이스 안에 있어야 하며, 그 밖의 경로를 선언하면 모듈·User 템플릿이 소유한 +경로와 다투게 되고 어느 쪽이 이기는지가 설치 순서에 좌우됩니다. + +오류 레이아웃에는 라우트가 없습니다 — 코어가 상황을 보고 직접 부르므로 경로로 도달하는 화면이 +아닙니다. + +라우트를 바꾼 뒤에는 `template:update --force` 로 반영합니다. + + +## 확장 오버라이드 + + +_오버라이드하는 레이아웃 확장 조각이 없습니다._ + + + +없습니다. 이 샘플은 다른 확장이 제공한 조각을 대체하지 않습니다. + +오버라이드는 `extensions/{확장}/` 에 그 확장의 조각과 같은 이름의 파일을 두면 성립합니다 — +플러그인이 제공한 조각의 디자인이 이 템플릿과 어긋날 때 **조각을 고치는 대신 템플릿이 자기 +버전을 얹는** 방향입니다. + +그 대가로 원본이 바뀌어도 사본은 따라가지 않습니다. 오버라이드를 두었다면 그 확장을 업그레이드한 +뒤 동작을 확인해야 합니다 — 원본이 핸들러 이름이나 필드 계약을 바꾸면 오버라이드만 옛 계약을 +붙들고 있게 되고, 증상은 "그 자리만 무반응" 으로 나타납니다. + diff --git a/templates/_bundled/gnuboard7-hello_user_template/AGENTS.md b/templates/_bundled/gnuboard7-hello_user_template/AGENTS.md new file mode 100644 index 00000000..0f80b452 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_user_template/AGENTS.md @@ -0,0 +1,169 @@ +# Hello 사용자 템플릿 — 에이전트 가이드 + +> 이 문서는 이 템플릿을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 템플릿 (gnuboard7-hello_user_template, type=user) — 학습용 최소 User 템플릿. Basic 컴포넌트 8 + 베이스 1 + 홈 1 + **오류 6종**. 홈이 학습용 모듈 API 를 `data_sources` 로 연동한다. `hidden: true` +2. 확장 방식: 훅 없음 — 확장점은 화면 구조와 데이터소스 선언이다. 제공 컴포넌트 목록이 곧 계약 +3. 건드리면 안 되는 것: 오류 레이아웃 6종 축소, 401 에서 직접 리다이렉트, 컴포넌트 등록을 한 번만 시도(재시도 필요), 샘플에 실제 사이트 화면 추가 +4. 작업 위치: `templates/_bundled/gnuboard7-hello_user_template` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan template:update gnuboard7-hello_user_template --force` +``` + +## 1. 이 확장은 무엇인가 + + +**학습용 최소 User 템플릿**입니다. 방문자 화면 템플릿이 성립하기 위한 최소 구성과, **모듈의 +공개 API 를 화면에 연결하는 법**을 보이는 것이 목적입니다. + +담긴 것은 넷입니다 — Basic 컴포넌트 8개, 베이스 레이아웃 `_user_base`, 홈 화면 하나, +**오류 레이아웃 6종**(401 · 403 · 404 · 500 · 503 · maintenance). + +**홈 화면이 이 샘플의 핵심**입니다. `data_sources` 로 학습용 모듈의 메모 API +(`/api/modules/gnuboard7-hello_module/memos`)를 호출해 목록을 그립니다 — 모듈이 데이터를, +템플릿이 화면을 담당하는 그누보드7 의 기본 경계를 가장 짧게 보여주는 예시입니다. 실제 게시판·이커머스 +모듈도 방문자 화면을 갖지 않고 같은 방식으로 템플릿에 맡깁니다. + +`manifest.hidden = true` 라 관리자 UI 의 템플릿 목록에 나타나지 않습니다. artisan CLI 로는 +정상 설치·활성화됩니다. + +**의도적으로 하지 않는 것**: 실제 사이트 화면 · composite/layout 컴포넌트 · 액션 핸들러 · +SEO 설정 · 확장 오버라이드. `sirsoft-basic` 이 컴포넌트 79개와 레이아웃 166개를 갖는 것과 +나란히 보면 무엇이 필수이고 무엇이 그 템플릿의 선택인지 드러납니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update gnuboard7-hello_user_template --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update gnuboard7-hello_user_template --force` (빌드 불필요) | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update gnuboard7-hello_user_template --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update gnuboard7-hello_user_template --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +서버 코드가 없으므로 흐름은 **컴포넌트 등록 → 라우트 → 레이아웃 → 데이터소스** 입니다. + +**컴포넌트 등록**: `src/index.ts` 가 모듈 로드 시점에 코어 `ComponentRegistry` 를 찾아 Basic +8개를 등록합니다. 레지스트리가 없으면 경고를 남기고 건너뜁니다. + +> 이 지점이 Admin 샘플과 다릅니다. `gnuboard7-hello_admin_template` 은 `initTemplate()` +> 안에서 **100ms 간격 최대 50회 재시도**로 등록하지만, 이 템플릿은 모듈 로드 시점에 한 번만 +> 시도합니다. 로드 순서에 따라 그 시점에 레지스트리가 아직 없으면 등록이 건너뛰어지고, 그러면 +> 레이아웃이 참조하는 컴포넌트 이름을 찾지 못해 화면이 빕니다 — 콘솔 경고 외에는 흔적이 없습니다. +> 새 템플릿을 만들 때는 **Admin 샘플의 재시도 형태를 따르는 것**이 안전합니다. + +**화면 렌더**: `routes.json` 이 `/` 를 `home` 에 대응 → 그 레이아웃이 `_user_base` 를 상속해 +헤더·푸터를 얻고 콘텐츠를 채웁니다 → `data_sources` 의 `memos` 가 학습용 모듈의 API 를 +`auto_fetch` 로 호출(`loading_strategy: progressive`) → 응답이 목록 컴포넌트에 바인딩됩니다. + +**오류 렌더**: 코어가 401/403/404/500/503/maintenance 상황을 만나면 활성 템플릿의 해당 +레이아웃을 부릅니다. 여섯 모두 `_user_base` 를 상속하므로 오류 화면에서도 공통 뼈대가 +유지됩니다. `401` 에서 **로그인 리다이렉트를 직접 구현하지 않습니다** — 코어 +`TemplateApp.showRouteError` 가드가 처리합니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 제공 컴포넌트 | 8개 | [제공 컴포넌트](docs/components.md#제공-컴포넌트) | +| 레이아웃 | 8개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) | +| 전용 핸들러 | 0개 | [템플릿 전용 핸들러](docs/handlers.md#템플릿-전용-핸들러) | +| 확장 오버라이드 | 0개 | [확장 오버라이드](docs/layouts.md#확장-오버라이드) | + + + +템플릿의 확장점은 훅이 아니라 **화면 구조**입니다. + +| 확장점 | 이 샘플에서 | +|---|---| +| 제공 컴포넌트 | Basic 8개. 다른 확장이 조각을 끼워 넣을 때 **여기 있는 것만** 쓸 수 있습니다 | +| 레이아웃 | 8개(베이스 1 + 홈 1 + 오류 6). 모듈이 `layout_extensions` 로 조각을 끼울 자리는 홈뿐입니다 | +| 확장 오버라이드 | 없음. `extensions/{확장}/` 에 같은 이름의 조각을 두면 그 확장이 제공한 원본을 대체합니다 | +| 전용 핸들러 | 없음 | + +**데이터소스가 이 템플릿의 실질적인 확장점**입니다. 홈 화면이 학습용 모듈의 API 를 호출하듯, +User 템플릿은 자기가 그리고 싶은 화면에 필요한 API 를 `data_sources` 로 선언해 가져옵니다. +모듈을 바꾸거나 늘려도 템플릿의 구조는 그대로이고 선언만 바뀝니다. + +그 대가로 **모듈의 응답 형태가 바뀌면 화면이 조용히 빕니다.** 의존한 모듈을 업그레이드한 뒤에는 +그 화면이 여전히 데이터를 그리는지 확인해야 합니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan template:update gnuboard7-hello_user_template --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 오류 레이아웃 6종(401·403·404·500·503·maintenance)이 모두 있는지 확인 — 하나라도 없으면 그 상황에서 백지가 된다 +- [ ] 컴포넌트 등록 경로를 손댔다면 재시도 여부를 확인 — 한 번만 시도하면 로드 순서에 따라 화면이 조용히 빈다 +- [ ] 의존 모듈(`gnuboard7-hello_module`)의 API 응답 형태가 바뀌면 홈 화면이 조용히 빈다 — 그 모듈을 올릴 때 함께 확인 +- [ ] 컴포넌트를 추가·삭제했다면 소스 · `template.json` 레지스트리 · `components.json` 을 함께 갱신 +- [ ] `manifest.hidden = true` 를 유지 (복제본에서만 제거) +- [ ] TSX 를 고쳤다면 `template:build --production` 후 `dist/` 동반 커밋 (`sourceMappingURL` 잔존 금지) +- [ ] 레이아웃 렌더링 테스트(`__tests__/layouts/*.test.tsx`)는 이 템플릿 디렉토리에 둔다 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 를 확인 — 이 확장은 편집기 스펙이 없어 `memos` 가 편집기 캔버스에서 빈 화면으로 보인다. `data_source` 를 더 늘리면 그 자리도 같은 상태가 된다 + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 오류 레이아웃 6종 중 일부를 빼기 | 401 · 403 · 404 · 500 · 503 · maintenance 전부 유지 | 코어가 그 상황에서 활성 템플릿의 레이아웃을 부른다 — 없으면 방문자에게 백지가 된다 | +| `401` 레이아웃에서 로그인으로 직접 리다이렉트 | 코어 `TemplateApp.showRouteError` 가드에 위임 | 이중 리다이렉트가 되고, 돌아올 위치를 코어가 이미 관리한다 | +| 컴포넌트 등록을 한 번만 시도 | Admin 샘플처럼 재시도 루프를 둔다 | 로드 순서에 따라 레지스트리가 아직 없을 수 있다 — 등록에 실패하면 화면이 조용히 빈다 | +| `extends` 없는 독립 레이아웃에서 `toast`·`openModal` 사용 | `_user_base` 를 상속하거나 호스트 컴포넌트를 직접 마운트 | 호스트가 없으면 핸들러는 성공으로 기록되는데 화면에는 아무것도 나타나지 않는다 | +| 모듈 API 응답 필드를 화면에서 그대로 가정하고 방어 없이 바인딩 | `{{값 ?? ''}}` 같은 폴백과 배열 경로 확인 | 응답 형태가 바뀌면 화면이 조용히 빈다 | +| 이 샘플에 실제 사이트 화면을 더해 "쓸모 있게" 만들기 | 짧게 유지하고, 필요한 화면은 별도 템플릿으로 | 샘플의 가치는 "최소 구성이 무엇인가" 를 보이는 것이다 | +| `manifest.hidden` 을 제거 | 그대로 둔다 (복제본에서만 제거) | 학습용 템플릿이 운영 사이트의 템플릿 목록에 섞인다 | +| 컴포넌트를 추가하면서 `template.json` 의 레지스트리를 갱신하지 않기 | 소스·레지스트리·`components.json` 을 함께 | 레이아웃이 참조하는 이름을 찾지 못해 그 컴포넌트만 조용히 렌더되지 않는다 | +| TSX 를 고치고 `dist/` 재빌드 없이 커밋 | `template:build --production` 후 `dist/` 동반 커밋 | 브라우저가 받는 것은 커밋된 `dist/` 다 — 소스 수정이 사문화된다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 0개 | — | +| Vitest | 2개 | `vitest.config.ts` | +| Playwright | 0개 | — | +| 시나리오 매니페스트 | 0개 | — | + +```bash +# Vitest (확장 디렉토리에서) (PowerShell) +cd templates/_bundled/gnuboard7-hello_user_template && powershell -Command "npm run test:run -- <대상>" + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + diff --git a/templates/_bundled/gnuboard7-hello_user_template/CHANGELOG.md b/templates/_bundled/gnuboard7-hello_user_template/CHANGELOG.md index 0baedd69..920ee9b9 100644 --- a/templates/_bundled/gnuboard7-hello_user_template/CHANGELOG.md +++ b/templates/_bundled/gnuboard7-hello_user_template/CHANGELOG.md @@ -9,6 +9,9 @@ ### Added - 아이콘(Font Awesome)을 템플릿에 함께 담아 외부 CDN 없이 동작합니다. +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. ## [0.1.0] - 2026-07-01 diff --git a/templates/_bundled/gnuboard7-hello_user_template/README.md b/templates/_bundled/gnuboard7-hello_user_template/README.md new file mode 100644 index 00000000..ab5cd7b3 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_user_template/README.md @@ -0,0 +1,193 @@ +# Hello 사용자 템플릿 + +**그누보드7 템플릿 · gnuboard7-hello_user_template** +학습용 최소 샘플 사용자 템플릿 (Basic 8개 컴포넌트) + + +

+ version 0.1.1 + type 템플릿 + 그누보드7 >=7.0.10 + license MIT + requires gnuboard7-hello_module +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +그누보드7 **사용자 템플릿이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 운영에 쓰기 +위한 것이 아니라, "방문자 화면 템플릿은 최소한 무엇을 갖춰야 하는가" 와 "모듈의 데이터를 화면에 +어떻게 연결하는가" 를 보이는 것이 목적입니다. + +담긴 것은 화면 부품 8개, 공통 뼈대 하나, 홈 화면 한 장, 그리고 **오류 화면 6종**입니다. +홈 화면은 학습용 모듈의 메모 목록을 불러와 보여줍니다 — **모듈은 데이터를, 템플릿은 화면을** +담당하는 그누보드7 의 기본 구조를 가장 짧게 보여주는 예시입니다. + +관리자 화면의 템플릿 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로 +설치·활성화할 수 있으며, 학습용 모듈이 함께 설치되어 있어야 홈 화면에 목록이 나옵니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 화면 부품 | 가장 기본적인 8개 (영역·버튼·제목 3종·링크·글자·이미지) | +| 공통 뼈대 | 모든 화면이 물려받는 방문자 기본 골격 (헤더 + 콘텐츠 + 푸터) | +| 홈 화면 | 학습용 모듈의 메모 목록을 불러와 표시 | +| 오류 화면 | 401·403·404·500·503·점검 중 6종 | +| 다국어 | 한국어·영어 공통 문구 | +| 테스트 | 데이터를 모의로 넣고 화면이 그려지는지 확인하는 예시 | + + +## 동작 방식 + + +```mermaid +flowchart TD + B[_user_base
헤더 + 콘텐츠 + 푸터] --> H[home
메모 목록] + B --> E401[401 권한 없음] + B --> E403[403 접근 거부] + B --> E404[404 없는 페이지] + B --> E500[500 서버 오류] + B --> E503[503 이용 불가] + B --> EM[maintenance 점검 중] +``` + +모든 화면이 하나의 공통 뼈대를 물려받습니다. 오류 화면에서도 사이트 골격이 유지되고, 뼈대를 +한 번 고치면 전 화면에 반영됩니다. + +```mermaid +flowchart LR + V[방문자] --> H[홈 화면] + H -->|목록 요청| M[학습용 모듈] + M --> DB[(메모 데이터)] +``` + +홈 화면은 자기 데이터를 갖지 않고 모듈에 요청해서 받아옵니다. 그래서 템플릿을 바꿔도 데이터는 +그대로 남고, 같은 데이터를 다른 디자인으로 보여줄 수 있습니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | +| 의존 모듈 | `gnuboard7-hello_module` `>=0.1.0` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan template:install gnuboard7-hello_user_template + +# 활성화 +php artisan template:activate gnuboard7-hello_user_template + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan template:update gnuboard7-hello_user_template --force +``` + + +## 제공 컴포넌트 + + +컴포넌트 8개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 8개 | + + + +방문자 화면을 그리는 데 쓰는 **부품 8개**입니다. 기본 사용자 템플릿이 79개를 갖는 것과 +비교하면 이 샘플이 얼마나 최소한만 담고 있는지 알 수 있습니다. + +부품 목록은 **다른 확장과의 계약**이기도 합니다. 모듈이나 플러그인이 이 템플릿의 화면에 조각을 +끼워 넣을 때 여기 없는 부품을 쓰면 그 조각은 그려지지 않습니다. + +전체 목록과 사용법은 [docs/components.md](docs/components.md) 에 있습니다. + + +## 사용 방법 + + +**설치해 보기**: 학습용 모듈을 먼저 설치한 뒤 이 템플릿을 설치합니다. + +```bash +php artisan module:install gnuboard7-hello_module +php artisan module:activate gnuboard7-hello_module +php artisan template:install gnuboard7-hello_user_template +php artisan template:activate gnuboard7-hello_user_template +``` + +활성화하면 사이트 첫 화면이 이 최소 템플릿으로 바뀌고, 관리자에서 등록한 메모가 홈 화면에 +목록으로 나옵니다. 원래 템플릿으로 되돌리려면 그 템플릿을 다시 활성화하면 됩니다. + +**모듈 데이터를 화면에 연결하는 법 배우기**: 홈 화면 파일(`layouts/home.json`)의 데이터 요청 +선언을 보면, 어떤 주소에서 무엇을 받아 화면 어디에 넣는지가 한눈에 들어옵니다. 새 템플릿을 +만들 때 이 선언 형태를 그대로 따라 쓰면 됩니다. + +**새 사용자 템플릿의 출발점으로 쓰기**: 이 디렉토리를 복제한 뒤 식별자를 바꾸고 학습용 표시를 +지우면 새 템플릿이 됩니다. 부품과 화면을 필요한 만큼 더해 나가면 됩니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `gnuboard7-hello_module` | 모듈 | `>=0.1.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 관리자 템플릿 목록에 이 템플릿이 없음 | 학습용이라 목록에서 제외됨 | 정상입니다. 명령줄로 설치·활성화합니다 | +| 홈 화면에 메모 목록이 비어 있음 | 학습용 모듈이 설치·활성화되지 않았거나 메모가 없음 | 모듈을 활성화하고 관리자에서 메모를 몇 건 등록합니다 | +| 화면이 아무것도 안 그려짐 | 빌드 결과물이 없거나 오래됨 | 템플릿을 다시 빌드하고 반영합니다 | +| 오류가 났는데 아무 화면도 안 보임 | 해당 오류 화면이 없음 | 오류 화면 6종이 모두 있는지 확인합니다 | +| 복제해서 만든 템플릿이 목록에 안 보임 | 복제본에 학습용 표시가 남아 있음 | 복제본의 `hidden` 표시를 지웁니다 | +| 새로 고칠 때마다 화면이 나오다 안 나오다 함 | 화면 부품 등록이 로드 순서를 탐 | 부품 등록에 재시도를 두는 형태(관리자 샘플 참고)로 고칩니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/templates/_bundled/gnuboard7-hello_user_template/docs/README.md b/templates/_bundled/gnuboard7-hello_user_template/docs/README.md new file mode 100644 index 00000000..3b5ea646 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_user_template/docs/README.md @@ -0,0 +1,21 @@ +# Hello 사용자 템플릿 개발자 문서 + +> templates/_bundled/gnuboard7-hello_user_template · 템플릿 + + +**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 1 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 8 · **핸들러 수**: 0 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | +| [components.md](components.md) | 템플릿이 제공하는 컴포넌트 | +| [layouts.md](layouts.md) | 레이아웃 목록과 라우트 매핑 | +| [handlers.md](handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | +| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | +| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 | +| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 | + diff --git a/templates/_bundled/gnuboard7-hello_user_template/docs/architecture.md b/templates/_bundled/gnuboard7-hello_user_template/docs/architecture.md new file mode 100644 index 00000000..cabeadc2 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_user_template/docs/architecture.md @@ -0,0 +1,76 @@ +# Hello 사용자 템플릿 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +목표가 둘입니다 — "User 템플릿의 **최소 구성**은 무엇인가" 와 "모듈의 데이터를 화면에 **어떻게 +연결하는가**". + +앞의 답은 Admin 샘플과 같습니다: Basic 컴포넌트 8개 · 베이스 레이아웃 · 화면 하나 · +**오류 레이아웃 6종**. 오류 6종이 선택이 아닌 이유도 같습니다 — 코어가 그 상황에서 활성 +템플릿의 레이아웃을 부르는데 없으면 방문자에게 백지가 됩니다. + +뒤의 답이 이 샘플만의 것입니다. 홈 화면이 `data_sources` 로 학습용 모듈의 메모 API 를 호출해 +목록을 그립니다 — **모듈이 데이터를, 템플릿이 화면을** 담당하는 경계를 가장 짧게 보여주는 +예시이며, 실제 게시판·이커머스 모듈도 방문자 화면을 갖지 않고 같은 방식으로 템플릿에 +맡깁니다. + +**의도적으로 하지 않는 것**: 실제 사이트 화면 · composite/layout 컴포넌트 · 액션 핸들러 · +SEO 설정 · 확장 오버라이드. `sirsoft-basic` 과 나란히 보면 무엇이 필수이고 무엇이 그 템플릿의 +선택인지가 드러납니다. + +`manifest.hidden = true` 는 학습용이 운영 사이트의 템플릿 목록에 섞이지 않게 하면서도 CLI 로는 +실제로 설치·동작하게 하는 장치입니다. + + +## 계층 지도 + + +``` +template.json manifest — type: user · features 플래그 · 컴포넌트 레지스트리 +routes.json `/` → home (auth_required: false) + │ +layouts/_user_base.json 방문자 공통 뼈대 (헤더 + 콘텐츠 + 푸터) + │ extends + ├─ layouts/home.json data_sources: 학습용 모듈 메모 API + └─ layouts/errors/{401,403,404,500,503,maintenance}.json ← 6종 필수 + │ +src/components/basic/ Div · Button · H1 · H2 · H3 · A · Span · Img + │ +src/index.ts ComponentRegistry 에 8개 등록 (모듈 로드 시점, 1회) + │ +dist/ 커밋되는 빌드 산출물 +__tests__/layouts/ createLayoutTest() + API 모킹 기반 렌더링 테스트 +``` + +**서버 코드가 없습니다.** 데이터는 전부 모듈·코어의 공개 API 에서 오며, 그래서 템플릿을 갈아 +끼워도 데이터는 그대로입니다. 반대로 이 템플릿만으로는 홈 화면이 비어 있습니다 — manifest 가 +학습용 모듈을 의존으로 선언하는 이유입니다. + +> **등록 방식이 Admin 샘플과 다릅니다.** `gnuboard7-hello_admin_template` 은 +> `initTemplate()` 안에서 100ms 간격 최대 50회 **재시도**로 컴포넌트를 등록하지만, 이 템플릿은 +> 모듈 로드 시점에 한 번만 시도하고 레지스트리가 없으면 경고를 남기고 건너뜁니다. 로드 순서에 +> 따라 등록이 누락되면 레이아웃이 컴포넌트 이름을 찾지 못해 화면이 조용히 빕니다 — 새 템플릿을 +> 만들 때는 **Admin 샘플의 재시도 형태를 따르는 것**이 안전합니다. + +레이아웃 렌더링 테스트는 **이 템플릿 디렉토리에 둡니다**(`__tests__/layouts/`). API 응답을 +모킹해 데이터소스 연동까지 검증하는 것이 이 샘플 테스트의 학습 포인트입니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update gnuboard7-hello_user_template --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update gnuboard7-hello_user_template --force` (빌드 불필요) | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update gnuboard7-hello_user_template --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update gnuboard7-hello_user_template --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/templates/_bundled/gnuboard7-hello_user_template/docs/components.md b/templates/_bundled/gnuboard7-hello_user_template/docs/components.md new file mode 100644 index 00000000..b7de5c23 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_user_template/docs/components.md @@ -0,0 +1,30 @@ +# Hello 사용자 템플릿 — 컴포넌트 + +> 템플릿이 제공하는 컴포넌트 · 진입점: [AGENTS.md](../AGENTS.md) + +## 제공 컴포넌트 + + +컴포넌트 8개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 8개 | + + + +Basic 8개뿐이며 composite·layout 은 없습니다 — HTML 태그를 그대로 래핑한 최소 단위만으로 +방문자 화면 골격을 그릴 수 있음을 보이는 구성입니다. + +`sirsoft-basic` 이 79개(basic 38 · composite 36 · layout 5)를 갖는 것과 비교하면 그 차이가 +곧 "필수" 와 "그 템플릿의 선택" 의 경계입니다. 상품 카드·이미지 뷰어·모바일 메뉴 같은 것들은 +그 템플릿이 자기 화면을 위해 만든 것이지 템플릿의 요건이 아닙니다. + +**이 목록이 다른 확장과의 계약**입니다. 모듈·플러그인이 이 템플릿의 화면에 조각을 끼워 넣을 때 +여기 없는 컴포넌트를 쓰면 그 조각은 렌더되지 않습니다. + +컴포넌트를 추가할 때는 소스(`src/components/{분류}/`) · `template.json` 의 레지스트리 · +`components.json` 을 함께 갱신합니다. 하나라도 빠지면 레이아웃이 그 이름을 찾지 못해 그 +컴포넌트만 조용히 렌더되지 않습니다. TSX 를 고쳤으면 `template:build --production` 후 +`dist/` 를 함께 커밋합니다. + diff --git a/templates/_bundled/gnuboard7-hello_user_template/docs/editor-spec.md b/templates/_bundled/gnuboard7-hello_user_template/docs/editor-spec.md new file mode 100644 index 00000000..524c9d3a --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_user_template/docs/editor-spec.md @@ -0,0 +1,80 @@ +# Hello 사용자 템플릿 — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +_이 확장은 편집기 스펙(`editor-spec.json`)을 두지 않습니다. 편집기는 코어 기본 팔레트와 활성 템플릿의 스펙만으로 이 확장의 화면을 다룹니다._ + + + +학습용 사용자 템플릿이라 편집기 스펙을 두지 않았습니다. 실제 사용자 템플릿이 무엇을 +선언하는지는 `sirsoft-basic` 의 같은 문서를 봅니다 — 팔레트 45 · 상태 범위 17 이 그 +규모입니다. + + +## 선언 블록 + + +_선언된 편집기 스펙 블록이 없습니다._ + + + +선언한 블록이 없습니다. 팔레트·중첩을 선언하지 않았으므로 이 템플릿을 활성화한 상태에서는 +편집기가 놓을 수 있는 컴포넌트가 없습니다. + + +## 컴포넌트 팔레트 + + +_이 확장은 편집기 팔레트에 항목을 추가하지 않습니다._ + + + +컴포넌트를 만드는 것은 템플릿의 일이므로, 이 확장이 팔레트에 얹을 것은 원래 없습니다. +편집기 팔레트는 활성 템플릿의 스펙이 정합니다 — 이 확장에 편집기 스펙이 생기더라도 +`componentPalette` 는 여전히 비어 있을 것입니다. + + +## 샘플 데이터와 페이지 상태 + + +_이 확장은 편집기 스펙을 두지 않아 선언된 샘플 데이터·페이지 상태가 없습니다._ + +**프리뷰 샘플이 없는 `data_source` 1개** — 편집기 캔버스에서 이 자리만 빈 화면이 됩니다. 실제 화면은 정상이라 오류도 경고도 남지 않습니다. + +`memos` + + + +`memos` 가 미커버입니다. 이 템플릿은 `gnuboard7-hello_module` 의 메모 목록을 화면에 +그리는데, 그 데이터의 프리뷰 샘플을 어느 쪽도 선언하지 않았기 때문입니다. + +이 한 건이 모듈과 템플릿의 역할 분담을 그대로 보여 줍니다 — 데이터를 주는 것은 모듈이고 +그리는 것은 템플릿이라, 샘플을 어느 쪽에 둘지 판단이 필요합니다. `memos` 를 이 템플릿만 +쓴다면 템플릿 스펙에, 여러 템플릿이 쓴다면 모듈 스펙에 둡니다. + + +## 수정 시 동반 의무 + + +_이 확장은 아직 편집기 스펙을 두지 않습니다. 아래 변경이 생기면 `editor-spec.json` 을 신설합니다._ + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + + + +이 템플릿을 실제 사용 템플릿으로 발전시킨다면 편집기 스펙 신설이 필요합니다. 템플릿 +스펙은 **컴포넌트 어휘**(팔레트·중첩·역량·컨트롤)를 담고, 거기에 이 템플릿이 그리는 +화면의 `sampleData` 를 더합니다. + +신설 순서는 `componentPalette` → `nesting` → `componentCapabilities` → `controls` +입니다. `sirsoft-basic/editor-spec/` 의 13개 블록 파일이 완성된 형태의 선례입니다. + diff --git a/templates/_bundled/gnuboard7-hello_user_template/docs/handlers.md b/templates/_bundled/gnuboard7-hello_user_template/docs/handlers.md new file mode 100644 index 00000000..930771fb --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_user_template/docs/handlers.md @@ -0,0 +1,47 @@ +# Hello 사용자 템플릿 — 핸들러 + +> 템플릿 전용 핸들러와 부트스트랩 · 진입점: [AGENTS.md](../AGENTS.md) + +## 템플릿 전용 핸들러 + + +_등록하는 액션 핸들러가 없습니다._ + + + +없습니다. 이 샘플의 화면은 코어 엔진의 기본 핸들러(`apiCall` · `navigate` · `setState` 등) +만으로 그려집니다. 홈 화면의 목록도 `data_sources` 선언만으로 채워지므로 별도 핸들러가 필요 +없습니다. + +템플릿 전용 핸들러가 필요해지는 경우는 **그 템플릿의 화면 구조에만 있는 동작**을 다룰 때입니다 — +테마 전환, 장바구니 선택 상태, 브라우저 저장소 관리 같은 것들이며, `sirsoft-basic` 이 32개를 +갖는 것이 그 예입니다. + +핸들러를 도입할 때는 `ActionDispatcher` 에 등록하는 부트스트랩 함수가 함께 필요하고, 그 등록도 +**레지스트리·디스패처 가용을 기다리는 재시도 루프** 안에 두어야 합니다. + + +## 부트스트랩 + + +_프론트 엔트리포인트가 없습니다._ + + + +전용 부트스트랩 함수가 없습니다. `src/index.ts` 가 **모듈 로드 시점에 곧바로** 코어 +`ComponentRegistry` 를 찾아 Basic 8개를 등록하고, 레지스트리가 없으면 경고를 남기고 +건너뜁니다. + +> **이 형태는 따라 하지 않는 것이 좋습니다.** 같은 샘플군의 +> `gnuboard7-hello_admin_template` 은 `initTemplate()` 안에서 100ms 간격 최대 50회 +> **재시도**하며 `window.load` 이후에 시작합니다. 한 번만 시도하는 이 형태는 로드 순서에 따라 +> 등록이 누락될 수 있고, 그러면 레이아웃이 컴포넌트 이름을 찾지 못해 **화면이 조용히 +> 빕니다** — 콘솔 경고 외에는 흔적이 없습니다. + +템플릿은 전역 객체를 노출하지 않습니다. 모듈·플러그인은 +`window.__[Name].initModule/initPlugin` 을 고정 이름으로 노출해야 하지만(로케일 전환 후 코어가 +그것을 다시 부릅니다), 템플릿은 코어가 부트스트랩 경로를 직접 알고 있습니다. + +액션 핸들러를 도입하면 그때 `ActionDispatcher` 등록이 추가되며, 그 등록도 같은 재시도 루프 +안에 두어야 합니다. + diff --git a/templates/_bundled/gnuboard7-hello_user_template/docs/layouts.md b/templates/_bundled/gnuboard7-hello_user_template/docs/layouts.md new file mode 100644 index 00000000..6c9d3029 --- /dev/null +++ b/templates/_bundled/gnuboard7-hello_user_template/docs/layouts.md @@ -0,0 +1,89 @@ +# Hello 사용자 템플릿 — 레이아웃 + +> 레이아웃 목록과 라우트 매핑 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 목록 + + +레이아웃 8개 (루트: `layouts`). + +| 그룹 | 개수 | +|---|---| +| `(root)` | 2개 | +| `errors` | 6개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `_user_base` | `(root)` | partial | - | +| `401` | `errors` | 화면 | `_user_base` | +| `403` | `errors` | 화면 | `_user_base` | +| `404` | `errors` | 화면 | `_user_base` | +| `500` | `errors` | 화면 | `_user_base` | +| `503` | `errors` | 화면 | `_user_base` | +| `maintenance` | `errors` | 화면 | `_user_base` | +| `home` | `(root)` | 화면 | `_user_base` | + + + +8개 중 **6개가 오류 레이아웃**입니다. 오류 6종(401 · 403 · 404 · 500 · 503 · maintenance)은 +선택이 아니라 **최소 구성**입니다 — 코어가 그 상황에서 활성 템플릿의 레이아웃을 부르는데 +없으면 방문자에게 백지가 됩니다. + +나머지 둘이 베이스(`_user_base`)와 홈(`home`)입니다. **홈이 이 샘플의 학습 포인트**로, +`data_sources` 로 학습용 모듈의 메모 API 를 `auto_fetch` 호출해 목록을 그립니다 +(`loading_strategy: progressive`). 모듈이 데이터를, 템플릿이 화면을 담당하는 경계가 이 한 +파일에 들어 있습니다. + +여덟 모두 `_user_base` 를 상속합니다. **`extends` 없는 독립 레이아웃을 만들면** 그 화면에서는 +`toast`·`openModal` 이 성공으로 기록되지만 화면에는 아무것도 나타나지 않습니다(호스트 +컴포넌트가 마운트되지 않아서). + +`401` 레이아웃에서 **로그인 리다이렉트를 직접 구현하지 않습니다** — 코어 +`TemplateApp.showRouteError` 가드가 처리하므로, 여기서 다시 이동시키면 이중 리다이렉트가 되고 +돌아올 위치도 어긋납니다. + +레이아웃 JSON 만 고쳤다면 빌드 없이 +`php artisan template:update gnuboard7-hello_user_template --force` 로 반영합니다. + + +## 라우트 매핑 + + +| 경로 | 레이아웃 | 이름 | +|---|---|---| +| `/` | `home` | - | + + + +하나뿐입니다 — `/` → `home`, 그리고 `auth_required: false`. + +**로그인 없이 접근하는 화면임을 선언**하는 것이 이 플래그의 역할입니다. User 템플릿의 대부분 +화면이 그렇고, 마이페이지처럼 회원 전용인 화면만 `true` 로 둡니다. 빠뜨리면 방문자가 첫 화면에서 +로그인으로 튕깁니다. + +오류 레이아웃에는 라우트가 없습니다 — 코어가 상황을 보고 직접 부르므로 경로로 도달하는 화면이 +아닙니다. + +실제 템플릿에서는 라우트 경로에 표현식을 쓸 수 있습니다(`sirsoft-basic` 의 상점 경로가 +이커머스 설정을 반영하듯). 이 샘플은 정적 경로 하나로 최소 형태만 보입니다. + +라우트를 바꾼 뒤에는 `template:update --force` 로 반영합니다. + + +## 확장 오버라이드 + + +_오버라이드하는 레이아웃 확장 조각이 없습니다._ + + + +없습니다. 이 샘플은 다른 확장이 제공한 조각을 대체하지 않습니다. + +오버라이드는 `extensions/{확장}/` 에 그 확장의 조각과 같은 이름의 파일을 두면 성립합니다 — +플러그인이 제공한 조각의 디자인이 이 템플릿과 어긋날 때 **조각을 고치는 대신 템플릿이 자기 +버전을 얹는** 방향입니다. + +그 대가로 원본이 바뀌어도 사본은 따라가지 않습니다. 오버라이드를 두었다면 그 확장을 업그레이드한 +뒤 동작을 확인해야 합니다 — 원본이 핸들러 이름이나 필드 계약을 바꾸면 오버라이드만 옛 계약을 +붙들고 있게 되고, 증상은 "그 자리만 무반응" 으로 나타납니다. + diff --git a/templates/_bundled/sirsoft-admin_basic/AGENTS.md b/templates/_bundled/sirsoft-admin_basic/AGENTS.md new file mode 100644 index 00000000..b3596452 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/AGENTS.md @@ -0,0 +1,152 @@ +# Admin Basic — 에이전트 가이드 + +> 이 문서는 이 템플릿을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 템플릿 (sirsoft-admin_basic) — 코어·모든 번들 확장의 관리자 화면을 그리는 유일한 admin 템플릿 +2. 확장 방식: 필수 컴포넌트(35개, config/template.php)만 써서 레이아웃 JSON 작성 — 다른 admin 템플릿으로 교체돼도 동작 보장 +3. 건드리면 안 되는 것: `Icon` 컴포넌트로 AdminSidebar 아이콘 렌더(반드시 `I`+FontAwesome 클래스), `_admin_base` 슬롯 구조를 임의로 재배치 +4. 작업 위치: `templates/_bundled/sirsoft-admin_basic` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan template:update sirsoft-admin_basic --force` +``` + +## 1. 이 확장은 무엇인가 + + +그누보드7 이 기본 제공하는 유일한 admin 타입 템플릿입니다 — 코어 관리자 화면(대시보드·사용자·역할· +설정·확장 관리 등)뿐 아니라 **모든 번들 모듈/플러그인의 관리자 레이아웃**(`resources/layouts/ +admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템플릿의 컴포넌트로 그려집니다. +그래서 이 템플릿의 공개 계약(필수 컴포넌트 35개, `_admin_base` 슬롯 구조)을 깨면 코어가 +아니라 **전체 번들 확장의 관리자 화면**이 동시에 영향을 받습니다 — 다른 번들 확장 하나를 +고치는 것과는 파급 범위가 다릅니다. + +**설계 원칙**: 모듈/플러그인 개발자가 "이 컴포넌트만 쓰면 다른 admin 템플릿으로 바꿔도 +안전하다"는 보장을 받도록, 필수 컴포넌트 목록(config/template.php)을 이 템플릿 하나가 아니라 +**admin 템플릿이라면 지켜야 할 계약**으로 취급합니다 — 이 템플릿에만 있는 편의 컴포넌트를 +필수 목록에 넣지 않습니다. + +**의도적으로 하지 않는 것**: 방문자용(user) 화면은 이 템플릿의 범위가 아닙니다(`sirsoft-basic` +소관). 또한 컴포넌트 Props 전체 레퍼런스는 이 문서가 다시 나열하지 않습니다 — 코어 +`docs/frontend/component-props*.md` 가 SSoT 이고, 이 문서는 이 템플릿에서만 유효한 계약 +(필수 컴포넌트·AdminSidebar·SlotContainer·베이스 레이아웃 구조)만 다룹니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update sirsoft-admin_basic --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update sirsoft-admin_basic --force` (빌드 불필요) | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` | +| `src/handlers/` | 템플릿 전용 액션 핸들러 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` | +| `editor-spec/` | 분할 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update sirsoft-admin_basic --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**새 관리자 화면 렌더**: 방문자가 `/admin/{path}` 접근 → `routes.json` 이 경로를 레이아웃 +이름으로 매핑 → 그 레이아웃이 `extends: _admin_base` 로 사이드바·헤더·Toast·콘텐츠 슬롯을 +상속 → `slots.content` 에 정의된 컴포넌트(대개 `PageHeader`+`DataGrid`/`Card`/`Form` +조합)가 데이터소스 API 를 호출해 화면을 채웁니다. 이 흐름은 코어 화면과 번들 확장 화면이 +완전히 동일합니다 — 확장이 관리자 화면을 추가할 때 이 템플릿을 복제하지 않고 자기 +`resources/layouts/admin/*.json` 에서 그대로 `_admin_base` 를 extends 합니다. + +**사이드바 접힘 상태 복원**: 페이지 로드 → `src/index.ts` 부트스트랩이 `initSidebar()` 직접 +호출(레이아웃 `init_actions` 아님) → localStorage 값을 `_global.sidebarCollapsed` 에 반영 → +`_admin_base.json` 의 사이드바 영역 className 표현식이 그 값을 읽어 접힘 스타일 적용. + +**로케일 전환**: 코어가 로케일을 바꾸면 이 템플릿의 `initTemplate()` 재등록 진입점이 호출되어 +핸들러 맵을 다시 등록합니다(§docs/handlers.md "부트스트랩"). 사이드바 접힘 등 1회성 부팅 +작업은 이 진입점에 섞지 않습니다 — 로케일 전환마다 중복 실행되면 안 되기 때문입니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 제공 컴포넌트 | 125개 | [제공 컴포넌트](docs/components.md#제공-컴포넌트) | +| 레이아웃 | 145개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) | +| 전용 핸들러 | 17개 | [템플릿 전용 핸들러](docs/handlers.md#템플릿-전용-핸들러) | +| 확장 오버라이드 | 0개 | [확장 오버라이드](docs/layouts.md#확장-오버라이드) | + + + +이 템플릿은 훅을 발행/구독하지 않습니다 — 관리자 화면 확장은 훅이 아니라 **레이아웃 확장 +오버라이드**(`extensions/{module-identifier}/*.json`, 지금은 0개)와 **컴포넌트 재사용** +(공통 컴포넌트를 그대로 쓰는 것) 두 경로로만 이뤄집니다. 다른 확장의 관리자 화면 UI 를 +이 템플릿에서만 다르게 그리고 싶다면 첫 번째 경로를, 새 화면 유형(예: 새로운 카드 스타일)이 +필요하면 `src/components/composite/` 에 컴포넌트를 추가하고 `components.json` 에 등록하는 +쪽을 씁니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan template:update sirsoft-admin_basic --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 필수 컴포넌트(35개) 의 Props 시그니처를 깨는 변경은 전체 번들 확장 관리자 화면에 영향 — 변경 전 `src/components/{basic,composite}/` 의 실사용처를 넓게 확인 +- [ ] `_admin_base.json` 슬롯 구조(`content` 슬롯 등) 변경 시 그 슬롯에 의존하는 모든 화면(145개 레이아웃 대다수) 영향 검토 +- [ ] AdminSidebar 의 `MenuItem`/`AdminSidebarProps` 인터페이스 확장 시 이 문서의 §docs/components.md "AdminSidebar 상세" 동기화 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec/` 블록을 함께 갱신 — 컴포넌트는 팔레트·역량·중첩 **넷 다** 손대야 편집기에서 온전히 동작하고, 하나만 빠지면 절반만 동작한다. 반영은 `php artisan template:update sirsoft-admin_basic --force` (편집기는 활성 디렉토리만 읽는다) + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| `AdminSidebar` 메뉴 아이콘을 `Icon name="home"` 식으로 렌더 | `I` 컴포넌트 + FontAwesome 클래스 문자열(`icon` 필드가 이미 `"fas fa-home"` 형태) | 메뉴 API 가 클래스 문자열을 내려주므로 `Icon`(IconName enum) 으로 받으면 렌더되지 않는다 | +| 필수 컴포넌트 목록 밖의 이 템플릿 전용 컴포넌트를 모듈 레이아웃에서 사용 | 필수 컴포넌트(config/template.php) 만 사용 | 다른 admin 템플릿으로 교체 시 그 화면만 깨진다 | +| 사이드바 접힘 상태를 레이아웃 `init_actions` 로 매번 복원 | 템플릿 부트스트랩(`src/index.ts`)에서 1회 복원 | `init_actions` 는 화면 진입마다 재실행되어 불필요한 반복 처리가 된다 | +| `_admin_base` 를 상속하는데 로그인 화면처럼 `initTheme`/메뉴 초기화를 다시 호출 | `_admin_base` 상속 화면은 이미 초기화된 전역 상태를 그대로 사용 | 중복 호출은 낭비이며, 두 초기화 지점의 결과가 어긋나면 화면 간 상태 불일치가 생긴다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 0개 | — | +| Vitest | 203개 | `vitest.config.ts` | +| Playwright | 8개 | `tests/Playwright` | +| 시나리오 매니페스트 | 2개 | `tests/scenarios` | + +```bash +# Vitest (확장 디렉토리에서) (PowerShell) +cd templates/_bundled/sirsoft-admin_basic && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index b3cd21b7..aef6171f 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -13,6 +13,9 @@ - 코드 편집기를 불러오지 못하면 안내와 함께 일반 입력창으로 전환되어, 레이아웃을 계속 편집하고 저장할 수 있습니다. - 확장(템플릿·모듈·플러그인)을 제거할 때 `custom/` 에 넣어 둔 파일의 사본이 보관되며, 제거 창이 그 보관 경로를 보여 줍니다. 보관된 파일이 없으면 창은 종전대로 곧바로 닫힙니다. - 환경설정 > 고급 에 리버스 프록시 진단이 추가되었습니다. 사이트가 받고 있는 프록시 정보, HTTPS 인식 여부, 방문자 IP 로 인식된 값과 직전 호출 IP 를 나란히 보여 줍니다. 읽기 전용이며 값은 `.env` 에서만 변경합니다. +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. ### Changed diff --git a/templates/_bundled/sirsoft-admin_basic/README.md b/templates/_bundled/sirsoft-admin_basic/README.md new file mode 100644 index 00000000..4432afd4 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/README.md @@ -0,0 +1,181 @@ +# Admin Basic + +**그누보드7 템플릿 · sirsoft-admin_basic** +그누보드7 기본 관리자 템플릿 + + +

+ version 1.0.8 + type 템플릿 + 그누보드7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +그누보드7 이 기본 제공하는 관리자(admin) 템플릿입니다. 코어 관리자 화면뿐 아니라 설치된 모든 +모듈/플러그인의 관리자 화면이 이 템플릿의 컴포넌트와 베이스 레이아웃을 그대로 사용합니다 — +확장을 설치하면 그 확장의 관리자 UI 도 자동으로 이 템플릿의 디자인(사이드바·헤더·색상·다크 +모드)을 따릅니다. + +이 템플릿은 사용자(방문자)용 화면을 그리지 않습니다 — 방문자 화면은 `sirsoft-basic` 템플릿의 +몫입니다. 또한 완전히 다른 디자인의 관리자 화면이 필요하면 이 템플릿을 고치는 대신 같은 +컴포넌트 계약(필수 컴포넌트 35개)을 구현하는 새 admin 템플릿을 만드는 것이 원칙입니다 — 그래야 +기존 확장들의 관리자 화면이 새 템플릿에서도 깨지지 않습니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 관리자 셸 | 사이드바(계층형 메뉴, 접힘 지원)·헤더·다크 모드·다국어 전환을 갖춘 공통 화면 골격(`_admin_base`) | +| 컴포넌트 125개 | HTML 래핑 39개(basic) + UI 패턴 캡슐화 80개(composite) + 페이지 구조 5개(layout) + 모달 1개 | +| 필수 컴포넌트 계약 | 35개 컴포넌트만 사용하면 다른 admin 템플릿으로 교체해도 화면이 보장되는 모듈 호환성 기준 | +| 레이아웃 145개 | 대시보드·사용자·역할·메뉴·설정·확장 관리·스케줄·활동/알림 로그 등 코어 관리자 화면 전체 | +| 확장 관리 UI | 모듈/플러그인/템플릿 설치·활성화·업데이트·삭제와 레이아웃 편집기(코드 편집 + 실시간 미리보기) | +| 본인인증(IDV) 챌린지 | 관리자 민감 작업에 걸리는 본인인증 화면 | + + +## 동작 방식 + + +```mermaid +flowchart TD + base["_admin_base (사이드바·헤더·Toast·콘텐츠 슬롯)"] --> list["목록 화면 (PageHeader+DataGrid+Pagination)"] + base --> detail["상세 화면 (PageHeader+Card)"] + base --> form["폼 화면 (Form+FormField)"] + base --> settings["설정 화면 (TabNavigation+_tab_*)"] + auth["인증 화면 (admin_login 등, _admin_base 미상속)"] + errors["에러 화면 (errors/*, 독립 레이아웃)"] +``` + +인증 화면(로그인/비밀번호 찾기·재설정)과 에러 화면은 의도적으로 `_admin_base` 를 상속하지 +않습니다 — 로그인 전이거나 정상 화면 렌더링 자체가 불가능한 상황이라 사이드바·헤더 같은 +"이미 로그인된 관리자" 전제의 UI 를 보여줄 수 없기 때문입니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan template:install sirsoft-admin_basic + +# 활성화 +php artisan template:activate sirsoft-admin_basic + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan template:update sirsoft-admin_basic --force +``` + +저장소: https://github.com/gnuboard/g7-template-sirsoft-admin_basic + + +## 제공 컴포넌트 + + +컴포넌트 125개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 39개 | +| `composite` | 80개 | +| `layout` | 5개 | +| `modals` | 1개 | + + + +운영자가 직접 켜고 끄는 설정 항목은 없습니다 — 이 표는 확장(모듈/플러그인) 개발자가 레이아웃을 +만들 때 참고하는 컴포넌트 인벤토리입니다. 전체 목록·Props 상세는 +[docs/components.md](docs/components.md) 와 코어 +[component-props.md](../../../docs/frontend/component-props.md) 를 참고하세요. + + +## 사용 방법 + + +**새 확장의 관리자 화면 만들기**: 확장의 `resources/layouts/admin/*.json` 에서 +`"extends": "_admin_base"` 로 베이스를 상속하고, `slots.content` 에 필수 컴포넌트만으로 화면을 +구성합니다. 필수 컴포넌트 목록 밖의 컴포넌트를 쓰면 다른 admin 템플릿으로 교체됐을 때 그 +화면만 깨집니다. + +**사이드바 메뉴 등록**: 확장이 `getAdminMenus()` 로 메뉴를 선언하면 이 템플릿의 `AdminSidebar` +가 자동으로 계층에 반영합니다 — 이 템플릿을 직접 수정할 필요가 없습니다. + +**레이아웃 실시간 편집**: `/admin/templates/sirsoft-admin_basic/edit` 에서 레이아웃 편집기로 +관리자 화면 자체를 코드 편집 + 실시간 미리보기로 수정할 수 있습니다(운영 환경에서는 신중하게 +사용). + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +formal 의존성이 양쪽 다 "없음"인 것은 이 템플릿이 코어만으로 동작하기 때문이지만, 실질적으로는 +**모든 번들 모듈/플러그인의 관리자 화면**이 이 템플릿의 필수 컴포넌트·`_admin_base` 계약에 +암묵적으로 의존합니다. 이 의존은 manifest 로 선언되지 않습니다 — 어느 admin 템플릿이든 같은 +계약(필수 컴포넌트 35개)만 구현하면 되므로, 확장이 "이 템플릿"이 아니라 "이 계약"에 의존하는 +형태이기 때문입니다. 이 템플릿의 필수 컴포넌트 Props 를 바꿀 때 영향 범위를 이 템플릿 +자신의 `dependencies` 목록으로는 알 수 없다는 뜻이며, 실제로는 활성 모듈/플러그인 전수의 +관리자 레이아웃을 확인해야 합니다. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 사이드바 메뉴 아이콘이 안 보임 | `Icon` 컴포넌트로 렌더 시도(API 는 FontAwesome 클래스 문자열을 내려줌) | `I` 컴포넌트로 교체 (`docs/components.md` "AdminSidebar 상세" 참고) | +| 다른 admin 템플릿으로 교체 후 특정 확장 화면이 깨짐 | 그 확장이 필수 컴포넌트 목록 밖의 컴포넌트를 사용 | 그 확장의 레이아웃을 필수 컴포넌트(35개)만으로 재작성하거나, 새 템플릿에 같은 컴포넌트를 구현 | +| 사이드바 접힘 상태가 새로고침 후 풀림 | localStorage 접근 실패(시크릿 모드 등) 또는 부트스트랩 순서 문제 | `initSidebar()` 가 템플릿 부트스트랩에서 호출되는지 확인 (`src/index.ts`) | +| 로그인 화면에 다크 모드/언어 전환이 적용 안 됨 | 로그인 화면은 `_admin_base` 를 상속하지 않아 초기화 경로가 다름 | `admin_login.json` 자체의 초기화 액션을 확인 — `_admin_base` 수정으로는 반영되지 않는다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/templates/_bundled/sirsoft-admin_basic/docs/README.md b/templates/_bundled/sirsoft-admin_basic/docs/README.md new file mode 100644 index 00000000..96fc6f73 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/README.md @@ -0,0 +1,21 @@ +# Admin Basic 개발자 문서 + +> templates/_bundled/sirsoft-admin_basic · 템플릿 + + +**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 29 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 145 · **핸들러 수**: 17 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | +| [components.md](components.md) | 템플릿이 제공하는 컴포넌트 | +| [layouts.md](layouts.md) | 레이아웃 목록과 라우트 매핑 | +| [handlers.md](handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | +| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | +| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 | +| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 | + diff --git a/templates/_bundled/sirsoft-admin_basic/docs/architecture.md b/templates/_bundled/sirsoft-admin_basic/docs/architecture.md new file mode 100644 index 00000000..73f9841d --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/architecture.md @@ -0,0 +1,60 @@ +# Admin Basic — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +이 템플릿의 설계는 "관리자 화면 디자인"과 "관리자 화면 구성"을 분리하는 데 집중합니다. +컴포넌트(디자인 구현)는 이 템플릿이 소유하지만, 어떤 화면을 어떻게 구성할지(레이아웃 JSON)는 +코어와 모든 번들 확장이 **각자 소유**합니다 — 이 템플릿은 그 구성을 그릴 부품만 제공합니다. +그래서 이 템플릿의 실질 소스는 컴포넌트(`src/components/`)와 코어 화면 레이아웃뿐이고, 확장 +관리자 화면(모듈/플러그인이 소유)은 이 템플릿 디렉토리 밖(`modules/`, `plugins/`)에 있습니다. + +필수 컴포넌트 계약(§AGENTS.md)이 이 분리를 지탱합니다 — 계약이 없으면 확장 개발자가 이 +템플릿에만 있는 컴포넌트를 무심코 써버려 "관리자 화면 디자인 교체"가 사실상 불가능해집니다. + + +## 계층 지도 + + +``` +routes.json (URL → 레이아웃 이름 매핑) + │ + ▼ +layouts/*.json (extends: _admin_base) + │ + ▼ +_admin_base.json (사이드바·헤더·Toast·PageTransitionIndicator·콘텐츠 슬롯·푸터) + │ + ▼ +src/components/{basic,composite,layout}/*.tsx (필수 컴포넌트 35개 포함 125개) + │ + ▼ +src/handlers/*.ts (이 템플릿 전용 핸들러 17개 — 범용 핸들러는 코어 ActionDispatcher) +``` + +이 트리는 코어 화면에만 적용되는 것이 아닙니다 — 모듈/플러그인의 관리자 레이아웃도 같은 +`_admin_base` → 컴포넌트 경로를 그대로 타므로, 이 템플릿을 고치면 그 확장들의 화면도 같은 +방향으로 바뀝니다(§AGENTS.md "수정 시 동반 의무" 참고). + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update sirsoft-admin_basic --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update sirsoft-admin_basic --force` (빌드 불필요) | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` | +| `src/handlers/` | 템플릿 전용 액션 핸들러 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` | +| `editor-spec/` | 분할 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update sirsoft-admin_basic --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/docs/frontend/templates/sirsoft-admin_basic/components.md b/templates/_bundled/sirsoft-admin_basic/docs/components.md similarity index 66% rename from docs/frontend/templates/sirsoft-admin_basic/components.md rename to templates/_bundled/sirsoft-admin_basic/docs/components.md index a1478fff..912ceb16 100644 --- a/docs/frontend/templates/sirsoft-admin_basic/components.md +++ b/templates/_bundled/sirsoft-admin_basic/docs/components.md @@ -1,35 +1,122 @@ -# sirsoft-admin_basic 컴포넌트 +# Admin Basic — 컴포넌트 -> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin, v0.2.14) -> **관련 문서**: [핸들러](./handlers.md) | [레이아웃](./layouts.md) | [컴포넌트 Props 레퍼런스](../../component-props.md) +> 템플릿이 제공하는 컴포넌트 · 진입점: [AGENTS.md](../AGENTS.md) ---- +## 제공 컴포넌트 -## TL;DR (5초 요약) + +컴포넌트 125개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 39개 | +| `composite` | 80개 | +| `layout` | 5개 | +| `modals` | 1개 | + + + +세 분류(basic/composite/layout)는 "얼마나 많이 조합됐는가"로 나뉩니다 — basic 은 HTML 태그를 +그대로 래핑(`Div`→`
`)한 최소 단위, composite 는 basic 을 여러 개 조합해 UI 패턴을 캡슐화한 +것(`DataGrid`가 `Table`+`Pagination`+정렬 로직을 감싸는 식), layout 은 페이지 구조 자체(Grid/ +Flex/Container)를 정의합니다. 레이아웃 JSON 저작자는 이 구분을 몰라도 되지만("HTML 태그 +직접 사용 금지" 규정만 지키면 됨), 컴포넌트를 새로 추가하는 쪽은 이 구분에 맞는 디렉토리 +(`src/components/{basic,composite,layout}/`)에 넣어야 `components.json` 카탈로그와 +`editor-spec.json` 팔레트 분류가 어긋나지 않습니다. + +위 개수는 코드에서 실측되므로 시간이 지나면 달라집니다 — 이 문서에 구체적인 개수를 하드코딩해 +적지 않습니다(과거 버전 문서가 37/66/8 로 적었다가 실제로는 39/80/5+modals 1 이 되어 있었던 +사례가 있습니다). 정확한 전체 목록·Props 는 [component-props.md](../../../../docs/frontend/component-props.md) +· [component-props-composite.md](../../../../docs/frontend/component-props-composite.md) 를 +따라갑니다 — 이 문서는 "이 템플릿에서만" 유효한 계약(필수 컴포넌트·AdminSidebar·SlotContainer) +만 다룹니다. + + +## 필수 컴포넌트 (모듈 호환성) + +모듈 개발자가 아래 컴포넌트만 사용하면 **다른 Admin 템플릿으로 교체해도 화면이 깨지지 +않습니다**. 목록의 SSoT 는 `config/template.php` 의 `required_admin_components` 이며, 아래 +표는 그 값을 그대로 옮긴 것입니다(코드가 SSoT — 표와 설정이 어긋나면 설정이 맞습니다). + +| 분류 | 컴포넌트 | +|---|---| +| Basic (27개) | `A`, `Button`, `Checkbox`, `Div`, `Form`, `H1`, `H2`, `H3`, `Icon`, `Img`, `Input`, `Label`, `Li`, `Nav`, `P`, `Section`, `Select`, `Span`, `Svg`, `Table`, `Tbody`, `Td`, `Textarea`, `Th`, `Thead`, `Tr`, `Ul` | +| Composite (8개) | `Alert`, `Badge`, `Card`, `DataTable`, `FormField`, `Modal`, `PageHeader`, `Pagination` | ```text -1. Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, H1~H4, Table 등) -2. Composite 66개: UI 패턴 캡슐화 (DataGrid, Modal, PageHeader, Card, FileUploader 등) -3. Layout 8개: 페이지 구조 (Container, Grid, Flex, SectionLayout, ThreeColumnLayout 등) -4. 모듈은 필수 컴포넌트(27 basic + 15 composite)만 사용 → 모든 Admin 템플릿 호환 보장 -5. components.json에 등록 필수, Props는 이 문서 + component-props.md 참조 +✅ 필수 컴포넌트 목록에 있는 것만 사용 (다른 Admin 템플릿 호환 보장) +✅ 템플릿의 베이스 레이아웃(`_admin_base`)을 extends +❌ 이 템플릿에만 있는 커스텀 컴포넌트 사용 금지 ``` ---- +기본은 `validate_on_install: false` — 설치 시 필수 컴포넌트 검증은 기본적으로 수행되지 않고 +경고에 그칩니다(`block_on_failure: false`). 검증을 강제하려면 두 설정을 함께 켭니다. -## 목차 +## AdminSidebar 상세 -1. [컴포넌트 개요](#컴포넌트-개요) -2. [Basic Components (37개)](#basic-components-37개) -3. [Composite Components (66개)](#composite-components-66개) -4. [Layout Components (8개)](#layout-components-8개) -5. [필수 컴포넌트 (모듈 호환성)](#필수-컴포넌트-모듈-호환성) -6. [모듈 개발자 가이드](#모듈-개발자-가이드) -7. [템플릿 개발자 가이드](#템플릿-개발자-가이드) +계층형 관리자 메뉴를 그리는 컴포넌트로, 데이터 소스로 받은 메뉴 트리를 그대로 넘기면 됩니다. ---- +```typescript +interface MenuItem { + id: string | number; + name: string | { ko: string; en: string }; + slug: string; + url?: string | null; + icon?: string; // FontAwesome 클래스 (예: "fas fa-home") + children?: MenuItem[]; + is_active?: boolean; + module_id?: number | null; // 모듈이 등록한 메뉴인지 식별 +} -## 컴포넌트 개요 +interface AdminSidebarProps { + logo?: string; + logoAlt?: string; + menu: MenuItem[]; // 필수 + collapsed?: boolean; + onToggleCollapse?: () => void; + className?: string; + currentLocale?: string; // 미지정 시 G7Core.locale.current() 자동 사용 + id?: string; // 레이아웃 편집기 코어 일괄 ID +} +``` + +아이콘은 `Icon` 컴포넌트(IconName enum)가 아니라 `I` 컴포넌트 + FontAwesome 클래스 문자열로 +렌더링됩니다 — 메뉴 API 가 `icon` 필드를 이미 `"fas fa-home"` 형태 클래스 문자열로 내려주기 +때문입니다. 새 메뉴 아이콘을 추가할 때 `Icon name="home"` 식으로 쓰면 렌더되지 않습니다. + +## SlotContainer 상세 + +동적 슬롯 렌더링 컨테이너입니다 — 다른 컴포넌트가 `slot` prop 으로 자신이 속할 슬롯 ID 를 +표현식으로 지정하면(예: `"{{_local.isVisible ? 'basic_filters' : 'detail_filters'}}"`), 그 +표현식이 상태 변화에 따라 재평가되어 컴포넌트가 슬롯 사이를 동적으로 이동합니다. + +```typescript +interface SlotContainerProps { + slotId: string; // 필수 — 렌더링할 슬롯 ID + className?: string; +} +``` + +```json +{ "id": "category_filter", "type": "basic", "name": "Div", "slot": "{{_local.isVisible ? 'basic_filters' : 'detail_filters'}}", "slotOrder": 1, "children": [] } +``` + +```json +{ "id": "basic_filters_container", "type": "composite", "name": "SlotContainer", "props": { "slotId": "basic_filters" } } +``` + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/components.md` 에 있던 원문을 +> 이 문서로 옮긴 것입니다(#601). 이관 시점 그대로 보존하되, **코드가 SSoT 인 값과 어긋나는 +> 부분에는 정정 주석**을 달았습니다 — 실측 총계는 위 「제공 컴포넌트」 블록이, 필수 컴포넌트 +> 목록은 `config/template.php` 의 `required_admin_components` 가 SSoT 입니다. + +### 컴포넌트 개요 + +> **정정(#601)**: 아래 개수는 이관 시점(2026-03) 문서 값입니다. 코드 실측은 basic 39 · composite 80 · +> layout 5 · modals 1 = **125종**이며 위 「제공 컴포넌트」 블록이 SSoT 입니다. 아래 목록에 없는 +> 컴포넌트가 있을 수 있습니다. | 타입 | 개수 | 설명 | |------|------|------| @@ -43,11 +130,13 @@ --- -## Basic Components (37개) +### Basic Components (37개) + +> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 39종). 목록 자체는 원문 그대로입니다. HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic 컴포넌트는 `className`, `children` props를 공통으로 지원합니다. -### 텍스트/링크 +#### 텍스트/링크 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -62,7 +151,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Pre` | 서식 유지 텍스트 래퍼 | - | - | | `Code` | 인라인 코드 래퍼 | - | - | -### 컨테이너 +#### 컨테이너 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -72,7 +161,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Form` | 폼 컨테이너 (자동 바인딩 지원) | - | - | | `Fragment` | React.Fragment — iterator에서 DOM 래퍼 없이 사용 | - | - | -### 폼 입력 +#### 폼 입력 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -85,7 +174,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `FileInput` | 파일 입력 (검증 포함) | accept, maxSize, onChange, onError, buttonText, placeholder, disabled | - | | `Button` | 버튼 | variant (`primary`\|`secondary`\|`danger`\|`success`), size (`sm`\|`md`\|`lg`) | - | -### 테이블 +#### 테이블 | 컴포넌트 | 설명 | |----------|------| @@ -97,7 +186,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Th` | 테이블 헤더 셀 | | `Td` | 테이블 데이터 셀 | -### 리스트 +#### 리스트 | 컴포넌트 | 설명 | |----------|------| @@ -105,7 +194,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Ol` | 순서 있는 리스트 | | `Li` | 리스트 아이템 | -### 미디어/아이콘 +#### 미디어/아이콘 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -116,11 +205,13 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## Composite Components (66개) +### Composite Components (66개) + +> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 80종). 목록 자체는 원문 그대로입니다. 기본 컴포넌트를 조합하여 UI 패턴을 캡슐화한 복합 컴포넌트입니다. -### 데이터 표시 +#### 데이터 표시 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -133,7 +224,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `EmptyState` | 데이터 없음 상태 표시 | title, description, iconName, illustrationSrc, ... | | `HtmlContent` | HTML 안전 렌더링 (DOMPurify XSS 방지) | content, isHtml, purifyConfig, text | -### 폼/입력 +#### 폼/입력 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -152,14 +243,14 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `FileUploader` | 파일 업로드 (드래그앤드롭, 이미지 압축) | attachmentableType, attachmentableId, collection, maxFiles, maxSize, ... | | `IconSelect` | 아이콘 선택 드롭다운 | value, onChange, options, placeholder, searchPlaceholder, ... | -### 에디터 +#### 에디터 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| | `HtmlEditor` | HTML/텍스트 편집기 (편집/미리보기 토글) | content, onChange, isHtml, onHtmlModeChange, previewMode, ... | | `CodeEditor` | JSON 코드 편집기 (Monaco Editor) | value, onChange, language, height, readOnly, ... | -### 네비게이션 +#### 네비게이션 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -169,7 +260,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `ActionMenu` | 드롭다운 액션 메뉴 | items, triggerLabel, triggerIconName, position, style | | `Dropdown` | 드롭다운 메뉴 | label, items, onItemClick, position, style | -### 모달/다이얼로그 +#### 모달/다이얼로그 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -180,7 +271,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Toast` | 토스트 알림 | toasts, position, onRemove | | `Alert` | 알림 메시지 | type, message, dismissible, onDismiss | -### 관리자 UI +#### 관리자 UI | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -195,7 +286,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `LanguageSelector` | 언어 선택 드롭다운 | availableLocales, languageText, apiEndpoint, onLanguageChange, inline | | `PageTransitionIndicator` | 페이지 전환 로딩 표시 | style | -### 레이아웃 편집 +#### 레이아웃 편집 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -205,7 +296,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `LayoutWarnings` | 레이아웃 경고 표시 | warnings | | `VersionList` | 버전 목록 아이템 | versions, selectedId, onSelect | -### 확장 관리 +#### 확장 관리 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -213,7 +304,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `ExtensionBadge` | 확장 섹션 뱃지 (identifier로 이름 자동 조회) | type, identifier, name, installedModules, installedPlugins, ... | | `ProductCard` | 상품 카드 (이미지, 제목, 가격, 액션) | imageUrl, imageAlt, title, subtitle, description, ... | -### 고급 기능 +#### 고급 기능 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -233,7 +324,9 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## Layout Components (8개) +### Layout Components (8개) + +> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 5종). 목록 자체는 원문 그대로입니다. 페이지 구조를 정의하는 레이아웃 컴포넌트입니다. @@ -250,11 +343,11 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## 필수 컴포넌트 (모듈 호환성) +### 필수 컴포넌트 (모듈 호환성) 모듈 개발자가 이 컴포넌트들만 사용하면 **모든 Admin 템플릿에서 동작이 보장**됩니다. -### 필수 Basic (27개) +#### 필수 Basic (27개) | 카테고리 | 컴포넌트 | |----------|----------| @@ -265,7 +358,12 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | 리스트 | `Ul`, `Li` | | 미디어 | `Icon`, `Img`, `Svg` | -### 필수 Composite (15개) +#### 필수 Composite (15개) + +> **정정(#601)**: 아래 표는 `config/template.php` 의 `required_admin_components` 와 일치하지 않습니다 — +> 실제 필수 Composite 는 위 「필수 컴포넌트 (모듈 호환성)」 절의 8종(`Alert` `Badge` `Card` +> `DataTable` `FormField` `Modal` `PageHeader` `Pagination`)이며 설정 파일이 SSoT 입니다. +> 아래 목록은 이관 시점 문서를 보존한 것이므로 필수 여부의 근거로 쓰지 않습니다. | 카테고리 | 컴포넌트 | 설명 | |----------|----------|------| @@ -285,7 +383,10 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | | `AdminSidebar` | 관리자 사이드바 | | | `SlotContainer` | 동적 슬롯 렌더링 | -### 설정 파일 +#### 설정 파일 + +> **정정(#601)**: 아래 PHP 스니펫은 이관 시점 값입니다. 현재 `config/template.php` 의 실제 배열은 위 +> 「필수 컴포넌트 (모듈 호환성)」 절의 표(Basic 27 + Composite 8)와 일치합니다. 필수 컴포넌트 목록은 `config/template.php`에 정의: @@ -300,9 +401,9 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## 모듈 개발자 가이드 +### 모듈 개발자 가이드 -### 핵심 원칙 +#### 핵심 원칙 ```text ✅ 필수 컴포넌트 목록에 있는 컴포넌트만 사용 @@ -310,7 +411,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic ❌ 특정 템플릿에만 존재하는 커스텀 컴포넌트 사용 금지 ``` -### 레이아웃 작성 예시 +#### 레이아웃 작성 예시 ```json { @@ -335,11 +436,11 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## 템플릿 개발자 가이드 +### 템플릿 개발자 가이드 Admin 타입 템플릿 개발 시 필수 컴포넌트를 반드시 구현해야 합니다. -### 구현 체크리스트 +#### 구현 체크리스트 ```text □ DataGrid - 정렬, 필터링, 페이지네이션 지원 @@ -358,18 +459,18 @@ Admin 타입 템플릿 개발 시 필수 컴포넌트를 반드시 구현해야 □ AdminSidebar - 계층형 메뉴, 다국어 지원 ``` -### Props 인터페이스 일관성 +#### Props 인터페이스 일관성 모든 Admin 템플릿은 동일한 Props 인터페이스를 구현해야 합니다. 상세 Props는 다음 문서를 참조: -- [컴포넌트 Props 레퍼런스 (Basic)](../../component-props.md) -- [컴포넌트 Props 레퍼런스 (Composite)](../../component-props-composite.md) +- [컴포넌트 Props 레퍼런스 (Basic)](../../../../docs/frontend/component-props.md) +- [컴포넌트 Props 레퍼런스 (Composite)](../../../../docs/frontend/component-props-composite.md) --- -## AdminSidebar 상세 +### AdminSidebar 상세 -### MenuItem 인터페이스 +#### MenuItem 인터페이스 ```typescript interface MenuItem { @@ -383,7 +484,7 @@ interface MenuItem { } ``` -### AdminSidebarProps +#### AdminSidebarProps ```typescript interface AdminSidebarProps { @@ -397,14 +498,14 @@ interface AdminSidebarProps { } ``` -### 아이콘 처리 +#### 아이콘 처리 ```text ✅ I 컴포넌트 + FontAwesome 클래스 () ❌ Icon 컴포넌트 + IconName enum (금지 — API가 FontAwesome 클래스 문자열 직접 제공) ``` -### 레이아웃 JSON 사용 예시 +#### 레이아웃 JSON 사용 예시 ```json { @@ -433,9 +534,9 @@ interface AdminSidebarProps { --- -## SlotContainer 상세 +### SlotContainer 상세 -### SlotContainerProps +#### SlotContainerProps ```typescript interface SlotContainerProps { @@ -444,7 +545,7 @@ interface SlotContainerProps { } ``` -### 슬롯 시스템 동작 +#### 슬롯 시스템 동작 ```text 1. slot 속성 컴포넌트 → SlotContext에 등록 @@ -452,7 +553,7 @@ interface SlotContainerProps { 3. 상태 변화 시 slot 표현식 재평가로 동적 이동 ``` -### 사용 예시 +#### 사용 예시 ```json { @@ -476,11 +577,11 @@ interface SlotContainerProps { --- -## 관련 문서 +### 관련 문서 -- [sirsoft-admin_basic 핸들러](./handlers.md) -- [sirsoft-admin_basic 레이아웃](./layouts.md) -- [sirsoft-basic 컴포넌트](../sirsoft-basic/components.md) -- [컴포넌트 개발 규칙](../../components.md) -- [컴포넌트 Props 레퍼런스](../../component-props.md) -- [컴포넌트 Props 레퍼런스 - Composite](../../component-props-composite.md) +- [sirsoft-admin_basic 핸들러](handlers.md) +- [sirsoft-admin_basic 레이아웃](layouts.md) +- [sirsoft-basic 컴포넌트](../../sirsoft-basic/docs/components.md) +- [컴포넌트 개발 규칙](../../../../docs/frontend/components.md) +- [컴포넌트 Props 레퍼런스](../../../../docs/frontend/component-props.md) +- [컴포넌트 Props 레퍼런스 - Composite](../../../../docs/frontend/component-props-composite.md) diff --git a/templates/_bundled/sirsoft-admin_basic/docs/editor-spec.md b/templates/_bundled/sirsoft-admin_basic/docs/editor-spec.md new file mode 100644 index 00000000..3ce86500 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/editor-spec.md @@ -0,0 +1,151 @@ +# Admin Basic — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `templates/_bundled/sirsoft-admin_basic/editor-spec.json` | +| 형태 | 분할 — manifest + `editor-spec/*.json` 13개 블록 | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | `tailwind` | +| 다크 모드 전략 | `ancestor-class` | + +> 분할 13블록 · 팔레트 79 · 스타일 컨트롤 303 · 편집 역량 86 · 중첩 컨테이너 19 · 프리뷰 샘플 70 · 페이지 상태 10 · 액션 레시피 14 + + + +이 스펙만 **분할**되어 있는 데는 이유가 있습니다. 팔레트·컨트롤·컴포넌트 역량을 한 파일에 +두면 만 줄을 넘겨 사람이 열어 보기 어려워지고, 블록 하나를 고칠 때마다 파일 전체가 diff 에 +잡힙니다. `$include` 는 그 분할을 런타임에 되돌리는 장치이므로 서빙 형태는 단일 파일과 +같습니다. + +`다크 모드 전략: ancestor-class` 는 Tailwind 규약(`조상 .dark`)을 그대로 따른다는 뜻입니다. +편집기 프리뷰는 페이지 전체가 아니라 캔버스 안만 다크로 바꿔야 하므로, 코어 CSS 서빙이 +`.dark` 셀렉터를 프리뷰 전용 마커로 치환해 내보냅니다 — 사용자 페이지 CSS 는 건드리지 +않습니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `componentPalette.entries` | 편집기 "요소 추가" 팔레트에 나타나는 항목 | 79 | `editor-spec/componentPalette.json` | +| `componentPalette.groups` | 팔레트 좌측 목록의 묶음 | 2 | `editor-spec/componentPalette.json` | +| `controls` | 재사용 스타일 컨트롤 정의 | 303 | `editor-spec/controls.json` | +| `componentCapabilities` | 컴포넌트별 편집 역량(어떤 속성을 편집기가 다루는가) | 86 | `editor-spec/componentCapabilities.json` | +| `nesting.draggable` | 캔버스에서 끌어 옮길 수 있는 컴포넌트 | 84 | `editor-spec/nesting.json` | +| `nesting.containers` | 자식을 담을 수 있는 컴포넌트와 그 허용 규칙 | 19 | `editor-spec/nesting.json` | +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 70 | `editor-spec/sampleData.json` | +| `sampleGlobal` | `_global.*` 프리뷰 baseline 시드 | 5 | `editor-spec/sampleGlobal.json` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `editor-spec/states.json` | +| `stateLabels` | 상태값 친화 명칭 카탈로그 | 8 | `editor-spec/stateLabels.json` | +| `actionRecipes` | 친화 명칭 → 액션 JSON 레시피 | 14 | `editor-spec/actionRecipes.json` | +| `conditionRecipes.operators` | 조건 표현식에 쓸 수 있는 연산자 | 35 | `editor-spec/conditionRecipes.json` | +| `computedRecipes` | 계산값 레시피 | 4 | `editor-spec/computedRecipes.json` | +| `errorRecipes` | 오류 처리 레시피 | 7 | `editor-spec/errorRecipes.json` | +| `loadingComponents` | 로딩 표시 컴포넌트 후보 | 2 | `editor-spec/loadingComponents.json` | + + + +블록 15행이 이 템플릿이 편집기에 제공하는 전부입니다. 수가 큰 셋(`controls` 303, +`componentCapabilities` 86, `nesting.draggable` 84)이 곧 "편집기로 무엇을 조작할 수 +있는가" 의 상한입니다 — 여기 없는 속성은 편집기 속성 패널에 나타나지 않습니다. + +컴포넌트를 새로 만들었는데 편집기에서 속성을 못 바꾸겠다면 `componentCapabilities` 에 +그 컴포넌트가 없는 것입니다. 팔레트에 아예 안 보인다면 `componentPalette.entries` 입니다. +둘은 다른 자리라 한쪽만 고치면 증상이 절반만 사라집니다. + + +## 컴포넌트 팔레트 + + +| 그룹 | 종류 | 컴포넌트 수 | +|---|---|---| +| 디자인 요소 | `design` | 62 | +| DB 요소 | `data` | 17 | + + + +그룹을 `디자인 요소`(62)와 `DB 요소`(17) 둘로만 나눈 것은 운영자가 편집기에서 하는 +판단이 그 둘로 갈리기 때문입니다 — "모양을 만드는 것" 과 "데이터를 붙이는 것". + +그룹을 늘리면 팔레트가 잘 정리된 것처럼 보이지만, 운영자는 찾으려는 컴포넌트가 어느 +묶음에 있는지 매번 추측하게 됩니다. 컴포넌트를 추가할 때는 새 그룹을 만들기 전에 기존 +두 그룹 중 어디에 속하는지 먼저 판단합니다. + +팔레트에 **무엇이 보이는가**를 정하는 것은 `groups` 입니다. `entries` 는 그 컴포넌트의 +친화 라벨과 신규 노드 골격(`defaultNode`)을 줄 뿐이라, `entries` 에만 있고 어느 묶음에도 +없는 컴포넌트는 팔레트에 나타나지 않습니다. 반대로 `groups` 에만 있고 `entries` 가 없는 +것은 정상이며, 라벨이 컴포넌트 정의의 설명으로 폴백됩니다. + +지금은 두 수가 우연히 같지만(entries 79 · 그룹 합계 62+17=79), 같아야 한다는 규칙은 없습니다. 컴포넌트를 +추가했는데 팔레트에 안 보인다면 먼저 `groups` 를 봅니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 70 | `sitemap_status` · `sitemap_progress_ws` · `locales` · `me` · `installed_modules` · `active_plugins` · `users` · `roles` · `availableChannels` · `identityProviders` · `identityPurposes` · `identityPolicies` … 외 58개 | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `_admin_base` · `*/admin/users` · `*/admin/users/:id/edit` · `*/admin/settings` · `*/admin/reset-password` · `*/admin/roles/:id/edit` · `*/admin/identity/challenge` · `*/admin/templates/:type` · `*/admin/login` · `*/admin/forgot-password` | + +**프리뷰 샘플이 없는 `data_source` 1개** — 편집기 캔버스에서 이 자리만 빈 화면이 됩니다. 실제 화면은 정상이라 오류도 경고도 남지 않습니다. + +`trustedProxy` + + + +`byDataSourceId` 70종은 코어 관리자 화면 전체를 덮습니다. 이 템플릿의 샘플이 코어뿐 +아니라 **여러 확장의 공용 ID**(`settings`·`roles`·`me` 등)까지 담는 것은 설계입니다 — +확장마다 같은 ID 의 샘플을 각자 두면 어느 것이 쓰이는지가 합본 순서에 좌우됩니다. + +`states.groups` 10종에 `_admin_base` 가 들어 있는 것은 모바일 드로어 때문입니다. +드로어는 햄버거를 눌러야 열리는데 편집기 캔버스에는 클릭이 없으므로, 열린 상태를 주입하지 +않으면 드로어 **안쪽을 편집할 수 없습니다.** + +미커버로 잡힌 `trustedProxy` 는 신뢰 프록시 진단 영역입니다. 이 영역은 서버 구성에 따라 +내용이 달라지는 읽기 전용 진단이라 편집기에서 손댈 것이 없지만, 샘플이 없으면 그 자리가 +빈 채로 보여 운영자가 레이아웃이 깨진 것으로 오해할 수 있습니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan template:update sirsoft-admin_basic --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +이 템플릿은 팔레트·역량·중첩을 모두 소유하므로 컴포넌트를 하나 추가할 때 손댈 자리가 +가장 많습니다. `componentPalette.entries` 에 넣고, `componentPalette.groups` 중 하나에 +이름을 넣고, `componentCapabilities` 에 편집 가능한 속성을 선언하고, `nesting` 에 +어디에 담길 수 있는지를 적습니다. 넷 중 하나라도 빠지면 그 컴포넌트는 편집기에서 +**절반만 동작**하고, 어느 단계가 빠졌는지는 증상으로 구분됩니다 (위 "선언 블록" 절 참조). + diff --git a/templates/_bundled/sirsoft-admin_basic/docs/handlers.md b/templates/_bundled/sirsoft-admin_basic/docs/handlers.md new file mode 100644 index 00000000..864014f9 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/handlers.md @@ -0,0 +1,584 @@ +# Admin Basic — 핸들러 + +> 템플릿 전용 핸들러와 부트스트랩 · 진입점: [AGENTS.md](../AGENTS.md) + +## 템플릿 전용 핸들러 + + +핸들러 17개 (정의: `src/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `detectAssetUrlMode` | (템플릿 전용 — 네임스페이스 없음) | +| `checkAssetUrlModeDrift` | (템플릿 전용 — 네임스페이스 없음) | +| `setTheme` | (템플릿 전용 — 네임스페이스 없음) | +| `initTheme` | (템플릿 전용 — 네임스페이스 없음) | +| `scrollToSection` | (템플릿 전용 — 네임스페이스 없음) | +| `initMenuFromUrl` | (템플릿 전용 — 네임스페이스 없음) | +| `initFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) | +| `saveFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) | +| `toggleFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) | +| `resetFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) | +| `saveMultilingualTag` | (템플릿 전용 — 네임스페이스 없음) | +| `cancelMultilingualTag` | (템플릿 전용 — 네임스페이스 없음) | +| `updateMultilingualTagValue` | (템플릿 전용 — 네임스페이스 없음) | +| `setDateRange` | (템플릿 전용 — 네임스페이스 없음) | +| `toggleSidebar` | (템플릿 전용 — 네임스페이스 없음) | +| `initSidebar` | (템플릿 전용 — 네임스페이스 없음) | +| `downloadAttachment` | (템플릿 전용 — 네임스페이스 없음) | + + + +`setLocale` 이 이 목록에 없는 것이 정상입니다 — 로케일 전환은 엔진 레벨(ActionDispatcher) +빌트인으로 승격되어 더 이상 이 템플릿이 등록하지 않습니다(`src/handlers/index.ts` 상단 주석 +참고). 과거 버전 문서가 `setLocale` 을 이 템플릿의 핸들러로 적었다면 그것은 이관 전 버전 +기준이므로 따르지 않습니다. 새 핸들러를 추가할 때는 `src/handlers/index.ts` 의 맵 객체 한 +곳에만 등록하면 자동으로 ActionDispatcher 에 반영됩니다 — 다른 곳에 흩어 등록하지 않습니다. + +이 핸들러들은 **이 템플릿에서만** 등록되므로 다른 템플릿에서는 미지원일 수 있습니다. 범용 +핸들러(`navigate`/`apiCall`/`setState` 등)는 [actions-handlers.md](../../../../docs/frontend/actions-handlers.md) +를 참고하고, 여기서는 이 템플릿 전용 핸들러만 다룹니다. + +### setTheme / initTheme + +다크/라이트/자동(`auto`, 시스템 설정 따름) 테마를 전환·복원합니다. `setTheme` 은 localStorage +저장 + `document.documentElement` 클래스 적용(Tailwind `dark:` variant 활성화)을, +`initTheme` 은 params 없이 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다. + +```json +{ "type": "click", "handler": "setTheme", "params": { "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" } } +``` + +### scrollToSection + +`params.selector`(CSS 선택자, 필수) 로 지정한 요소로 부드럽게 스크롤합니다. `params.offset` +(기본 `0`, 음수면 위로)은 고정 헤더 높이를 보상할 때 씁니다. + +```json +{ "type": "click", "handler": "scrollToSection", "params": { "selector": "#features", "offset": -80 } } +``` + +### initMenuFromUrl + +현재 URL 경로를 사이드바 메뉴 항목과 매칭해 활성 메뉴(및 부모 메뉴의 펼침 상태)를 자동 +설정합니다. params 없이 `_admin_base.json` 의 `init_actions` 에서 호출합니다. + +### 필터 가시성 핸들러 4종 + +목록 화면 필터 패널의 표시/숨김을 localStorage 에 저장해 새로고침 후에도 유지합니다. + +| 핸들러 | params | 설명 | +|---|---|---| +| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 (`init_actions`에서 호출) | +| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 | +| `toggleFilterVisibility` | `{ key }` | 특정 필터 키 가시성 토글 | +| `resetFilterVisibility` | 없음 | 전체 초기화 | + +### 다국어 태그 핸들러 3종 + +`MultilingualInput` 컴포넌트가 쓰는 태그 편집 핸들러입니다. + +| 핸들러 | params | 설명 | +|---|---|---| +| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 | +| `cancelMultilingualTag` | 없음 | 편집 취소 | +| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 | + +### setDateRange + +날짜 필터 프리셋 버튼(`today`/`week`/`month`/`3months`/`6months`/`1year`) 클릭 시 시작·종료일을 +`YYYY-MM-DDTHH:mm:ss` 형식으로 계산해 **반환**합니다(자체적으로 상태를 갱신하지 않음) — 레이아웃 +JSON 이 `sequence` + `setState` 로 반환값(`$prev.startDate` 등)을 원하는 필드에 반영합니다. + +```json +{ "type": "click", "handler": "sequence", "actions": [ + { "handler": "setDateRange", "params": { "preset": "today" } }, + { "handler": "setState", "params": { "target": "local", "filter.dateFrom": "{{$prev.startDate}}", "filter.dateTo": "{{$prev.endDate}}" } } +] } +``` + +### 사이드바 접힘 핸들러 2종 + +데스크톱 좌측 사이드바 접힘 상태를 `localStorage`(`g7_admin_sidebar_collapsed`) 와 +`_global.sidebarCollapsed` 에 반영합니다. 모바일 슬라이드 사이드바 상태(`_global.sidebarOpen`)와는 +**독립된 상태**이므로 데스크톱 접힘과 모바일 열림/닫힘을 같은 상태로 착각하지 않습니다. + +| 핸들러 | params | 설명 | +|---|---|---| +| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (`init_actions`에서 호출) | +| `toggleSidebar` | 없음 | 접힘 상태 반전 + 저장 | + +### downloadAttachment + +관리자 화면에서 첨부파일을 `` 직접 링크가 아니라 `G7Core.api.get(url, {responseType: +'blob'})` 로 요청한 뒤 objectURL 로 변환해 다운로드합니다. `` 네비게이션은 Authorization +헤더가 실리지 않아 요청이 guest 로 통과하고 활동이력의 행위자(`user_id`)가 비게 되므로, 코어 +ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리자 본인 이력으로 정확히 남습니다. + +```json +{ "type": "click", "handler": "downloadAttachment", "params": { "url": "{{attachment.download_url}}", "filename": "{{attachment.original_name}}" } } +``` + +### 자산 URL 방식 재감지 2종 + +관리자 환경설정 화면에서 정적 자산 URL 방식(확장자 있음/없음)을 **브라우저에서** 재감지합니다. +서버가 자기 자신에게 curl 하면 nginx vhost·프록시 체인을 우회해 오판할 수 있어, 실제 방문자 +경로를 재현하려면 브라우저가 프로브를 던져야 합니다. + +| 핸들러 | 호출 시점 | 동작 | +|---|---|---| +| `detectAssetUrlMode` | 관리자가 "재감지" 버튼 클릭 | 프로브 쌍(확장자 있음/없음)을 던져 판정한 뒤 폼 상태(`general.asset_url_mode`)와 감지 상태 문구를 갱신 — **저장은 하지 않음**(관리자가 결과를 보고 직접 저장) | +| `checkAssetUrlModeDrift` | 대시보드 진입 시 자동 | 저장된 설정값과 실제 감지 결과를 대조해 어긋나면 전역 상태(`assetUrlModeDrift`)에 실어 대시보드에 경고 노출 | + +봇은 JavaScript 를 실행하지 않으므로 클라이언트측 자가 복구가 있어도 저장값이 틀린 채로 +남으면 SEO 렌더링은 계속 깨집니다 — `checkAssetUrlModeDrift` 가 그 간극을 관리자에게 드러내는 +유일한 지점입니다. 판정 불가(`unavailable`)일 때는 아무 것도 표시하지 않습니다 — 일시적 +네트워크 장애를 결함 신호로 오인시키지 않기 위함입니다. + + +## 부트스트랩 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `src/index.ts` | +| 전역 객체 | **미노출** | +| 재등록 진입점 | `initTemplate()` | + +재등록 진입점이 전역에 고정 이름으로 노출되지 않으면 로케일 전환 후 이 확장의 액션이 전부 무반응이 됩니다 (오류·토스트 없음). + + + +전역 객체가 "미노출"인 것은 실수가 아니라 템플릿과 모듈/플러그인의 재등록 규약 차이입니다 — +모듈/플러그인은 여러 개가 동시에 활성화되므로 서로를 식별할 `window.__[Name]` 고정 이름이 +필요하지만, 템플릿은 사이트에 활성 템플릿이 항상 하나뿐이라 그런 식별 필요가 없습니다. +`initTemplate()` 을 고칠 때는 핸들러 재등록 +외의 1회성 부팅 작업(예: 사이드바 초기 상태 복원)을 섞지 않습니다 — 로케일 전환마다 재실행되면 +안 되는 작업이기 때문입니다(사이드바 복원은 `initSidebar` 를 레이아웃 `init_actions` 에서 +별도로 호출하는 이유이기도 합니다). + + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/handlers.md` 에 있던 원문을 +> 이 문서로 옮긴 것입니다(#601). 핸들러별 params·동작·사용 예시가 여기에 있습니다. +> 이관 시점 그대로 보존하되, 현재 코드와 어긋나는 부분에는 정정 주석을 달았습니다 — +> 등록 핸들러의 SSoT 는 위 「템플릿 전용 핸들러」 블록입니다. + +### setLocale + +> **정정(#601)**: `setLocale` 은 더 이상 이 템플릿이 등록하는 핸들러가 아닙니다 — 엔진(ActionDispatcher) +> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, 동작·파라미터는 +> 같지만 **소유 주체가 템플릿이 아니라 엔진**입니다. + +앱 언어를 변경합니다. 번역 파일을 다시 로드하고 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 참조 +``` + +--- + +### 관련 문서 + +- [액션 핸들러 개요](../../../../docs/frontend/actions-handlers.md) +- [sirsoft-admin_basic 컴포넌트](components.md) +- [sirsoft-admin_basic 레이아웃](layouts.md) +- [sirsoft-basic 핸들러](../../sirsoft-basic/docs/handlers.md) diff --git a/templates/_bundled/sirsoft-admin_basic/docs/layouts.md b/templates/_bundled/sirsoft-admin_basic/docs/layouts.md new file mode 100644 index 00000000..21eb695f --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/layouts.md @@ -0,0 +1,621 @@ +# Admin Basic — 레이아웃 + +> 레이아웃 목록과 라우트 매핑 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 목록 + + +레이아웃 145개 (루트: `layouts`). + +| 그룹 | 개수 | +|---|---| +| `(root)` | 27개 | +| `auth` | 1개 | +| `errors` | 6개 | +| `overrides` | 1개 | +| `partials` | 110개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `_admin_base` | `(root)` | partial | - | +| `admin_activity_log_list` | `(root)` | 화면 | `_admin_base` | +| `admin_dashboard` | `(root)` | 화면 | `_admin_base` | +| `admin_forgot_password` | `(root)` | 화면 | - | +| `admin_identity_logs` | `(root)` | 화면 | `_admin_base` | +| `admin_language_pack_list` | `(root)` | 화면 | `_admin_base` | +| `admin_language_packs` | `(root)` | 화면 | `admin_language_pack_list` | +| `admin_language_packs_install_modal` | `(root)` | 화면 | - | +| `admin_login` | `(root)` | 화면 | - | +| `admin_menu_list` | `(root)` | 화면 | `_admin_base` | +| `admin_module_language_packs` | `(root)` | 화면 | `admin_language_pack_list` | +| `admin_module_list` | `(root)` | 화면 | `_admin_base` | +| `admin_notification_log_list` | `(root)` | 화면 | `_admin_base` | +| `admin_plugin_language_packs` | `(root)` | 화면 | `admin_language_pack_list` | +| `admin_plugin_list` | `(root)` | 화면 | `_admin_base` | +| `admin_reset_password` | `(root)` | 화면 | - | +| `admin_role_form` | `(root)` | 화면 | `_admin_base` | +| `admin_role_list` | `(root)` | 화면 | `_admin_base` | +| `admin_schedule_list` | `(root)` | 화면 | `_admin_base` | +| `admin_settings` | `(root)` | 화면 | `_admin_base` | +| `admin_template_language_packs` | `(root)` | 화면 | `admin_language_pack_list` | +| `admin_template_layout_edit` | `(root)` | 화면 | `_admin_base` | +| `admin_template_list` | `(root)` | 화면 | `_admin_base` | +| `admin_user_detail` | `(root)` | 화면 | `_admin_base` | +| `admin_user_form` | `(root)` | 화면 | `_admin_base` | +| `admin_user_list` | `(root)` | 화면 | `_admin_base` | +| `identity_challenge` | `auth` | 화면 | `_admin_base` | +| `401` | `errors` | 화면 | `_admin_base` | +| `403` | `errors` | 화면 | `_admin_base` | +| `404` | `errors` | 화면 | `_admin_base` | +| `500` | `errors` | 화면 | `_admin_base` | +| `503` | `errors` | 화면 | `_admin_base` | +| `maintenance` | `errors` | 화면 | `_admin_base` | +| `index` | `overrides` | 화면 | `_admin_base` | +| `_identity_challenge_modal` | `partials` | partial | - | +| `_modal_changelog` | `partials` | partial | - | +| `_modal_license` | `partials` | partial | - | +| `_modal_notification_delete_all_confirm` | `partials` | partial | - | +| `_partial_datagrid` | `partials` | partial | - | +| `_partial_filter` | `partials` | partial | - | +| `_content` | `partials` | partial | - | +| `_modal_log_detail` | `partials` | partial | - | +| `_modal_purge_confirm` | `partials` | partial | - | +| `_partial_datagrid` | `partials` | partial | - | +| `_partial_filter` | `partials` | partial | - | +| `_content` | `partials` | partial | - | +| `_drawer_manifest_preview` | `partials` | partial | - | +| `_modal_detail` | `partials` | partial | - | +| `_modal_install` | `partials` | partial | - | +| `_modal_install_bundled` | `partials` | partial | - | +| `_modal_refresh_cache` | `partials` | partial | - | +| `_modal_slot_conflict` | `partials` | partial | - | +| `_modal_uninstall` | `partials` | partial | - | +| `_modal_update` | `partials` | partial | - | +| `_modal_delete` | `partials` | partial | - | +| `_panel_detail` | `partials` | partial | - | +| `_panel_form` | `partials` | partial | - | +| `_panel_menu_list` | `partials` | partial | - | +| `_panel_view` | `partials` | partial | - | +| `_drawer_manifest_preview` | `partials` | partial | - | +| `_modal_deactivate_warning` | `partials` | partial | - | +| `_modal_detail` | `partials` | partial | - | +| `_modal_extension_license` | `partials` | partial | - | +| `_modal_force_activate` | `partials` | partial | - | +| `_modal_force_deactivate` | `partials` | partial | - | +| `_modal_install` | `partials` | partial | - | +| `_modal_manual_install` | `partials` | partial | - | +| `_modal_reactivate_language_packs` | `partials` | partial | - | +| `_modal_refresh_layouts` | `partials` | partial | - | +| `_modal_uninstall` | `partials` | partial | - | +| `_modal_update` | `partials` | partial | - | +| `_modal_log_detail` | `partials` | partial | - | +| `_partial_datagrid` | `partials` | partial | - | +| `_partial_filter` | `partials` | partial | - | +| `_drawer_manifest_preview` | `partials` | partial | - | +| `_modal_detail` | `partials` | partial | - | +| `_modal_extension_license` | `partials` | partial | - | +| `_modal_force_activate` | `partials` | partial | - | +| `_modal_force_deactivate` | `partials` | partial | - | +| `_modal_install` | `partials` | partial | - | +| `_modal_manual_install` | `partials` | partial | - | +| `_modal_reactivate_language_packs` | `partials` | partial | - | +| `_modal_refresh_layouts` | `partials` | partial | - | +| `_modal_uninstall` | `partials` | partial | - | +| `_modal_update` | `partials` | partial | - | +| `_modal_delete` | `partials` | partial | - | +| `_modal_delete` | `partials` | partial | - | +| `_modal_duplicate` | `partials` | partial | - | +| `_modal_form` | `partials` | partial | - | +| `_modal_history` | `partials` | partial | - | +| `_modal_run` | `partials` | partial | - | +| `_tab_schedules` | `partials` | partial | - | +| `_modal_cache_delete` | `partials` | partial | - | +| `_modal_core_changelog` | `partials` | partial | - | +| `_modal_core_update_guide` | `partials` | partial | - | +| `_modal_core_update_result` | `partials` | partial | - | +| `_modal_identity_message_definition_add` | `partials` | partial | - | +| `_modal_identity_message_definition_delete` | `partials` | partial | - | +| `_modal_identity_message_definition_reset` | `partials` | partial | - | +| `_modal_identity_message_template_form` | `partials` | partial | - | +| `_modal_identity_message_template_preview` | `partials` | partial | - | +| `_modal_identity_policy_delete` | `partials` | partial | - | +| `_modal_identity_policy_form` | `partials` | partial | - | +| `_modal_mail_template_form` | `partials` | partial | - | +| `_modal_notification_definition_reset` | `partials` | partial | - | +| `_modal_notification_template_form` | `partials` | partial | - | +| `_modal_notification_template_preview` | `partials` | partial | - | +| `_modal_password_confirm` | `partials` | partial | - | +| `_tab_advanced` | `partials` | partial | - | +| `_tab_drivers` | `partials` | partial | - | +| `_tab_general` | `partials` | partial | - | +| `_tab_identity` | `partials` | partial | - | +| `_tab_identity_basic` | `partials` | partial | - | +| `_tab_identity_messages` | `partials` | partial | - | +| `_tab_identity_policies` | `partials` | partial | - | +| `_tab_identity_providers` | `partials` | partial | - | +| `_tab_info` | `partials` | partial | - | +| `_tab_language_packs` | `partials` | partial | - | +| `_tab_mail` | `partials` | partial | - | +| `_tab_mail_templates` | `partials` | partial | - | +| `_tab_notification_definitions` | `partials` | partial | - | +| `_tab_security` | `partials` | partial | - | +| `_tab_seo` | `partials` | partial | - | +| `_tab_upload` | `partials` | partial | - | +| `_modal_extension_preview_layout` | `partials` | partial | - | +| `_modal_extension_version_history` | `partials` | partial | - | +| `_modal_version_history` | `partials` | partial | - | +| `_drawer_manifest_preview` | `partials` | partial | - | +| `_modal_activate` | `partials` | partial | - | +| `_modal_deactivate` | `partials` | partial | - | +| `_modal_detail` | `partials` | partial | - | +| `_modal_extension_license` | `partials` | partial | - | +| `_modal_force_activate` | `partials` | partial | - | +| `_modal_install` | `partials` | partial | - | +| `_modal_manual_install` | `partials` | partial | - | +| `_modal_reactivate_language_packs` | `partials` | partial | - | +| `_modal_refresh_layouts` | `partials` | partial | - | +| `_modal_uninstall` | `partials` | partial | - | +| `_modal_update` | `partials` | partial | - | +| `_tab_admin` | `partials` | partial | - | +| `_tab_user` | `partials` | partial | - | +| `_content_section` | `partials` | partial | - | +| `_header_section` | `partials` | partial | - | +| `_info_card` | `partials` | partial | - | +| `template_partial_test` | `(root)` | 화면 | `_admin_base` | + + + +그룹은 "이 파일이 독립된 화면 URL을 갖는가"로 나뉩니다 — `(root)`/`auth`/`errors`/`overrides` 는 +라우트에 직접 매핑되는 화면이고, `partials`(110개, 전체의 76%)는 화면에 `extends`/포함되는 +조각(탭·모달·패널)이라 그 자체로는 URL이 없습니다. partial 비중이 압도적으로 큰 것은 관리자 +화면 하나가 보통 탭 여러 개 + 모달 여러 개로 구성되기 때문입니다(예: 확장 관리 화면 하나가 +설치/삭제/업데이트/강제활성화 등 모달을 5~10개씩 갖습니다). + +이 목록은 코드에서 실측되므로 구체적인 개수·파일명을 프로즈에 하드코딩해 반복하지 않습니다 +— 과거 버전 문서가 손으로 그린 전체 페이지 트리(약 20개 화면 기준)는 이미 145개로 늘어난 +현재 상태와 크게 어긋나 있었습니다. 전체 목록은 항상 위 생성 표를 신뢰합니다. + +### 화면 유형별 구성 패턴 + +새 관리자 화면을 만들 때 아래 패턴 중 가장 가까운 것을 참고합니다(구체적 파일명이 아니라 +**구조**를 재사용하는 것이 목적입니다 — 실제 예시 파일은 위 표에서 비슷한 이름을 찾습니다). + +**목록 화면**: `extends: _admin_base`, `slots.content` 에 `PageHeader`(제목·액션) → +`FilterGroup`(선택) → `DataGrid`/`CardGrid`(목록) → `Pagination`. data_sources 는 +`auto_fetch: true` + `params`에 `_local.page`/`per_page` 바인딩. 삭제·상태변경은 `apiCall`, +상세/수정 이동은 `navigate`, 필터·페이지네이션은 `setState`. 목록 컨텍스트 왕복 규약 +(`mergeQuery`)을 지킵니다(§CLAUDE.md). + +**상세 화면**: `extends: _admin_base`, `data_sources`에 `route.id` 기반 상세 API, +`slots.content`에 `PageHeader` + `Card` 여러 개(기본 정보/활동 내역/권한 정보 등 섹션별 분리). + +**폼 화면(생성/수정 겸용)**: `route.id` 존재 여부로 생성/수정을 분기, `Form` 안에 +`FormField`+`Input`/`Select`/`Toggle` 조합. `Button` 은 `type="button"` 명시(submit 방지), +서버 검증 에러는 `FormField` 의 `error` prop 으로 표시. + +**설정(탭) 화면**: `TabNavigation` + `activeTab` 상태에 따라 `_tab_*.json` partial 을 +조건부 렌더링. 탭 전환은 `setState`, 저장은 `apiCall`, 파괴적 동작 확인은 `openModal`. + +**확장 관리 화면**(모듈/플러그인/템플릿 목록): `DataGrid`/`CardGrid` + `StatusBadge`(상태) + +`ActionMenu`(설치/활성화/비활성화/삭제/업데이트) + `ExtensionBadge`. 설치·삭제·업데이트마다 +전용 확인 모달(`_modal_install`/`_modal_uninstall`/`_modal_update` 등)을 개별 파일로 분리 — +한 모달에 여러 동작을 조건 분기로 몰아넣지 않습니다(모달마다 문구·부작용이 다릅니다). + +**에러 화면**(`errors/*.json`): `extends` 없는 독립 레이아웃. `Div`(중앙 정렬) 안에 +`Icon`+`H1`(코드)+`P`(메시지)+`Button`(홈 이동). 독립 레이아웃이므로 `Toast`/`Modal` 같은 +전역 호스트 컴포넌트가 필요하면 직접 마운트해야 합니다(§CLAUDE.md "독립 레이아웃의 글로벌 +호스트 컴포넌트"). + +### `_admin_base.json` 에 대한 정정 + +과거 버전 문서는 `_admin_base.json` 이 `init_actions: [initTheme, initMenuFromUrl]` 를 갖는다고 +적었으나, 현재 `_admin_base.json` 에는 `init_actions` 키 자체가 없습니다 — 두 핸들러는 현재 +`_admin_base` 를 상속하지 않는 인증 화면(`admin_login`/`admin_forgot_password`/ +`admin_reset_password`)에서만 호출됩니다. 사이드바 접힘 상태 복원(`initSidebar`)은 레이아웃이 +아니라 템플릿 부트스트랩(`src/index.ts`)에서 직접 호출됩니다. `_admin_base` 를 고칠 때 이 +문서의 낡은 구조를 그대로 믿지 말고 실제 JSON 을 확인하세요. + + +## 라우트 매핑 + + +| 경로 | 레이아웃 | 이름 | +|---|---|---| +| `*/admin` | `-` | - | +| `*/admin/login` | `admin_login` | - | +| `*/admin/forgot-password` | `admin_forgot_password` | - | +| `*/admin/reset-password` | `admin_reset_password` | - | +| `*/admin/dashboard` | `admin_dashboard` | - | +| `*/admin/users` | `admin_user_list` | - | +| `*/admin/users/create` | `admin_user_form` | - | +| `*/admin/users/:id` | `admin_user_detail` | - | +| `*/admin/users/:id/edit` | `admin_user_form` | - | +| `*/admin/modules` | `admin_module_list` | - | +| `*/admin/settings/language-packs` | `-` | - | +| `*/admin/modules/:identifier/language-packs` | `admin_module_language_packs` | - | +| `*/admin/plugins/:identifier/language-packs` | `admin_plugin_language_packs` | - | +| `*/admin/templates/:identifier/language-packs` | `admin_template_language_packs` | - | +| `*/admin/plugins` | `admin_plugin_list` | - | +| `*/admin/menus` | `admin_menu_list` | - | +| `*/admin/templates` | `-` | - | +| `*/admin/templates/:identifier/edit` | `admin_template_layout_edit` | - | +| `*/admin/templates/:type` | `admin_template_list` | - | +| `*/admin/template/partial` | `template_partial_test` | - | +| `*/admin/activity-logs` | `admin_activity_log_list` | - | +| `*/admin/notification-logs` | `admin_notification_log_list` | - | +| `*/admin/settings` | `admin_settings` | - | +| `*/admin/roles` | `admin_role_list` | - | +| `*/admin/roles/create` | `admin_role_form` | - | +| `*/admin/roles/:id/edit` | `admin_role_form` | - | +| `*/admin/schedules` | `admin_schedule_list` | - | +| `*/admin/identity/logs` | `admin_identity_logs` | - | +| `*/admin/identity/challenge` | `auth/identity_challenge` | - | + + + +`레이아웃` 열이 `-` 인 두 행(`*/admin`, `*/admin/settings/language-packs`)은 레이아웃이 +없다는 뜻이 아니라 **다른 라우트로 리다이렉트되는 진입점**입니다 — 예를 들어 `/admin` 은 +로그인 여부에 따라 `/admin/login` 또는 `/admin/dashboard` 로 넘어가는 게이트 라우트입니다. +새 화면을 추가할 때 이 표에 라우트를 등록하는 것만으로 끝나지 않습니다 — 사이드바 메뉴에서 +그 화면으로 이동하는 진입점도 함께 추가해야 실제로 도달 가능해집니다(라우트만 있고 메뉴 +항목이 없으면 URL을 직접 입력해야만 닿는 화면이 됩니다). + + +## 확장 오버라이드 + + +_오버라이드하는 레이아웃 확장 조각이 없습니다._ + + + +이 템플릿은 현재 어떤 모듈/플러그인의 레이아웃 확장 조각도 오버라이드하지 않습니다 — +`sirsoft-basic` 템플릿과 달리 관리자 화면은 확장이 끼워 넣는 조각(예: 이커머스 문의 설정, +GDPR 배너)을 코어 대시보드 위젯 형태로만 받고, 이 템플릿이 그 조각을 대체할 필요가 아직 +없었기 때문입니다. 특정 확장의 관리자 UI 를 이 템플릿에서만 다르게 보이게 하려면 +`extensions/{module-identifier}/*.json` 을 신설합니다(§docs/extension/layout-extensions.md +"템플릿 오버라이드"). + + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/layouts.md` 에 있던 원문을 +> 이 문서로 옮긴 것입니다(#601). 페이지 맵 트리와 화면 유형별 패턴 상세가 여기에 있습니다. +> 이관 시점 그대로 보존하되, 현재 코드와 어긋나는 부분에는 정정 주석을 달았습니다 — +> 레이아웃·라우트의 SSoT 는 위 「레이아웃 목록」·「라우트 매핑」 블록입니다. + +### 페이지 맵 (트리 구조) + +```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 스키마](../../../../docs/frontend/layout-json.md) +- [레이아웃 상속](../../../../docs/frontend/layout-json-inheritance.md) +- [sirsoft-basic 레이아웃](../../sirsoft-basic/docs/layouts.md) diff --git a/templates/_bundled/sirsoft-admin_basic/editor-spec.json b/templates/_bundled/sirsoft-admin_basic/editor-spec.json index a6fc40cf..102fece6 100644 --- a/templates/_bundled/sirsoft-admin_basic/editor-spec.json +++ b/templates/_bundled/sirsoft-admin_basic/editor-spec.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "templateId": "sirsoft-admin_basic", "version": "1.0.0", - "description": "레이아웃 편집기 스펙 — Phase 3 (nesting + componentPalette 블록). controls/componentCapabilities/actionRecipes 등은 Phase 4/5 에서 추가.", + "description": "관리자 템플릿 레이아웃 편집기 스펙 — 컴포넌트 팔레트·스타일 컨트롤·편집 역량·중첩 규칙과 관리자 화면 프리뷰 샘플.", "styleSystem": "tailwind", "darkMode": { "comment": "Tailwind 다크는 조상.dark 클래스. 편집기 프리뷰 격리: 코어 CSS 서빙 API 가 편집기용 CSS 의.dark 셀렉터를 프리뷰 전용 마커(.g7le-preview-dark)로 치환해 서빙한다. 사용자 페이지 CSS 는 원본 그대로.", diff --git a/templates/_bundled/sirsoft-basic/AGENTS.md b/templates/_bundled/sirsoft-basic/AGENTS.md new file mode 100644 index 00000000..24709d18 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/AGENTS.md @@ -0,0 +1,189 @@ +# Basic — 에이전트 가이드 + +> 이 문서는 이 템플릿을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 템플릿 (sirsoft-basic, type=user) — 방문자가 보는 사이트 전체 화면 166개. 게시판·이커머스 모듈은 방문자 화면을 갖지 않으므로 상점·게시판 UI 는 여기가 소유한다. 서버 코드 0줄 +2. 확장 방식: 훅 0/0 — 확장점은 화면 구조다. 다른 확장이 `layout_extensions` 로 조각을 끼우고, `extensions/{확장}/` 오버라이드로 원본 조각을 대체한다 +3. 건드리면 안 되는 것: `extends` 없는 독립 레이아웃에서 toast/modal 사용, 상점 경로 하드코딩(표현식 유지), 목록 이동의 `mergeQuery` 누락, 비회원 주문 토큰 진입 정리 제거 +4. 작업 위치: `templates/_bundled/sirsoft-basic` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan template:update sirsoft-basic --force` +``` + +## 1. 이 확장은 무엇인가 + + +방문자가 보는 **사이트 전체 화면**을 소유하는 사용자(User) 템플릿입니다. 홈·게시판·상점· +마이페이지·인증·오류 화면 166개가 여기 있습니다. + +**이것이 왜 템플릿에 있는가**가 이 확장을 이해하는 열쇠입니다. 게시판 모듈도 이커머스 모듈도 +레이아웃이 전부 `admin` 그룹이고 방문자 화면을 갖지 않습니다 — 두 모듈은 관리자 CRUD 와 공개 +API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이 템플릿입니다. 상점 디자인을 바꾸는 +작업은 이커머스 모듈이 아니라 **여기**입니다. + +**설계 원칙 넷**: + +1. **모든 화면이 `_user_base` 를 상속한다.** 헤더·푸터·모바일 네비와 토스트·모달 호스트가 + 거기 있습니다. `extends` 없이 독립 레이아웃을 만들면 `toast`·`openModal` 이 성공으로 + 기록되지만 화면에는 아무것도 나타나지 않습니다. +2. **화면은 조각으로 나눈다.** 166개 중 124개가 partial 입니다. 조각은 여러 화면이 공유하며, + 화면 하나를 고칠 때는 그 화면 이름의 partials 디렉토리를 함께 엽니다. +3. **모듈 설정이 라우트에 스며든다.** 상점 경로 9개가 표현식이라 운영자가 이커머스 설정에서 + 상점 경로를 바꾸거나 루트에 두면 라우트가 그에 맞춰 바뀝니다. +4. **다른 확장의 조각을 갈아 끼울 수 있다.** `extensions/{확장}/` 오버라이드가 그 장치이며, + 지금은 주소 검색 조각 하나가 있습니다. + +**의도적으로 하지 않는 것**: 서버 코드 일체(PHP 0줄 · 모델 0 · 라우트 파일 없음 · 훅 발행 0). +데이터는 전부 모듈·코어의 공개 API 에서 옵니다. 그래서 이 템플릿을 다른 것으로 바꿔도 데이터는 +그대로이고, 반대로 이 템플릿만으로는 아무 기능도 동작하지 않습니다 — manifest 가 게시판· +이커머스·페이지 모듈과 주소 검색 플러그인을 의존으로 선언하는 이유입니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update sirsoft-basic --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update sirsoft-basic --force` (빌드 불필요) | +| `extensions/` | 다른 확장 화면에 주입하는 레이아웃 조각 | `php artisan template:update sirsoft-basic --force` (빌드 불필요) | +| `seo-config.json` | SEO 렌더 설정 | `php artisan template:update sirsoft-basic --force` | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update sirsoft-basic --force` | +| `src/handlers/` | 템플릿 전용 액션 핸들러 | `php artisan template:build` → `php artisan template:update sirsoft-basic --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan template:update sirsoft-basic --force` | +| `editor-spec/` | 분할 편집기 스펙 | `php artisan template:update sirsoft-basic --force` | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update sirsoft-basic --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +서버 코드가 없으므로 흐름은 전부 **라우트 → 레이아웃 → 데이터소스 → 컴포넌트**입니다. + +**화면 렌더**: 브라우저가 경로 진입 → `routes.json` 이 그 경로에 대응하는 레이아웃을 지목 → +레이아웃이 `_user_base` 를 상속해 헤더·푸터를 얻고 콘텐츠 슬롯을 채움 → `data_sources` 가 +모듈·코어의 공개 API 를 호출 → 응답을 컴포넌트에 바인딩. 상점 경로는 이 첫 단계에서 +**표현식이 평가**되어 운영자가 지정한 경로가 됩니다. + +**장바구니(비회원 포함)**: 진입 시 `initCartKey` 가 localStorage 의 장바구니 키를 확인하고 +없으면 API 로 발급받습니다 → 담기·수량 변경·옵션 변경은 `setCartOption` · +`recalculateCart` 등이 이커머스 API 를 호출하고 결과로 상태를 갱신 → 로그인하면 코어 +`auth.after_login` 을 구독하는 이커머스 리스너가 비회원 장바구니를 회원 장바구니로 병합합니다 +(그 병합은 서버 쪽 일이며 이 템플릿은 키만 넘깁니다). + +**비회원 주문 조회**: 주문 완료 시 서버가 발급한 토큰을 `saveGuestOrderToken` 이 보관 → +조회 화면이 그 토큰으로 API 를 호출하면 서버의 `VerifyGuestOrderToken` 미들웨어가 신원을 +확인합니다. **토큰이 곧 신원**이므로 `clearGuestTokenOnEntry` 가 진입 시점에 남은 토큰을 +정리합니다 — 공용 PC 에서 다음 사용자가 남의 주문을 열지 못하게 하는 방어입니다. + +**본인인증(IDV)**: 어떤 API 가 428 을 돌려주면 코어 인터셉터가 그것을 잡아, 부트스트랩에서 +등록한 launcher 로 인증 화면을 엽니다. 인증을 마치면 원래 액션이 재개됩니다 — 각 화면이 +428 을 개별 처리하지 않습니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 제공 컴포넌트 | 79개 | [제공 컴포넌트](docs/components.md#제공-컴포넌트) | +| 레이아웃 | 166개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) | +| 전용 핸들러 | 32개 | [템플릿 전용 핸들러](docs/handlers.md#템플릿-전용-핸들러) | +| 확장 오버라이드 | 1개 | [확장 오버라이드](docs/layouts.md#확장-오버라이드) | + + + +이 템플릿은 훅을 **발행하지도 구독하지도 않습니다**(0/0). 템플릿의 확장점은 훅이 아니라 +**화면 구조** 자체입니다. + +| 확장점 | 어떻게 쓰는가 | +|---|---| +| 레이아웃 166개 | 다른 확장이 `layout_extensions` 로 자기 조각을 끼워 넣습니다 — 이커머스의 헤더 통화 선택기·마이페이지 마일리지 카드, 마케팅의 회원가입 동의 항목이 그 예입니다 | +| 제공 컴포넌트 79개 | 조각을 만드는 확장이 이 컴포넌트로 화면을 구성합니다. **여기 없는 컴포넌트를 쓰면 그 조각은 이 템플릿에서 렌더되지 않습니다** | +| 전용 핸들러 32개 | 조각의 액션에서 부를 수 있습니다. 네임스페이스가 붙은 10개(`sirsoft-basic.*`)는 전체 이름으로, 나머지는 이름만으로 부릅니다 | +| 확장 오버라이드 | `extensions/{확장}/` 에 같은 이름의 조각을 두면 그 확장이 제공한 원본을 대체합니다 | + +**다른 확장이 이 템플릿에 화면을 얹는 방향이 정상**입니다. 반대로 이 템플릿이 모듈의 관리자 +화면에 개입하지는 않습니다. + +`seo-config.json` 도 확장점입니다 — 봇 요청에 서버가 화면을 렌더할 때 어떤 속성을 HTML 로 +내보낼지 이 파일이 정합니다. 새 컴포넌트가 텍스트를 담는 새 prop 을 쓰면 `text_props` 에 +추가해야 봇 화면에 그 글자가 나타납니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan template:update sirsoft-basic --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 새 화면을 추가했다면 `_user_base` 를 상속하는지, `routes.json` 에 경로가 등록됐는지 확인 +- [ ] 상점 관련 경로·링크는 표현식(`route_path` · `no_route`)을 유지 — 문자열로 굳히지 않는다 +- [ ] 목록 클러스터(목록 → 상세 → 형제 상세 → 폼 → 복귀) 이동 전 leg 에 `mergeQuery: true` +- [ ] 텍스트를 담는 새 컴포넌트 prop 을 도입했다면 `seo-config.json` 의 `text_props` 에 추가 (봇 화면에서만 글자가 사라진다) +- [ ] 의존 모듈(게시판·이커머스·페이지)의 공개 API 응답 형태가 바뀌면 이 템플릿의 화면이 조용히 빈다 — 그 모듈을 올릴 때 함께 확인 +- [ ] `extensions/sirsoft-daum_postcode/` 오버라이드는 원본이 바뀌어도 따라가지 않는다 — 그 플러그인 업그레이드 후 확인 +- [ ] TSX/TS 를 고쳤다면 `template:build --production` 후 `dist/` 동반 커밋 (`sourceMappingURL` 잔존 금지) +- [ ] 프론트엔드 변경은 Playwright spec 동반 — 단위 테스트만으로는 화면 회귀가 드러나지 않는다 +- [ ] 레이아웃·컴포넌트·`data_source` 를 건드렸다면 [`docs/editor-spec.md`](docs/editor-spec.md) 의 동반 의무 표를 따라 `editor-spec/` 블록을 함께 갱신 — 컴포넌트는 팔레트·역량·중첩 **넷 다** 손대야 편집기에서 온전히 동작하고, 하나만 빠지면 절반만 동작한다. 반영은 `php artisan template:update sirsoft-basic --force` (편집기는 활성 디렉토리만 읽는다) + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| `extends` 없는 독립 레이아웃에서 `toast`·`openModal` 사용 | `_user_base` 를 상속하거나, 독립 레이아웃이라면 `Toast`·모달 호스트 컴포넌트를 직접 마운트 | 호스트가 없으면 핸들러는 성공으로 기록되는데 화면에는 아무것도 나타나지 않는다 | +| 상점 경로를 `/shop/...` 로 하드코딩 | `routes.json` 의 표현식 유지 (`route_path` · `no_route` 반영) | 운영자가 경로를 바꾸거나 루트에 두면 하드코딩한 링크만 조용히 깨진다 | +| 목록 → 상세 → 목록 이동에서 `mergeQuery` 누락 | 목록 클러스터 내 모든 이동에 `"mergeQuery": true` | 검색어·페이지·필터가 사라져 사용자가 처음부터 다시 찾아야 한다 | +| 비회원 주문 토큰을 진입 시점에 정리하지 않음 | `clearGuestTokenOnEntry` 유지 | 토큰이 곧 신원이다 — 공용 PC 에서 다음 사용자가 남의 주문을 연다 | +| `401` 오류 레이아웃에서 로그인으로 직접 리다이렉트 | 코어 `TemplateApp.showRouteError` 가드에 위임 | 이중 리다이렉트가 되고, 돌아올 위치를 코어가 이미 관리한다 | +| 모듈·플러그인에서 `AuthManager.updateConfig()` 호출 | 템플릿 부트스트랩에서만 | 여러 확장이 로그인 경로를 다투면 어느 값이 이기는지 설치 순서에 좌우된다 | +| 새 컴포넌트가 텍스트를 담는 prop 을 추가하면서 `seo-config.json` 을 그대로 두기 | `text_props` 에 그 prop 추가 | 봇 화면에서만 그 글자가 사라진다 — 사람 눈에는 정상이라 검색 노출이 줄어든 뒤에야 드러난다 | +| 레이아웃 JSON 에 빌드된 CSS 에 없는 Tailwind 클래스 사용 | 기존 레이아웃에 쓰인 클래스이거나 빌드 산출물에 존재하는지 확인 | 그 스타일만 조용히 빠져 화면이 어긋난다 | +| `dist/` 재빌드 없이 `src/` 만 고치고 커밋 | `template:build --production` 후 `dist/` 동반 커밋 | 브라우저가 받는 것은 커밋된 `dist/` 다 — 소스 수정이 사문화된다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 0개 | — | +| Vitest | 141개 | `vitest.config.ts` | +| Playwright | 8개 | `tests/Playwright` | +| 시나리오 매니페스트 | 3개 | `tests/scenarios` | + +```bash +# Vitest (확장 디렉토리에서) (PowerShell) +cd templates/_bundled/sirsoft-basic && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test templates/_bundled/sirsoft-basic/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 8c3e48dc..0a6364b2 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -11,6 +11,9 @@ - 아이콘(Font Awesome)과 본문 글꼴(Pretendard)을 템플릿에 함께 담았습니다. 이제 외부 CDN 에 연결하지 않고 사이트 자신의 서버에서 불러오므로, 폐쇄망이나 외부 접속이 제한된 환경에서도 아이콘과 글꼴이 정상 표시됩니다. - 이미지 업로드 시 쓰는 압축 라이브러리도 함께 담아, 업로드할 때마다 외부로 나가던 요청이 없어졌습니다. - 검색엔진 크롤러가 보는 페이지도 같은 아이콘 파일을 사이트 자신의 주소에서 불러옵니다. +- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다. +- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다. +- 문서의 제품 표기를 「그누보드7」로 통일했습니다. ### Fixed diff --git a/templates/_bundled/sirsoft-basic/README.md b/templates/_bundled/sirsoft-basic/README.md new file mode 100644 index 00000000..bdffe7de --- /dev/null +++ b/templates/_bundled/sirsoft-basic/README.md @@ -0,0 +1,210 @@ +# Basic + +**그누보드7 템플릿 · sirsoft-basic** +그누보드7 기본 사용자 템플릿 + + +

+ version 1.1.3 + type 템플릿 + 그누보드7 >=7.0.10 + license MIT + requires sirsoft-board + requires sirsoft-ecommerce + requires sirsoft-page + requires sirsoft-daum_postcode +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +방문자가 보는 **사이트 전체 화면**을 담당하는 기본 사용자 템플릿입니다. 홈·게시판·상점· +마이페이지·로그인·오류 화면이 모두 여기 들어 있습니다. + +그누보드7 에서 게시판이나 쇼핑몰 모듈은 데이터와 관리자 화면을 담당하고, **방문자에게 보이는 +모습은 템플릿이 정합니다.** 그래서 상점이나 게시판의 디자인을 바꾸고 싶다면 그 모듈이 아니라 +이 템플릿(또는 다른 사용자 템플릿)을 손봅니다. + +다크 모드·반응형·다국어·다중 통화를 기본으로 지원하며, 상점 경로처럼 운영자가 환경설정에서 +바꾸는 값은 화면과 주소에 자동으로 반영됩니다. + +이 템플릿만으로는 동작하지 않습니다 — 게시판·이커머스·페이지 모듈과 주소 검색 플러그인이 +함께 설치·활성화되어 있어야 합니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 홈·공통 | 헤더·푸터·모바일 네비게이션, 통합 검색, 알림 센터, 다크/라이트 전환 | +| 인증 | 로그인·회원가입·비밀번호 찾기/재설정·본인인증 화면, 소셜 로그인 버튼 | +| 게시판 | 게시판 목록·글 목록·글 보기·글쓰기, 인기글, 게시판 유형별 표시 | +| 상점 | 상품 목록·카테고리·상품 상세·장바구니·주문서·주문 완료, 비회원 주문 조회와 재주문 | +| 마이페이지 | 프로필·비밀번호 변경·주문 내역·마일리지·찜·배송지·알림·내 게시글·문의 | +| 단일 문서 | 회사소개·약관 같은 페이지 표시 | +| 오류 화면 | 401·403·404·500·503·점검 중 | +| 다국어·다통화 | 언어 전환, 표시 통화 선택과 통화별 가격 표시 | +| 반응형·다크 모드 | 모바일/데스크톱 레이아웃 분기, 시스템 설정 연동 테마 | + + +## 동작 방식 + + +```mermaid +flowchart TD + B[_user_base
헤더 · 푸터 · 모바일 네비 · 토스트/모달] --> A[auth
로그인·가입·본인인증] + B --> BD[board
게시판 목록·글·작성] + B --> S[shop
상품·장바구니·주문] + B --> M[mypage
프로필·주문·마일리지] + B --> P[page
단일 문서] + B --> E[errors
401·403·404·500·503] +``` + +모든 화면이 하나의 베이스를 물려받습니다. 헤더·푸터·모바일 메뉴와 알림 표시가 그 베이스에 +있어, 사이트 전체의 공통 요소를 한 곳에서 바꿀 수 있습니다. + +```mermaid +flowchart LR + V[방문자] --> T[템플릿 화면] + T -->|공개 API 호출| MOD[게시판·이커머스·페이지 모듈] + MOD --> DB[(데이터)] +``` + +화면은 이 템플릿이 그리고 데이터는 모듈이 제공합니다. 그래서 템플릿을 바꿔도 게시글이나 +주문 데이터는 그대로 남습니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| 그누보드7 코어 | `>=7.0.10` | +| PHP | `^8.2` | +| 의존 모듈 | `sirsoft-board` `>=1.0.0` | +| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` | +| 의존 모듈 | `sirsoft-page` `>=1.1.0` | +| 의존 플러그인 | `sirsoft-daum_postcode` `>=1.0.0` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan template:install sirsoft-basic + +# 활성화 +php artisan template:activate sirsoft-basic + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan template:update sirsoft-basic --force +``` + +저장소: https://github.com/gnuboard/g7-template-sirsoft-basic + + +## 제공 컴포넌트 + + +컴포넌트 79개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 38개 | +| `composite` | 36개 | +| `layout` | 5개 | + + + +위 개수는 이 템플릿이 화면을 그리는 데 쓰는 **부품**의 수입니다. 운영자가 직접 다룰 일은 +없지만, 화면을 직접 손보거나 확장을 붙일 때는 **여기 있는 부품만 쓸 수 있습니다** — 목록에 없는 +부품을 쓴 화면 조각은 이 템플릿에서 렌더되지 않습니다. + +관리자 템플릿(`sirsoft-admin_basic`)과는 구성이 다릅니다. 표·필터·다국어 입력 같은 관리 도구가 +없고, 대신 상품 카드·이미지 뷰어·수량 선택기·게시글 반응·모바일 메뉴·소셜 로그인처럼 **방문자 +화면에 필요한 것들**이 있습니다. + +전체 목록과 각 부품의 사용법은 [docs/components.md](docs/components.md) 에 있습니다. + + +## 사용 방법 + + +**도입**: 템플릿을 설치·활성화하면 사이트의 방문자 화면이 이 템플릿으로 바뀝니다. 게시판· +이커머스·페이지 모듈과 주소 검색 플러그인이 함께 활성화되어 있어야 모든 화면이 정상 동작합니다 — +예를 들어 이커머스가 없으면 상점 메뉴로 들어갔을 때 데이터를 받지 못합니다. + +**상점 주소 바꾸기**: 이커머스 환경설정에서 상점 경로를 바꾸면(`shop` → `store`) 이 템플릿의 +상점 화면 주소도 함께 바뀝니다. 상점을 사이트 첫 화면으로 쓰려면 같은 설정에서 "경로 없음" +으로 두면 `/products` 처럼 최상위 주소가 됩니다. 템플릿을 고칠 필요가 없습니다. + +**색상·문구 손보기**: 화면 구성은 레이아웃 파일(JSON)이 정하고 부품 모양은 컴포넌트가 +정합니다. 레이아웃만 고치는 변경(문구·배치·표시 항목)은 다시 빌드할 필요 없이 +`php artisan template:update sirsoft-basic --force` 로 반영됩니다. + +**다른 템플릿으로 교체**: 이 템플릿은 화면만 담당하므로, 다른 사용자 템플릿으로 바꿔도 +게시글·주문·회원 데이터는 그대로입니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +| 확장 | 유형 | 버전 제약 | 번들 | +|---|---|---|---| +| `sirsoft-board` | 모듈 | `>=1.0.0` | ✅ | +| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ | +| `sirsoft-page` | 모듈 | `>=1.1.0` | ✅ | +| `sirsoft-daum_postcode` | 플러그인 | `>=1.0.0` | ✅ | + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [docs/editor-spec.md](docs/editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 상점 메뉴로 들어가면 화면이 비어 있음 | 이커머스 모듈이 비활성이거나 상품이 없음 | 모듈 활성화 여부와 상품 진열 상태를 확인합니다 | +| 상점 주소가 예전 경로 그대로 | 브라우저에 저장된 예전 링크 | 이 템플릿의 상점 주소는 이커머스 설정을 따릅니다. 메뉴를 통해 다시 들어가면 새 주소가 적용됩니다 | +| 게시판 메뉴는 보이는데 글 목록이 안 나옴 | 게시판 모듈이 비활성이거나 그 게시판의 접근 권한이 제한됨 | 모듈 활성화와 게시판별 권한 설정을 확인합니다 | +| 주소 검색 버튼이 없거나 눌러도 반응이 없음 | 주소 검색 플러그인이 비활성이거나 외부 접속이 차단됨 | 플러그인 활성화를 확인합니다. 검색을 불러오지 못하면 주소를 직접 입력할 수 있습니다 | +| 비회원으로 주문 조회를 했는데 이전 주문이 열림 | 브라우저에 남아 있던 조회 정보 | 이 템플릿은 조회 화면에 들어갈 때마다 남은 정보를 정리합니다. 계속 발생하면 브라우저 데이터를 지우고 다시 시도합니다 | +| 알림 메시지나 팝업이 뜨지 않음 | 그 화면이 공통 베이스를 쓰지 않음 | 직접 추가한 화면이라면 공통 베이스를 상속하도록 고칩니다 | +| 검색엔진 노출 화면에 일부 글자가 빠짐 | 새 부품이 쓰는 항목이 검색엔진용 렌더 설정에 없음 | `seo-config.json` 의 텍스트 항목 목록을 확인합니다 | +| 화면 일부의 여백·색이 어긋남 | 새로 쓴 스타일 클래스가 빌드된 CSS 에 없음 | 기존 화면에서 쓰이던 클래스인지 확인하고, 필요하면 템플릿을 다시 빌드합니다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/templates/_bundled/sirsoft-basic/docs/README.md b/templates/_bundled/sirsoft-basic/docs/README.md new file mode 100644 index 00000000..576e3ac2 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/docs/README.md @@ -0,0 +1,21 @@ +# Basic 개발자 문서 + +> templates/_bundled/sirsoft-basic · 템플릿 + + +**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 40 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 166 · **핸들러 수**: 32 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | +| [components.md](components.md) | 템플릿이 제공하는 컴포넌트 | +| [layouts.md](layouts.md) | 레이아웃 목록과 라우트 매핑 | +| [handlers.md](handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | +| [editor-spec.md](editor-spec.md) | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | +| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 | +| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 | + diff --git a/templates/_bundled/sirsoft-basic/docs/architecture.md b/templates/_bundled/sirsoft-basic/docs/architecture.md new file mode 100644 index 00000000..da11fa95 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/docs/architecture.md @@ -0,0 +1,85 @@ +# Basic — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +"모듈은 데이터, 템플릿은 화면" 이라는 경계가 이 템플릿의 존재 이유입니다. + +게시판 모듈도 이커머스 모듈도 레이아웃이 전부 `admin` 그룹이고 방문자 화면을 갖지 않습니다. +두 모듈이 방문자 화면을 소유하면 템플릿마다 다른 디자인을 그 모듈이 전부 알아야 하는데, 공개 +API 로만 노출하면 템플릿이 자유롭게 구성할 수 있습니다. 그 대가로 **방문자가 보는 커머스· +게시판 UI 는 사실상 이 템플릿이 전부**이며, 상점 화면을 고치는 작업은 이커머스 모듈이 아니라 +여기입니다. + +그 위에 화면 조직 원칙 셋이 있습니다. + +- **단일 베이스.** 166개 전부 `_user_base` 를 상속합니다. 헤더·푸터·모바일 네비와 토스트·모달 + 호스트가 거기 있어, 상속하지 않은 화면에서는 전역 UI 가 통째로 없습니다. +- **조각 중심.** 124개가 partial 입니다. 화면이 커서가 아니라, 조각을 여러 화면이 공유하기 + 때문입니다 — 상품 카드·주소 폼·탭 구조가 그 예입니다. +- **설정이 라우트에 스며든다.** 상점 경로는 정적 문자열이 아니라 표현식입니다. 운영자가 상점을 + `/store` 로 옮기거나 루트에 두면 `routes.json` 의 그 표현식이 따라갑니다. + +**서버 코드가 없습니다** — PHP 0줄 · 모델 0 · 라우트 파일 없음 · 훅 발행/구독 0. 데이터는 전부 +모듈·코어의 공개 API 에서 오며, 그래서 이 템플릿만으로는 아무 기능도 동작하지 않습니다. +manifest 가 게시판·이커머스·페이지 모듈과 주소 검색 플러그인을 **의존으로 선언**하는 이유이고, +반대로 템플릿을 갈아 끼워도 데이터는 그대로인 이유이기도 합니다. + + +## 계층 지도 + + +``` +routes.json (경로 → 레이아웃. 상점 9개는 표현식 — 운영자 설정이 평가되어 들어온다) + │ + ▼ +layouts/_user_base.json (헤더 · 푸터 · 모바일 네비 · 토스트/모달 호스트 · 콘텐츠 슬롯) + │ extends + ▼ +layouts/{auth,board,shop,mypage,page,search,users,errors}/*.json 화면 42 + │ partial 참조 + ▼ +layouts/partials/** 조각 124 + │ data_sources → 모듈·코어 공개 API + │ actions → 코어 빌트인 핸들러 + 이 템플릿의 전용 핸들러 32 + ▼ +src/components/{basic,composite,layout}/ 컴포넌트 79 → dist/ (커밋되는 빌드 산출물) + +extensions/{확장}/*.json 다른 확장이 제공한 조각을 대체하는 오버라이드 +seo-config.json 봇 화면 렌더 규칙 (어떤 prop 을 HTML 로 내보낼지) +src/index.ts initTemplate() — 핸들러 등록 · IDV launcher · iOS 판정 보정 +``` + +**두 방향의 주입이 이 템플릿에서 만납니다.** 다른 확장이 `layout_extensions` 로 이 템플릿의 +화면에 조각을 끼워 넣고(이커머스 통화 선택기·마케팅 동의 항목), 이 템플릿은 +`extensions/{확장}/` 으로 그 확장이 제공한 조각을 자기 것으로 대체합니다. 앞은 확장이 화면을 +넓히는 통로이고, 뒤는 템플릿이 디자인 주도권을 되찾는 통로입니다. + +`seo-config.json` 은 계층 밖에 있지만 화면과 짝을 이룹니다 — 봇 요청에는 React 가 아니라 서버 +렌더러가 화면을 그리므로, 컴포넌트가 새 prop 에 텍스트를 담기 시작하면 이 파일의 `text_props` +에 그 prop 을 더해야 봇 화면에도 글자가 실립니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update sirsoft-basic --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update sirsoft-basic --force` (빌드 불필요) | +| `extensions/` | 다른 확장 화면에 주입하는 레이아웃 조각 | `php artisan template:update sirsoft-basic --force` (빌드 불필요) | +| `seo-config.json` | SEO 렌더 설정 | `php artisan template:update sirsoft-basic --force` | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update sirsoft-basic --force` | +| `src/handlers/` | 템플릿 전용 액션 핸들러 | `php artisan template:build` → `php artisan template:update sirsoft-basic --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan template:update sirsoft-basic --force` | +| `editor-spec/` | 분할 편집기 스펙 | `php artisan template:update sirsoft-basic --force` | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update sirsoft-basic --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/docs/frontend/templates/sirsoft-basic/components.md b/templates/_bundled/sirsoft-basic/docs/components.md similarity index 65% rename from docs/frontend/templates/sirsoft-basic/components.md rename to templates/_bundled/sirsoft-basic/docs/components.md index 1556fcf4..c913f316 100644 --- a/docs/frontend/templates/sirsoft-basic/components.md +++ b/templates/_bundled/sirsoft-basic/docs/components.md @@ -1,23 +1,68 @@ -# sirsoft-basic 컴포넌트 +# Basic — 컴포넌트 + +> 템플릿이 제공하는 컴포넌트 · 진입점: [AGENTS.md](../AGENTS.md) + +## 제공 컴포넌트 + + +컴포넌트 79개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 38개 | +| `composite` | 36개 | +| `layout` | 5개 | + + + +세 분류는 "얼마나 조합됐는가" 로 나뉩니다 — `basic` 은 HTML 태그를 그대로 래핑한 최소 단위 +(`Div`→`
`), `composite` 는 basic 을 조합해 UI 패턴을 캡슐화한 것(`Header` 가 로고·네비· +검색·사용자 메뉴를 감싸는 식), `layout` 은 페이지 구조(Container/Grid/Flex)입니다. 새 컴포넌트는 +이 구분에 맞는 디렉토리(`src/components/{basic,composite,layout}/`)에 넣어야 `components.json` +카탈로그와 `editor-spec.json` 팔레트 분류가 어긋나지 않습니다. + +**이 템플릿은 방문자 화면 전용**이라 관리자 템플릿(`sirsoft-admin_basic`)과 컴포넌트 구성이 +크게 다릅니다. `DataGrid` · `AdminSidebar` · `MultilingualInput` 같은 관리 도구가 없고, 대신 +`ProductCard` · `ProductImageViewer` · `QuantitySelector` · `PostReactions` · `MobileNav` · +`SocialLoginButtons` 처럼 상점·게시판·인증 화면에 필요한 것들이 있습니다. **모듈이 방문자 +화면을 그리지 않고 API 만 제공하는 구조**(게시판·이커머스 모두 레이아웃이 전부 `admin` 그룹) +이므로, 방문자가 보는 커머스·게시판 UI 는 사실상 이 템플릿의 컴포넌트가 전부입니다. + +위 개수는 코드에서 실측되므로 시간이 지나면 달라집니다 — 이 문서에 구체적 개수를 하드코딩하지 +않습니다. 정확한 전체 목록·Props 는 코어의 컴포넌트 Props 레퍼런스를 따르며, 이 문서는 "이 +템플릿에서만" 유효한 것을 다룹니다. + + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-basic/components.md` 에 있던 원문을 이 문서로 +> 옮긴 것입니다(#601). 이관 시점 그대로 보존하되, **코드가 SSoT 인 값과 어긋나는 부분에는 +> 정정 주석**을 달았습니다 — 실측 총계는 위 「제공 컴포넌트」 블록이 SSoT 입니다. + +### sirsoft-basic 컴포넌트 > **템플릿 식별자**: `sirsoft-basic` (type: user, v0.4.16) -> **관련 문서**: [핸들러](./handlers.md) | [레이아웃](./layouts.md) | [컴포넌트 Props 레퍼런스](../../component-props.md) +> **관련 문서**: [핸들러](handlers.md) | [레이아웃](layouts.md) | [컴포넌트 Props 레퍼런스](../../../../docs/frontend/component-props.md) + +> **정정(#601)**: 위 버전(v0.4.16)은 이관 시점 문서 값입니다. 현재 버전은 `template.json` 이 +> SSoT 입니다. --- -## TL;DR (5초 요약) +### TL;DR (5초 요약) ```text 1. Basic 26개: HTML 래핑 (Div, Button, Input, Select, Form, A, H1~H4, PasswordInput 등) 2. Composite 27개: UI 패턴 캡슐화 (Header, Footer, Modal, ProductCard, Pagination 등) 3. Layout 5개: 페이지 구조 (Container, Grid, Flex, SectionLayout, ThreeColumnLayout) + (정정(#601): 개수는 이관 시점 값 — 실측은 위 「제공 컴포넌트」 블록이 SSoT) 4. 사용자(User) 템플릿 전용 — 모듈 레이아웃(user/ 하위)에서 사용 5. features: dark_mode, responsive, multi_language, multi_currency 지원 ``` --- -## 목차 +### 목차 1. [컴포넌트 개요](#컴포넌트-개요) 2. [Basic Components (26개)](#basic-components-26개) @@ -27,7 +72,13 @@ --- -## 컴포넌트 개요 +### 컴포넌트 개요 + +> **정정(#601)**: 아래 개수는 이관 시점 문서 값입니다. 코드 실측은 basic 38 · composite 36 · +> layout 5 = **79종**이며 위 「제공 컴포넌트」 블록이 SSoT 입니다. 아래 목록에 없는 컴포넌트가 +> 있습니다(basic 의 `Code` · `FileInput` · `I` · `Ol` · `Optgroup` · `Section` · `Svg` · +> `Tbody` · `Td` · `Th` · `Thead` · `Tr`, composite 의 `BrandMark` · `NotificationCenter` · +> `PageLoading` · `SlotContainer` 등). | 타입 | 개수 | 설명 | |------|------|------| @@ -41,11 +92,13 @@ --- -## Basic Components (26개) +### Basic Components (26개) + +> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 38종). 목록 자체는 원문 그대로입니다. HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. -### 텍스트/링크 +#### 텍스트/링크 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -58,7 +111,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | `Span` | 인라인 텍스트 | - | - | | `Label` | 라벨 | - | - | -### 컨테이너 +#### 컨테이너 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -69,7 +122,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | `Footer` | HTML footer 래퍼 | - | - | | `Hr` | 수평선 | - | - | -### 폼 입력 +#### 폼 입력 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -81,14 +134,14 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | `PasswordInput` | 비밀번호 입력 (보기/숨기기, 조건 검증, 확인 일치) | label, error, showToggle, showValidation, isConfirmField, confirmTarget, showRules, ... | - | | `Button` | 버튼 | variant, size | - | -### 미디어 +#### 미디어 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| | `Icon` | FontAwesome 아이콘 | name | - | | `Img` | 이미지 | src, alt | - | -### 기타 +#### 기타 | 컴포넌트 | 설명 | |----------|------| @@ -98,11 +151,13 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. --- -## Composite Components (27개) +### Composite Components (27개) + +> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 36종). 목록 자체는 원문 그대로입니다. 기본 컴포넌트를 조합하여 UI 패턴을 캡슐화한 복합 컴포넌트입니다. -### 사이트 레이아웃 +#### 사이트 레이아웃 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -110,7 +165,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | `Footer` | 사이트 푸터 (저작권, 링크, 소셜) | siteName | | `MobileNav` | 모바일 네비게이션 드로어 | - | -### 쇼핑몰 +#### 쇼핑몰 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -118,20 +173,20 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | `ProductImageViewer` | 상품 이미지 뷰어 (메인 + 썸네일 + 라이트박스) | images | | `QuantitySelector` | 수량 선택기 (+/- 버튼) | value, min, max | -### 게시판 +#### 게시판 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| | `PostReactions` | 게시글 리액션 버튼 그룹 | postId, reactions | | `ExpandableContent` | 콘텐츠 펼치기/접기 (높이 초과 시 그라데이션) | maxHeight, expandText, collapseText | -### 인증 +#### 인증 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| | `SocialLoginButtons` | 소셜 로그인 버튼 그룹 | providers | -### 사용자 +#### 사용자 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -139,7 +194,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | `AvatarUploader` | 아바타 이미지 업로드 (원형 UI, 즉시 업로드) | src, fallbackText, size, uploadEndpoint, deleteEndpoint, showDeleteButton, ... | | `UserInfo` | 사용자 정보 표시 및 드롭다운 | name, userId, subText, isGuest, showDropdown, clickable, ... | -### 에디터/콘텐츠 +#### 에디터/콘텐츠 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -148,13 +203,13 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | `RichTextEditor` | 리치 텍스트 에디터 | value, placeholder | | `ImageGallery` | 이미지 갤러리 (썸네일 + 메인 이미지) | images | -### 파일/미디어 +#### 파일/미디어 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| | `FileUploader` | 파일 업로드 | accept, maxSize, multiple | -### 네비게이션/피드백 +#### 네비게이션/피드백 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -177,7 +232,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | `Toast` | 토스트 알림 | toasts, position, duration | | `ThemeToggle` | 테마 전환 토글 (auto/light/dark) | autoText, lightText, darkText | -### 페이지 전환 +#### 페이지 전환 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -187,7 +242,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. --- -## Layout Components (5개) +### Layout Components (5개) 페이지 구조를 정의하는 레이아웃 컴포넌트입니다. @@ -201,9 +256,9 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. --- -## sirsoft-admin_basic과의 차이 +### sirsoft-admin_basic과의 차이 -### 공통 컴포넌트 +#### 공통 컴포넌트 두 템플릿 모두에 존재하는 컴포넌트: @@ -213,7 +268,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | Composite | ConfirmDialog, FileUploader, HtmlContent, HtmlEditor, ImageGallery, Modal, Pagination, ProductCard, SearchBar, TabNavigation, ThemeToggle, Toast | | Layout | Container, Flex, Grid, SectionLayout, ThreeColumnLayout | -### sirsoft-basic 전용 컴포넌트 +#### sirsoft-basic 전용 컴포넌트 | 타입 | 전용 컴포넌트 | 설명 | |------|-------------|------| @@ -232,16 +287,19 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. | Composite | `PageTransitionBlur` | 전환 블러 효과 | | Composite | `PageSkeleton` | 스켈레톤 UI | -### sirsoft-admin_basic 전용 컴포넌트 (이 템플릿에 없음) +#### sirsoft-admin_basic 전용 컴포넌트 (이 템플릿에 없음) AdminSidebar, AdminHeader, AdminFooter, PageHeader, DataGrid, CodeEditor, DynamicFieldList, MultilingualInput, TagInput, Toggle, RadioGroup, FormField, SlotContainer, FilterGroup 등 관리자 전용 컴포넌트 39개+ +> **정정(#601)**: `SlotContainer` 는 이 템플릿에도 있습니다(`src/components/composite/SlotContainer.tsx`). +> 나머지 항목은 이관 시점 그대로입니다. + --- -## 관련 문서 +### 관련 문서 -- [sirsoft-basic 핸들러](./handlers.md) -- [sirsoft-basic 레이아웃](./layouts.md) -- [sirsoft-admin_basic 컴포넌트](../sirsoft-admin_basic/components.md) -- [컴포넌트 개발 규칙](../../components.md) -- [컴포넌트 Props 레퍼런스](../../component-props.md) +- [sirsoft-basic 핸들러](handlers.md) +- [sirsoft-basic 레이아웃](layouts.md) +- [sirsoft-admin_basic 컴포넌트](../../sirsoft-admin_basic/docs/components.md) +- [컴포넌트 개발 규칙](../../../../docs/frontend/components.md) +- [컴포넌트 Props 레퍼런스](../../../../docs/frontend/component-props.md) diff --git a/templates/_bundled/sirsoft-basic/docs/editor-spec.md b/templates/_bundled/sirsoft-basic/docs/editor-spec.md new file mode 100644 index 00000000..5a691ec5 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/docs/editor-spec.md @@ -0,0 +1,139 @@ +# Basic — 레이아웃 편집기 스펙 + +> 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 · 진입점: [AGENTS.md](../AGENTS.md) + +## 선언 요약 + + +| 항목 | 값 | +|---|---| +| manifest | `templates/_bundled/sirsoft-basic/editor-spec.json` | +| 형태 | 분할 — manifest + `editor-spec/*.json` 13개 블록 | +| 스펙 버전 | `1.0.0` | +| 스타일 시스템 | `tailwind` | +| 다크 모드 전략 | `ancestor-class` | + +> 분할 13블록 · 팔레트 45 · 스타일 컨트롤 173 · 편집 역량 51 · 중첩 컨테이너 14 · 프리뷰 샘플 59 · 엔드포인트 샘플 1 · 페이지 상태 17 · 액션 레시피 20 + + + +사용자 템플릿의 스펙도 관리자 템플릿과 같은 분할 형태입니다. 두 템플릿이 형태를 공유하는 +것은 의도입니다 — 편집기는 어느 템플릿이 활성이든 같은 방식으로 스펙을 읽어야 하고, +템플릿마다 형태가 다르면 편집기가 템플릿별 분기를 갖게 됩니다. + +`다크 모드 전략: ancestor-class` 역시 같습니다. 프리뷰 격리 규칙을 두 템플릿이 공유하므로 +편집기 캔버스의 다크 표현이 템플릿에 따라 달라지지 않습니다. + + +## 선언 블록 + + +| 블록 | 역할 | 항목 수 | 출처 | +|---|---|---|---| +| `componentPalette.entries` | 편집기 "요소 추가" 팔레트에 나타나는 항목 | 45 | `editor-spec/componentPalette.json` | +| `componentPalette.groups` | 팔레트 좌측 목록의 묶음 | 2 | `editor-spec/componentPalette.json` | +| `controls` | 재사용 스타일 컨트롤 정의 | 173 | `editor-spec/controls.json` | +| `componentCapabilities` | 컴포넌트별 편집 역량(어떤 속성을 편집기가 다루는가) | 51 | `editor-spec/componentCapabilities.json` | +| `nesting.draggable` | 캔버스에서 끌어 옮길 수 있는 컴포넌트 | 45 | `editor-spec/nesting.json` | +| `nesting.containers` | 자식을 담을 수 있는 컴포넌트와 그 허용 규칙 | 14 | `editor-spec/nesting.json` | +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 59 | `editor-spec/sampleData.json` | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 1 | `editor-spec/sampleData.json` | +| `sampleGlobal` | `_global.*` 프리뷰 baseline 시드 | 6 | `editor-spec/sampleGlobal.json` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 17 | `editor-spec/states.json` | +| `stateLabels` | 상태값 친화 명칭 카탈로그 | 7 | `editor-spec/stateLabels.json` | +| `actionRecipes` | 친화 명칭 → 액션 JSON 레시피 | 20 | `editor-spec/actionRecipes.json` | +| `conditionRecipes.operators` | 조건 표현식에 쓸 수 있는 연산자 | 37 | `editor-spec/conditionRecipes.json` | +| `computedRecipes` | 계산값 레시피 | 4 | `editor-spec/computedRecipes.json` | +| `errorRecipes` | 오류 처리 레시피 | 7 | `editor-spec/errorRecipes.json` | +| `loadingComponents` | 로딩 표시 컴포넌트 후보 | 2 | `editor-spec/loadingComponents.json` | + + + +블록 16행 중 팔레트가 관리자 템플릿보다 작습니다(45 대 79). 사용자 화면은 관리자 화면보다 +쓰는 컴포넌트가 좁기 때문이고, 이 차이가 곧 두 템플릿이 별개로 존재하는 이유입니다 — +사용자 편집기에 관리자 전용 컴포넌트를 늘어놓으면 운영자가 쓸 수 없는 것을 고르게 됩니다. + +컴포넌트를 추가할 때 "관리자에도 있으니 여기도" 라는 판단은 하지 않습니다. 그 컴포넌트가 +사용자 화면에서 실제로 쓰이는지가 기준입니다. + + +## 컴포넌트 팔레트 + + +| 그룹 | 종류 | 컴포넌트 수 | +|---|---|---| +| 디자인 요소 | `design` | 36 | +| DB 요소 | `data` | 9 | + + + +그룹은 관리자 템플릿과 같은 둘(`디자인 요소` 36 · `DB 요소` 9)입니다. 그룹 체계를 +공유하는 것은 운영자가 두 편집기를 오갈 때 같은 자리에서 같은 종류를 찾게 하기 +위해서입니다. + +팔레트에 **무엇이 보이는가**를 정하는 것은 `groups` 입니다. `entries` 는 그 컴포넌트의 +친화 라벨과 신규 노드 골격(`defaultNode`)을 줄 뿐이라, `entries` 에만 있고 어느 묶음에도 +없는 컴포넌트는 팔레트에 나타나지 않습니다. 반대로 `groups` 에만 있고 `entries` 가 없는 +것은 정상이며, 라벨이 컴포넌트 정의의 설명으로 폴백됩니다. + +지금은 두 수가 우연히 같지만(entries 45 · 그룹 합계 36+9=45), 같아야 한다는 규칙은 없습니다. 컴포넌트를 +추가했는데 팔레트에 안 보인다면 먼저 `groups` 를 봅니다. + + +## 샘플 데이터와 페이지 상태 + + +| 자리 | 역할 | 개수 | ID | +|---|---|---|---| +| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 59 | `mileage_balance` · `mileage_history` · `user` · `userNotifications` · `searchResults` · `profile` · `userProfile` · `addresses` · `userAddresses` · `boardList` · `boards` · `home_boards` … 외 47개 | +| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 1 | `/api/modules/sirsoft-page/pages/*` | +| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 17 | `/login` · `/search` · `/mypage/notifications` · `/mypage/profile/edit` · `/forgot-password` · `/reset-password` · `/identity/challenge` · `/mypage/change-password` · `/mypage/wishlist` · `/mypage/addresses` · `/users/:userId` · `/users/:userId/posts` … 외 5개 | + +_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._ + + + +`byDataSourceId` 59종이 사용자 화면 전반을 덮고, `byEndpointPattern` 1종이 +`sirsoft-page` 의 공개 페이지를 덮습니다. 다른 확장 소유 경로를 이 템플릿이 덮는 것은 +그 화면을 **렌더하는 쪽이 템플릿**이기 때문입니다 — 페이지 모듈은 데이터를 주고, 그리는 +것은 템플릿입니다. + +`states.groups` 17종은 로그인·검색·마이페이지처럼 로그인 여부와 데이터 유무로 화면이 +갈리는 자리입니다. 사용자 화면은 "비어 있는 상태" 가 관리자 화면보다 흔하므로, 새 화면을 +만들 때는 데이터가 있는 경우보다 **없는 경우**를 먼저 상태로 등록합니다. + + +## 수정 시 동반 의무 + + +| 이런 변경을 했다면 | 편집기 스펙에서 함께 할 일 | +|---|---| +| 컴포넌트를 새로 만들었다 | `componentPalette` 에 항목 추가 · `componentCapabilities` 에 편집 역량 선언 · `nesting` 에 담길 자리 규정 | +| 레이아웃에 `data_sources` 를 추가했다 | `sampleData` 에 같은 ID 로 프리뷰 응답 추가 (없으면 편집기 캔버스만 빈 화면) | +| `_global.*` 을 새로 읽는다 | `sampleGlobal` 에 baseline 값 추가 | +| 빈 목록·오류 같은 화면 변종을 추가했다 | `states` 에 변종 추가 · `stateLabels` 에 친화 명칭 | +| 새 액션·조건 패턴을 도입했다 | `actionRecipes` / `conditionRecipes` 에 친화 명칭 등록 | + +편집기 스펙은 JSON 이므로 빌드가 필요 없습니다. 다만 편집기 서빙은 **활성 디렉토리만** 읽으므로(`_bundled` 폴백 없음) 편집 후 반드시 반영합니다: + +```bash +php artisan template:update sirsoft-basic --force +``` + + + +위 표는 "무엇을 함께 고치는가" 만 말합니다. 실제로 놓치는 자리는 **반영 절차**입니다 — +편집기가 읽는 것은 활성 디렉토리이고 `_bundled` 폴백이 없으므로, `_bundled` 에서 스펙을 +고치고 update 커맨드를 돌리지 않으면 편집기에는 **직전 내용이 그대로 보입니다.** 파일은 +고쳤는데 화면이 안 바뀌었다면 거의 이 경우입니다. + +또 하나는 검증 시점입니다. 편집기 스펙은 스키마 검증을 통과해도 "레이아웃이 실제로 쓰는 +ID 와 맞는가" 는 확인해 주지 않습니다. 그 어긋남은 편집기 캔버스에서만 빈 화면으로 +나타나고 실제 화면은 정상이므로, 위 "샘플 데이터와 페이지 상태" 절의 미커버 목록이 유일한 +통로입니다. + +사용자 화면은 모듈이 데이터를 주고 템플릿이 그립니다. 그래서 모듈 레이아웃에 +`data_source` 가 늘었을 때 고칠 자리가 모듈 스펙일 수도, 이 템플릿 스펙일 수도 있습니다. +기준은 그 ID 가 그 모듈만 쓰는가(모듈 스펙), 여러 확장이 함께 쓰는가(템플릿 스펙) +입니다. + diff --git a/templates/_bundled/sirsoft-basic/docs/handlers.md b/templates/_bundled/sirsoft-basic/docs/handlers.md new file mode 100644 index 00000000..0ace4f7d --- /dev/null +++ b/templates/_bundled/sirsoft-basic/docs/handlers.md @@ -0,0 +1,551 @@ +# Basic — 핸들러 + +> 템플릿 전용 핸들러와 부트스트랩 · 진입점: [AGENTS.md](../AGENTS.md) + +## 템플릿 전용 핸들러 + + +핸들러 32개 (정의: `src/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `addSelectedItemIfComplete` | `sirsoft-basic.addSelectedItemIfComplete` | +| `updateSelectedItemQuantity` | `sirsoft-basic.updateSelectedItemQuantity` | +| `removeSelectedItem` | `sirsoft-basic.removeSelectedItem` | +| `updateNoOptionQuantity` | `sirsoft-basic.updateNoOptionQuantity` | +| `setBlockAdditionalOption` | `sirsoft-basic.setBlockAdditionalOption` | +| `getDisplayPrice` | `sirsoft-basic.getDisplayPrice` | +| `formatCurrency` | `sirsoft-basic.formatCurrency` | +| `getCurrencySymbol` | `sirsoft-basic.getCurrencySymbol` | +| `loadPreferredCurrency` | `sirsoft-basic.loadPreferredCurrency` | +| `savePreferredCurrency` | `sirsoft-basic.savePreferredCurrency` | +| `setTheme` | (템플릿 전용 — 네임스페이스 없음) | +| `initTheme` | (템플릿 전용 — 네임스페이스 없음) | +| `redirectToLoginWithReturn` | (템플릿 전용 — 네임스페이스 없음) | +| `downloadAttachment` | (템플릿 전용 — 네임스페이스 없음) | +| `toggleCartItemSelection` | (템플릿 전용 — 네임스페이스 없음) | +| `selectAllCartItems` | (템플릿 전용 — 네임스페이스 없음) | +| `setCartOption` | (템플릿 전용 — 네임스페이스 없음) | +| `openCartDeleteModal` | (템플릿 전용 — 네임스페이스 없음) | +| `openCartOptionModal` | (템플릿 전용 — 네임스페이스 없음) | +| `recalculateCart` | (템플릿 전용 — 네임스페이스 없음) | +| `findMatchingOption` | (템플릿 전용 — 네임스페이스 없음) | +| `initCartOptionSelection` | (템플릿 전용 — 네임스페이스 없음) | +| `initCartKey` | (템플릿 전용 — 네임스페이스 없음) | +| `getCartKey` | (템플릿 전용 — 네임스페이스 없음) | +| `clearCartKey` | (템플릿 전용 — 네임스페이스 없음) | +| `regenerateCartKey` | (템플릿 전용 — 네임스페이스 없음) | +| `saveToStorage` | (템플릿 전용 — 네임스페이스 없음) | +| `loadFromStorage` | (템플릿 전용 — 네임스페이스 없음) | +| `initGuestOrderToken` | (템플릿 전용 — 네임스페이스 없음) | +| `saveGuestOrderToken` | (템플릿 전용 — 네임스페이스 없음) | +| `clearGuestOrderToken` | (템플릿 전용 — 네임스페이스 없음) | +| `clearGuestTokenOnEntry` | (템플릿 전용 — 네임스페이스 없음) | + + + +32개가 **두 종류로 갈립니다** — 네임스페이스를 붙여 등록한 10개(`sirsoft-basic.*`)와 붙이지 +않은 22개. 레이아웃에서 부를 때 전자는 전체 이름을 그대로 써야 하고, 후자는 이름만 씁니다. + +| 무리 | 예 | 무엇을 하는가 | +|---|---|---| +| 상품 옵션 (`sirsoft-basic.*` 5) | `addSelectedItemIfComplete` · `updateSelectedItemQuantity` · `removeSelectedItem` · `updateNoOptionQuantity` · `setBlockAdditionalOption` | 상품 상세에서 옵션 조합이 완성될 때마다 선택 목록을 갱신 | +| 통화 (`sirsoft-basic.*` 5) | `getDisplayPrice` · `formatCurrency` · `getCurrencySymbol` · `loadPreferredCurrency` · `savePreferredCurrency` | 표시 통화 전환과 금액 포맷 | +| 테마 2 | `setTheme` · `initTheme` | 다크/라이트 전환 (관리자 템플릿과 **같은 localStorage 키**를 공유) | +| 장바구니 8 | `toggleCartItemSelection` · `setCartOption` · `recalculateCart` · `findMatchingOption` 등 | 선택·옵션 변경·재계산 | +| 저장소 6 | `initCartKey` · `getCartKey` · `saveToStorage` 등 | 비회원 장바구니 키 관리(localStorage + API 발급) | +| 비회원 주문 4 | `initGuestOrderToken` · `saveGuestOrderToken` · `clearGuestOrderToken` · `clearGuestTokenOnEntry` | 비회원 주문 조회 토큰 보관·폐기 | +| 기타 2 | `redirectToLoginWithReturn` · `downloadAttachment` | 로그인 후 원래 자리 복귀, 첨부 내려받기 | + +**비회원 토큰 4종이 이 템플릿에서 가장 조심스러운 자리입니다.** 그 토큰이 곧 신원이므로 +(서버측 `VerifyGuestOrderToken` 이 그것만 보고 주문을 엽니다), 브라우저에 남아 있으면 다음 +사용자가 남의 주문을 열 수 있습니다. `clearGuestTokenOnEntry` 가 진입 시점에 정리하는 것이 +그 방어입니다. + +`setLocale` 은 이 템플릿의 핸들러가 아닙니다 — 엔진(`ActionDispatcher`) 빌트인이라 등록이 +필요 없습니다. + + +## 부트스트랩 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `src/index.ts` | +| 전역 객체 | **미노출** | +| 재등록 진입점 | `initTemplate()` | + +재등록 진입점이 전역에 고정 이름으로 노출되지 않으면 로케일 전환 후 이 확장의 액션이 전부 무반응이 됩니다 (오류·토스트 없음). + + + +전역 객체가 **미노출**입니다. 모듈·플러그인은 `window.__[Name].initModule/initPlugin` 을 고정 +이름으로 노출해야 하지만(로케일 전환 후 코어가 그것을 다시 부릅니다), 템플릿은 코어가 부트스트랩 +경로를 직접 알고 있어 전역 노출이 필요하지 않습니다. + +`initTemplate()` 이 그 진입점이며 모듈 로드 시점에 스스로 실행됩니다. 하는 일이 셋입니다: + +1. **핸들러 등록** — `handlerMap` 전량을 `ActionDispatcher` 에 올립니다. 개별 등록이 아니라 + 맵을 순회하므로, 핸들러를 추가할 때 등록 코드를 함께 고칠 필요가 없습니다. +2. **IDV launcher 등록** — `window.G7Core.identity.setLauncher()` 로 본인인증 화면을 여는 + 방법을 코어에 알립니다. 이것이 없으면 428 응답을 받은 화면이 인증 창을 띄우지 못합니다. +3. **iOS 판정 보정** — 서버 UA 판정이 놓치는 iPadOS(데스크탑 UA)를 클라이언트 신호로 바로잡아 + `appConfig.isIos` 에 반영합니다. 체크아웃의 애플페이 노출이 이 값을 봅니다. + +**`ActionDispatcher` 가용을 기다리는 재시도 루프**(100ms × 최대 50회)가 들어 있습니다. +`window.load` 이후에 시작하며, 그 안에서 세 작업이 함께 일어납니다. + +레이아웃 편집기 위젯 등록만은 **이 함수 밖**에서 모듈 로드 즉시 실행됩니다. 편집기 URL 을 직접 +하드로드한 경로에서는 `window.load` 게이트를 기다리면 등록이 편집기 셸 마운트보다 늦어 위젯이 +누락되기 때문입니다("Unsupported control"). 진입 경로와 무관하게 결정적이어야 하는 등록은 이 +자리에 둡니다. + +이 템플릿은 `AuthManager.updateConfig()` 를 부르지 않습니다 — 코어 기본값을 그대로 씁니다. +호출이 필요해지면 **템플릿 부트스트랩에서만** 하고(모듈·플러그인에서 부르면 안 됩니다), +`loginPath` 는 `/` 로 시작하는 동일 origin 경로여야 합니다. `//` 로 시작하거나 외부 origin 을 +주면 open redirect 가 됩니다. + + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-basic/handlers.md` 에 있던 원문을 이 문서로 옮긴 것입니다(#601). 이관 시점 그대로 +> 보존하되, **코드가 SSoT 인 값과 어긋나는 부분에는 정정 주석**을 달았습니다. + +### sirsoft-basic 핸들러 + +> **템플릿 식별자**: `sirsoft-basic` (type: user) +> **관련 문서**: [액션 핸들러 개요](../../../../docs/frontend/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 로드 → 상태에 설정 | + +--- + +### 핸들러 등록 맵 + +> **정정(#601)**: 아래 표는 이관 시점 값(23개)입니다. 코드 실측은 **32개**이며 위 「템플릿 전용 +> 핸들러」 블록이 SSoT 입니다. 아래 표에 없는 9개는 +> `sirsoft-basic.removeSelectedItem` · `sirsoft-basic.updateNoOptionQuantity` · +> `sirsoft-basic.setBlockAdditionalOption` · `redirectToLoginWithReturn` · +> `downloadAttachment` · `initGuestOrderToken` · `saveGuestOrderToken` · +> `clearGuestOrderToken` · `clearGuestTokenOnEntry` 입니다. + +**소스**: `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 참조 +``` + +--- + +### 관련 문서 + +- [액션 핸들러 개요](../../../../docs/frontend/actions-handlers.md) +- [sirsoft-basic 컴포넌트](components.md) +- [sirsoft-basic 레이아웃](layouts.md) +- [sirsoft-admin_basic 핸들러](../../sirsoft-admin_basic/docs/handlers.md) diff --git a/docs/frontend/templates/sirsoft-basic/layouts.md b/templates/_bundled/sirsoft-basic/docs/layouts.md similarity index 59% rename from docs/frontend/templates/sirsoft-basic/layouts.md rename to templates/_bundled/sirsoft-basic/docs/layouts.md index d9d1ec57..b1f46c14 100644 --- a/docs/frontend/templates/sirsoft-basic/layouts.md +++ b/templates/_bundled/sirsoft-basic/docs/layouts.md @@ -1,11 +1,315 @@ -# sirsoft-basic 레이아웃 +# Basic — 레이아웃 + +> 레이아웃 목록과 라우트 매핑 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 목록 + + +레이아웃 166개 (루트: `layouts`). + +| 그룹 | 개수 | +|---|---| +| `(root)` | 2개 | +| `auth` | 5개 | +| `board` | 5개 | +| `errors` | 6개 | +| `mypage` | 11개 | +| `page` | 1개 | +| `partials` | 124개 | +| `search` | 1개 | +| `shop` | 9개 | +| `users` | 2개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `_user_base` | `(root)` | partial | - | +| `forgot_password` | `auth` | 화면 | `_user_base` | +| `identity_challenge` | `auth` | 화면 | `_user_base` | +| `login` | `auth` | 화면 | `_user_base` | +| `register` | `auth` | 화면 | `_user_base` | +| `reset_password` | `auth` | 화면 | `_user_base` | +| `boards` | `board` | 화면 | `_user_base` | +| `form` | `board` | 화면 | `_user_base` | +| `index` | `board` | 화면 | `_user_base` | +| `popular` | `board` | 화면 | `_user_base` | +| `show` | `board` | 화면 | `_user_base` | +| `401` | `errors` | 화면 | `_user_base` | +| `403` | `errors` | 화면 | `_user_base` | +| `404` | `errors` | 화면 | `_user_base` | +| `500` | `errors` | 화면 | `_user_base` | +| `503` | `errors` | 화면 | `_user_base` | +| `maintenance` | `errors` | 화면 | `_user_base` | +| `home` | `(root)` | 화면 | `_user_base` | +| `addresses` | `mypage` | 화면 | `_user_base` | +| `board` | `mypage` | 화면 | `_user_base` | +| `change-password` | `mypage` | 화면 | `_user_base` | +| `inquiries` | `mypage` | 화면 | `_user_base` | +| `mileage` | `mypage` | 화면 | `_user_base` | +| `notifications` | `mypage` | 화면 | `_user_base` | +| `orders` | `mypage` | 화면 | `_user_base` | +| `show` | `mypage` | 화면 | `_user_base` | +| `profile-edit` | `mypage` | 화면 | `_user_base` | +| `profile` | `mypage` | 화면 | `_user_base` | +| `wishlist` | `mypage` | 화면 | `_user_base` | +| `show` | `page` | 화면 | `_user_base` | +| `_identity_challenge_modal` | `partials` | partial | - | +| `_modal_notification_delete_all_confirm` | `partials` | partial | - | +| `_login_form` | `partials` | partial | - | +| `_modal_privacy` | `partials` | partial | - | +| `_modal_terms` | `partials` | partial | - | +| `_redirect_if_logged_in` | `partials` | partial | - | +| `_register_form` | `partials` | partial | - | +| `_board_card` | `partials` | partial | - | +| `_parent_post` | `partials` | partial | - | +| `_password_verify_modal` | `partials` | partial | - | +| `_post_form` | `partials` | partial | - | +| `_type_renderer` | `partials` | partial | - | +| `_admin_links` | `partials` | partial | - | +| `_empty_states` | `partials` | partial | - | +| `_loading_error` | `partials` | partial | - | +| `_type_renderer` | `partials` | partial | - | +| `_write_button` | `partials` | partial | - | +| `_popular_item` | `partials` | partial | - | +| `_popular_list` | `partials` | partial | - | +| `_comment_input` | `partials` | partial | - | +| `_comment_item` | `partials` | partial | - | +| `_comment_section` | `partials` | partial | - | +| `_navigation` | `partials` | partial | - | +| `_post_attachments` | `partials` | partial | - | +| `_reply_section` | `partials` | partial | - | +| `_type_renderer` | `partials` | partial | - | +| `_modal_delete` | `partials` | partial | - | +| `_modal_report` | `partials` | partial | - | +| `_password_verify_modal` | `partials` | partial | - | +| `form` | `partials` | 화면 | - | +| `index` | `partials` | 화면 | - | +| `show` | `partials` | 화면 | - | +| `index` | `partials` | 화면 | - | +| `index` | `partials` | 화면 | - | +| `_currency_selector` | `partials` | partial | - | +| `_board_summary` | `partials` | partial | - | +| `_community_guide` | `partials` | partial | - | +| `_popular_boards` | `partials` | partial | - | +| `_recent_posts` | `partials` | partial | - | +| `_shop_promo` | `partials` | partial | - | +| `_stat_card_boards` | `partials` | partial | - | +| `_stat_card_comments` | `partials` | partial | - | +| `_stat_card_posts` | `partials` | partial | - | +| `_stat_card_users` | `partials` | partial | - | +| `_welcome_card` | `partials` | partial | - | +| `_tab_navigation` | `partials` | partial | - | +| `_list` | `partials` | partial | - | +| `_modal_address` | `partials` | partial | - | +| `_modal_confirm_delete` | `partials` | partial | - | +| `_modal_confirm_overwrite` | `partials` | partial | - | +| `_list` | `partials` | partial | - | +| `_my_comments` | `partials` | partial | - | +| `_my_posts` | `partials` | partial | - | +| `_list` | `partials` | partial | - | +| `_list` | `partials` | partial | - | +| `_list` | `partials` | partial | - | +| `_history` | `partials` | partial | - | +| `_items` | `partials` | partial | - | +| `_list` | `partials` | partial | - | +| `_modal_cancel` | `partials` | partial | - | +| `_modal_change_address` | `partials` | partial | - | +| `_modal_confirm_purchase` | `partials` | partial | - | +| `_modal_write_review` | `partials` | partial | - | +| `_orderer` | `partials` | partial | - | +| `_payment` | `partials` | partial | - | +| `_shipping` | `partials` | partial | - | +| `_status_header` | `partials` | partial | - | +| `_edit` | `partials` | partial | - | +| `_modal_withdraw` | `partials` | partial | - | +| `_password_verify_section` | `partials` | partial | - | +| `_view` | `partials` | partial | - | +| `_list` | `partials` | partial | - | +| `_search_filters` | `partials` | partial | - | +| `_search_input` | `partials` | partial | - | +| `_search_results` | `partials` | partial | - | +| `_search_states` | `partials` | partial | - | +| `_search_tabs` | `partials` | partial | - | +| `_section` | `partials` | partial | - | +| `_list` | `partials` | partial | - | +| `_section` | `partials` | partial | - | +| `_item_card` | `partials` | partial | - | +| `_item_list` | `partials` | partial | - | +| `_section` | `partials` | partial | - | +| `_cart_item` | `partials` | partial | - | +| `_cart_list` | `partials` | partial | - | +| `_cart_summary` | `partials` | partial | - | +| `_checkout_discount` | `partials` | partial | - | +| `_checkout_items` | `partials` | partial | - | +| `_checkout_mileage` | `partials` | partial | - | +| `_checkout_orderer` | `partials` | partial | - | +| `_checkout_payment` | `partials` | partial | - | +| `_checkout_shipping` | `partials` | partial | - | +| `_checkout_summary` | `partials` | partial | - | +| `_modal_address_manage` | `partials` | partial | - | +| `_modal_cart_delete_confirm` | `partials` | partial | - | +| `_modal_cart_option_change` | `partials` | partial | - | +| `_modal_cart_unavailable` | `partials` | partial | - | +| `_modal_coupon_download` | `partials` | partial | - | +| `_modal_exclusive_coupon_confirm` | `partials` | partial | - | +| `_modal_temp_order_not_found` | `partials` | partial | - | +| `_product_purchase_card` | `partials` | partial | - | +| `_admin_edit_link` | `partials` | partial | - | +| `_header` | `partials` | partial | - | +| `_info_summary` | `partials` | partial | - | +| `_modal_cart_added` | `partials` | partial | - | +| `_modal_coupon_download_confirm` | `partials` | partial | - | +| `_modal_inquiry_delete` | `partials` | partial | - | +| `_modal_login_required` | `partials` | partial | - | +| `_modal_qna_reply` | `partials` | partial | - | +| `_modal_qna_write` | `partials` | partial | - | +| `_modal_review_image` | `partials` | partial | - | +| `_price_mobile` | `partials` | partial | - | +| `_purchase_card` | `partials` | partial | - | +| `_review_avatar` | `partials` | partial | - | +| `_tab_detail` | `partials` | partial | - | +| `_tab_qna` | `partials` | partial | - | +| `_tab_reviews` | `partials` | partial | - | +| `_category_breadcrumb` | `partials` | partial | - | +| `_category_filter` | `partials` | partial | - | +| `_new_products` | `partials` | partial | - | +| `_popular_products` | `partials` | partial | - | +| `_product_grid` | `partials` | partial | - | +| `_recent_products` | `partials` | partial | - | +| `_search_filter_bar` | `partials` | partial | - | +| `index` | `search` | 화면 | `_user_base` | +| `cart` | `shop` | 화면 | `_user_base` | +| `category` | `shop` | 화면 | `_user_base` | +| `checkout` | `shop` | 화면 | `_user_base` | +| `guest_order_form` | `shop` | 화면 | `_user_base` | +| `guest_order_show` | `shop` | 화면 | `_user_base` | +| `index` | `shop` | 화면 | `_user_base` | +| `order_complete` | `shop` | 화면 | `_user_base` | +| `reorder` | `shop` | 화면 | `_user_base` | +| `show` | `shop` | 화면 | `_user_base` | +| `posts` | `users` | 화면 | `_user_base` | +| `show` | `users` | 화면 | `_user_base` | + + + +166개 중 **124개가 partial** 입니다(화면 42). 이 비율이 이 템플릿의 구조를 그대로 보여줍니다 — +화면 하나가 여러 조각으로 나뉘어 있고, 조각은 여러 화면이 공유합니다. 화면 하나를 고칠 때는 +그 화면 이름의 partials 디렉토리를 함께 열어야 전체가 보입니다. + +그룹은 방문자 여정과 1:1 입니다 — `auth`(로그인·가입·비밀번호·본인인증) · `board`(게시판) · +`shop`(상점) · `mypage`(마이페이지) · `page`(단일 문서) · `search` · `users` · `errors`. +`(root)` 둘은 `_user_base`(모든 화면의 베이스)와 `home` 입니다. + +**모든 화면이 `_user_base` 를 상속합니다.** 헤더·푸터·모바일 네비·토스트·모달 호스트가 거기 +있으므로, `extends` 없이 독립 레이아웃을 새로 만들면 그 화면에서는 `toast` 나 `openModal` 이 +성공으로 기록되지만 **화면에는 아무것도 나타나지 않습니다**(호스트 컴포넌트가 마운트되지 않아서). +새 화면은 특별한 이유가 없는 한 `_user_base` 를 상속합니다. + +`errors/` 6종(401·403·404·500·503·maintenance)은 코어가 오류 상황에서 직접 부릅니다. 401 은 +**로그인 리다이렉트를 여기서 구현하지 않습니다** — 코어 `TemplateApp.showRouteError` 가드가 +처리하므로, 이 레이아웃에서 다시 이동시키면 이중 리다이렉트가 됩니다. + + +## 라우트 매핑 + + +| 경로 | 레이아웃 | 이름 | +|---|---|---| +| `/` | `home` | - | +| `/login` | `auth/login` | - | +| `/register` | `auth/register` | - | +| `/forgot-password` | `auth/forgot_password` | - | +| `/reset-password` | `auth/reset_password` | - | +| `/identity/challenge` | `auth/identity_challenge` | - | +| `/boards` | `board/boards` | - | +| `/boards/popular` | `board/popular` | - | +| `/board/:slug` | `board/index` | - | +| `/board/:slug/write` | `board/form` | - | +| `/board/:slug/:id` | `board/show` | - | +| `/board/:slug/:id/edit` | `board/form` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/products` | `shop/index` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/category/:slug` | `shop/category` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/products/:product_code` | `shop/show` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/cart` | `shop/cart` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/checkout` | `shop/checkout` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/orders/:id/complete` | `shop/order_complete` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/guest/orders` | `shop/guest_order_form` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/guest/orders/:order_number` | `shop/guest_order_show` | - | +| `/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/reorder/:id` | `shop/reorder` | - | +| `/mypage` | `-` | - | +| `/mypage/profile` | `mypage/profile` | - | +| `/mypage/profile/edit` | `mypage/profile-edit` | - | +| `/mypage/change-password` | `mypage/change-password` | - | +| `/mypage/orders` | `mypage/orders` | - | +| `/mypage/orders/:order_number` | `mypage/orders/show` | - | +| `/mypage/mileage` | `mypage/mileage` | - | +| `/mypage/wishlist` | `mypage/wishlist` | - | +| `/mypage/addresses` | `mypage/addresses` | - | +| `/mypage/notifications` | `mypage/notifications` | - | +| `/mypage/board` | `mypage/board` | - | +| `/mypage/inquiries` | `mypage/inquiries` | - | +| `/users/:userId` | `users/show` | - | +| `/users/:userId/posts` | `users/posts` | - | +| `/page/:slug` | `page/show` | - | +| `/search` | `search/index` | - | +| `/404` | `errors/404` | - | +| `/403` | `errors/403` | - | +| `/500` | `errors/500` | - | + + + +상점 경로 9개만 **표현식**입니다. 운영자가 이커머스 환경설정에서 상점 경로(`route_path`, +기본 `shop`)를 바꾸거나 아예 루트로 두면(`no_route`), 그 설정이 라우트 문자열에 그대로 +반영됩니다 — `routes.json` 이 정적 파일이 아니라 **표현식을 담을 수 있다**는 것이 이 템플릿의 +전제입니다. + +그래서 상점 라우트를 손볼 때는 세 경우를 함께 생각해야 합니다: 기본(`/shop/...`) · 운영자 +지정 경로(`/store/...`) · 루트 배치(`/products`). 문자열을 하드코딩하면 뒤의 둘이 조용히 +깨집니다. + +`/mypage` 자체는 레이아웃이 `-` 입니다 — 진입하면 하위 탭 중 하나로 넘기는 자리이며 자기 +화면을 갖지 않습니다. + +라우트를 바꾸면 `php artisan template:update sirsoft-basic --force` 로 반영합니다. 빌드는 +필요 없습니다. + + +## 확장 오버라이드 + + +| 대상 | 설명 | +|---|---| +| `extensions/sirsoft-daum_postcode/user-address-search.json` | 모듈/플러그인 확장 조각을 대체하는 오버라이드 | + + + +오버라이드는 **확장이 제공한 조각을 이 템플릿의 것으로 갈아 끼우는** 장치입니다. 지금은 하나 +있습니다 — `sirsoft-daum_postcode` 의 주소 검색 조각. + +플러그인이 제공하는 원본 조각은 이커머스 관리자 화면에 맞춰져 있어, 방문자 화면의 배송지 입력 +디자인과 어긋납니다. 조각을 고치는 대신 **템플릿이 자기 버전을 얹는** 것이 이 방향입니다 — +플러그인을 업데이트해도 이 오버라이드는 남고, 다른 템플릿은 원본을 그대로 씁니다. + +그 대가로 **원본이 바뀌면 이 사본은 따라가지 않습니다.** 플러그인이 조각의 동작(핸들러 이름· +필드 계약)을 바꾸면 오버라이드만 옛 계약을 붙들고 있게 되고, 증상은 "주소 검색 버튼이 +무반응" 으로만 나타납니다. 해당 플러그인을 업그레이드한 뒤에는 이 오버라이드를 함께 확인합니다. + + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-basic/layouts.md` 에 있던 원문을 이 문서로 옮긴 것입니다(#601). 이관 시점 그대로 +> 보존하되, **코드가 SSoT 인 값과 어긋나는 부분에는 정정 주석**을 달았습니다. + +### sirsoft-basic 레이아웃 > **템플릿 식별자**: `sirsoft-basic` (type: user) -> **관련 문서**: [컴포넌트](./components.md) | [핸들러](./handlers.md) | [레이아웃 JSON 스키마](../../layout-json.md) +> **관련 문서**: [컴포넌트](components.md) | [핸들러](handlers.md) | [레이아웃 JSON 스키마](../../../../docs/frontend/layout-json.md) + +> **정정(#601)**: 아래 페이지 맵·개수는 이관 시점 값입니다. 실측 총계와 그룹별 분포는 위 +> 「레이아웃 목록」 블록이 SSoT 이며, 이관 이후 추가·삭제된 레이아웃이 있을 수 있습니다. --- -## TL;DR (5초 요약) +### TL;DR (5초 요약) ```text 1. 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) @@ -17,7 +321,7 @@ --- -## 목차 +### 목차 1. [페이지 맵 (트리 구조)](#페이지-맵-트리-구조) 2. [카테고리별 가이드](#카테고리별-가이드) @@ -31,7 +335,7 @@ --- -## 페이지 맵 (트리 구조) +### 페이지 맵 (트리 구조) ```text _user_base.json (베이스 레이아웃) @@ -238,9 +542,9 @@ _user_base.json (베이스 레이아웃) --- -## 카테고리별 가이드 +### 카테고리별 가이드 -### 인증 페이지 패턴 +#### 인증 페이지 패턴 **대표**: `auth/login.json`, `auth/register.json` @@ -270,7 +574,7 @@ slots.content: --- -### 게시판 패턴 +#### 게시판 패턴 **대표**: `board/index.json`, `board/show.json`, `board/form.json` @@ -357,11 +661,11 @@ form.json: --- -### 쇼핑몰 패턴 +#### 쇼핑몰 패턴 **대표**: `shop/index.json`, `shop/show.json`, `shop/cart.json`, `shop/checkout.json` -#### 상품 목록 (shop/index.json) +##### 상품 목록 (shop/index.json) ```text extends: _user_base @@ -378,7 +682,7 @@ slots.content: └── Pagination ``` -#### 상품 상세 (shop/show.json) +##### 상품 상세 (shop/show.json) ```text extends: _user_base @@ -409,7 +713,7 @@ slots.content: - `apiCall` — 장바구니 추가, 리뷰 작성, 위시리스트 토글 - `saveToLocalStorage` — 최근 본 상품 저장 -#### 장바구니 (shop/cart.json) +##### 장바구니 (shop/cart.json) ```text extends: _user_base @@ -432,7 +736,7 @@ slots.content: - `state` 섹션으로 모달/선택 상태 관리 - 장바구니 핸들러: `toggleCartItemSelection`, `selectAllCartItems`, `recalculateCart` -#### 결제 (shop/checkout.json) +##### 결제 (shop/checkout.json) ```text extends: _user_base @@ -455,7 +759,7 @@ slots.content: --- -### 마이페이지 패턴 +#### 마이페이지 패턴 **대표**: `mypage/profile.json`, `mypage/orders.json` @@ -496,7 +800,7 @@ slots.content: --- -### 검색 패턴 +#### 검색 패턴 **대표**: `search/index.json` @@ -532,9 +836,9 @@ slots.content: --- -### 기타 페이지 패턴 +#### 기타 페이지 패턴 -#### 사용자 프로필 (users/show.json, users/posts.json) +##### 사용자 프로필 (users/show.json, users/posts.json) ```text extends: _user_base @@ -545,7 +849,7 @@ slots.content: └── 게시글 목록 (users/posts.json) ``` -#### 정적 페이지 (page/show.json) +##### 정적 페이지 (page/show.json) ```text extends: _user_base @@ -555,7 +859,7 @@ slots.content: └── HtmlContent (본문 렌더링) ``` -#### 홈 (home.json) +##### 홈 (home.json) ```text extends: _user_base @@ -578,7 +882,7 @@ slots.content: --- -### 에러 페이지 패턴 +#### 에러 페이지 패턴 **대표**: `errors/404.json` @@ -598,9 +902,9 @@ components: --- -## 베이스 레이아웃 구조 +### 베이스 레이아웃 구조 -### _user_base.json +#### _user_base.json 모든 사용자 페이지의 공통 구조를 정의합니다. @@ -638,10 +942,10 @@ _user_base.json --- -## 관련 문서 +### 관련 문서 -- [sirsoft-basic 컴포넌트](./components.md) -- [sirsoft-basic 핸들러](./handlers.md) -- [sirsoft-admin_basic 레이아웃](../sirsoft-admin_basic/layouts.md) -- [레이아웃 JSON 스키마](../../layout-json.md) -- [레이아웃 상속](../../layout-json-inheritance.md) +- [sirsoft-basic 컴포넌트](components.md) +- [sirsoft-basic 핸들러](handlers.md) +- [sirsoft-admin_basic 레이아웃](../../sirsoft-admin_basic/docs/layouts.md) +- [레이아웃 JSON 스키마](../../../../docs/frontend/layout-json.md) +- [레이아웃 상속](../../../../docs/frontend/layout-json-inheritance.md) diff --git a/templates/_bundled/sirsoft-basic/editor-spec.json b/templates/_bundled/sirsoft-basic/editor-spec.json index bbedd196..45412abf 100644 --- a/templates/_bundled/sirsoft-basic/editor-spec.json +++ b/templates/_bundled/sirsoft-basic/editor-spec.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "templateId": "sirsoft-basic", "version": "1.0.0", - "description": "레이아웃 편집기 스펙 — Phase 3 (nesting + componentPalette 블록). controls/componentCapabilities/actionRecipes 등은 Phase 4/5 에서 추가.", + "description": "사용자 템플릿 레이아웃 편집기 스펙 — 컴포넌트 팔레트·스타일 컨트롤·편집 역량·중첩 규칙과 사용자 화면 프리뷰 샘플.", "styleSystem": "tailwind", "darkMode": { "comment": "Tailwind 다크는 조상.dark 클래스. 편집기 프리뷰는 관리자 admin 의 html.dark 조상과 독립적으로 라이트/다크를 보여줘야 하므로, 코어 CSS 서빙 API 가 편집기용 CSS 의.dark 셀렉터를 프리뷰 전용 마커(.g7le-preview-dark)로 치환해 서빙한다. 사용자 페이지 CSS 는 원본 그대로.", diff --git a/tests/Feature/Documentation/ExtensionDocContractTest.php b/tests/Feature/Documentation/ExtensionDocContractTest.php new file mode 100644 index 00000000..ee6e095b --- /dev/null +++ b/tests/Feature/Documentation/ExtensionDocContractTest.php @@ -0,0 +1,1344 @@ + + */ + private const DOC_BACKLOG = [ + ]; + + /** + * 집필 백로그의 상한 (S1 종료 시점의 번들 확장 수). + * + * "추가 금지" 를 주석으로만 적으면 강제되지 않습니다 — 줄 하나를 보태면 문서 없는 확장이 + * 조용히 통과하고 아무 테스트도 red 가 되지 않습니다. 상한을 상수로 고정해, 백로그를 + * 늘리려면 이 숫자를 올리는 **눈에 보이는 행위**를 거치게 합니다. 집필이 진행되면 이 + * 숫자도 함께 내려갑니다 (백로그는 줄어들기만 하므로 상한도 단조 감소한다). + */ + private const DOC_BACKLOG_CEILING = 0; + + /** + * `_bundled` 스캔이 번들 확장 전수를 발견하는지 확인합니다. + */ + public function test_inventory_discovers_every_bundled_extension(): void + { + $records = (new ExtensionInventory)->collect('all'); + + $this->assertNotEmpty($records, '번들 확장을 하나도 발견하지 못했습니다.'); + + foreach ($records as $record) { + $this->assertDirectoryExists($record['path']); + $this->assertFileExists($record['manifestPath']); + $this->assertNotSame('', $record['id']); + $this->assertContains($record['type'], ExtensionInventory::types()); + } + + // 디스크의 manifest 개수와 스캔 결과가 일치해야 한다 (조용한 누락 금지). + $onDisk = 0; + foreach ([['modules', 'module.json'], ['plugins', 'plugin.json'], ['templates', 'template.json']] as [$dir, $manifest]) { + $root = base_path($dir.'/_bundled'); + if (! is_dir($root)) { + continue; + } + $onDisk += count(glob($root.'/*/'.$manifest) ?: []); + } + + $this->assertSame($onDisk, count($records), '스캔 결과가 디스크의 manifest 수와 다릅니다.'); + } + + /** + * 백로그에 없는 확장은 필수 문서를 갖추고 있어야 합니다. + * + * 백로그 자신도 검사 대상입니다 — 실재하지 않는 확장이 남아 있으면 목록이 낡은 것이고, + * 문서를 갖춘 확장이 남아 있으면 지워야 할 줄을 지우지 않은 것입니다. + */ + public function test_documented_extensions_have_every_required_document(): void + { + $records = (new ExtensionInventory)->collect('all'); + $backlog = array_flip(self::DOC_BACKLOG); + $ids = []; + $missing = []; + $staleBacklog = []; + + foreach ($records as $record) { + $key = $record['type'].'/'.$record['id']; + $ids[$key] = true; + + $present = []; + $absent = []; + + foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) { + $abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc); + if (is_file($abs)) { + $present[] = $doc; + } else { + $absent[] = $doc; + } + } + + if (isset($backlog[$key])) { + if ($absent === []) { + $staleBacklog[] = $key.' — 문서가 완비되었으니 DOC_BACKLOG 에서 지우세요'; + } + + continue; + } + + foreach ($absent as $doc) { + $missing[] = $key.' → '.$doc; + } + } + + foreach (array_keys($backlog) as $key) { + if (! isset($ids[$key])) { + $staleBacklog[] = $key.' — 존재하지 않는 확장입니다 (DOC_BACKLOG 에서 지우세요)'; + } + } + + $this->assertSame([], $missing, "필수 문서 누락:\n".implode("\n", $missing)); + $this->assertSame([], $staleBacklog, "DOC_BACKLOG 가 낡았습니다:\n".implode("\n", $staleBacklog)); + + // 백로그는 줄어들기만 한다 — 상한을 **정확히** 일치시켜 래칫으로 만든다. + // + // `<=` 로 두면 항목 하나를 집필해 지우는 순간(20 → 19) 상한 20 아래에 빈자리가 + // 하나 영구히 열린다. 그 다음부터는 문서 없는 확장을 백로그에 얹어도 red 가 되지 + // 않는다 — 막으려던 것이 첫 집필과 동시에 되살아난다. `===` 는 백로그에서 한 줄을 + // 지울 때 상한도 함께 내리게 강제하고, 늘리려면 그 숫자를 올리는 행위가 diff 에 남는다. + $this->assertSame( + self::DOC_BACKLOG_CEILING, + count(self::DOC_BACKLOG), + 'DOC_BACKLOG 와 DOC_BACKLOG_CEILING 이 어긋났습니다. 확장을 집필해 백로그에서 ' + .'지웠다면 상한도 같은 수만큼 내리세요. 늘려야 할 근거가 정말 있다면 상한을 ' + .'올리고 그 사유를 남기세요 — 그것이 "문서 없는 확장을 새로 들인다" 는 선언입니다.', + ); + + $this->assertSame( + array_values(array_unique(self::DOC_BACKLOG)), + array_values(self::DOC_BACKLOG), + 'DOC_BACKLOG 에 중복 항목이 있습니다 (상한 판정이 왜곡됩니다).', + ); + } + + /** + * 작성된 문서는 필수 섹션과 자동 생성 블록을 갖추고 있어야 합니다. + * + * 문서를 만들다 만 상태(헤딩만 있고 블록이 없거나 그 반대)를 잡습니다. + * + * 백로그 확장은 제외합니다 — 표준 골격 이전에 손으로 쓰인 README 5개가 그 상태이며, + * 재정비는 집필 단계의 작업입니다. 백로그에서 빠지는 순간 이 검사가 전면 적용됩니다. + */ + public function test_existing_documents_have_required_sections_and_blocks(): void + { + $records = (new ExtensionInventory)->collect('all'); + $backlog = array_flip(self::DOC_BACKLOG); + $problems = []; + + foreach ($records as $record) { + if (isset($backlog[$record['type'].'/'.$record['id']])) { + continue; + } + + foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) { + $abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc); + if (! is_file($abs)) { + continue; + } + + $content = (string) file_get_contents($abs); + $meta = ExtensionDocScaffolder::DOCUMENTS[$doc]; + $label = $record['type'].'/'.$record['id'].' → '.$doc; + + foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) { + if (! ExtensionDocScaffolder::hasSection($content, $section)) { + $problems[] = $label.' : 필수 섹션 없음 — '.$section; + } + } + + $present = ExtensionDocScaffolder::presentBlockKeys($content); + foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) { + if (! in_array($block, $present, true)) { + $problems[] = $label.' : 자동 생성 블록 없음 — '.$block; + } + } + } + } + + $this->assertSame([], $problems, "문서 골격 위반:\n".implode("\n", $problems)); + } + + /** + * 자동 생성 블록의 훅 목록이 소스 실측과 일치해야 합니다 (문서 부패 검출). + * + * 훅은 다른 확장이 잡는 계약이므로, 문서와 코드가 어긋나면 그 확장이 잡을 수 없는 + * 훅 이름을 문서가 광고하게 됩니다. + */ + public function test_generated_blocks_match_measured_source(): void + { + $inventory = new ExtensionInventory; + $scaffolder = new ExtensionDocScaffolder; + $drifted = []; + + foreach ($inventory->collect('all') as $record) { + $documents = array_filter( + ExtensionDocScaffolder::documentsForType($record['type']), + fn (string $doc): bool => is_file($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc)), + ); + + if ($documents === []) { + continue; + } + + $bodies = $scaffolder->renderBlocks($this->contextFor($record, $inventory)); + + foreach ($documents as $doc) { + $abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc); + $content = (string) file_get_contents($abs); + + $subject = []; + foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) { + if (isset($bodies[$block])) { + $subject[$block] = $bodies[$block]; + } + } + + $merged = ExtensionDocScaffolder::replaceBlocks($content, $subject); + + if (! $merged['unchanged']) { + $drifted[] = $record['type'].'/'.$record['id'].' → '.$doc; + } + } + } + + $this->assertSame( + [], + $drifted, + "자동 생성 블록이 코드 실측과 어긋납니다 (`php artisan ext:docgen` 재실행 필요):\n".implode("\n", $drifted), + ); + } + + /** + * 드리프트 판정식 자체가 살아 있어야 합니다 (공허 통과 방지). + * + * 위 검사는 **문서를 가진 확장**만 대상으로 하는데, 표준 골격으로 전환된 확장이 아직 + * 없어 실질 대상이 0건입니다. 그래서 판정식이 깨져도 계속 초록입니다 — "드리프트 없음" + * 과 "드리프트를 보지 않음" 이 구분되지 않습니다. 낡은 훅 표를 심은 합성 문서로 판정식이 + * 실제로 드리프트를 잡는지 고정합니다. + */ + public function test_drift_detection_actually_detects_drift(): void + { + $stale = "# 확장점\n\n" + .ExtensionDocScaffolder::wrap('hooks-published', '| 훅 이름 | 유형 |\n|---|---|\n| `옛.훅.이름` | action |') + ."\n"; + + $fresh = '| 훅 이름 | 유형 |'."\n".'|---|---|'."\n".'| `새.훅.이름` | action |'; + + $drifted = ExtensionDocScaffolder::replaceBlocks($stale, ['hooks-published' => $fresh]); + + $this->assertFalse( + $drifted['unchanged'], + '블록 본문이 실측과 다른데 드리프트로 판정되지 않았습니다 — 판정식이 죽어 있습니다.', + ); + $this->assertStringContainsString('새.훅.이름', $drifted['content']); + $this->assertStringNotContainsString('옛.훅.이름', $drifted['content']); + + // 같은 본문이면 드리프트가 아니어야 한다 (거짓 양성 금지). + $same = ExtensionDocScaffolder::replaceBlocks($drifted['content'], ['hooks-published' => $fresh]); + $this->assertTrue($same['unchanged'], '동일 본문인데 드리프트로 판정했습니다.'); + } + + /** + * 훅 이름을 조립해 발행하는 확장도 발행 훅 표에 실려야 합니다. + * + * 리터럴 스캔만으로는 `self::PLUGIN_ID.'.consent.granted'` 형태를 읽지 못해, 훅을 12곳에서 + * 발행하는 확장이 "훅을 발행하지 않습니다" 로 문서화됩니다. 그래서 확장이 `getHooks()` 로 + * 선언한 목록이 1차 출처입니다 — 이 계약이 깨지면 그 확장의 확장점이 통째로 비공개가 되고, + * 문서는 사실이 아닌 문장을 광고합니다. + */ + public function test_declared_hooks_reach_the_published_table(): void + { + $inventory = new ExtensionInventory; + $scaffolder = new ExtensionDocScaffolder; + + $records = array_values(array_filter( + $inventory->collect('plugin:sirsoft-gdpr'), + fn (array $r): bool => $r['id'] === 'sirsoft-gdpr', + )); + + $this->assertNotEmpty($records, 'sirsoft-gdpr 를 찾지 못했습니다.'); + + $ctx = $this->contextFor($records[0], $inventory); + + $this->assertGreaterThan( + 0, + $ctx['hooks']['publishedSites'], + '이 확장은 훅 발행 호출을 갖고 있어야 합니다 (전제 확인).', + ); + $this->assertNotSame( + [], + $ctx['hooks']['published'], + '발행 호출이 있는데 발행 훅이 0종입니다 — 선언이 반영되지 않았습니다.', + ); + + $block = $scaffolder->renderBlocks($ctx)['hooks-published']; + + $this->assertStringNotContainsString( + '훅을 발행하지 않습니다', + $block, + '훅을 발행하는 확장에 "발행하지 않습니다" 가 렌더되었습니다.', + ); + + foreach ($ctx['hooks']['published'] as $hook) { + $this->assertStringContainsString( + $hook['name'], + $block, + "선언된 훅 '{$hook['name']}' 이 발행 훅 표에 없습니다.", + ); + } + } + + /** + * 자동 생성 블록 교체가 블록 밖 텍스트를 손상하지 않아야 합니다. + * + * 이 계약이 깨지면 사람이 쓴 서술이 재생성 때마다 소실됩니다 — `api:docgen` 이 과거 + * 34,000줄을 날린 사고가 그 형태였고, 그래서 이 생성기에는 파괴적 재생성 플래그가 없습니다. + */ + public function test_block_replacement_preserves_human_text(): void + { + $human = [ + 'intro' => '사람이 쓴 서론 — 생성기가 건드리면 안 된다.', + 'intent' => '사람이 쓴 의도 서술 — 블록 사이에 있어도 보존되어야 한다.', + 'outro' => '사람이 쓴 마지막 문단.', + ]; + + $document = "# 제목\n\n{$human['intro']}\n\n" + .ExtensionDocScaffolder::wrap('models', "| 옛 |\n|---|\n| 표 |')") + ."\n\n\n{$human['intent']}\n\n\n" + .ExtensionDocScaffolder::wrap('tables', '옛 테이블 표') + ."\n\n## 마무리\n\n{$human['outro']}\n"; + + $bodies = [ + 'models' => "| 새 |\n|---|\n| 표 |", + 'tables' => '새 테이블 표', + 'enums' => '문서에 마커가 없는 블록 — 주입되면 안 된다', + ]; + + $result = ExtensionDocScaffolder::replaceBlocks($document, $bodies); + + foreach ($human as $key => $text) { + $this->assertStringContainsString($text, $result['content'], "사람 서술 손실: {$key}"); + } + + $this->assertStringContainsString('| 새 |', $result['content']); + $this->assertStringNotContainsString('| 옛 |', $result['content']); + $this->assertStringContainsString('새 테이블 표', $result['content']); + $this->assertStringNotContainsString('옛 테이블 표', $result['content']); + + $this->assertStringNotContainsString( + '주입되면 안 된다', + $result['content'], + '문서에 마커가 없는 블록은 임의 위치에 주입하지 않는다 (누락으로 보고).', + ); + $this->assertSame(['enums'], $result['missing']); + $this->assertSame(['models', 'tables'], $result['replaced']); + + // 멱등: 같은 본문으로 다시 돌리면 한 글자도 바뀌지 않아야 한다. + $again = ExtensionDocScaffolder::replaceBlocks($result['content'], [ + 'models' => $bodies['models'], + 'tables' => $bodies['tables'], + ]); + $this->assertTrue($again['unchanged'], '재실행이 멱등이 아닙니다.'); + } + + /** + * 미채움 마커 목록이 PHP · 검사 스크립트 · audit 룰 세 곳에서 일치해야 합니다. + * + * 세 곳이 갈라지면 한쪽이 세지 않는 마커가 생기고, 그 자리는 영영 집계되지 않습니다. + */ + public function test_todo_marker_list_is_consistent_across_tooling(): void + { + $markers = ExtensionDocScaffolder::todoMarkers(); + + $this->assertCount(5, $markers, '미채움 마커는 5종으로 고정입니다.'); + + $script = (string) file_get_contents(base_path('.claude/scripts/check-extension-docs.cjs')); + $rule = (string) file_get_contents(base_path('.claude/scripts/audit/rules/extension-doc-unfilled-markers.cjs')); + + foreach ($markers as $marker) { + $this->assertStringContainsString( + "'{$marker}'", + $script, + "check-extension-docs.cjs 에 마커 '{$marker}' 가 없습니다.", + ); + $this->assertStringContainsString( + "'{$marker}'", + $rule, + "extension-doc-unfilled-markers.cjs 에 마커 '{$marker}' 가 없습니다.", + ); + } + + // 포함만 단언하면 방향이 하나뿐이라 JS 쪽 **초과** 항목(6번째 마커·오탈자 잔여)이 + // 그대로 살아 있다. 두 도구가 세는 모집단이 갈라지면 잔량 집계가 서로 다른 답을 + // 내는데, 그 차이는 어느 쪽 출력에도 드러나지 않는다. + foreach ([$script, $rule] as $i => $source) { + preg_match_all("/'(TODO: [^']+)'/u", $source, $m); + $found = array_values(array_unique($m[1])); + sort($found); + $expected = $markers; + sort($expected); + + $this->assertSame( + $expected, + $found, + ($i === 0 ? 'check-extension-docs.cjs' : 'extension-doc-unfilled-markers.cjs') + .' 의 마커 목록이 PHP SSoT 와 다릅니다 (초과 또는 누락).', + ); + } + } + + /** + * 빈 서술 축이 PHP 와 검사 스크립트 양쪽에 존재하고 같은 판정을 내려야 합니다. + * + * 미채움은 두 축입니다 — `TODO:` 마커 잔량과, 마커를 지우고 서술을 쓰지 않은 빈 + * `@intent` 블록. 후자가 한쪽에만 있으면 그 도구만 통과시키는데, 결과가 "다 채웠다" + * 와 구분되지 않아 비어 있는 문서가 완비로 집계됩니다. + */ + public function test_empty_intent_axis_agrees_across_tooling(): void + { + $filled = " +서술이 있다. +"; + $empty = " + +"; + + $this->assertSame(0, ExtensionDocScaffolder::emptyIntentBlocks($filled)); + $this->assertSame(1, ExtensionDocScaffolder::emptyIntentBlocks($empty)); + $this->assertSame(2, ExtensionDocScaffolder::emptyIntentBlocks($empty." +".$empty)); + $this->assertSame(1, ExtensionDocScaffolder::emptyIntentBlocks($filled." +".$empty)); + + $script = (string) file_get_contents(base_path('.claude/scripts/check-extension-docs.cjs')); + + $this->assertStringContainsString( + 'countEmptyIntentBlocks', + $script, + 'check-extension-docs.cjs 에 빈 서술 축이 없습니다 — PHP 만 세면 하네스가 통과시킵니다.', + ); + $this->assertStringContainsString( + "id: 'emptyIntent'", + $script, + 'check-extension-docs.cjs 의 빈 서술 축 식별자가 없습니다.', + ); + } + + /** + * 모든 번들 확장에서 수집기와 렌더러가 예외 없이 동작해야 합니다. + * + * 확장 하나의 특이 구조(다국어 배열 라벨 등)가 생성 전체를 중단시키는 것을 막습니다. + */ + public function test_every_extension_renders_without_error(): void + { + $inventory = new ExtensionInventory; + $scaffolder = new ExtensionDocScaffolder; + + foreach ($inventory->collect('all') as $record) { + $label = $record['type'].'/'.$record['id']; + $ctx = $this->contextFor($record, $inventory); + + $this->assertSame( + [], + $ctx['surface']['errors'], + "{$label}: 선언형 표면 수집 중 실패한 getter 가 있습니다.", + ); + + // 수집 **실패** 는 errors 에 남지 않는다 — 진입 클래스 로드·인스턴스화 실패는 + // 조기 반환이라 errors 가 빈 배열인 채 available=false 가 된다. errors 만 + // 단언하면 모듈 20개의 표면이 전부 죽어도 이 테스트는 초록이고, 그 사이 문서에는 + // "확인하지 못했습니다" 가 대량으로 기록된다. 템플릿은 선언형 표면을 갖지 않는 + // 것이 정상이므로 대상에서 뺀다. + if ($record['type'] !== ExtensionInventory::TYPE_TEMPLATE) { + $this->assertTrue( + $ctx['surface']['available'], + "{$label}: 선언형 표면을 읽지 못했습니다 — ".($ctx['surface']['reason'] ?? '사유 미기록'), + ); + } + + $blocks = $scaffolder->renderBlocks($ctx); + $this->assertNotEmpty($blocks, "{$label}: 렌더된 블록이 없습니다."); + + foreach ($blocks as $key => $body) { + $this->assertIsString($body, "{$label}: 블록 '{$key}' 본문이 문자열이 아닙니다."); + $this->assertNotSame('', trim($body), "{$label}: 블록 '{$key}' 본문이 비었습니다."); + } + + foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) { + $skeleton = $scaffolder->skeleton($doc, $ctx); + $this->assertNotSame('', trim($skeleton), "{$label}: {$doc} 골격이 비었습니다."); + + foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) { + $this->assertTrue( + ExtensionDocScaffolder::hasSection($skeleton, $section), + "{$label}: {$doc} 골격에 섹션 '{$section}' **헤딩**이 없습니다.", + ); + } + + foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) { + $this->assertContains( + $block, + ExtensionDocScaffolder::presentBlockKeys($skeleton), + "{$label}: {$doc} 골격에 블록 '{$block}' 마커가 없습니다.", + ); + } + } + } + } + + /** + * 섹션 판정이 낱말 등장이 아니라 헤딩을 봅니다. + * + * 절 이름을 부분문자열로 찾으면 이 축은 실패할 수 없습니다. README 는 자동 생성 + * 인라인 목차가 12개 절 이름을 전부 담고, `docs/data-model.md` 의 모델 표는 헤더에 + * `| 모델 | 테이블 |` 을 싣습니다 — 헤딩을 통째로 지워도 통과합니다. 실측 선례: + * `sirsoft-gdpr/README.md` 는 `## 변경 이력` 헤딩이 없는데 본문에 그 낱말이 3회 + * 등장한다는 이유로 "섹션 있음" 으로 판정됐습니다. + */ + public function test_section_detection_requires_a_heading_not_a_word(): void + { + $section = '변경 이력'; + + // 헤딩 없이 낱말만 있는 형태 — 인라인 목차 · 표 헤더 · 산문 + $decoys = [ + "[소개](#소개) · [{$section}](#변경-이력) · [라이선스](#라이선스)\n", + "| 항목 | {$section} |\n|---|---|\n| a | b |\n", + "회원/게스트의 모든 동의 {$section}을 조회할 수 있습니다.\n", + "`## {$section}` 처럼 적으면 됩니다.\n", + ]; + + foreach ($decoys as $i => $decoy) { + $this->assertFalse( + ExtensionDocScaffolder::hasSection($decoy, $section), + "낱말 등장(#{$i})이 섹션 존재로 판정됐습니다 — 헤딩을 지워도 통과하게 됩니다.", + ); + } + + foreach (["## {$section}\n", "### {$section}\n", "본문\n\n## {$section} \n\n다음"] as $i => $real) { + $this->assertTrue( + ExtensionDocScaffolder::hasSection($real, $section), + "실제 헤딩(#{$i})을 섹션 없음으로 판정했습니다.", + ); + } + + // 접두 일치로 다른 절을 인정하지 않는다 (`문서` 가 `문서 목차` 를 먹으면 안 된다). + $this->assertFalse(ExtensionDocScaffolder::hasSection("## 문서 목차\n", '문서')); + $this->assertTrue(ExtensionDocScaffolder::hasSection("## 문서 목차\n", '문서 목차')); + } + + /** + * 골격의 절 아래에 **그 절의 블록**이 놓이는지 단언합니다. + * + * 헤딩 존재와 블록 마커 존재를 각각만 보면 짝이 어긋나도 전부 초록입니다 — 절을 + * 하나 끼우는 순간 그 뒤 블록이 통째로 밀려 엉뚱한 헤딩 밑에 박히는데, 그 상태가 + * 어떤 게이트에도 걸리지 않습니다. + */ + public function test_skeleton_places_each_block_under_its_own_section(): void + { + $inventory = new ExtensionInventory; + $scaffolder = new ExtensionDocScaffolder; + + foreach ($inventory->collect('all') as $record) { + $ctx = $this->contextFor($record, $inventory); + + foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) { + if (! ExtensionDocScaffolder::pairsSectionsWithBlocks($doc)) { + continue; + } + + $skeleton = $scaffolder->skeleton($doc, $ctx); + $overrides = ExtensionDocScaffolder::DOCUMENTS[$doc]['sectionOverrides'][$record['type']] ?? []; + + foreach (ExtensionDocScaffolder::DOCUMENTS[$doc]['blocks'] as $section => $blockKey) { + $heading = '## '.($overrides[$section] ?? $section); + $headingPos = mb_strpos($skeleton, $heading); + $blockPos = mb_strpos($skeleton, ExtensionDocScaffolder::GEN_PREFIX.$blockKey.' START'); + + $this->assertNotFalse($headingPos, "{$record['id']}: {$doc} 에 '{$heading}' 헤딩이 없습니다."); + $this->assertNotFalse($blockPos, "{$record['id']}: {$doc} 에 '{$blockKey}' 블록이 없습니다."); + $this->assertGreaterThan( + $headingPos, + $blockPos, + "{$record['id']}: {$doc} 의 '{$blockKey}' 블록이 '{$heading}' 절 아래에 있지 않습니다.", + ); + + // 다음 절이 시작되기 전에 그 블록이 나와야 한다 (다른 절로 밀리지 않음). + $nextHeading = mb_strpos($skeleton, ' +## ', $headingPos + mb_strlen($heading)); + if ($nextHeading !== false) { + $this->assertLessThan( + $nextHeading, + $blockPos, + "{$record['id']}: {$doc} 의 '{$blockKey}' 블록이 다음 절로 밀려 있습니다.", + ); + } + } + } + } + } + + /** + * 프로세스 경계를 넘는 리터럴 2종이 PHP·JS 양쪽에서 일치하는지 단언합니다. + * + * 코어 인덱스 스캐너는 `@generated:stats` 마커와 "확인하지 못했습니다" 문장을 **문자열로** + * 다시 적어 두고 파싱합니다. 생성기 쪽 문구가 바뀌면 스캐너가 조용히 실패해 인덱스가 + * 다시 `훅 0 · 라우트 0` 을 사실로 싣습니다 — 미채움 마커 5종에는 이미 양방향 집합 + * 단언이 있는데 같은 성질의 이 두 리터럴만 그 취급을 받지 못했습니다. + */ + public function test_cross_process_literals_match_the_index_scanner(): void + { + $scanner = base_path('.claude/scripts/generate-docs-index.cjs'); + + if (! is_file($scanner)) { + $this->markTestSkipped('내부 스캐너가 없는 배포본입니다.'); + } + + $js = (string) file_get_contents($scanner); + + // 생성기가 실제로 방출하는 마커에서 키 부분을 뽑아 스캐너 소스와 대조한다. + // (스캐너는 정규식으로 공백을 흡수하므로 마커 전문이 리터럴로 있지는 않다.) + $wrapped = ExtensionDocScaffolder::wrap('stats', '**훅 수**: 1'); + + $this->assertStringContainsString('@generated:stats START', $wrapped); + $this->assertStringContainsString('@generated:stats END', $wrapped); + + $this->assertStringContainsString( + '@generated:stats', + $js, + '인덱스 스캐너가 집계 블록 마커를 그대로 알고 있어야 합니다.', + ); + + // 수집 실패 문구 — 통지는 둘(표면 전체 실패 · 개별 getter 실패)이고 스캐너는 그 + // 둘이 공유하는 어구를 찾는다. 소스에 리터럴이 있는지만 보면 "어딘가에 그 문자열이 + // 있다" 만 확인할 뿐, **실제로 방출되는 문장**이 그것을 담는지는 보지 않는다 — + // 개별 getter 실패 통지가 다른 어구로 시작해 스캐너에 통째로 새던 것이 그 사각이다. + $marker = ExtensionDocScaffolder::SURFACE_NOTICE_MARKER; + + $this->assertStringContainsString( + // 스캐너 정규식은 `**` 를 이스케이프하므로 그 형태로 대조한다. + str_replace('**', '\*\*', $marker), + $js, + '인덱스 스캐너가 표면 실패 판정 어구를 읽지 못하면 점검 불가가 0 으로 굳습니다.', + ); + + // 두 통지가 실제로 그 어구를 담고 방출되는지 렌더 결과로 단언한다. + $inventory = new ExtensionInventory; + $record = $inventory->find('module', 'sirsoft-board'); + + $this->assertNotNull($record, '대조 기준 확장(sirsoft-board)이 없습니다.'); + + $ctx = $this->contextFor($record, $inventory); + $scaffolder = new ExtensionDocScaffolder; + + // (1) 개별 getter 실패 — 본문은 살리고 사유를 덧붙이는 통지 + $partial = $ctx; + $partial['surface']['errors'] = ['getRoutes' => '테스트 주입']; + + foreach (['stats', 'hooks-published', 'hooks-subscribed', 'permissions'] as $key) { + $this->assertStringContainsString( + $marker, + $scaffolder->renderBlock($key, $partial), + "개별 getter 실패 시 `{$key}` 블록에 판정 어구가 실려야 인덱스가 그 수치를 점검 불가로 가릅니다.", + ); + } + + // (2) 표면 전체 실패 — 본문을 대체하는 통지 + $whole = $ctx; + $whole['surface']['available'] = false; + $whole['surface']['reason'] = '테스트 주입'; + $whole['surface']['errors'] = []; + + // 본문을 대체하는 블록(permissions 등)뿐 아니라, 읽어낸 사실을 함께 싣는 블록 + // (수치·훅 표)에도 붙어야 한다. 그 셋만 통지 대상에서 빠져 있으면 진입 클래스를 + // 통째로 못 읽은 확장의 문서가 "발행 훅 N종 … 이 중 N종은 선언에 없어 자동 감지" + // 라는 거짓 문장을 경고 없이 싣고, 인덱스 스캐너는 그 수치를 실측으로 옮긴다. + foreach (['permissions', 'stats', 'hooks-published', 'hooks-subscribed'] as $key) { + $this->assertStringContainsString( + $marker, + $scaffolder->renderBlock($key, $whole), + "표면 전체 실패 시 `{$key}` 블록에도 같은 판정 어구가 실려야 합니다.", + ); + } + } + + /** + * 세지 못한 지표가 0 으로 굳지 않는지 단언합니다. + * + * 수집기는 셀 수 없는 지표를 `null` 로 올립니다("0 을 돌려주면 주소가 없다는 사실 + * 주장이 된다"). 그 계약은 소비처가 지키지 않으면 그 자리에서 끝납니다 — 배지가 + * `?? 0` 으로 받으면 수집기가 구분해 올린 값이 다시 0 이 되고, 템플릿은 표면 실패 + * 통지 대상이 아니라 단서도 남지 않아 인덱스가 그 0 을 실측으로 옮깁니다. + */ + public function test_unmeasured_stats_do_not_collapse_to_zero(): void + { + $inventory = new ExtensionInventory; + $record = $inventory->find('template', 'sirsoft-basic'); + + $this->assertNotNull($record, '대조 기준 템플릿(sirsoft-basic)이 없습니다.'); + + $ctx = $this->contextFor($record, $inventory); + + // 정상 경로 — 실측이 그대로 실린다. + $this->assertIsInt( + ExtensionDocScaffolder::statsOf($ctx)['라우트 수'], + '`routes.json` 을 읽은 템플릿은 주소 수가 정수여야 합니다.', + ); + + // 세지 못한 경로 — null 이 0 으로 바뀌지 않아야 한다. + $unmeasured = $ctx; + $unmeasured['frontend']['routeCount'] = null; + + $this->assertNull( + ExtensionDocScaffolder::statsOf($unmeasured)['라우트 수'], + '세지 못한 지표를 0 으로 받으면 "주소가 없다" 는 사실 주장이 됩니다.', + ); + + $block = (new ExtensionDocScaffolder)->renderBlock('stats', $unmeasured); + + $this->assertStringContainsString( + ExtensionDocScaffolder::STAT_UNMEASURED, + $block, + '세지 못한 지표는 수치 자리에 그 사실이 드러나야 합니다.', + ); + $this->assertStringNotContainsString( + '**라우트 수**: 0', + $block, + '세지 못한 주소 수가 0 으로 실리면 안 됩니다.', + ); + $this->assertStringContainsString( + ExtensionDocScaffolder::SURFACE_NOTICE_MARKER, + $block, + '단서가 없으면 인덱스 스캐너가 이 블록을 실측으로 읽습니다.', + ); + } + + /** + * 상세 문서로 거는 앵커가 그 문서의 실제 절 이름인지 단언합니다. + * + * 요약 표는 절 이름을 문자열로 다시 적어 앵커를 만듭니다. 절 이름을 바꾸면 헤딩 + * 검사는 통과하는데 요약의 링크만 조용히 끊깁니다 — 앵커 유효성을 보는 장치가 + * 없었습니다. + */ + public function test_summary_anchors_point_at_real_sections(): void + { + $source = (string) file_get_contents( + (string) (new \ReflectionClass(ExtensionDocScaffolder::class))->getFileName() + ); + + preg_match_all( + '/docLink\(\$ctx,\s*\'([^\']+)\',\s*\'([^\']+)\'\)/u', + $source, + $matches, + PREG_SET_ORDER + ); + + $this->assertNotEmpty($matches, 'docLink 호출을 하나도 찾지 못했습니다 — 판정식이 낡았습니다.'); + + foreach ($matches as [, $doc, $anchor]) { + $known = []; + foreach (['module', 'plugin', 'template'] as $type) { + $known = array_merge($known, ExtensionDocScaffolder::sectionsFor($doc, $type)); + } + + $this->assertContains( + $anchor, + $known, + "docLink 가 '{$doc}' 의 '{$anchor}' 절을 가리키는데 그런 절이 없습니다 — 링크가 끊깁니다.", + ); + } + } + + /** + * 템플릿 유형은 API·모델·훅 문서를 요구하지 않아야 합니다. + * + * 유형별 골격 분기가 사라지면 템플릿에 채울 수 없는 문서가 요구됩니다. + */ + public function test_document_set_differs_by_extension_type(): void + { + $module = ExtensionDocScaffolder::documentsForType(ExtensionInventory::TYPE_MODULE); + $template = ExtensionDocScaffolder::documentsForType(ExtensionInventory::TYPE_TEMPLATE); + + // `docs/editor-spec.md` 는 세 유형 공통이다. 편집기 스펙을 두지 않는 확장에도 문서를 + // 두는 것은 "왜 없어도 되는가 / 언제 필요해지는가" 를 적을 자리가 필요하기 때문이며, + // 그 자리가 사라지면 미보유가 누락으로 오해되거나 필요한 시점을 놓친다. + foreach (['AGENTS.md', 'README.md', 'docs/README.md', 'docs/architecture.md', 'docs/editor-spec.md'] as $shared) { + $this->assertContains($shared, $module); + $this->assertContains($shared, $template); + } + + $this->assertContains('docs/data-model.md', $module); + $this->assertContains('docs/extension-points.md', $module); + $this->assertNotContains('docs/data-model.md', $template); + $this->assertNotContains('docs/extension-points.md', $template); + + $this->assertContains('docs/components.md', $template); + $this->assertContains('docs/layouts.md', $template); + $this->assertNotContains('docs/components.md', $module); + } + + /** + * 고아 블록(문서에 실재하지만 필수 목록 밖) 검출이 살아 있는지 단언합니다. + * + * 이 축은 저장소에 문서가 0세트인 동안 실측이 항상 0건이라, 검출부가 깨져도 + * `--check` 이슈 수만 줄고 아무 게이트도 붉어지지 않습니다. 검출식을 직접 겨눕니다. + */ + public function test_orphan_block_detection_is_alive(): void + { + $doc = 'AGENTS.md'; + $known = ExtensionDocScaffolder::blocksFor($doc); + + $this->assertNotEmpty($known, "{$doc} 의 필수 블록 목록이 비었습니다 — 모집단이 없습니다."); + + $legit = $known[0]; + $content = "# 제목\n\n" + .ExtensionDocScaffolder::wrap($legit, '본문')."\n\n" + .ExtensionDocScaffolder::wrap('legacy-orphan', '낡은 실측')."\n"; + + $present = ExtensionDocScaffolder::presentBlockKeys($content); + + $this->assertContains($legit, $present, '필수 블록을 찾지 못했습니다.'); + $this->assertContains( + 'legacy-orphan', + $present, + '목록 밖 블록을 찾지 못하면 그 블록은 영영 갱신되지 않고 누락으로도 보고되지 않습니다.', + ); + + $orphans = array_values(array_filter( + $present, + static fn (string $key): bool => ! in_array($key, $known, true), + )); + + $this->assertSame(['legacy-orphan'], $orphans, '고아 판정이 필수 블록까지 잡거나 놓치고 있습니다.'); + } + + /** + * 유형별로 다른 표면을 생성기가 실제 파일 배치대로 서술하는지 단언합니다. + * + * 이 축의 결함은 예외도 경고도 남기지 않습니다 — 없는 파일을 요구하거나, 있는 + * 디렉토리를 통째로 빠뜨린 문서가 조용히 커밋됩니다. 그 문서는 골격에 한 번만 + * 쓰이고 자동 생성 블록이 아니라 재생성으로 고쳐지지도 않으므로, 틀린 채로 굳습니다. + */ + public function test_skeleton_matches_actual_extension_layout(): void + { + $inventory = new ExtensionInventory; + $scaffolder = new ExtensionDocScaffolder; + $records = $inventory->collect(); + + $this->assertNotEmpty($records, '확장을 하나도 찾지 못했습니다 — 모집단이 비었습니다.'); + + $checkedTemplate = 0; + $checkedControllers = 0; + + foreach ($records as $record) { + $ctx = $this->contextFor($record, $inventory); + $agents = $scaffolder->skeleton('AGENTS.md', $ctx); + + if ($record['type'] === ExtensionInventory::TYPE_TEMPLATE) { + $checkedTemplate++; + + // 번들 템플릿은 PHP 패키지가 아니라 composer.json 을 갖지 않는다. + $this->assertFileDoesNotExist( + $record['path'].DIRECTORY_SEPARATOR.'composer.json', + "전제가 깨졌습니다: {$record['id']} 에 composer.json 이 생겼습니다.", + ); + + $this->assertStringNotContainsString( + '`composer.json` 동기화', + $agents, + "{$record['id']}: 템플릿에 없는 composer.json 동기화를 동반 의무로 요구하고 있습니다.", + ); + + // 주소는 routes.json 에 있다 — 선언형 표면만 보면 구조적으로 항상 0 이다. + $declared = $ctx['frontend']['routeCount'] ?? null; + + if (is_file($record['path'].DIRECTORY_SEPARATOR.'routes.json')) { + $this->assertIsInt( + $declared, + "{$record['id']}: routes.json 이 있는데 주소 수를 세지 못했습니다.", + ); + $this->assertSame( + $declared, + ExtensionDocScaffolder::statsOf($ctx)['라우트 수'], + "{$record['id']}: 집계 배지의 라우트 수가 routes.json 실측과 다릅니다.", + ); + } + } + + // 컨트롤러 자리는 두 갈래(`src/Http/Controllers/` · `src/Controllers/`)다. + // 실재하는 갈래가 디렉토리 지도에 나타나지 않으면 그 확장 문서에서 + // "API 표면 변경 시 api:docgen 재실행" 절차가 통째로 사라진다. + foreach (['src/Http/Controllers', 'src/Controllers'] as $candidate) { + if (! is_dir($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $candidate))) { + continue; + } + + $checkedControllers++; + + $this->assertStringContainsString( + $candidate.'/', + $agents, + "{$record['id']}: {$candidate}/ 가 실재하는데 디렉토리 지도에 없습니다.", + ); + } + + // 다른 확장 화면에 주입하는 레이아웃 조각도 유형마다 자리가 다르다. + $extRoot = $record['type'] === ExtensionInventory::TYPE_TEMPLATE + ? 'extensions' + : 'resources/extensions'; + + if (is_dir($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $extRoot))) { + $this->assertNotEmpty( + $ctx['frontend']['layoutExtensions'], + "{$record['id']}: {$extRoot}/ 가 실재하는데 레이아웃 확장 조각이 수집되지 않았습니다.", + ); + + // getLayoutExtensions() 기본 구현은 위와 같은 디렉토리를 glob() 한 절대경로라 + // 파일 목록과 100% 중복이다. 정규화 없이 실으면 같은 파일이 두 번(상대·절대) 나오고 + // 로컬 머신 절대경로가 커밋되는 확장 저장소에 그대로 남는다. + // 템플릿은 'layout-extensions' 가 아니라 'template-overrides' 블록(docs/layouts.md)이다 + // — 그 개념(오버라이드)이 모듈/플러그인의 발행과 반대 방향이라 문서 자리가 다르다. + $blockKey = $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'template-overrides' : 'layout-extensions'; + $block = $scaffolder->renderBlocks($ctx)[$blockKey] ?? ''; + $this->assertNotSame('', $block, "{$record['id']}: {$blockKey} 블록이 렌더되지 않았습니다."); + $this->assertStringNotContainsString( + str_replace('\\', '/', $record['path']), + str_replace('\\', '/', $block), + "{$record['id']}: 레이아웃 확장 표에 로컬 절대경로가 남아 있습니다.", + ); + $rowCount = substr_count($block, "\n| `"); + $this->assertSame( + count($ctx['frontend']['layoutExtensions']), + $rowCount, + "{$record['id']}: 레이아웃 확장 표 행 수가 실제 파일 수와 다릅니다 (중복 행 의심).", + ); + } + + // getNotificationDefinitions() 의 표준 계약은 리스트 배열 + 원소별 'type' 키다 + // (NotificationSyncHelper::sync() 소비 형태). 'key'/'event' 로 읽으면 정수 인덱스 + // 배열에서 이름을 못 찾아 모든 행이 '-' 로 찍힌다 — 표가 있으나 마나 해진다. + $declaredNotifications = $ctx['surface']['values']['getNotificationDefinitions'] ?? []; + if (is_array($declaredNotifications) && $declaredNotifications !== []) { + $notificationsBlock = $scaffolder->renderBlocks($ctx)['notifications'] ?? ''; + $this->assertStringNotContainsString( + "\n| `-` |", + $notificationsBlock, + "{$record['id']}: 알림 정의 표의 알림 키가 비어 있습니다 ('type' 필드 매핑 확인).", + ); + } + + // getSettingsLayout() 은 getModulePath()/getPluginPath() 기준 절대경로를 돌려준다 + // (§레이아웃 확장과 같은 결함군) — relativeToExtension() 을 거치지 않으면 로컬 머신 + // 절대경로가 그대로 커밋 문서(docs/settings.md · README.md)에 실린다. + $settingsLayout = $ctx['surface']['values']['getSettingsLayout'] ?? null; + if (is_string($settingsLayout) && $settingsLayout !== '') { + $rendered = $scaffolder->renderBlocks($ctx); + // record['path'] 는 Symfony Finder 산출물(슬래시)과 코드 조립 문자열(백슬래시)이 + // 섞인 혼합 구분자다 — 양쪽을 슬래시로 정규화하지 않으면 정규화된 needle 이 + // 정규화되지 않은 haystack 안의 진짜 누출을 놓친다. + $needle = str_replace('\\', '/', $record['path']); + foreach (['settings-schema', 'settings-summary'] as $settingsBlockKey) { + $this->assertStringNotContainsString( + $needle, + str_replace('\\', '/', $rendered[$settingsBlockKey] ?? ''), + "{$record['id']}: {$settingsBlockKey} 블록에 로컬 절대경로가 남아 있습니다.", + ); + } + } + } + + $this->assertGreaterThan(0, $checkedTemplate, '템플릿을 하나도 검사하지 않았습니다.'); + $this->assertGreaterThan(0, $checkedControllers, '컨트롤러 디렉토리를 하나도 검사하지 않았습니다.'); + } + + /** + * 스케줄 주기가 계약 키(`schedule`)에서 읽히는지 단언합니다. + * + * `getSchedules()` 의 표준 계약 키는 `schedule` 이다 — `AbstractModule`/`AbstractPlugin` + * 의 주석과 `routes/console.php` 의 실제 소비부가 그 SSoT 다. 렌더러가 다른 키 + * (`expression`/`cron`/`frequency`)만 보면 주기 열이 **모든 확장에서 영구히 `-`** 가 + * 되는데, 그 모양은 "주기를 선언하지 않았다" 와 구분되지 않아 아무도 이상을 알아채지 + * 못한다. 실제로 그 상태로 board·ecommerce 두 확장이 문서화되었다. + * + * 확장 문서를 대조하지 않고 합성 입력으로 판정하는 이유는, 스케줄을 선언한 확장이 + * 저장소에서 사라지면 실측 대조가 **공허 통과**하기 때문이다. + */ + public function test_schedule_period_reads_the_contract_key(): void + { + $ctx = [ + 'record' => ['type' => 'module', 'id' => 'vendor-ext'], + 'surface' => [ + 'available' => true, + 'values' => [ + 'getSchedules' => [ + [ + 'command' => 'vendor-ext:do-something', + 'schedule' => 'hourly', + 'description' => '계약 키만 선언한 스케줄', + ], + ], + ], + ], + ]; + + $block = (new ExtensionDocScaffolder)->renderBlock('schedules', $ctx); + + $this->assertStringContainsString( + 'hourly', + $block, + '계약 키 `schedule` 로 선언한 주기가 표에 실리지 않습니다 — 주기 열이 모든 확장에서 `-` 가 됩니다.', + ); + $this->assertStringNotContainsString( + '| `-` |', + $block, + '주기를 선언한 스케줄이 미선언으로 렌더되었습니다.', + ); + } + + /** + * 네임스페이스를 붙인 핸들러 등록 키가 수집·렌더 양쪽에서 살아남는지 단언합니다. + * + * `'vendor-ext.doThing': handler` 처럼 네임스페이스를 붙인 키는 `.`·`-` 때문에 반드시 + * 따옴표로 감싸인다. 수집기가 식별자 키만 보면 그 항목이 통째로 빠지는데, 결과가 + * "그만큼만 등록했다" 와 같은 모양이라 누락이 드러나지 않는다 — 실제로 sirsoft-basic 이 + * 32개 중 10개를 그렇게 잃고 있었다. + * + * 렌더 축도 함께 잠근다. 템플릿은 namespace 가 null 이지만 네임스페이스를 붙여 등록하는 + * 핸들러를 함께 가질 수 있어, 그 경우 "네임스페이스 없음" 으로 적으면 사실과 반대가 된다. + */ + public function test_namespaced_handler_keys_survive_collection_and_render(): void + { + $source = <<<'TS' + export const handlerMap = { + plainOne: plainOneHandler, + 'vendor-ext.doThing': doThingHandler, + "vendor-ext.doOther": doOtherHandler, + }; + TS; + + $inventory = new FrontendInventory; + $method = (new \ReflectionClass(FrontendInventory::class))->getMethod('objectKeys'); + $method->setAccessible(true); + + /** @var array $keys */ + $keys = $method->invoke($inventory, $source, 'handlerMap'); + + $this->assertSame( + ['plainOne', 'vendor-ext.doThing', 'vendor-ext.doOther'], + $keys, + '따옴표로 감싼 네임스페이스 등록 키가 수집에서 빠졌습니다 — 누락이 "그만큼만 등록했다" 로 보입니다.', + ); + + $block = (new ExtensionDocScaffolder)->renderBlock('handlers', [ + 'record' => ['type' => 'template', 'id' => 'vendor-ext'], + 'surface' => ['available' => true, 'values' => []], + 'frontend' => [ + 'handlers' => [ + 'namespace' => null, + 'names' => $keys, + 'source' => 'src/handlers/index.ts', + ], + ], + ]); + + $this->assertStringContainsString( + '핸들러 3개', + $block, + '수집한 핸들러 수가 표에 그대로 실려야 합니다.', + ); + $this->assertStringContainsString( + '`vendor-ext.doThing`', + $block, + '네임스페이스를 붙여 등록한 핸들러는 그 전체 이름이 호출 이름입니다.', + ); + $this->assertStringContainsString( + '`plainOne` | (템플릿 전용', + $block, + '네임스페이스 없이 등록한 핸들러는 기존 표기를 유지해야 합니다.', + ); + } + + /** + * 확장 README 의 첫 화면이 이미지 배지가 아니라 H1 제목인지 단언합니다. + * + * PO 결정(2026-08-31): 확장 README 상단의 확장명은 shields.io 이미지 배지가 아니라 평범한 + * H1 마크다운 제목이다. 확장은 20개가 병렬로 존재하는 대등한 구성요소이고, 각자가 코어와 + * 같은 히어로 브랜딩을 받으면 "이 확장이 곧 독립 프로젝트" 라는 착시를 준다. + * + * 모집단은 손으로 적지 않고 `_bundled` 스캔에서 파생한다. 실제로 S2 가 이 축을 + * `height="120"` 잔존 0 으로 닫았는데, S3 이 `height="60"` 으로 쓴 11개가 그 모집단 밖이라 + * 게이트가 초록인 채 결정이 절반만 적용된 상태로 남았다. 판정은 높이가 아니라 **형태** + * (`style=for-the-badge` 이미지가 상단에 있는가)로 한다. + * + * `@generated:badges` 블록 안의 version/type/G7/license 배지는 대상이 아니다 — 그것은 + * manifest 에서 오는 flat-square 정보 배지이고 계획서 §1.3 이 유지하기로 한 것이다. + */ + public function test_extension_readme_leads_with_a_heading_not_a_hero_badge(): void + { + $records = (new ExtensionInventory)->collect('all'); + $this->assertNotEmpty($records, '번들 확장을 하나도 발견하지 못했습니다.'); + + $checked = 0; + $heroes = []; + $missingHeading = []; + + foreach ($records as $record) { + $readme = $record['path'].'/README.md'; + if (! is_file($readme)) { + continue; + } + + $checked++; + $body = (string) file_get_contents($readme); + + // 자동 생성 배지 블록 앞부분(사람이 쓰는 히어로 영역)만 본다. + $head = $body; + $blockAt = strpos($body, '