diff --git a/AGENTS.md b/AGENTS.md
index 7ccefc71..1730b7d6 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/) (47개)
+### 프론트엔드 [frontend/](docs/frontend/) (44개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -95,9 +95,6 @@
| [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-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/) (31개)
@@ -177,21 +174,32 @@
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
-### 확장 개발자 문서 (9개 확장, 자동 스캔)
+### 확장 개발자 문서 (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 |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0b38a93..247db960 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,13 +22,14 @@
- 프록시 뒤에서 구동 중인데 신뢰 프록시가 지정되지 않았으면 관리자 대시보드가 그 사실을 알립니다. 환경설정 > 고급 에서 사이트가 인식한 접속 방식과 방문자 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/` 상세 문서를 두는 형식을 정의했으며, 문서 내용은 확장별로 순차 반영됩니다.
+- 확장(모듈·플러그인·템플릿)이 개발자 문서를 갖추기 위한 체계가 마련되었습니다. 확장마다 `AGENTS.md`(확장을 고치는 사람용 — 설계 의도·확장점·수정 시 동반 의무·금지 패턴)와 `README.md`(도입 검토·운영자용 — 기능·설치·사용 방법·트러블슈팅), `docs/` 상세 문서를 두는 형식을 정의했으며, **동봉된 확장 20개 전부에 문서가 채워졌습니다.** 새 확장을 만들면 스캐폴딩 단계에서 이 문서 골격이 함께 생성됩니다.
- 확장 문서에서 코드로 확인되는 부분(발행·구독 훅, 라우트, 권한, 메뉴, 설정 항목, 모델과 테이블, 레이아웃, 액션 핸들러, 테스트 실행 경로, 다른 확장과의 의존 관계)을 `php artisan ext:docgen` 이 자동으로 채우고 유지합니다. 사람이 쓴 서술은 손대지 않고 자동 생성 표만 교체하며, `php artisan ext:docgen --check` 로 문서가 코드와 어긋났는지 확인할 수 있습니다. 개발 대시보드에서도 실행할 수 있습니다.
### Changed
- 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 레이아웃 편집기를 여는 중 네트워크가 잠시 끊겨도 자동으로 다시 시도합니다. 끝내 실패하면 내부 파일 경로 대신 다음에 무엇을 하면 되는지를 안내합니다.
+- 번들 템플릿의 컴포넌트·핸들러·레이아웃 상세 문서와 확장이 사용하는 활동 로그 항목 목록이 각 확장의 문서로 옮겨졌습니다. 확장이 기능을 늘릴 때 코어 문서를 함께 고쳐야 하던 의존이 사라졌으며, 코어 문서에는 총계와 각 확장 문서로의 링크만 남습니다.
### Fixed
diff --git a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php
index b84d3e2f..31d30972 100644
--- a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php
+++ b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php
@@ -1369,7 +1369,10 @@ class ExtensionDocScaffolder
$rows[] = [
$this->code((string) ($schedule['name'] ?? $schedule['command'] ?? $key)),
- $this->code((string) ($schedule['expression'] ?? $schedule['cron'] ?? $schedule['frequency'] ?? '-')),
+ // 표준 계약 키는 `schedule` 이다 (AbstractModule/AbstractPlugin 의 getSchedules()
+ // 주석과 routes/console.php 소비부가 SSoT). 이 키를 빼면 주기 열이 모든 확장에서
+ // 영구히 '-' 가 되는데, "주기를 선언하지 않았다" 와 구분되지 않는다.
+ $this->code((string) ($schedule['schedule'] ?? $schedule['expression'] ?? $schedule['cron'] ?? $schedule['frequency'] ?? '-')),
(string) ($schedule['description'] ?? '-'),
];
}
@@ -1894,9 +1897,17 @@ class ExtensionDocScaffolder
$rows = [];
foreach ($handlers['names'] as $name) {
+ // 등록 키가 이미 네임스페이스를 포함하면(따옴표 키) 그 값 자체가 호출 이름이다.
+ // 템플릿은 namespace 가 null 이지만 네임스페이스를 붙여 등록하는 핸들러를 함께
+ // 가질 수 있어, 그 경우 "네임스페이스 없음" 으로 적으면 사실과 반대가 된다.
+ $dot = strrpos($name, '.');
+ $qualified = $dot !== false
+ ? $name
+ : ($namespace !== null ? "{$namespace}.{$name}" : null);
+
$rows[] = [
- $this->code($name),
- $namespace !== null ? $this->code("{$namespace}.{$name}") : '(템플릿 전용 — 네임스페이스 없음)',
+ $this->code($dot !== false ? substr($name, $dot + 1) : $name),
+ $qualified !== null ? $this->code($qualified) : '(템플릿 전용 — 네임스페이스 없음)',
];
}
@@ -2234,20 +2245,15 @@ class ExtensionDocScaffolder
$sections,
);
+ // 확장명은 히어로 이미지가 아니라 평범한 H1 이다 (PO 결정 2026-08-31). 확장 20개는
+ // 대등하게 병렬로 존재하는 구성요소이고, 각자가 코어와 같은 히어로 브랜딩을 받으면
+ // "이 확장이 곧 독립 프로젝트" 라는 착시를 준다. `@generated:badges` 블록의
+ // flat-square 정보 배지는 manifest 에서 오는 것이라 그대로 둔다.
$lines = [];
- $lines[] = '
= ({ children }) => (
- [컴포넌트 패턴](components-patterns.md)
- [컴포넌트 고급 기능](components-advanced.md)
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
-- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md)
+- [sirsoft-basic 컴포넌트](../../templates/_bundled/sirsoft-basic/docs/components.md)
diff --git a/docs/frontend/components.md b/docs/frontend/components.md
index bace9ebb..b6a88f08 100644
--- a/docs/frontend/components.md
+++ b/docs/frontend/components.md
@@ -25,11 +25,6 @@
| [components-advanced.md](components-advanced.md) | componentEvent, 아이콘, 체크리스트 | 이벤트 통신, 아이콘 규칙, 개발 체크리스트 |
-### 템플릿별 레퍼런스
-
-| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
-|--------------|---------|--------|--------|
-| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) |
@@ -156,6 +151,6 @@ import { Icon, IconName } from '../basic/Icon';
- [레이아웃 JSON 스키마](./layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법
- [데이터 바인딩](./data-binding.md) - props에서 데이터 바인딩 사용법
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
-- [sirsoft-basic 컴포넌트](./templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개)
+- [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 a6237de1..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/_bundled/sirsoft-admin_basic/docs/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/responsive-layout.md b/docs/frontend/responsive-layout.md
index bfb6a302..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/_bundled/sirsoft-admin_basic/docs/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 b6e9e466..4eae544c 100644
--- a/docs/frontend/template-handlers.md
+++ b/docs/frontend/template-handlers.md
@@ -20,9 +20,6 @@
## 템플릿별 상세 문서
-| 템플릿 식별자 | 핸들러 문서 | TL;DR 핵심 |
-|--------------|-----------|----------|
-| `sirsoft-basic` | [handlers.md](templates/sirsoft-basic/handlers.md) | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
@@ -58,4 +55,4 @@
- [템플릿 개발 가이드](template-development.md)
- [다크 모드 지원](dark-mode.md)
- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
-- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/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
index b46d7ef4..ca328706 100644
--- a/docs/frontend/templates/README.md
+++ b/docs/frontend/templates/README.md
@@ -7,7 +7,7 @@
| 템플릿 | 상태 | 문서 위치 |
|---|---|---|
| `sirsoft-admin_basic` | 이관 완료 | [templates/_bundled/sirsoft-admin_basic/docs/](../../../templates/_bundled/sirsoft-admin_basic/docs/README.md) |
-| `sirsoft-basic` | 이관 대기 (S3) | [components.md](sirsoft-basic/components.md) · [handlers.md](sirsoft-basic/handlers.md) · [layouts.md](sirsoft-basic/layouts.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-basic/handlers.md b/docs/frontend/templates/sirsoft-basic/handlers.md
deleted file mode 100644
index 30380f17..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 핸들러](../../../../templates/_bundled/sirsoft-admin_basic/docs/handlers.md)
diff --git a/modules/_bundled/gnuboard7-hello_module/AGENTS.md b/modules/_bundled/gnuboard7-hello_module/AGENTS.md
new file mode 100644
index 00000000..497ddbdb
--- /dev/null
+++ b/modules/_bundled/gnuboard7-hello_module/AGENTS.md
@@ -0,0 +1,181 @@
+# 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` 가 그것을 받아 로그를 남깁니다.
+
+이 한 흐름에 G7 모듈의 규약이 전부 들어 있습니다:
+
+- 검증은 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` 의 구독이 조용히 끊긴다
+
+## 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/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..5968ab21 100644
--- a/modules/_bundled/gnuboard7-hello_module/CHANGELOG.md
+++ b/modules/_bundled/gnuboard7-hello_module/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [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/`(상세 문서)로 구성됩니다.
+
## [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..a6838cbd
--- /dev/null
+++ b/modules/_bundled/gnuboard7-hello_module/README.md
@@ -0,0 +1,176 @@
+# Hello 모듈
+
+**G7 모듈 · gnuboard7-hello_module**
+학습용 최소 샘플 모듈 (Memo CRUD)
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+그누보드7 **모듈이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 업무에 쓰는 기능은
+없고, 메모를 등록·수정·삭제하는 가장 단순한 화면 하나가 전부입니다.
+
+모듈을 처음 만들어 보는 개발자가 "무엇을 어디에 두어야 하는가" 를 파악하는 데 쓰거나, 새
+모듈을 시작할 때 **복제해서 이름만 바꾸는 출발점**으로 씁니다.
+
+관리자 화면의 모듈 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로는
+정상적으로 설치·활성화할 수 있으며, 설치하면 관리자에 "Hello 메모" 메뉴가 생깁니다.
+
+이 샘플은 짧게 유지하는 것이 원칙입니다 — 기능이 늘어나면 구조를 보러 온 사람이 읽어야 할
+코드가 함께 늘어나기 때문입니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 메모 관리 | 제목·내용으로 메모를 등록·수정·삭제하는 관리자 화면 |
+| 메모 목록 | 방문자 화면용 목록 레이아웃 1개 (사용자 템플릿 연동 예시) |
+| 권한 | 메모 관리 권한 4종(읽기·생성·수정·삭제) |
+| 다국어 | 한국어·영어 (관리자 문구와 화면 문구 각각) |
+| 확장 지점 | 메모 생성 시점 알림용 연결점 1개 |
+| 테스트 | 기능 테스트와 단위 테스트 예시 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ A[운영자] -->|메모 등록| ADM[관리자 화면]
+ ADM --> SVC[메모 처리]
+ SVC --> DB[(메모 데이터)]
+ SVC -->|생성 알림| L[연결된 확장]
+ T[사용자 템플릿] -->|목록 조회| SVC
+```
+
+운영자가 메모를 등록하면 저장과 함께 "메모가 생성되었다" 는 신호가 나갑니다. 다른 확장은 그
+신호를 받아 자기 일을 할 수 있습니다 — 같이 제공되는 학습용 플러그인이 그 예입니다.
+
+실제 모듈도 구조는 같고, 다루는 대상과 규모만 다릅니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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/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..e69da05f
--- /dev/null
+++ b/modules/_bundled/gnuboard7-hello_module/docs/README.md
@@ -0,0 +1,22 @@
+# 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [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..121919fb
--- /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
+```
+
+이 지도가 곧 **G7 모듈의 규약**입니다 — 검증은 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/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/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 4f3bcb54..dac74732 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [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/`(상세 문서)로 구성됩니다.
+
## [1.1.0] - 2026-08-24
### Added
diff --git a/modules/_bundled/sirsoft-board/README.md b/modules/_bundled/sirsoft-board/README.md
index 51628325..a1854129 100644
--- a/modules/_bundled/sirsoft-board/README.md
+++ b/modules/_bundled/sirsoft-board/README.md
@@ -5,7 +5,7 @@
-
+
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/extension-points.md b/modules/_bundled/sirsoft-board/docs/extension-points.md
index 336b8e62..b71ea23d 100644
--- a/modules/_bundled/sirsoft-board/docs/extension-points.md
+++ b/modules/_bundled/sirsoft-board/docs/extension-points.md
@@ -287,8 +287,8 @@ _등록하는 브로드캐스트 채널이 없습니다._
| 스케줄 | 주기 | 설명 |
|---|---|---|
-| `sirsoft-board:aggregate-stats` | `-` | 대시보드 게시물 현황 집계 |
-| `sirsoft-board:prune-attachments --scheduled` | `-` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 |
+| `sirsoft-board:aggregate-stats` | `hourly` | 대시보드 게시물 현황 집계 |
+| `sirsoft-board:prune-attachments --scheduled` | `daily` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 |
@@ -321,3 +321,84 @@ _등록하는 브로드캐스트 채널이 없습니다._
중복이 아니라 **관점의 차이**입니다 — 관리자가 직접 블라인드했는지, 신고 처리 결과로
블라인드됐는지에 따라 원 작성자에게 보이는 문구(원인 설명)가 갈라져야 하기 때문입니다.
+
+## 활동 로그 훅
+
+> 이 확장이 코어 활동 로그(`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/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..6e8c4d32
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/AGENTS.md
@@ -0,0 +1,232 @@
+# 이커머스 — 에이전트 가이드
+
+> 이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [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()`)·라우트 이름이 서로 가리키는 대상이 일치하는지 확인
+
+## 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/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..acbd5764 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -6,6 +6,10 @@
## [1.2.1] - 2026-08-28
+### Added
+
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
+
### 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..cf8f7bc4
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/README.md
@@ -0,0 +1,225 @@
+# 이커머스
+
+**G7 모듈 · sirsoft-ecommerce**
+그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+온라인 상점 운영에 필요한 상품·주문·결제·배송·취소/환불·쿠폰·마일리지·리뷰·문의를 한곳에서
+관리하는 모듈입니다. 관리자 화면에서 상품을 등록하고 주문을 처리하면, 방문자가 보는 상점
+화면은 템플릿(`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
+```
+
+주문 상태는 위 순서로 진행하며, 결제 완료 이후 배송 시작 전까지는 취소가 가능합니다(어느
+상태까지 취소를 허용할지는 환경설정에서 조정합니다). 취소하면 그 주문에 쓰인 쿠폰과 마일리지가
+자동으로 되돌아갑니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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/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..29a22065
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/docs/README.md
@@ -0,0 +1,22 @@
+# 이커머스 개발자 문서
+
+> 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [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/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-page/AGENTS.md b/modules/_bundled/sirsoft-page/AGENTS.md
new file mode 100644
index 00000000..1656e861
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/AGENTS.md
@@ -0,0 +1,207 @@
+# 페이지 — 에이전트 가이드
+
+> 이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [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 팩까지 동반
+
+## 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/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..e751d789 100644
--- a/modules/_bundled/sirsoft-page/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-page/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [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/`(상세 문서)로 구성됩니다.
+
## [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..32d9185a
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/README.md
@@ -0,0 +1,182 @@
+# 페이지
+
+**G7 모듈 · sirsoft-page**
+정적 페이지(정보/정책/안내) 관리 모듈
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+회사소개·이용약관·개인정보처리방침처럼 **주소가 고정된 문서 한 장**을 만들고 관리하는
+모듈입니다. 관리자 화면에서 주소(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
+```
+
+저장할 때마다 현재 내용과 버전 이력이 함께 갱신됩니다. 되돌리기도 "예전으로 덮어쓰기"가 아니라
+**그 내용으로 다시 한 번 저장**하는 것이라, 되돌린 사실 자체가 이력에 남습니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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/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..91d30ce2
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/docs/README.md
@@ -0,0 +1,22 @@
+# 페이지 개발자 문서
+
+> 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [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/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/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..64040dd5
--- /dev/null
+++ b/plugins/_bundled/gnuboard7-hello_plugin/AGENTS.md
@@ -0,0 +1,177 @@
+# 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` 만
+
+## 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md b/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md
index d22ec620..16b97f97 100644
--- a/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md
+++ b/plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [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/`(상세 문서)로 구성됩니다.
+
## [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..3549c536
--- /dev/null
+++ b/plugins/_bundled/gnuboard7-hello_plugin/README.md
@@ -0,0 +1,182 @@
+# Hello 플러그인
+
+**G7 플러그인 · gnuboard7-hello_plugin**
+학습용 최소 샘플 플러그인 (Hello 모듈 훅 소비)
+
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+그누보드7 **플러그인이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 업무에 쓰는 기능은
+없습니다.
+
+플러그인의 핵심 역할은 **다른 확장의 코드를 고치지 않고 그 동작에 끼어드는 것**입니다. 이
+샘플은 그 두 가지 방식을 하나씩 보여줍니다 — 학습용 모듈에 메모가 등록되면 기록을 남기고,
+메모 제목이 화면에 나가기 전에 앞에 표시를 붙이는 것입니다.
+
+관리자 화면의 플러그인 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로
+설치·활성화할 수 있으며, 학습용 모듈이 함께 설치되어 있어야 동작을 확인할 수 있습니다.
+
+플러그인은 자기 페이지를 가질 수 없습니다 — 설정 화면과 "다른 화면에 끼워 넣는 조각" 만
+허용됩니다. 이 샘플에는 설정 화면 하나가 있습니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 기록 남기기 | 학습용 모듈에 메모가 등록되면 로그 파일에 기록 (설정으로 끌 수 있음) |
+| 제목 가공 | 메모 제목 앞에 표시를 붙이는 예시 |
+| 설정 화면 | 기록 사용 여부를 켜고 끄는 관리자 설정 |
+| 연결점 제공 | 기록을 남긴 직후 다른 확장이 반응할 수 있는 연결점 |
+| 다국어 | 한국어·영어 화면 문구 |
+| 테스트 | 모듈이 신호를 보내고 이 플러그인이 받는지 확인하는 예시 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ M[학습용 모듈
메모 등록] -->|생성 신호| P[이 플러그인]
+ P -->|설정이 켜져 있으면| LOG[(로그 기록)]
+ LOG -->|기록 완료 신호| X[다른 확장]
+ M -.제목 가공 요청.-> P2[제목 앞에 표시 붙이기]
+```
+
+모듈은 이 플러그인의 존재를 모릅니다. 모듈이 "메모가 등록되었다" 는 신호를 보내면, 그 신호를
+듣고 있던 이 플러그인이 자기 일을 합니다. 그래서 플러그인을 꺼도 모듈은 그대로 동작합니다.
+
+기록을 남긴 뒤에는 이 플러그인도 신호를 보냅니다 — 신호를 받은 확장이 다시 신호를 보내며
+이어지는 것이 확장 시스템의 기본 구조입니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [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..d9e3948a
--- /dev/null
+++ b/plugins/_bundled/gnuboard7-hello_plugin/docs/README.md
@@ -0,0 +1,21 @@
+# 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [../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/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..19c7d81c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-ckeditor5/AGENTS.md
@@ -0,0 +1,207 @@
+# 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 을 함께 갱신·실행한다 (단위 테스트만으로는 편집기 장착 회귀가 드러나지 않는다)
+
+## 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/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..a9099d40 100644
--- a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md
@@ -10,6 +10,7 @@
- CKEditor 5 본체·스타일·번역 파일을 플러그인에 함께 담았습니다. 이제 외부 CDN 에 연결하지 않고 사이트 자신의 서버에서 불러오므로, 폐쇄망이나 외부 접속이 제한된 환경에서도 에디터가 동작합니다.
- 에디터를 불러오지 못한 경우 안내와 함께 임시 입력창으로 자동 전환됩니다. 작성한 내용은 그대로 저장되고, 이미 저장된 글을 수정할 때는 기존 본문이 임시 입력창에 그대로 실립니다. [다시 시도] 로 편집기를 되살리면 입력해 둔 내용이 그대로 이어집니다.
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
### Fixed
diff --git a/plugins/_bundled/sirsoft-ckeditor5/README.md b/plugins/_bundled/sirsoft-ckeditor5/README.md
new file mode 100644
index 00000000..16ecf934
--- /dev/null
+++ b/plugins/_bundled/sirsoft-ckeditor5/README.md
@@ -0,0 +1,204 @@
+# CKEditor 5 WYSIWYG 에디터
+
+**G7 플러그인 · sirsoft-ckeditor5**
+CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다.
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+글을 쓰는 자리에 **위지윅 편집기**를 제공하는 플러그인입니다. 설치·활성화하면 게시판 글쓰기,
+상품 설명, 페이지 내용처럼 본문을 입력하는 화면이 자동으로 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[정리]
+```
+
+미사용 이미지 정리는 "본문 어디에도 그 이미지 주소가 없고, 올린 지 보존기간이 지났을 때"만
+동작합니다. 게다가 **비활성 상태인 모듈이 하나라도 있으면 검사 자체를 멈춥니다** — 그 모듈의
+글을 확인할 수 없어 쓰이는 이미지를 안 쓰인다고 오판할 수 있기 때문입니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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/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..91fae51f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-ckeditor5/docs/README.md
@@ -0,0 +1,22 @@
+# 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [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/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..8ca6bc07
--- /dev/null
+++ b/plugins/_bundled/sirsoft-daum_postcode/AGENTS.md
@@ -0,0 +1,175 @@
+# 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 을 함께 갱신·실행한다 (단위 테스트만으로는 장착 회귀가 드러나지 않는다)
+
+## 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md b/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md
index 1bf286ef..b729fc91 100644
--- a/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-daum_postcode/CHANGELOG.md
@@ -6,6 +6,10 @@
## [1.0.3] - 2026-08-25
+### Added
+
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
+
### 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..0ec6f033
--- /dev/null
+++ b/plugins/_bundled/sirsoft-daum_postcode/README.md
@@ -0,0 +1,183 @@
+# Daum 우편번호
+
+**G7 플러그인 · sirsoft-daum_postcode**
+Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+주소를 입력하는 자리에 **우편번호 검색 창**을 붙여 주는 플러그인입니다. 설치·활성화하면
+배송지 입력 같은 주소 입력 화면에 검색 버튼이 생기고, 검색해서 고른 주소가 우편번호·기본
+주소·도로명·지번 칸에 자동으로 채워집니다.
+
+Daum(카카오)이 제공하는 무료 서비스를 사용하므로 **API 키 발급이나 별도 계약이 필요 없습니다.**
+다만 주소 검색 창 자체는 Daum 서버에서 내려받으므로, 인터넷이 차단된 환경에서는 검색이
+동작하지 않습니다. 그럴 때는 안내가 뜨고 **주소를 직접 입력**할 수 있으므로 주문이나 배송지
+등록이 막히지는 않습니다.
+
+이 플러그인은 주소를 찾아 칸에 넣어 주는 데까지만 합니다. 그 주소를 어디에 어떻게 저장할지는
+주소 입력 화면을 가진 확장(예: 이커머스)이 정합니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 주소 검색 | 도로명·지번·건물명·우편번호로 검색해 정확한 주소 선택 |
+| 자동 입력 | 선택한 주소를 우편번호·기본주소·도로명·지번 칸에 자동으로 채움 |
+| 오타 방지 | 검색으로 채우는 칸은 직접 수정할 수 없게 잠금 (상세주소는 직접 입력) |
+| 표시 방식 | 화면 안에 겹쳐 띄우는 레이어 방식과 별도 창 팝업 방식 중 선택 |
+| 모양 조정 | 팝업 크기와 테마 색상을 사이트에 맞게 설정 |
+| 연결 실패 대비 | 검색을 불러오지 못하면 안내 후 직접 입력 허용, 재시도 제공 |
+| 연동 지점 | 주소 선택 시점에 다른 확장이 반응할 수 있는 확장점 제공 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ F[주소 입력 화면] -->|검색 자리| P[이 플러그인]
+ P -->|검색 창 열기| D[Daum 우편번호 서비스]
+ D -->|선택한 주소| P
+ P --> FIELD[우편번호·주소 칸 채움]
+ P -.연결 실패.-> M[안내 + 직접 입력]
+```
+
+주소 입력 화면은 "검색 버튼이 들어갈 자리" 만 비워 두고 이 플러그인이 그 자리를 채웁니다.
+그래서 이커머스 배송지든 다른 확장의 주소 입력이든 같은 방식으로 동작합니다.
+
+검색 창을 불러오지 못하면 잠겨 있던 주소 칸이 **편집 가능한 상태로 남아** 직접 입력할 수
+있습니다. 검색이 안 된다고 화면 전체가 막히지 않도록 한 것입니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
+| [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..27ea9047
--- /dev/null
+++ b/plugins/_bundled/sirsoft-daum_postcode/docs/README.md
@@ -0,0 +1,21 @@
+# 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [../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/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/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md
index 3751f453..71cec178 100644
--- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md
@@ -6,6 +6,10 @@
## [1.0.4] - 2026-08-31
+### Added
+
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
+
### Fixed
- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
diff --git a/plugins/_bundled/sirsoft-marketing/AGENTS.md b/plugins/_bundled/sirsoft-marketing/AGENTS.md
new file mode 100644
index 00000000..a2b06761
--- /dev/null
+++ b/plugins/_bundled/sirsoft-marketing/AGENTS.md
@@ -0,0 +1,198 @@
+# 마케팅 동의 — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [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 구조를 벗어난 설계라는 신호
+
+## 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/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..e7f30711 100644
--- a/plugins/_bundled/sirsoft-marketing/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-marketing/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.4] - 2026-08-31
+
+### Added
+
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
+
## [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..0b07b414
--- /dev/null
+++ b/plugins/_bundled/sirsoft-marketing/README.md
@@ -0,0 +1,200 @@
+# 마케팅 동의
+
+**G7 플러그인 · sirsoft-marketing**
+이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인
+
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+회원에게 **마케팅 정보 수신 동의**를 받고 그 이력을 남기는 플러그인입니다. 이메일·SMS 같은
+수신 채널을 관리자 화면에서 원하는 만큼 추가할 수 있고, 제3자 제공 동의·정보 공개 동의처럼
+채널이 아닌 법정 동의 항목도 함께 다룹니다.
+
+동의 항목은 회원가입 폼과 마이페이지, 관리자의 회원 상세·수정 화면에 자동으로 나타납니다.
+이 플러그인은 자기 화면을 갖지 않고 **기존 화면에 항목을 얹는** 방식이라, 도입해도 회원
+관리 흐름이 달라지지 않습니다.
+
+동의와 철회는 모두 기록됩니다 — 언제, 어느 경로로, 어느 IP 에서 이루어졌는지가 남습니다.
+동의 여부만 남기면 나중에 "동의를 받았다" 는 사실을 증명할 수 없기 때문입니다.
+
+의도적으로 하지 않는 것: **실제 발송**. 이 플러그인은 "누가 무엇에 동의했는가" 까지만
+답하고, 그 동의를 근거로 메일이나 문자를 보내는 것은 발송 담당 확장의 몫입니다.
+
+
+## 주요 기능
+
+
+| 영역 | 설명 |
+|---|---|
+| 수신 채널 관리 | 이메일·SMS 등 수신 채널을 관리자 화면에서 추가·수정·사용중지 |
+| 법정 동의 항목 | 마케팅 활용 동의·제3자 제공 동의·정보 공개 동의를 각각 켜고 끔 |
+| 약관 연결 | 동의 항목마다 약관 페이지를 지정해 회원이 내용을 확인하고 동의 |
+| 가입 시 수집 | 회원가입 폼에 동의 항목이 자동으로 나타남 |
+| 마이페이지 관리 | 회원이 언제든 스스로 동의·철회 |
+| 관리자 조회·수정 | 회원 상세·수정 화면에서 동의 상태 확인과 변경 |
+| 동의 이력 | 동의·철회 행위마다 시각·경로·IP 기록, 동의 횟수 누적 |
+| 연동 지점 | 동의·철회 시점에 다른 확장이 반응할 수 있는 확장점 제공 |
+
+
+## 동작 방식
+
+
+```mermaid
+flowchart LR
+ R[회원가입 폼] -->|동의 항목 주입| P[이 플러그인]
+ M[마이페이지] -->|동의/철회| P
+ A[관리자 회원 화면] -->|조회·변경| P
+ P --> S[(현재 동의 상태)]
+ P --> H[(동의 이력)]
+ P -.동의/철회 알림.-> X[발송·연동 확장]
+```
+
+이 플러그인은 회원 화면들을 고치지 않고 **그 화면에 항목만 얹습니다.** 어느 경로로 동의가
+바뀌든 현재 상태와 이력이 함께 기록되고, 그 변화를 다른 확장이 받아 수신 목록에 반영할 수
+있습니다.
+
+수신 채널을 새로 추가하는 것은 **설정 변경만으로** 끝납니다. 프로그램을 다시 배포하거나
+데이터베이스를 고칠 필요가 없습니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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/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..0d42c1cb
--- /dev/null
+++ b/plugins/_bundled/sirsoft-marketing/docs/README.md
@@ -0,0 +1,22 @@
+# 마케팅 동의 개발자 문서
+
+> 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [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/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..be2ccaf0
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/AGENTS.md
@@ -0,0 +1,221 @@
+# 비즈뿌리오 메시지 발송 — 에이전트 가이드
+
+> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [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` 잔존 금지)
+
+## 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/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..36830d35 100644
--- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-message_bizppurio/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.1] - 2026-08-31
+
+### Added
+
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
+
## [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..2cd06828 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
+# 비즈뿌리오 메시지 발송
+**G7 플러그인 · sirsoft-message_bizppurio**
+비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.
+
+
-
-
-
-
+
+
+
+
-
-비즈뿌리오(Bizppurio)를 연동해 문자(SMS/LMS)와 카카오 알림톡을 발송하는 G7 플러그인입니다.
-
-G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다.
-
-[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 관리](#알림톡-템플릿-관리) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트)
+
---
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+비즈뿌리오(Bizppurio)를 연동해 **문자(SMS/LMS)와 카카오 알림톡**을 발송하는 플러그인입니다.
+
+G7 코어 알림 시스템에 문자·알림톡 채널을 추가하므로, 회원가입·주문 완료처럼 코어와 모듈이
+이미 발화하는 알림을 문자와 알림톡으로도 자동 발송할 수 있습니다. 알림을 새로 만들 필요 없이
+**기존 알림의 채널만 켜면** 됩니다.
+
+발송 결과는 비즈뿌리오가 보내는 통보(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 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다.
-
----
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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,80 @@ 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/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 +289,7 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
주요 코드 예시:
| 코드 | 분류 | 사유 |
-|:---:|------|------|
+|:---:|---|---|
| `1000` `4100` `6600` `7000` | 성공 | 발송/리포트 성공 |
| `9070` | 잔액 부족 | 잔액 부족(문자) |
| `9071` | 잔액 부족 | 후불 한도 초과 |
@@ -173,95 +297,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..5b54335b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/README.md
@@ -0,0 +1,22 @@
+# 비즈뿌리오 메시지 발송 개발자 문서
+
+> 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
+| [api/](api/README.md) | API 레퍼런스 |
+| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
+| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
+
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/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/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
index d416b9d6..49242449 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [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/`(상세 문서)로 구성됩니다.
+
## [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 83d283d1..fd4db78a 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/README.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/README.md
@@ -5,7 +5,7 @@ KG 이니시스 표준결제를 sirsoft-ecommerce 에 연결하는 결제 플러
-
+
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/extension-points.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md
index fc79ae42..4c74751f 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md
@@ -56,6 +56,29 @@
로그에 성공처럼 남을 수 있습니다.
+## 활동 로그 훅
+
+> 이 확장이 코어 활동 로그(`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)가
+> 먼저 실행돼 실제 취소가 성공한 뒤에야 기록이 남습니다 — 순서가 뒤바뀌면 실패한 취소도
+> 성공처럼 로그에 남습니다.
+
## 훅 리스너
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/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
index d4b3c23d..ea0caec9 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/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.4] - 2026-08-31
+
+### Added
+
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
+
## [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 6696b025..85ceb13c 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/README.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/README.md
@@ -5,7 +5,7 @@ NHN KCP Standard Pay 결제를 sirsoft-ecommerce 에 연결하는 결제 플러
-
+
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/extension-points.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md
index eac1100f..a5d98e68 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md
@@ -54,6 +54,29 @@
생깁니다.
+## 활동 로그 훅
+
+> 이 확장이 코어 활동 로그(`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)가
+> 먼저 실행돼 실제 취소가 성공한 뒤에야 기록이 남습니다 — 순서가 뒤바뀌면 실패한 취소도
+> 성공처럼 로그에 남습니다.
+
## 훅 리스너
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/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
index 1fce5f39..44192624 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/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.3] - 2026-08-31
+
+### Added
+
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
+
## [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 250d0d33..4b72791c 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/README.md
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/README.md
@@ -5,7 +5,7 @@
-
+
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/extension-points.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md
index eabb9fb5..4395ff52 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md
@@ -48,6 +48,29 @@
사각이 생깁니다.
+## 활동 로그 훅
+
+> 이 확장이 코어 활동 로그(`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)가
+> 먼저 실행돼 실제 취소가 성공한 뒤에야 기록이 남습니다 — 순서가 뒤바뀌면 실패한 취소도
+> 성공처럼 로그에 남습니다.
+
## 훅 리스너
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/CHANGELOG.md b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md
index bd44dd40..492d50ed 100644
--- a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-tosspayments/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.3] - 2026-08-31
+
+### Added
+
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
+
## [1.0.2] - 2026-08-19
### Fixed
diff --git a/plugins/_bundled/sirsoft-tosspayments/README.md b/plugins/_bundled/sirsoft-tosspayments/README.md
index c16051cf..bca51140 100644
--- a/plugins/_bundled/sirsoft-tosspayments/README.md
+++ b/plugins/_bundled/sirsoft-tosspayments/README.md
@@ -5,7 +5,7 @@
-
+
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/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/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
index 98f38f78..e632cf9e 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md
@@ -4,6 +4,12 @@ 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/`(상세 문서)로 구성됩니다.
+
## [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
index a8e980d8..d4e2e735 100644
--- a/plugins/_bundled/sirsoft-verification_kginicis/README.md
+++ b/plugins/_bundled/sirsoft-verification_kginicis/README.md
@@ -5,7 +5,7 @@ KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프
-
+
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/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/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md
index 97007adc..71fcf931 100644
--- a/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/CHANGELOG.md
@@ -4,6 +4,12 @@ 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/`(상세 문서)로 구성됩니다.
+
## [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
index 098a7197..2f42090d 100644
--- a/plugins/_bundled/sirsoft-verification_nhnkcp/README.md
+++ b/plugins/_bundled/sirsoft-verification_nhnkcp/README.md
@@ -5,7 +5,7 @@ NHN KCP 휴대폰 본인확인(V2 REST)을 G7 코어 IDV 인프라에 Provider
-
+
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/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/templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md b/templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md
new file mode 100644
index 00000000..f1c7bb69
--- /dev/null
+++ b/templates/_bundled/gnuboard7-hello_admin_template/AGENTS.md
@@ -0,0 +1,165 @@
+# 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`)는 이 템플릿 디렉토리에 둔다 — 코어 디렉토리에 두지 않는다
+
+## 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) | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
+| [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..6f524ce7 100644
--- a/templates/_bundled/gnuboard7-hello_admin_template/CHANGELOG.md
+++ b/templates/_bundled/gnuboard7-hello_admin_template/CHANGELOG.md
@@ -9,6 +9,7 @@
### Added
- 아이콘(Font Awesome)을 템플릿에 함께 담아 외부 CDN 없이 동작합니다.
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
## [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..dbc76e8e
--- /dev/null
+++ b/templates/_bundled/gnuboard7-hello_admin_template/README.md
@@ -0,0 +1,180 @@
+# Hello Admin Template
+
+**G7 템플릿 · gnuboard7-hello_admin_template**
+그누보드7 학습용 최소 Admin 템플릿 스켈레톤
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+그누보드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종은 있어야 합니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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) | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
+| [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..aa58a292
--- /dev/null
+++ b/templates/_bundled/gnuboard7-hello_admin_template/docs/README.md
@@ -0,0 +1,20 @@
+# 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) | 템플릿 전용 핸들러와 부트스트랩 |
+| [../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/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..e2189880
--- /dev/null
+++ b/templates/_bundled/gnuboard7-hello_user_template/AGENTS.md
@@ -0,0 +1,167 @@
+# 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`)를 호출해 목록을 그립니다 — 모듈이 데이터를,
+템플릿이 화면을 담당하는 G7 의 기본 경계를 가장 짧게 보여주는 예시입니다. 실제 게시판·이커머스
+모듈도 방문자 화면을 갖지 않고 같은 방식으로 템플릿에 맡깁니다.
+
+`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`)는 이 템플릿 디렉토리에 둔다
+
+## 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) | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
+| [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..967eecf8 100644
--- a/templates/_bundled/gnuboard7-hello_user_template/CHANGELOG.md
+++ b/templates/_bundled/gnuboard7-hello_user_template/CHANGELOG.md
@@ -9,6 +9,7 @@
### Added
- 아이콘(Font Awesome)을 템플릿에 함께 담아 외부 CDN 없이 동작합니다.
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
## [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..ed78a42b
--- /dev/null
+++ b/templates/_bundled/gnuboard7-hello_user_template/README.md
@@ -0,0 +1,192 @@
+# Hello 사용자 템플릿
+
+**G7 템플릿 · gnuboard7-hello_user_template**
+학습용 최소 샘플 사용자 템플릿 (Basic 8개 컴포넌트)
+
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+그누보드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[(메모 데이터)]
+```
+
+홈 화면은 자기 데이터를 갖지 않고 모듈에 요청해서 받아옵니다. 그래서 템플릿을 바꿔도 데이터는
+그대로 남고, 같은 데이터를 다른 디자인으로 보여줄 수 있습니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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) | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
+| [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..8c280451
--- /dev/null
+++ b/templates/_bundled/gnuboard7-hello_user_template/docs/README.md
@@ -0,0 +1,20 @@
+# 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) | 템플릿 전용 핸들러와 부트스트랩 |
+| [../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/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/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
index b3cd21b7..2ade5f23 100644
--- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
@@ -13,6 +13,7 @@
- 코드 편집기를 불러오지 못하면 안내와 함께 일반 입력창으로 전환되어, 레이아웃을 계속 편집하고 저장할 수 있습니다.
- 확장(템플릿·모듈·플러그인)을 제거할 때 `custom/` 에 넣어 둔 파일의 사본이 보관되며, 제거 창이 그 보관 경로를 보여 줍니다. 보관된 파일이 없으면 창은 종전대로 곧바로 닫힙니다.
- 환경설정 > 고급 에 리버스 프록시 진단이 추가되었습니다. 사이트가 받고 있는 프록시 정보, HTTPS 인식 여부, 방문자 IP 로 인식된 값과 직전 호출 IP 를 나란히 보여 줍니다. 읽기 전용이며 값은 `.env` 에서만 변경합니다.
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
### Changed
diff --git a/templates/_bundled/sirsoft-admin_basic/docs/components.md b/templates/_bundled/sirsoft-admin_basic/docs/components.md
index 51e75884..912ceb16 100644
--- a/templates/_bundled/sirsoft-admin_basic/docs/components.md
+++ b/templates/_bundled/sirsoft-admin_basic/docs/components.md
@@ -581,7 +581,7 @@ interface SlotContainerProps {
- [sirsoft-admin_basic 핸들러](handlers.md)
- [sirsoft-admin_basic 레이아웃](layouts.md)
-- [sirsoft-basic 컴포넌트](../../../../docs/frontend/templates/sirsoft-basic/components.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/handlers.md b/templates/_bundled/sirsoft-admin_basic/docs/handlers.md
index 58780c4f..864014f9 100644
--- a/templates/_bundled/sirsoft-admin_basic/docs/handlers.md
+++ b/templates/_bundled/sirsoft-admin_basic/docs/handlers.md
@@ -581,4 +581,4 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
- [액션 핸들러 개요](../../../../docs/frontend/actions-handlers.md)
- [sirsoft-admin_basic 컴포넌트](components.md)
- [sirsoft-admin_basic 레이아웃](layouts.md)
-- [sirsoft-basic 핸들러](../../../../docs/frontend/templates/sirsoft-basic/handlers.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
index a701e0f0..21eb695f 100644
--- a/templates/_bundled/sirsoft-admin_basic/docs/layouts.md
+++ b/templates/_bundled/sirsoft-admin_basic/docs/layouts.md
@@ -618,4 +618,4 @@ _admin_base.json
- [sirsoft-admin_basic 핸들러](handlers.md)
- [레이아웃 JSON 스키마](../../../../docs/frontend/layout-json.md)
- [레이아웃 상속](../../../../docs/frontend/layout-json-inheritance.md)
-- [sirsoft-basic 레이아웃](../../../../docs/frontend/templates/sirsoft-basic/layouts.md)
+- [sirsoft-basic 레이아웃](../../sirsoft-basic/docs/layouts.md)
diff --git a/templates/_bundled/sirsoft-basic/AGENTS.md b/templates/_bundled/sirsoft-basic/AGENTS.md
new file mode 100644
index 00000000..1d098560
--- /dev/null
+++ b/templates/_bundled/sirsoft-basic/AGENTS.md
@@ -0,0 +1,187 @@
+# 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 동반 — 단위 테스트만으로는 화면 회귀가 드러나지 않는다
+
+## 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) | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
+| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
+
diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md
index 8c3e48dc..12b3443e 100644
--- a/templates/_bundled/sirsoft-basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md
@@ -11,6 +11,7 @@
- 아이콘(Font Awesome)과 본문 글꼴(Pretendard)을 템플릿에 함께 담았습니다. 이제 외부 CDN 에 연결하지 않고 사이트 자신의 서버에서 불러오므로, 폐쇄망이나 외부 접속이 제한된 환경에서도 아이콘과 글꼴이 정상 표시됩니다.
- 이미지 업로드 시 쓰는 압축 라이브러리도 함께 담아, 업로드할 때마다 외부로 나가던 요청이 없어졌습니다.
- 검색엔진 크롤러가 보는 페이지도 같은 아이콘 파일을 사이트 자신의 주소에서 불러옵니다.
+- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
### Fixed
diff --git a/templates/_bundled/sirsoft-basic/README.md b/templates/_bundled/sirsoft-basic/README.md
new file mode 100644
index 00000000..8bdffc19
--- /dev/null
+++ b/templates/_bundled/sirsoft-basic/README.md
@@ -0,0 +1,209 @@
+# Basic
+
+**G7 템플릿 · sirsoft-basic**
+그누보드7 기본 사용자 템플릿
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
+
+---
+
+## 소개
+
+
+방문자가 보는 **사이트 전체 화면**을 담당하는 기본 사용자 템플릿입니다. 홈·게시판·상점·
+마이페이지·로그인·오류 화면이 모두 여기 들어 있습니다.
+
+그누보드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[(데이터)]
+```
+
+화면은 이 템플릿이 그리고 데이터는 모듈이 제공합니다. 그래서 템플릿을 바꿔도 게시글이나
+주문 데이터는 그대로 남습니다.
+
+
+## 요구 사항
+
+
+| 항목 | 값 |
+|---|---|
+| G7 코어 | `>=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) | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
+| [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..04344852
--- /dev/null
+++ b/templates/_bundled/sirsoft-basic/docs/README.md
@@ -0,0 +1,20 @@
+# 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) | 템플릿 전용 핸들러와 부트스트랩 |
+| [../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 ab099b4f..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 컴포넌트](../../../../templates/_bundled/sirsoft-admin_basic/docs/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/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 4ae239d8..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 레이아웃](../../../../templates/_bundled/sirsoft-admin_basic/docs/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/tests/Feature/Documentation/ExtensionDocContractTest.php b/tests/Feature/Documentation/ExtensionDocContractTest.php
index a73253ed..f448314f 100644
--- a/tests/Feature/Documentation/ExtensionDocContractTest.php
+++ b/tests/Feature/Documentation/ExtensionDocContractTest.php
@@ -36,17 +36,6 @@ class ExtensionDocContractTest extends TestCase
* @var array
*/
private const DOC_BACKLOG = [
- 'module/gnuboard7-hello_module',
- 'module/sirsoft-ecommerce',
- 'module/sirsoft-page',
- 'plugin/gnuboard7-hello_plugin',
- 'plugin/sirsoft-ckeditor5',
- 'plugin/sirsoft-daum_postcode',
- 'plugin/sirsoft-marketing',
- 'plugin/sirsoft-message_bizppurio',
- 'template/gnuboard7-hello_admin_template',
- 'template/gnuboard7-hello_user_template',
- 'template/sirsoft-basic',
];
/**
@@ -57,7 +46,7 @@ class ExtensionDocContractTest extends TestCase
* 늘리려면 이 숫자를 올리는 **눈에 보이는 행위**를 거치게 합니다. 집필이 진행되면 이
* 숫자도 함께 내려갑니다 (백로그는 줄어들기만 하므로 상한도 단조 감소한다).
*/
- private const DOC_BACKLOG_CEILING = 11;
+ private const DOC_BACKLOG_CEILING = 0;
/**
* `_bundled` 스캔이 번들 확장 전수를 발견하는지 확인합니다.
@@ -960,6 +949,343 @@ class ExtensionDocContractTest extends TestCase
$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, '