docs(core,extensions): 확장 20개 개발자 문서 완비와 문서 소유 이관

번들 확장 20개 전부에 AGENTS.md · README.md · docs/ 를 채우고, 코어가 들고
있던 확장 소유 문서 두 갈래를 그 확장으로 옮긴다. 번들 템플릿의 컴포넌트·
핸들러·레이아웃 상세와 확장이 구독하는 활동 로그 훅 목록이 그 대상이며,
코어에는 총계와 링크만 남아 확장이 기능을 늘릴 때 코어 문서를 고쳐야 하던
역방향 의존이 사라진다.

전수 완비를 확인하고 강제를 조인다 — 문서 동반 룰을 대상 목록 없는 error 로
승격하고, 검사 스크립트가 문서 미보유를 실패로 올리며, 미채움 마커 baseline 을
0 으로 기록한다. 한쪽만 조이면 "새 확장이 문서 없이 들어와도 초록" 인 상태가
남는데 그 결과는 이상 0건과 구분되지 않는다.

집필 과정에서 드러난 생성기 결함 셋을 함께 고친다. 스케줄 주기 열이 계약 키를
읽지 않아 모든 확장에서 '-' 였고, 네임스페이스를 붙인 핸들러 등록 키가 수집에서
통째로 빠졌으며, README 골격이 폐기된 히어로 배지를 계속 찍어내고 있었다.
셋 다 산출물이 아니라 원천이 틀린 것이라, 가드의 모집단에 생성기 출력 자체를
넣어 다음 확장이 같은 상태로 태어나는 경로를 막는다.
This commit is contained in:
HeuJung
2026-08-31 22:57:36 +09:00
parent 8328b1db77
commit 6c63536f81
181 changed files with 13287 additions and 1148 deletions
+14 -6
View File
@@ -10,7 +10,7 @@
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 확장 132훅 = 총 198훅 (확장별 목록은 그 확장이 소유) |
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... |
@@ -47,7 +47,7 @@
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
### 프론트엔드 [frontend/](docs/frontend/) (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 |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
+2 -1
View File
@@ -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
@@ -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[] = '<p align="center">';
$lines[] = sprintf(
' <img src="https://img.shields.io/badge/%s-%s-000000?style=for-the-badge&labelColor=0066FF" height="60" alt="%s">',
rawurlencode(str_replace(['-', '_'], ['--', '__'], $name)),
rawurlencode(str_replace(['-', '_'], ['--', '__'], $record['id'])),
$this->escape($name),
);
$lines[] = '</p>';
$lines[] = '# '.$name;
$lines[] = '';
$lines[] = '<p align="center">';
$lines[] = " <strong>G7 {$label} · {$record['id']}</strong><br>";
$lines[] = ' '.$this->escape($description);
$lines[] = '</p>';
$lines[] = "**G7 {$label} · {$record['id']}**";
$lines[] = $this->escape($description);
$lines[] = '';
$lines[] = self::wrap('badges', $this->renderBlock('badges', $ctx));
$lines[] = '';
@@ -465,6 +465,15 @@ class FrontendInventory
}
if (preg_match('/^([A-Za-z_$][\w$]*)\s*[,:]/', $line, $km)) {
$keys[] = $km[1];
continue;
}
// 네임스페이스를 붙인 등록 키(`'vendor-ext.doThing': handler`)는 `.`·`-` 때문에
// 반드시 따옴표로 감싸인다. 식별자 키만 보면 그 항목이 통째로 빠지는데, 결과가
// "그만큼만 등록했다" 와 같은 모양이라 누락이 드러나지 않는다 (sirsoft-basic 이
// 32개 중 10개를 그렇게 잃고 있었다).
if (preg_match('/^["\']([^"\']+)["\']\s*:/', $line, $km)) {
$keys[] = $km[1];
}
}
+3 -6
View File
@@ -10,7 +10,7 @@
| 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------|
| [백엔드](backend/) | 37개 | 정상 |
| [프론트엔드](frontend/) | 49개 | 정상 |
| [프론트엔드](frontend/) | 46개 | 정상 |
| [확장 시스템](extension/) | 32개 | 정상 |
| 공통 | 20개 | 정상 |
| [AI 도구](ai-tools/) | - | 정상 |
@@ -43,7 +43,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| 4 | [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) | extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입) |
| 5 | [컴포넌트 개발 규칙](frontend/components.md) | HTML 태그 직접 사용 금지 |
| 6 | [컴포넌트 Props 레퍼런스](frontend/component-props.md) | - |
| 7 | [sirsoft-basic 컴포넌트](frontend/templates/sirsoft-basic/components.md) | Basic (26개), Composite 등 |
| 7 | [sirsoft-basic 컴포넌트](../templates/_bundled/sirsoft-basic/docs/components.md) | 확장 소유 문서 (basic / composite / layout) |
| 8 | [데이터 바인딩 및 표현식](frontend/data-binding.md) | API 데이터: {{user.name}}, URL 파라미터: {{route.id}} |
| 9 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - |
| 10 | [액션 핸들러 가이드](frontend/actions.md) | 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션) |
@@ -169,7 +169,7 @@ sirsoft-admin_basic 컴포넌트 문서는 확장이 소유합니다 — [templa
| [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) |
| [validation.md](backend/validation.md) | 검증 (Validation) |
### 프론트엔드 (49개)
### 프론트엔드 (46개)
| 문서 | 제목 |
|------|------|
@@ -219,9 +219,6 @@ sirsoft-admin_basic 컴포넌트 문서는 확장이 소유합니다 — [templa
| [template-development.md](frontend/template-development.md) | 템플릿 개발 가이드라인 |
| [template-handlers.md](frontend/template-handlers.md) | 템플릿 전용 핸들러 |
| [README.md](frontend/README.md) | 템플릿별 컴포넌트·핸들러·레이아웃 문서 |
| [components.md](frontend/components.md) | sirsoft-basic 컴포넌트 |
| [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 |
| [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 |
### 확장 시스템 (32개)
+1 -1
View File
@@ -174,7 +174,7 @@ global.window = {
- docs/extension/template-basics.md
- docs/extension/template-commands.md
- templates/_bundled/sirsoft-admin_basic/docs/components.md
- docs/frontend/templates/sirsoft-basic/components.md
- templates/_bundled/sirsoft-basic/docs/components.md
## 테스트 실행
\`\`\`powershell
+1 -1
View File
@@ -30,7 +30,7 @@
| 문서 | 제목 | 핵심 내용 |
|------|------|----------|
| [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 확장 132훅 = 총 198훅 (확장별 목록은 그 확장이 소유) |
| [activity-log.md](activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel... |
| [admin-settings-access.md](admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → Setting... |
| [api-documentation.md](api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 +... |
+42 -343
View File
@@ -5,7 +5,7 @@
## TL;DR (5초 요약)
```text
1. 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅
1. 코어 66훅 + 확장 132훅 = 총 198훅 (확장별 목록은 그 확장이 소유)
2. Listener에서 Log::channel('activity')->info() 직접 호출 (Monolog → ActivityLogHandler → DB)
3. 스냅샷 패턴: before_update(priority 5) → 캡처, after_update → ChangeDetector로 비교
4. 사용자 행위: ActivityLogType::User (장바구니/위시리스트/쿠폰 다운로드/주문/결제)
@@ -18,11 +18,9 @@
1. [아키텍처 개요](#1-아키텍처-개요)
2. [코어 훅 (CoreActivityLogListener)](#2-코어-훅-coreactivityloglistener)
3. [이커머스 모듈 훅](#3-이커머스-모듈-훅)
4. [게시판 모듈 훅 (BoardActivityLogListener)](#4-게시판-모듈-훅-boardactivityloglistener)
5. [페이지 모듈 훅 (PageActivityLogListener)](#5-페이지-모듈-훅-pageactivityloglistener)
6. [스냅샷/변경감지 패턴](#6-스냅샷변경감지-패턴)
7. [새 모듈에 ActivityLog 추가하기](#7-새-모듈에-activitylog-추가하기)
3. [확장 모듈 훅](#3-확장-모듈-훅)
4. [스냅샷/변경감지 패턴](#4-스냅샷변경감지-패턴)
5. [새 모듈에 ActivityLog 추가하기](#5-새-모듈에-activitylog-추가하기)
---
@@ -192,344 +190,30 @@ Service → doAction('hook.name') → ActivityLogListener → Log::channel('acti
---
## 3. 이커머스 모듈 훅
## 3. 확장 모듈 훅
**모듈**: `sirsoft-ecommerce`
**총 92훅** (7개 Listener)
확장이 구독하는 활동 로그 훅 목록은 **그 확장이 소유**합니다(#601). 확장이 훅을 추가할 때
코어 문서를 고쳐야 하는 역방향 의존을 없애기 위해서이며, 코어에는 아래 총계와 링크만 남습니다.
### 3.1 OrderActivityLogListener (21훅)
| 확장 | 훅 수 | 문서 |
|------|------|------|
| `sirsoft-ecommerce` | 92 | [docs/extension-points.md](../../modules/_bundled/sirsoft-ecommerce/docs/extension-points.md) |
| `sirsoft-board` | 30 | [docs/extension-points.md](../../modules/_bundled/sirsoft-board/docs/extension-points.md) |
| `sirsoft-page` | 7 | [docs/extension-points.md](../../modules/_bundled/sirsoft-page/docs/extension-points.md) |
| `sirsoft-pay_kginicis` | 1 | [docs/extension-points.md](../../plugins/_bundled/sirsoft-pay_kginicis/docs/extension-points.md) |
| `sirsoft-pay_nhnkcp` | 1 | [docs/extension-points.md](../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/extension-points.md) |
| `sirsoft-pay_nicepayments` | 1 | [docs/extension-points.md](../../plugins/_bundled/sirsoft-pay_nicepayments/docs/extension-points.md) |
| **합계** | **132** | |
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/OrderActivityLogListener.php`
코어 66훅 + 확장 132훅 = **총 198훅**입니다.
#### OrderService (8훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.order.before_update` | `captureOrderSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.order.after_update` | `handleOrderAfterUpdate` | `order.update` | Admin | Order |
| `sirsoft-ecommerce.order.after_delete` | `handleOrderAfterDelete` | `order.delete` | Admin | Order |
| `sirsoft-ecommerce.order.after_bulk_update` | `handleOrderAfterBulkUpdate` | `order.bulk_update` | Admin | - |
| `sirsoft-ecommerce.order.after_bulk_status_update` | `handleOrderAfterBulkStatusUpdate` | `order.bulk_status_update` | Admin | - |
| `sirsoft-ecommerce.order.after_bulk_shipping_update` | `handleOrderAfterBulkShippingUpdate` | `order.bulk_shipping_update` | Admin | - |
| `sirsoft-ecommerce.order.after_update_shipping_address` | `handleOrderAfterUpdateShippingAddress` | `order.update_shipping_address` | Admin | Order |
| `sirsoft-ecommerce.order.after_send_email` | `handleOrderAfterSendEmail` | `order.send_email` | Admin | - |
#### OrderOptionService (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.order_option.after_status_change` | `handleOrderOptionAfterStatusChange` | `order_option.status_change` | Admin | OrderOption |
| `sirsoft-ecommerce.order_option.after_bulk_status_change` | `handleOrderOptionAfterBulkStatusChange` | `order_option.bulk_status_change` | Admin | - |
#### OrderCancellationService (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.order.before_cancel` | `captureOrderCancelSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.order.after_cancel` | `handleOrderAfterCancel` | `order.cancel` | Admin | Order |
| `sirsoft-ecommerce.order.after_partial_cancel` | `handleOrderAfterPartialCancel` | `order.partial_cancel` | Admin | Order |
| `sirsoft-ecommerce.coupon.restore` | `handleCouponRestore` | `coupon.restore` | Admin | Order |
| `sirsoft-ecommerce.mileage.restore` | `handleMileageRestore` | `mileage.restore` | Admin | Order |
#### OrderProcessingService (6훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.order.after_create` | `handleOrderAfterCreate` | `order.create` | **User** | Order |
| `sirsoft-ecommerce.order.after_payment_complete` | `handleOrderAfterPaymentComplete` | `order.payment_complete` | **User** | Order |
| `sirsoft-ecommerce.order.payment_failed` | `handleOrderAfterPaymentFailed` | `order.payment_failed` | **User** | Order |
| `sirsoft-ecommerce.coupon.use` | `handleCouponUse` | `coupon.use` | **User** | Order |
| `sirsoft-ecommerce.mileage.use` | `handleMileageUse` | `mileage.use` | **User** | Order |
| `sirsoft-ecommerce.mileage.earn` | `handleMileageEarn` | `mileage.earn` | **User** | Order |
### 3.2 ProductActivityLogListener (10훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/ProductActivityLogListener.php`
> 이 리스너는 `ProductLogService`를 사용하는 별도 패턴입니다 (Log::channel 대신).
| 훅 이름 | Listener 메서드 | Priority | 비고 |
|---------|----------------|----------|------|
| `sirsoft-ecommerce.product.after_create` | `logCreated` | 50 | 상품 생성 로그 |
| `sirsoft-ecommerce.product.before_update` | `captureSnapshot` | 5 | 스냅샷 캡처 |
| `sirsoft-ecommerce.product.after_update` | `logUpdated` | 50 | 변경사항 비교 후 로그 |
| `sirsoft-ecommerce.product.before_delete` | `logDeleted` | 50 | 삭제 전 로그 기록 |
| `sirsoft-ecommerce.product.before_bulk_update` | `captureProductBulkUpdateSnapshot` | 5 | 일괄 수정 전 스냅샷 |
| `sirsoft-ecommerce.product.after_bulk_update` | `handleProductAfterBulkUpdate` | 20 | 일괄 수정 로그 |
| `sirsoft-ecommerce.product.before_bulk_price_update` | `captureProductBulkPriceSnapshot` | 5 | 일괄 가격 수정 전 스냅샷 |
| `sirsoft-ecommerce.product.after_bulk_price_update` | `handleProductAfterBulkPriceUpdate` | 20 | 일괄 가격 수정 로그 |
| `sirsoft-ecommerce.product.before_bulk_stock_update` | `captureProductBulkStockSnapshot` | 5 | 일괄 재고 수정 전 스냅샷 |
| `sirsoft-ecommerce.product.after_bulk_stock_update` | `handleProductAfterBulkStockUpdate` | 20 | 일괄 재고 수정 로그 |
### 3.3 CouponActivityLogListener (6훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/CouponActivityLogListener.php`
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.coupon.after_create` | `handleAfterCreate` | `coupon.create` | Admin | Coupon |
| `sirsoft-ecommerce.coupon.before_update` | `captureCouponSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.coupon.after_update` | `handleAfterUpdate` | `coupon.update` | Admin | Coupon |
| `sirsoft-ecommerce.coupon.after_delete` | `handleAfterDelete` | `coupon.delete` | Admin | - |
| `sirsoft-ecommerce.coupon.before_bulk_status` | `captureCouponBulkStatusSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.coupon.after_bulk_status` | `handleAfterBulkStatus` | `coupon.bulk_status` | Admin | - |
### 3.4 ShippingPolicyActivityLogListener (8훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/ShippingPolicyActivityLogListener.php`
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.shipping_policy.after_create` | `handleAfterCreate` | `shipping_policy.create` | Admin | ShippingPolicy |
| `sirsoft-ecommerce.shipping_policy.before_update` | `captureSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.shipping_policy.after_update` | `handleAfterUpdate` | `shipping_policy.update` | Admin | ShippingPolicy |
| `sirsoft-ecommerce.shipping_policy.after_delete` | `handleAfterDelete` | `shipping_policy.delete` | Admin | - |
| `sirsoft-ecommerce.shipping_policy.after_toggle_active` | `handleAfterToggleActive` | `shipping_policy.toggle_active` | Admin | ShippingPolicy |
| `sirsoft-ecommerce.shipping_policy.after_set_default` | `handleAfterSetDefault` | `shipping_policy.set_default` | Admin | ShippingPolicy |
| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | `handleAfterBulkDelete` | `shipping_policy.bulk_delete` | Admin | - |
| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | `handleAfterBulkToggleActive` | `shipping_policy.bulk_toggle_active` | Admin | - |
### 3.5 CategoryActivityLogListener (6훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/CategoryActivityLogListener.php`
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.category.after_create` | `handleAfterCreate` | `category.create` | Admin | Category |
| `sirsoft-ecommerce.category.before_update` | `captureCategorySnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.category.after_update` | `handleAfterUpdate` | `category.update` | Admin | Category |
| `sirsoft-ecommerce.category.after_delete` | `handleAfterDelete` | `category.delete` | Admin | - |
| `sirsoft-ecommerce.category.after_toggle_status` | `handleAfterToggleStatus` | `category.toggle_status` | Admin | Category |
| `sirsoft-ecommerce.category.after_reorder` | `handleAfterReorder` | `category.reorder` | Admin | - |
### 3.6 EcommerceAdminActivityLogListener (51훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/EcommerceAdminActivityLogListener.php`
#### Brand (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.brand.after_create` | `handleBrandAfterCreate` | `brand.create` | Admin | Brand |
| `sirsoft-ecommerce.brand.before_update` | `captureBrandSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.brand.after_update` | `handleBrandAfterUpdate` | `brand.update` | Admin | Brand |
| `sirsoft-ecommerce.brand.after_delete` | `handleBrandAfterDelete` | `brand.delete` | Admin | Brand |
| `sirsoft-ecommerce.brand.after_toggle_status` | `handleBrandAfterToggleStatus` | `brand.toggle_status` | Admin | Brand |
#### ProductLabel (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.label.after_create` | `handleLabelAfterCreate` | `label.create` | Admin | ProductLabel |
| `sirsoft-ecommerce.label.before_update` | `captureLabelSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.label.after_update` | `handleLabelAfterUpdate` | `label.update` | Admin | ProductLabel |
| `sirsoft-ecommerce.label.after_delete` | `handleLabelAfterDelete` | `label.delete` | Admin | ProductLabel |
| `sirsoft-ecommerce.label.after_toggle_status` | `handleLabelAfterToggleStatus` | `label.toggle_status` | Admin | ProductLabel |
#### ProductCommonInfo (4훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product-common-info.after_create` | `handleCommonInfoAfterCreate` | `common_info.create` | Admin | ProductCommonInfo |
| `sirsoft-ecommerce.product-common-info.before_update` | `captureCommonInfoSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product-common-info.after_update` | `handleCommonInfoAfterUpdate` | `common_info.update` | Admin | ProductCommonInfo |
| `sirsoft-ecommerce.product-common-info.after_delete` | `handleCommonInfoAfterDelete` | `common_info.delete` | Admin | ProductCommonInfo |
#### ProductNoticeTemplate (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product-notice-template.after_create` | `handleNoticeTemplateAfterCreate` | `notice_template.create` | Admin | ProductNoticeTemplate |
| `sirsoft-ecommerce.product-notice-template.before_update` | `captureNoticeTemplateSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product-notice-template.after_update` | `handleNoticeTemplateAfterUpdate` | `notice_template.update` | Admin | ProductNoticeTemplate |
| `sirsoft-ecommerce.product-notice-template.after_delete` | `handleNoticeTemplateAfterDelete` | `notice_template.delete` | Admin | ProductNoticeTemplate |
| `sirsoft-ecommerce.product-notice-template.after_copy` | `handleNoticeTemplateAfterCopy` | `notice_template.copy` | Admin | ProductNoticeTemplate |
#### ExtraFeeTemplate (10훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.extra_fee_template.after_create` | `handleExtraFeeAfterCreate` | `extra_fee_template.create` | Admin | ExtraFeeTemplate |
| `sirsoft-ecommerce.extra_fee_template.before_update` | `captureExtraFeeSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.extra_fee_template.after_update` | `handleExtraFeeAfterUpdate` | `extra_fee_template.update` | Admin | ExtraFeeTemplate |
| `sirsoft-ecommerce.extra_fee_template.after_delete` | `handleExtraFeeAfterDelete` | `extra_fee_template.delete` | Admin | ExtraFeeTemplate |
| `sirsoft-ecommerce.extra_fee_template.after_toggle_active` | `handleExtraFeeAfterToggleActive` | `extra_fee_template.toggle_active` | Admin | ExtraFeeTemplate |
| `sirsoft-ecommerce.extra_fee_template.before_bulk_delete` | `captureExtraFeeBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.extra_fee_template.after_bulk_delete` | `handleExtraFeeAfterBulkDelete` | `extra_fee_template.bulk_delete` | Admin | - |
| `sirsoft-ecommerce.extra_fee_template.before_bulk_toggle_active` | `captureExtraFeeBulkToggleSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.extra_fee_template.after_bulk_toggle_active` | `handleExtraFeeAfterBulkToggleActive` | `extra_fee_template.bulk_toggle_active` | Admin | - |
| `sirsoft-ecommerce.extra_fee_template.after_bulk_create` | `handleExtraFeeAfterBulkCreate` | `extra_fee_template.bulk_create` | Admin | - |
#### ShippingCarrier (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.shipping_carrier.after_create` | `handleCarrierAfterCreate` | `shipping_carrier.create` | Admin | ShippingCarrier |
| `sirsoft-ecommerce.shipping_carrier.before_update` | `captureCarrierSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.shipping_carrier.after_update` | `handleCarrierAfterUpdate` | `shipping_carrier.update` | Admin | ShippingCarrier |
| `sirsoft-ecommerce.shipping_carrier.after_delete` | `handleCarrierAfterDelete` | `shipping_carrier.delete` | Admin | ShippingCarrier |
| `sirsoft-ecommerce.shipping_carrier.after_toggle_status` | `handleCarrierAfterToggleStatus` | `shipping_carrier.toggle_status` | Admin | ShippingCarrier |
#### ProductImage (3훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product-image.after_upload` | `handleImageAfterUpload` | `product_image.upload` | Admin | ProductImage |
| `sirsoft-ecommerce.product-image.after_delete` | `handleImageAfterDelete` | `product_image.delete` | Admin | ProductImage |
| `sirsoft-ecommerce.product-image.after_reorder` | `handleImageAfterReorder` | `product_image.reorder` | Admin | - |
#### ShippingPolicy Bulk (4훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.shipping_policy.before_bulk_delete` | `captureShippingPolicyBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.shipping_policy.after_bulk_delete` | `handleShippingPolicyAfterBulkDelete` | `shipping_policy.bulk_delete` | Admin | - |
| `sirsoft-ecommerce.shipping_policy.before_bulk_toggle_active` | `captureShippingPolicyBulkToggleSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.shipping_policy.after_bulk_toggle_active` | `handleShippingPolicyAfterBulkToggleActive` | `shipping_policy.bulk_toggle_active` | Admin | - |
#### ProductOption (6훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product_option.before_bulk_price_update` | `captureOptionBulkPriceSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product_option.after_bulk_price_update` | `handleOptionAfterBulkPriceUpdate` | `product_option.bulk_price_update` | Admin | - |
| `sirsoft-ecommerce.product_option.before_bulk_stock_update` | `captureOptionBulkStockSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product_option.after_bulk_stock_update` | `handleOptionAfterBulkStockUpdate` | `product_option.bulk_stock_update` | Admin | - |
| `sirsoft-ecommerce.option.before_bulk_update` | `captureOptionBulkUpdateSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.option.after_bulk_update` | `handleOptionAfterBulkUpdate` | `product_option.bulk_update` | Admin | - |
#### ProductReview (4훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.product-review.after_create` | `handleReviewAfterCreate` | `review.create` | Admin | ProductReview |
| `sirsoft-ecommerce.product-review.after_delete` | `handleReviewAfterDelete` | `review.delete` | Admin | ProductReview |
| `sirsoft-ecommerce.product-review.before_bulk_delete` | `captureReviewBulkDeleteSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-ecommerce.product-review.after_bulk_delete` | `handleReviewAfterBulkDelete` | `product_review.bulk_delete` | Admin | - |
### 3.7 EcommerceUserActivityLogListener (7훅)
**파일**: `modules/_bundled/sirsoft-ecommerce/src/Listeners/EcommerceUserActivityLogListener.php`
#### Cart (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.cart.after_add` | `handleCartAfterAdd` | `cart.add` | **User** | Cart |
| `sirsoft-ecommerce.cart.after_update_quantity` | `handleCartAfterUpdateQuantity` | `cart.update_quantity` | **User** | Cart |
| `sirsoft-ecommerce.cart.after_change_option` | `handleCartAfterChangeOption` | `cart.change_option` | **User** | Cart |
| `sirsoft-ecommerce.cart.after_delete` | `handleCartAfterDelete` | `cart.delete` | **User** | - |
| `sirsoft-ecommerce.cart.after_delete_all` | `handleCartAfterDeleteAll` | `cart.delete_all` | **User** | - |
#### Wishlist (1훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.wishlist.after_toggle` | `handleWishlistAfterToggle` | `wishlist.add` / `wishlist.remove` | **User** | Product |
#### User Coupon (1훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-ecommerce.user_coupon.after_download` | `handleUserCouponAfterDownload` | `user_coupon.download` | **User** | CouponIssue |
확장에 새 활동 로그 항목을 추가할 때도 **다국어 키는 코어가 SSoT** 입니다 — action 라벨과
description 본문을 코어 `lang/{ko,en}/activity_log.php` 에 정의해야 하며, 모듈 lang 파일에
넣으면 해석되지 않습니다. 번들 일본어 팩도 같은 작업 단위에서 동기화합니다.
---
## 4. 게시판 모듈 훅 (BoardActivityLogListener)
**파일**: `modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php`
**총 32훅**
### Board (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.board.after_create` | `handleBoardAfterCreate` | `board.create` | Admin | Board |
| `sirsoft-board.board.before_update` | `captureBoardSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.board.after_update` | `handleBoardAfterUpdate` | `board.update` | Admin | Board |
| `sirsoft-board.board.after_delete` | `handleBoardAfterDelete` | `board.delete` | Admin | Board |
| `sirsoft-board.board.after_add_to_menu` | `handleBoardAfterAddToMenu` | `board.add_to_menu` | Admin | Board |
| `sirsoft-board.settings.after_bulk_apply` | `handleSettingsAfterBulkApply` | `board_settings.bulk_apply` | Admin | - |
### BoardType (4훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.board_type.after_create` | `handleBoardTypeAfterCreate` | `board_type.create` | Admin | BoardType |
| `sirsoft-board.board_type.before_update` | `captureBoardTypeSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.board_type.after_update` | `handleBoardTypeAfterUpdate` | `board_type.update` | Admin | BoardType |
| `sirsoft-board.board_type.after_delete` | `handleBoardTypeAfterDelete` | `board_type.delete` | Admin | BoardType |
### Post (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.post.after_create` | `handlePostAfterCreate` | `post.create` | Admin | Post |
| `sirsoft-board.post.before_update` | `capturePostSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.post.after_update` | `handlePostAfterUpdate` | `post.update` | Admin | Post |
| `sirsoft-board.post.after_delete` | `handlePostAfterDelete` | `post.delete` | Admin | Post |
| `sirsoft-board.post.after_blind` | `handlePostAfterBlind` | `post.blind` | Admin | Post |
| `sirsoft-board.post.after_restore` | `handlePostAfterRestore` | `post.restore` | Admin | Post |
### Comment (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.comment.after_create` | `handleCommentAfterCreate` | `comment.create` | Admin | Comment |
| `sirsoft-board.comment.before_update` | `captureCommentSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.comment.after_update` | `handleCommentAfterUpdate` | `comment.update` | Admin | Comment |
| `sirsoft-board.comment.after_delete` | `handleCommentAfterDelete` | `comment.delete` | Admin | Comment |
| `sirsoft-board.comment.after_blind` | `handleCommentAfterBlind` | `comment.blind` | Admin | Comment |
| `sirsoft-board.comment.after_restore` | `handleCommentAfterRestore` | `comment.restore` | Admin | Comment |
### Attachment (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.attachment.after_upload` | `handleAttachmentAfterUpload` | `attachment.upload` | Admin | Attachment |
| `sirsoft-board.attachment.after_delete` | `handleAttachmentAfterDelete` | `attachment.delete` | Admin | Attachment |
### Report (8훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.report.after_create` | `handleReportAfterCreate` | `report.create` | Admin | Report |
| `sirsoft-board.report.after_update_status` | `handleReportAfterUpdateStatus` | `report.update_status` | Admin | Report |
| `sirsoft-board.report.before_bulk_update_status` | `captureReportBulkStatusSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-board.report.after_bulk_update_status` | `handleReportAfterBulkUpdateStatus` | `report.bulk_update_status` | Admin | - |
| `sirsoft-board.report.after_delete` | `handleReportAfterDelete` | `report.delete` | Admin | Report |
| `sirsoft-board.report.after_restore_content` | `handleReportAfterRestoreContent` | `report.restore_content` | Admin | Report |
| `sirsoft-board.report.after_blind_content` | `handleReportAfterBlindContent` | `report.blind_content` | Admin | Report |
| `sirsoft-board.report.after_delete_content` | `handleReportAfterDeleteContent` | `report.delete_content` | Admin | Report |
---
## 5. 페이지 모듈 훅 (PageActivityLogListener)
**파일**: `modules/_bundled/sirsoft-page/src/Listeners/PageActivityLogListener.php`
**총 8훅**
### Page (6훅, 스냅샷 포함)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-page.page.after_create` | `handlePageAfterCreate` | `page.create` | Admin | Page |
| `sirsoft-page.page.before_update` | `capturePageSnapshot` | _(스냅샷 캡처)_ | - | - |
| `sirsoft-page.page.after_update` | `handlePageAfterUpdate` | `page.update` | Admin | Page |
| `sirsoft-page.page.after_delete` | `handlePageAfterDelete` | `page.delete` | Admin | Page |
| `sirsoft-page.page.after_publish` | `handlePageAfterPublish` | `page.publish` / `page.unpublish` | Admin | Page |
| `sirsoft-page.page.after_restore` | `handlePageAfterRestore` | `page.restore` | Admin | Page |
### PageAttachment (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-page.attachment.after_upload` | `handleAttachmentAfterUpload` | `page_attachment.upload` | Admin | PageAttachment |
| `sirsoft-page.attachment.after_delete` | `handleAttachmentAfterDelete` | `page_attachment.delete` | Admin | PageAttachment |
---
## 6. 스냅샷/변경감지 패턴
## 4. 스냅샷/변경감지 패턴
ActivityLog에서 수정(update) 작업의 변경 이력을 기록하려면 **스냅샷 패턴**을 사용합니다.
@@ -592,16 +276,31 @@ public function handleAfterUpdate(Model $entity): void
- `BackedEnum` 자동 변환 지원
- 스냅샷이 `null`이면 `null` 반환 (변경 없음)
### ProductActivityLogListener의 별도 패턴
### 스냅샷은 Service 가 잡아 넘긴다
`ProductActivityLogListener`는 `ProductLogService`를 주입받아 사용하는 별도 패턴입니다:
- `ChangeDetector` 대신 자체 `detectChanges()` 메서드로 변경 감지
- 해시 비교로 옵션/추가옵션/이미지 변경 감지
- `Log::channel('activity')` 대신 `ProductLogService`를 통해 처리로그 테이블에 기록
리스너는 `before_*` 훅을 구독해 스냅샷을 잡지 않습니다. **Service 가 수정 직전에 스냅샷을
만들어 `after_*` 훅의 인자로 넘기고**, 리스너는 그것을 `ChangeDetector::detect()` 에 그대로
전달합니다:
```php
// Service (예: ProductService::update())
HookManager::doAction('sirsoft-ecommerce.product.after_update', $product, $snapshot);
// Listener
public function handleProductAfterUpdate(Product $product, ?array $snapshot = null): void
{
$changes = ChangeDetector::detect($product, $snapshot);
// ...
}
```
`before_*` 훅 자체는 발행되므로 다른 확장이 구독할 수 있습니다 — 다만 **활동 로그 리스너의
구독 대상은 아닙니다.** 확장별 구독 목록에서 `before_*` 가 보이지 않는 것은 누락이 아니라
이 구조 때문입니다.
---
## 7. 새 모듈에 ActivityLog 추가하기
## 5. 새 모듈에 ActivityLog 추가하기
### Step 1: Listener 클래스 생성
+14
View File
@@ -110,6 +110,20 @@
mermaid 문법 오류는 렌더 시점에만 드러나므로, 새 형식을 도입할 때는 실제 렌더를 눈으로 확인한다.
### README 첫 화면
확장명은 히어로 이미지 배지가 아니라 평범한 H1 제목으로 적는다. 확장은 서로 대등하게 병렬로
존재하는 구성요소이고, 각자가 코어와 같은 히어로 브랜딩을 달면 그 확장 하나가 독립 프로젝트인
것처럼 보인다. `@generated:badges` 블록의 버전·유형·코어 제약·라이선스 배지는 manifest 에서
오는 정보 표시이므로 그대로 둔다.
```markdown
# 페이지
**G7 모듈 · sirsoft-page**
정적 페이지(정보/정책/안내) 관리 모듈
```
## 4. 자동 생성 블록 규약
생성기는 **마커 안쪽만** 쓴다.
-6
View File
@@ -36,12 +36,6 @@
---
<!-- AUTO-GENERATED-START: frontend-readme-docs -->
### 템플릿별 레퍼런스
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|--------------|---------|--------|--------|
| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) |
### 컴포넌트 개발
| 문서 | 설명 |
+1 -1
View File
@@ -1145,6 +1145,6 @@ id prop 사용: scrollIntoView 등 DOM selector로 접근해야 할 때 필수
- [컴포넌트 개발 규칙](components.md) - basic, composite, layout 컴포넌트
- [레이아웃 JSON 스키마](layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법
- [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 컴포넌트 목록 (확장 소유)
- [에디터 컴포넌트](editors.md) - HtmlEditor, CodeEditor 상세 가이드
- [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어
+1 -1
View File
@@ -453,4 +453,4 @@ export const Container: React.FC<ContainerProps> = ({ 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)
+1 -6
View File
@@ -25,11 +25,6 @@
| [components-advanced.md](components-advanced.md) | componentEvent, 아이콘, 체크리스트 | 이벤트 통신, 아이콘 규칙, 개발 체크리스트 |
<!-- AUTO-GENERATED-START: frontend-template-reference -->
### 템플릿별 레퍼런스
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|--------------|---------|--------|--------|
| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) |
<!-- AUTO-GENERATED-END: frontend-template-reference -->
@@ -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) - 전역/로컬 상태 관리 및 동기화 패턴
+1 -1
View File
@@ -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)
---
+1 -1
View File
@@ -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)
---
+1 -4
View File
@@ -20,9 +20,6 @@
## 템플릿별 상세 문서
<!-- AUTO-GENERATED-START: frontend-template-handlers -->
| 템플릿 식별자 | 핸들러 문서 | TL;DR 핵심 |
|--------------|-----------|----------|
| `sirsoft-basic` | [handlers.md](templates/sirsoft-basic/handlers.md) | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
<!-- AUTO-GENERATED-END: frontend-template-handlers -->
@@ -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)
+1 -1
View File
@@ -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)
를 참고하세요.
@@ -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)
@@ -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. 이 확장은 무엇인가
<!-- @intent START -->
**학습용 최소 샘플 모듈**입니다. 실제 업무 기능을 제공하지 않으며, 모듈이 필요로 하는
계층을 **하나씩만** 담아 "모듈은 이런 모양이다" 를 보여주는 것이 유일한 목적입니다.
도메인은 메모(Memo) 하나이고 필드는 셋뿐입니다. 그 위에 Model · Migration · Factory ·
Seeder · Repository(인터페이스 + 구현) · Service · FormRequest · Resource · Controller ·
Listener · Layout · Test · 다국어(백엔드 PHP + 프론트 JSON)가 각 1개씩 있습니다. 실제
모듈은 이 계층을 엔티티 수만큼 늘린 것입니다.
**설계 원칙: 짧게 유지한다.** 샘플의 가치는 완결성이 아니라 **한눈에 읽히는 것**입니다.
여기에 기능을 더하면 계층 구조를 보러 온 사람이 도메인 로직을 읽게 되므로, 새 기능이
필요하면 이 샘플이 아니라 별도 확장을 만듭니다.
`manifest.hidden = true` 라 관리자 UI 의 모듈 목록에 나타나지 않습니다. artisan CLI 로는
정상 설치·활성화됩니다 — 학습용이 운영 화면에 섞이지 않게 하면서도 실제로 동작해 봐야
학습이 되기 때문입니다.
**의도적으로 하지 않는 것**: 검색 색인·SEO·알림·스케줄·미들웨어·브로드캐스트. 각 축의 사용법은
그것을 실제로 쓰는 확장(게시판·이커머스)의 문서가 다룹니다.
<!-- @intent END -->
## 2. 디렉토리 지도
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update gnuboard7-hello_module --force` (빌드 불필요) |
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan module:update gnuboard7-hello_module --force` |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
계층 하나씩을 지나는 **가장 짧은 CRUD** 가 이 샘플의 전부입니다.
**메모 생성**: `Admin\MemoController::store()` → `MemoRequest`(검증) →
`MemoService::create()` → `MemoRepositoryInterface`(인터페이스 주입) → `MemoRepository` →
`Memo` 모델. 저장 직후 `gnuboard7-hello_module.memo.created` 액션 훅을 발행하고,
`LogMemoCreatedListener` 가 그것을 받아 로그를 남깁니다.
이 한 흐름에 G7 모듈의 규약이 전부 들어 있습니다:
- 검증은 Service 가 아니라 **FormRequest** 에 둔다
- Service 는 구체 Repository 가 아니라 **인터페이스**를 주입받는다
- 부가 작업(로그·알림·집계)은 Service 안이 아니라 **훅 리스너**로 뺀다
- 응답 형태는 컨트롤러가 조립하지 않고 **Resource** 가 정한다
**같은 모듈이 자기 훅을 구독하는 것**도 의도된 예시입니다. 실제로는 다른 확장이 구독하지만,
샘플 하나만 설치해도 훅 흐름이 눈에 보이게 하려고 리스너를 같이 넣었습니다 —
`gnuboard7-hello_plugin` 을 함께 설치하면 **바깥에서 구독하는** 모습도 볼 수 있습니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 1개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 1개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 1개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 0개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
발행 훅 하나(`memo.created`)가 전부입니다. 실제 모듈이라면 도메인마다
`before_*` → `filter_*_data` → `after_*` 3단을 두지만, 샘플에서는 **훅이 무엇인지**만
보이면 되므로 하나로 줄였습니다.
`gnuboard7-hello_plugin` 이 이 훅을 구독합니다. 두 샘플을 함께 설치하면 "모듈이 발행하고
플러그인이 받는" 확장 시스템의 기본 관계를 실제로 확인할 수 있습니다 — 플러그인이 그
모듈에 `dependencies` 로 묶여 있는 것도 그 관계의 표현입니다.
`gnuboard7-hello_user_template` 도 이 모듈에 의존합니다. 그쪽은 훅이 아니라 **공개 API 를
`data_sources` 로 소비**하는 관계이며, 모듈이 데이터를, 템플릿이 화면을 담당하는 경계를
보여줍니다.
미들웨어·브로드캐스트 채널·스케줄·알림은 없습니다. 샘플에 넣으면 계층 구조를 보러 온 사람이
읽어야 할 코드가 늘어납니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update gnuboard7-hello_module --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:gnuboard7-hello_module` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] 계층을 늘리기 전에 "이것이 샘플에 필요한가" 를 먼저 묻는다 — 샘플의 가치는 한눈에 읽히는 것이다
- [ ] `manifest.hidden = true` 를 유지 (복제본에서만 제거)
- [ ] 규약(FormRequest 검증 · Repository 인터페이스 주입 · 훅으로 부가작업 분리)이 흐트러지지 않았는지 확인 — 이 코드는 본보기로 읽힌다
- [ ] `docs/extension/sample-extensions.md` 의 계층 표와 어긋나지 않는지 확인 (파일을 추가·삭제했다면 그 표도 갱신)
- [ ] 발행 훅 이름을 바꾸면 `gnuboard7-hello_plugin` 의 구독이 조용히 끊긴다
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 이 샘플에 기능을 더해 "쓸모 있게" 만들기 | 짧게 유지하고, 필요한 기능은 별도 확장으로 | 샘플의 가치는 한눈에 읽히는 것이다. 계층을 보러 온 사람이 도메인 로직을 읽게 되면 목적이 사라진다 |
| `manifest.hidden` 을 제거 | 그대로 둔다 (복제본에서만 제거) | 학습용 모듈이 운영 사이트의 모듈 목록에 섞인다 |
| 복제해 새 모듈을 만들면서 `hidden` 을 남겨 두기 | 복제본에서는 제거하거나 `false` | 새 모듈이 관리자 UI 에 나타나지 않는다 |
| 복제 후 식별자·네임스페이스를 부분만 치환 | `gnuboard7-hello_module` · `Gnuboard7\HelloModule` · `hello_module` · `Memo` 계열을 **전부** 치환 | 남은 옛 이름이 오토로드 실패나 테이블 이름 충돌로 나타난다 |
| 검증 로직을 `MemoService` 에 넣기 | `MemoRequest` (FormRequest) | 샘플이 잘못된 본을 보이면 그것을 따라 한 모듈이 전부 같은 형태가 된다 |
| `MemoService` 가 `MemoRepository` 구체 클래스를 타입힌트 | `MemoRepositoryInterface` | 위와 같은 이유 — 샘플은 규약의 본보기다 |
| 훅 발행 없이 Service 안에서 로그·알림을 직접 수행 | 훅 발행 + 리스너 | 부가 작업이 Service 에 쌓이면 그 Service 를 재사용할 수 없다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 3개 | `modules/_bundled/gnuboard7-hello_module/tests` |
| Vitest | 0개 | — |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 0개 | — |
기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit modules/_bundled/gnuboard7-hello_module/tests --filter='<대상클래스>'
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @generated:test-commands END -->
## 8. 문서 목차
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
@@ -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
@@ -0,0 +1,176 @@
# Hello 모듈
**G7 모듈 · gnuboard7-hello_module**
학습용 최소 샘플 모듈 (Memo CRUD)
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-0.1.2-0066FF?style=flat-square" alt="version 0.1.2">
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.0-1F883D?style=flat-square" alt="G7 &gt;=7.0.0">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
그누보드7 **모듈이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 업무에 쓰는 기능은
없고, 메모를 등록·수정·삭제하는 가장 단순한 화면 하나가 전부입니다.
모듈을 처음 만들어 보는 개발자가 "무엇을 어디에 두어야 하는가" 를 파악하는 데 쓰거나, 새
모듈을 시작할 때 **복제해서 이름만 바꾸는 출발점**으로 씁니다.
관리자 화면의 모듈 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로는
정상적으로 설치·활성화할 수 있으며, 설치하면 관리자에 "Hello 메모" 메뉴가 생깁니다.
이 샘플은 짧게 유지하는 것이 원칙입니다 — 기능이 늘어나면 구조를 보러 온 사람이 읽어야 할
코드가 함께 늘어나기 때문입니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 메모 관리 | 제목·내용으로 메모를 등록·수정·삭제하는 관리자 화면 |
| 메모 목록 | 방문자 화면용 목록 레이아웃 1개 (사용자 템플릿 연동 예시) |
| 권한 | 메모 관리 권한 4종(읽기·생성·수정·삭제) |
| 다국어 | 한국어·영어 (관리자 문구와 화면 문구 각각) |
| 확장 지점 | 메모 생성 시점 알림용 연결점 1개 |
| 테스트 | 기능 테스트와 단위 테스트 예시 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
A[운영자] -->|메모 등록| ADM[관리자 화면]
ADM --> SVC[메모 처리]
SVC --> DB[(메모 데이터)]
SVC -->|생성 알림| L[연결된 확장]
T[사용자 템플릿] -->|목록 조회| SVC
```
운영자가 메모를 등록하면 저장과 함께 "메모가 생성되었다" 는 신호가 나갑니다. 다른 확장은 그
신호를 받아 자기 일을 할 수 있습니다 — 같이 제공되는 학습용 플러그인이 그 예입니다.
실제 모듈도 구조는 같고, 다루는 대상과 규모만 다릅니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.0` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan module:install gnuboard7-hello_module
# 활성화
php artisan module:activate gnuboard7-hello_module
# 업데이트 (번들 소스 기준 강제 반영)
php artisan module:update gnuboard7-hello_module --force
```
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_별도의 관리자 설정 항목이 없습니다._
<!-- @generated:settings-summary END -->
<!-- @intent START -->
설정 항목이 없습니다. 이 샘플은 설정 화면 없이도 모듈의 계층 구조를 보여줄 수 있어 일부러
두지 않았습니다.
설정 화면이 있는 모듈의 예를 보려면 함께 제공되는 학습용 플러그인
(`gnuboard7-hello_plugin`)을 참고합니다 — 그쪽에 설정 스키마와 설정 화면 예시가 있습니다.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**설치해 보기**: 관리자 화면에는 나타나지 않으므로 명령줄로 설치합니다.
```bash
php artisan module:install gnuboard7-hello_module
php artisan module:activate gnuboard7-hello_module
```
활성화하면 관리자에 "Hello 메모" 메뉴가 생깁니다. 메모를 몇 건 등록해 보면 목록·작성 화면과
권한이 어떻게 맞물리는지 확인할 수 있습니다.
**새 모듈의 출발점으로 쓰기**: 이 디렉토리를 복제한 뒤 식별자·네임스페이스·도메인 이름을 모두
바꾸고, `hidden` 표시를 지우면 새 모듈이 됩니다. 자세한 절차는 확장 시스템 문서의 "학습용 샘플
확장" 항목을 참고합니다.
**함께 보면 좋은 것**: 학습용 플러그인·관리자 템플릿·사용자 템플릿 샘플이 함께 제공됩니다. 넷을
모두 설치하면 모듈이 데이터를, 템플릿이 화면을, 플러그인이 부가 동작을 담당하는 구조를 한 번에
볼 수 있습니다.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `gnuboard7-hello_plugin` | 플러그인 | `>=0.1.0` |
| `gnuboard7-hello_user_template` | 템플릿 | `>=0.1.0` |
<!-- @generated:integrations END -->
## 문서
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
## 트러블슈팅
<!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 관리자 모듈 목록에 이 모듈이 없음 | 학습용이라 목록에서 제외됨 | 정상입니다. 명령줄로 설치·활성화합니다 |
| 복제해서 만든 모듈이 관리자 목록에 안 보임 | 복제본에 학습용 표시가 남아 있음 | 복제본의 `hidden` 표시를 지웁니다 |
| 복제 후 설치하면 오류가 남 | 식별자·네임스페이스 치환이 일부만 이루어짐 | 옛 이름이 남아 있는지 전체 검색으로 확인하고 오토로드를 갱신합니다 |
| "Hello 메모" 메뉴가 보이지 않음 | 그 계정 역할에 메모 관리 권한이 없음 | 역할에 메모 관리 권한을 부여합니다 |
| 메모를 등록해도 아무 일도 일어나지 않음 | 학습용 플러그인이 설치되지 않음 | 생성 신호를 받아 동작하는 예시는 그 플러그인에 있습니다 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -2,7 +2,7 @@
"name": "modules/gnuboard7-hello_module",
"description": "Hello sample module for Gnuboard7 (learning purpose)",
"type": "library",
"version": "0.1.1",
"version": "0.1.2",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -0,0 +1,22 @@
# Hello 모듈 개발자 문서
> modules/_bundled/gnuboard7-hello_module · 모듈
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 1 · **구독 훅 수**: 1 · **라우트 수**: 7 · **모델 수**: 1 · **테이블 수**: 1 · **마이그레이션 수**: 1 · **레이아웃 수**: 3 · **핸들러 수**: 0
<!-- @generated:stats END -->
## 문서 목차
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 |
|---|---|
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [api/](api/README.md) | API 레퍼런스 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,79 @@
# Hello 모듈 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
"모듈이 필요로 하는 계층을 **하나씩만** 담는다" 가 이 확장의 유일한 설계 목표입니다. 도메인은
메모 하나, 필드는 셋뿐이고, 그 위에 Model · Migration · Factory · Seeder · Repository(인터페이스
+ 구현) · Service · FormRequest · Resource · Controller · Listener · Layout · Test · 다국어가
각 1개씩 있습니다. 실제 모듈은 이 계층을 엔티티 수만큼 늘린 것입니다.
**짧게 유지하는 것이 기능보다 우선입니다.** 샘플의 가치는 완결성이 아니라 한눈에 읽히는
것이므로, 기능을 더하면 계층 구조를 보러 온 사람이 도메인 로직을 읽게 됩니다.
`manifest.hidden = true` 는 학습용이 운영 사이트의 모듈 목록에 섞이지 않게 하면서도 CLI 로는
실제로 설치·동작하게 하는 장치입니다 — 읽기만 해서는 학습이 되지 않기 때문입니다.
**의도적으로 하지 않는 것**: 검색 색인·SEO·알림·스케줄·미들웨어·브로드캐스트·설정 화면. 각
축의 사용법은 그것을 실제로 쓰는 확장의 문서가 다룹니다. 설정 화면 예시는 함께 제공되는
`gnuboard7-hello_plugin` 에 있습니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
module.php 진입 클래스 — 권한 · 메뉴 · 리스너 선언
│
Http/Controllers/Admin/MemoController RESTful CRUD
│
Http/Requests/Admin/MemoRequest 검증 (Service 가 아니라 여기)
│
Services/MemoService 비즈니스 로직 + 훅 발행
│ Contracts/Repositories/MemoRepositoryInterface ← 이것을 주입받는다
▼
Repositories/MemoRepository Eloquent 구현
│
Models/Memo gnuboard7_hello_module_memos
Http/Resources/MemoResource 응답 형태 (컨트롤러가 조립하지 않는다)
Listeners/LogMemoCreatedListener memo.created 구독 — 부가 작업은 여기
resources/layouts/ admin 2 + user 1
```
이 지도가 곧 **G7 모듈의 규약**입니다 — 검증은 FormRequest, 데이터 접근은 Repository
인터페이스, 부가 작업은 훅 리스너, 응답 형태는 Resource. 샘플이 잘못된 본을 보이면 그것을 따라
한 모듈이 전부 같은 형태가 되므로, 이 네 경계는 편의를 위해서도 흐트러뜨리지 않습니다.
`user_memo_list` 레이아웃 하나가 `user` 그룹인 것에 주의합니다. 실제 도메인 모듈(게시판·
이커머스)은 방문자 화면을 소유하지 않고 템플릿에 맡기지만, 이 샘플은 **모듈도 사용자 레이아웃을
가질 수 있다**는 사실을 보이기 위해 하나를 둡니다.
<!-- @intent END -->
## 디렉토리
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update gnuboard7-hello_module --force` (빌드 불필요) |
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan module:update gnuboard7-hello_module --force` |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,87 @@
# Hello 모듈 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
| `Memo` | `gnuboard7_hello_module_memos` | 3 | - | - |
<!-- @generated:models END -->
<!-- @intent START -->
`Memo` 하나이며 fillable 이 셋뿐입니다. 관계도 특성(SoftDeletes·검색 색인 등)도 없습니다 —
"모델은 이런 모양이다" 를 보이는 데 그 이상이 필요하지 않기 때문입니다.
실제 모듈이 모델에 붙이는 것들(관계·캐스팅·스코프·SoftDeletes·검색 색인·`HasUserOverrides`)은
그것을 실제로 쓰는 확장의 문서를 참고합니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 테이블 | 모델 |
|---|---|
| `gnuboard7_hello_module_memos` | `Memo` |
<!-- @generated:tables END -->
<!-- @intent START -->
`gnuboard7_hello_module_memos` 하나입니다. 테이블 이름에 **확장 식별자 전체가 접두사로**
들어가는 것에 주의합니다 — 확장은 같은 데이터베이스를 공유하므로, 짧은 이름(`memos`)을 쓰면
다른 확장과 충돌합니다.
복제해서 새 모듈을 만들 때 이 접두사도 함께 바꿔야 합니다. 마이그레이션 파일명·클래스 안의
테이블 이름·모델의 `$table` 이 모두 대상입니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
마이그레이션 1개.
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|---|---|---|---|
| `2026_04_21_000001_create_gnuboard7_hello_module_memos_table.php` | `gnuboard7_hello_module_memos` | `gnuboard7_hello_module_memos` | ✅ |
<!-- @generated:migrations END -->
<!-- @intent START -->
하나이며 테이블 생성뿐입니다. 한국어 `comment` 와 `down()` 이 붙어 있는 것이 규약의
본보기입니다.
실제 모듈에서 새 컬럼을 더할 때는 이 `create_*` 파일을 고치지 않습니다 — 이미 설치된 사이트는
그 파일을 다시 실행하지 않으므로 반영되지 않습니다. 새 `add_*` 파일을 더하고, 기존 행을
손봐야 하면 `upgrades/` 의 업그레이드 스텝 백필을 함께 씁니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
없습니다. 메모에는 상태도 분류도 없어 닫힌 어휘가 생기지 않았습니다.
실제 모듈에서 상태·타입·분류를 다룰 때는 문자열 리터럴이 아니라 Enum 을 단일 출처로 둡니다 —
화면 필터 옵션·검증 게이트·실제 기록 값 셋이 같은 Enum 에서 파생되지 않으면, 빠진 값으로
기록된 행이 어떤 필터로도 도달할 수 없게 됩니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `MemoRepository` | 구현 | 메모 Repository 구현체 |
| `MemoRepositoryInterface` | 인터페이스 | 메모 Repository 인터페이스 |
<!-- @generated:repositories END -->
<!-- @intent START -->
인터페이스와 구현이 1:1 로 짝을 이룹니다. **`MemoService` 는 인터페이스만 주입받습니다** —
구체 클래스를 타입힌트하면 그 Service 를 다른 구현으로 바꿀 수 없고, 테스트에서 대역을 끼울
수도 없습니다.
바인딩은 모듈 서비스 프로바이더가 담당합니다. 새 Repository 를 더할 때는 인터페이스·구현·
바인딩 셋을 함께 만듭니다 — 바인딩을 빠뜨리면 주입 시점에 해결 실패로 드러납니다.
<!-- @intent END -->
@@ -0,0 +1,125 @@
# Hello 모듈 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 1종 / 호출 지점 1곳. 이 중 1종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `gnuboard7-hello_module.memo.created` | action | — | `src/Services/MemoService.php:62` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
하나뿐입니다. 실제 모듈이라면 도메인마다 `before_*` → `filter_*_data` → `after_*` 3단을
두지만, 샘플에서는 **훅이 무엇이고 어떻게 발행하는가**만 보이면 되므로 하나로 줄였습니다.
`MemoService::create()` 가 저장 직후 이 액션을 발행합니다. 발행 지점이 컨트롤러가 아니라
Service 인 것이 규약입니다 — 컨트롤러에서 발행하면 같은 로직을 다른 경로(커맨드·시더·다른
서비스)에서 부를 때 훅이 발화하지 않습니다.
`getHooks()` 선언에 없어 소스에서 자동 감지된 상태입니다. 선언에 추가하면 유형과 설명이 표에
함께 실리며, 실제 모듈에서는 발행 훅을 선언하는 편이 구독하는 쪽에 계약을 드러냅니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `gnuboard7-hello_module.memo.created` | action (미선언) | `LogMemoCreatedListener` | `onMemoCreated` | 10 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
자기가 발행한 훅 하나를 자기가 구독합니다. 실제로는 다른 확장이 구독하는 것이 정상이지만,
**샘플 하나만 설치해도 훅 흐름이 눈에 보이도록** 리스너를 같이 넣었습니다.
`gnuboard7-hello_plugin` 을 함께 설치하면 같은 훅을 **바깥에서 구독하는** 모습을 볼 수
있습니다 — 그쪽이 확장 시스템의 실제 사용 형태입니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `LogMemoCreatedListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/LogMemoCreatedListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
`LogMemoCreatedListener` 하나이며 `HookListenerInterface` 를 구현하고
`getSubscribedHooks()` 로 자기 구독을 선언합니다(명시 등록).
하는 일은 로그 한 줄이지만, 그 자리가 중요합니다 — **부가 작업은 Service 안이 아니라 리스너로
뺀다**는 규약의 본보기입니다. Service 에 로그·알림을 쌓으면 그 Service 를 다른 맥락에서
재사용할 수 없습니다.
리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 를 직접 부르지 않습니다 — 데이터
접근이 필요하면 Repository 인터페이스를 주입받습니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_레이아웃 확장이 없습니다._
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
없습니다. 이 샘플은 다른 확장의 화면에 조각을 주입하지 않습니다.
주입 예시가 필요하면 실제로 그렇게 하는 확장(이커머스의 관리자 대시보드 위젯, 마케팅의 회원가입
동의 항목)의 문서를 참고합니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
없습니다. 코어가 제공하는 인증 미들웨어만 씁니다.
샘플에 미들웨어를 넣으면 계층 구조를 보러 온 사람이 읽어야 할 코드가 늘어납니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
없습니다. 같은 이유로 두지 않았습니다.
실시간이 필요하면 이 모듈이 발행하는 `memo.created` 를 구독해 소비하는 쪽에서
`HookManager::broadcast()` 로 자기 채널에 내보내는 것이 방향입니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
없습니다. 샘플에는 시간 축 동작이 없습니다.
스케줄 선언 형태(`command` · `schedule` · `description` · `enabled_config`)는 실제로 스케줄을
쓰는 확장의 문서를 참고합니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
없습니다. 알림은 수신자 해석·채널 게이트·템플릿까지 함께 필요해 "하나씩만" 원칙으로 담기
어렵습니다.
알림이 필요한 예시는 실제로 알림을 발송하는 확장의 문서를 참고합니다.
<!-- @intent END -->
@@ -0,0 +1,86 @@
# Hello 모듈 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 3개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 2개 |
| `user` | 1개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `admin_memo_form` | `admin` | 화면 | `_admin_base` |
| `admin_memo_list` | `admin` | 화면 | `_admin_base` |
| `user_memo_list` | `user` | 화면 | `_user_base` |
<!-- @generated:layouts END -->
<!-- @intent START -->
3개이며 그중 하나가 `user` 그룹인 것이 학습 포인트입니다.
| 레이아웃 | 그룹 | 무엇을 보여주는가 |
|---|---|---|
| `admin_memo_list` · `admin_memo_form` | `admin` | 관리자 목록·작성 화면의 최소 형태 (`_admin_base` 상속) |
| `user_memo_list` | `user` | **모듈도 사용자 레이아웃을 가질 수 있다** (`_user_base` 상속) |
실제 도메인 모듈(게시판·이커머스)은 방문자 화면을 소유하지 않고 템플릿에 맡깁니다 — 템플릿마다
디자인이 달라야 하기 때문입니다. 이 샘플의 `user_memo_list` 는 그 규칙의 예외가 아니라, 구조상
가능하다는 사실을 보이는 예시입니다.
모듈 레이아웃은 위치에 따라 등록 대상이 갈립니다 — `admin/` 하위는 Admin 템플릿에,
`user/` 하위는 User 템플릿에 등록됩니다.
레이아웃 JSON 만 고쳤다면 빌드 없이
`php artisan module:update gnuboard7-hello_module --force` 로 반영합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 액션 핸들러가 없습니다._
<!-- @generated:handlers END -->
<!-- @intent START -->
없습니다. 이 샘플의 화면은 코어 엔진의 기본 핸들러(`apiCall` · `navigate` · `setState` 등)
만으로 충분합니다.
핸들러를 처음 추가할 때는 셋이 함께 필요합니다 — 엔트리 파일, `window.__[Name].initModule()`
재등록 진입점, 그리고 `--production` 으로 구운 `dist/` 커밋. 진입점을 빠뜨리면 로케일 전환
직후 그 핸들러들이 오류 없이 무반응이 됩니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_프론트 엔트리포인트가 없습니다._
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
없습니다 — 등록할 액션 핸들러가 없기 때문입니다.
핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록
진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부
무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅
작업을 포함하지 않습니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_프론트 에셋이 없습니다._
<!-- @generated:assets END -->
<!-- @intent START -->
없습니다. 이 샘플의 프론트엔드는 레이아웃 JSON 3개뿐이라 빌드할 실행 코드가 없습니다.
그래서 반영이 `php artisan module:update gnuboard7-hello_module --force` 하나로 끝납니다.
JS 를 더하면 그때 빌드(`module:build --production`)·`dist/` 커밋·전역 진입점 셋이 함께
필요해집니다.
구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도
서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다.
<!-- @intent END -->
@@ -0,0 +1,116 @@
# Hello 모듈 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_`getSettingsSchema()` 선언이 없습니다._
기본값 파일: `config/settings/defaults.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
설정이 없습니다. 이 샘플은 설정 화면 없이도 모듈의 계층 구조를 보여줄 수 있어 일부러 두지
않았습니다.
설정 스키마와 설정 화면 레이아웃의 예시는 함께 제공되는 `gnuboard7-hello_plugin` 에 있습니다 —
`getSettingsSchema()` 선언과 `resources/layouts/admin/plugin_settings.json` 이 짝을 이루는
형태입니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 카테고리 | 이름 | 액션 | 라우트 키 |
|---|---|---|---|
| `memos` | 메모 관리 | `read`, `create`, `update`, `delete` | `memo` |
<!-- @generated:permissions END -->
<!-- @intent START -->
`memos` 하나에 `read`/`create`/`update`/`delete` 네 액션입니다. 라우트 키 `memo` 가 선언되어
있어 관리자 라우트에 스코프 미들웨어가 걸립니다.
권한 이름은 코어가 `{확장식별자}.{카테고리}.{액션}` 으로 조립합니다
(`gnuboard7-hello_module.memos.read`). 확장 식별자가 앞에 붙으므로 다른 확장과 이름이 겹칠
걱정이 없습니다.
**권한만 추가하고 메뉴를 빠뜨리면 화면에 도달할 길이 없고, 반대면 눌러도 403 입니다.** 새
화면을 더할 때는 권한·메뉴·라우트 이름 셋이 서로를 정확히 가리키는지 함께 확인합니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 구분 | slug | 이름 | URL | 하위 |
|---|---|---|---|---|
| 관리자 | `gnuboard7-hello_module` | Hello 메모 | `/admin/memos` | - |
<!-- @generated:menus END -->
<!-- @intent START -->
관리자 메뉴 하나(`/admin/memos`)입니다. 하위 메뉴가 없어 최상위 항목이 바로 목록 화면으로
갑니다.
메뉴는 **권한과 짝을 이룰 때만 보입니다** — 그 역할에 `memos.read` 가 없으면 렌더되지
않습니다. 설치 직후 메뉴가 보이지 않는다면 대부분 권한 부여가 빠진 것입니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/modules/gnuboard7-hello_module/...` |
| `web` | `src/routes/web.php` | `/modules/gnuboard7-hello_module/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
파일이 둘(`api.php` · `web.php`)인 것이 이 샘플의 학습 포인트입니다. 실제 도메인 모듈은
대개 `api.php` 만 두지만, **모듈이 web 라우트도 가질 수 있다**는 사실을 보이기 위해 둘 다
둡니다.
두 파일의 URL prefix 가 다릅니다 — API 는 `/api/modules/{id}/`, web 은 `/modules/{id}/`.
확장이 다른 확장의 경로를 침범하지 않도록 코어가 강제하는 규칙입니다.
모든 라우트에 `name()` 이 필요합니다. 이름이 없으면 미들웨어 self-gate 의 `targets` 패턴과
IDV 정책의 라우트명 인덱스가 그 라우트를 찾지 못해, 보호가 걸린 것처럼 보이지만 실제로는
통과합니다.
라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 확장 라우트는 활성 상태인 확장의 것만
등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 404 가 됩니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `gnuboard7-hello_plugin` | 플러그인 | `>=0.1.0` |
| `gnuboard7-hello_user_template` | 템플릿 | `>=0.1.0` |
<!-- @generated:dependencies END -->
<!-- @intent START -->
이 모듈은 아무 확장에도 의존하지 않습니다. 관계는 한 방향으로 들어옵니다 — 학습용 플러그인과
학습용 사용자 템플릿이 이 모듈을 요구합니다.
**두 의존의 성격이 다른 것이 학습 포인트**입니다:
| 확장 | 어떻게 묶이는가 |
|---|---|
| `gnuboard7-hello_plugin` | 이 모듈이 발행하는 훅(`memo.created`)을 구독 — 확장이 다른 확장의 흐름에 끼어드는 형태 |
| `gnuboard7-hello_user_template` | 이 모듈의 공개 API 를 `data_sources` 로 소비 — 모듈이 데이터를, 템플릿이 화면을 담당하는 경계 |
넷을 모두 설치하면 이 세 역할(데이터·화면·부가 동작)이 어떻게 나뉘는지 실제로 확인할 수
있습니다.
발행 훅 이름이나 공개 API 응답 형태를 바꾸면 두 확장이 조용히 끊깁니다 — 샘플에서도 그 규율은
같습니다.
<!-- @intent END -->
@@ -5,7 +5,7 @@
"ko": "Hello 모듈",
"en": "Hello Module"
},
"version": "0.1.1",
"version": "0.1.2",
"license": "MIT",
"description": {
"ko": "학습용 최소 샘플 모듈 (Memo CRUD)",
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.1.0-0066FF?style=flat-square" alt="version 1.1.0">
<img src="https://img.shields.io/badge/version-1.1.1-0066FF?style=flat-square" alt="version 1.1.1">
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.10-1F883D?style=flat-square" alt="G7 &gt;=7.0.10">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -287,8 +287,8 @@ _등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 스케줄 | 주기 | 설명 |
|---|---|---|
| `sirsoft-board:aggregate-stats` | `-` | 대시보드 게시물 현황 집계 |
| `sirsoft-board:prune-attachments --scheduled` | `-` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 |
| `sirsoft-board:aggregate-stats` | `hourly` | 대시보드 게시물 현황 집계 |
| `sirsoft-board:prune-attachments --scheduled` | `daily` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 |
<!-- @generated:schedules END -->
<!-- @intent START -->
@@ -321,3 +321,84 @@ _등록하는 브로드캐스트 채널이 없습니다._
중복이 아니라 **관점의 차이**입니다 — 관리자가 직접 블라인드했는지, 신고 처리 결과로
블라인드됐는지에 따라 원 작성자에게 보이는 문구(원인 설명)가 갈라져야 하기 때문입니다.
<!-- @intent END -->
## 활동 로그 훅
> 이 확장이 코어 활동 로그(`activity_logs`)에 기록을 남기기 위해 구독하는 훅 30개입니다.
> 코어 `docs/backend/activity-log-hooks.md` 에 있던 목록을 이 확장 소유로 옮긴 것입니다(#601) —
> 확장이 훅을 더할 때 코어 문서를 고쳐야 하던 역방향 의존을 없애기 위해서입니다. 코어 문서에는
> 총계와 이 문서로의 링크만 남습니다.
> 새 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문,
> 그리고 번들 일본어 팩까지 함께 정의해야 합니다 — **모듈 lang 파일에 넣으면 해석되지
> 않습니다.**
### 게시판 모듈 훅 (BoardActivityLogListener)
**파일**: `modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php`
**총 30훅**
> 이 표에 `before_*` 훅이 없는 것은 누락이 아닙니다. 수정 전 스냅샷은 이 리스너가
> `before_*` 훅으로 직접 잡지 않고 **Service 가 잡아 `after_*` 훅의 인자로 넘깁니다**
> (`ChangeDetector::detect($model, $snapshot)`). `before_*` 훅 자체는 발행되며 그 목록은
> 위 「발행 훅」 절에 있습니다.
#### Board (7훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.board.after_create` | `handleBoardAfterCreate` | `board.create` | Admin | Board |
| `sirsoft-board.board.after_update` | `handleBoardAfterUpdate` | `board.update` | Admin | Board |
| `sirsoft-board.board.after_delete` | `handleBoardAfterDelete` | `board.delete` | Admin | Board |
| `sirsoft-board.board.after_add_to_menu` | `handleBoardAfterAddToMenu` | `board.add_to_menu` | Admin | Board |
| `sirsoft-board.board.after_remove_from_menu` | `handleBoardAfterRemoveFromMenu` | `board.remove_from_menu` | Admin | Board |
| `sirsoft-board.settings.after_bulk_apply` | `handleSettingsAfterBulkApply` | `board_settings.bulk_apply` | Admin | - |
| `sirsoft-board.settings.after_bulk_apply_aborted` | `handleSettingsAfterBulkApplyAborted` | `board_settings.bulk_apply_aborted` | Admin | - |
#### BoardType (3훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.board_type.after_create` | `handleBoardTypeAfterCreate` | `board_type.create` | Admin | BoardType |
| `sirsoft-board.board_type.after_update` | `handleBoardTypeAfterUpdate` | `board_type.update` | Admin | BoardType |
| `sirsoft-board.board_type.after_delete` | `handleBoardTypeAfterDelete` | `board_type.delete` | Admin | BoardType |
#### Post (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.post.after_create` | `handlePostAfterCreate` | `post.create` | Admin | Post |
| `sirsoft-board.post.after_update` | `handlePostAfterUpdate` | `post.update` | Admin | Post |
| `sirsoft-board.post.after_delete` | `handlePostAfterDelete` | `post.delete` | Admin | Post |
| `sirsoft-board.post.after_blind` | `handlePostAfterBlind` | `post.blind` | Admin | Post |
| `sirsoft-board.post.after_restore` | `handlePostAfterRestore` | `post.restore` | Admin | Post |
#### Comment (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.comment.after_create` | `handleCommentAfterCreate` | `comment.create` | Admin | Comment |
| `sirsoft-board.comment.after_update` | `handleCommentAfterUpdate` | `comment.update` | Admin | Comment |
| `sirsoft-board.comment.after_delete` | `handleCommentAfterDelete` | `comment.delete` | Admin | Comment |
| `sirsoft-board.comment.after_blind` | `handleCommentAfterBlind` | `comment.blind` | Admin | Comment |
| `sirsoft-board.comment.after_restore` | `handleCommentAfterRestore` | `comment.restore` | Admin | Comment |
#### Attachment (3훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.attachment.after_upload` | `handleAttachmentAfterUpload` | `attachment.upload` | Admin | Attachment |
| `sirsoft-board.attachment.after_delete` | `handleAttachmentAfterDelete` | `attachment.delete` | Admin | Attachment |
| `sirsoft-board.attachment.after_download` | `handleAttachmentAfterDownload` | `attachment.download` | Admin / User | Attachment |
#### Report (7훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-board.report.after_create` | `handleReportAfterCreate` | `report.create` | Admin | Report |
| `sirsoft-board.report.after_update_status` | `handleReportAfterUpdateStatus` | `report.update_status` | Admin | Report |
| `sirsoft-board.report.after_bulk_update_status` | `handleReportAfterBulkUpdateStatus` | `report.bulk_update_status` | Admin | - |
| `sirsoft-board.report.after_delete` | `handleReportAfterDelete` | `report.delete` | Admin | Report |
| `sirsoft-board.report.after_restore_content` | `handleReportAfterRestoreContent` | `report.restore_content` | Admin | Report |
| `sirsoft-board.report.after_blind_content` | `handleReportAfterBlindContent` | `report.blind_content` | Admin | Report |
| `sirsoft-board.report.after_delete_content` | `handleReportAfterDeleteContent` | `report.delete_content` | Admin | Report |
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
"version": "1.1.0",
"version": "1.1.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
"version": "1.1.0",
"version": "1.1.1",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -0,0 +1,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. 이 확장은 무엇인가
<!-- @intent START -->
상품·주문·결제·배송·취소/환불·쿠폰·마일리지·리뷰·문의를 소유하는 커머스 도메인 모듈입니다.
모델 47종·소유 테이블 51개·발행 훅 508종으로 번들 확장 중 가장 큰 표면을 갖습니다.
**소유 범위는 관리자 CRUD + 공개 API 까지입니다.** 레이아웃 206개가 전부 `admin` 그룹인 것이
그 증거입니다 — 방문자가 실제로 보는 상품 목록·상세·장바구니·주문서 화면은 이 모듈이 그리지
않고, 템플릿(`sirsoft-basic`)이 이 모듈의 공개 API 를 소비해 그립니다. 그래서 쇼핑 화면의
디자인 변경은 이 모듈이 아니라 템플릿 쪽 작업입니다.
**설계 원칙 넷**:
1. **PG 는 이 모듈이 알지 않는다.** 결제 연동 코드는 전부 플러그인(`pay_kginicis` ·
`pay_nhnkcp` · `pay_nicepayments` · `tosspayments`)에 있고, 그 플러그인들이 이 모듈에
의존합니다(역방향 아님). 새 PG 는 이 모듈을 고치지 않고 플러그인 추가만으로 붙습니다 —
결제수단 카탈로그에 자기 능력(`needs_pg` / `pg_locked` / `pg_provider`)을 선언하는 것이
그 접합면입니다.
2. **금액은 계산기 하나만 지난다.** `OrderCalculationService` 의 9단계 계산이 상품 상세·
장바구니·체크아웃·주문 생성·결제 완료 검증·부분 취소 **여섯 지점 전부**의 단일 출처입니다.
화면마다 금액을 다시 계산하면 같은 장바구니가 화면마다 다른 값을 보이게 되고, 그 어긋남은
결제 금액 검증에서야 예외로 드러납니다.
3. **통화는 설정이 정하고, 거래 시점에 동결된다.** 기본 통화(저장 기준)·표시 통화(구매자
선택)·결제 통화(PG 청구)는 각각 따로 설정되며 셋이 모두 다를 수 있습니다. 주문이 생기면
그 시점의 통화·소수 자릿수·절사 규칙·환산 분모가 `currency_snapshot` 에 박제되고, 이후
운영자가 통화 설정을 바꿔도 과거 주문의 표기는 변하지 않습니다.
4. **마일리지는 원장이 SSoT 다.** `ecommerce_mileage_transactions` 가 원장이고
`ecommerce_mileage_balances` 는 단방향 파생 캐시입니다. 차감·검증 같은 금전 판정은 캐시가
아니라 원장 `FOR UPDATE` 재검증으로 하고, 캐시는 같은 트랜잭션 마지막 단계에서 재계산합니다.
**의도적으로 하지 않는 것**: PG 통신 코드·실시간 브로드캐스트(채널 0개)·방문자 쇼핑 화면
레이아웃, 그리고 상품 문의 게시판의 **콘텐츠 저장소**입니다. 문의 본문은 게시판 모듈이 글로
보관하고 이 모듈은 상품↔글 피벗(`ecommerce_product_inquiries`)만 갖습니다. 그런데도 manifest
의존에는 게시판이 없습니다 — 연결이 코드 결합이 아니라 훅 구독이라 게시판이 없으면 문의 기능만
비고 나머지는 그대로 동작합니다.
<!-- @intent END -->
## 2. 디렉토리 지도
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-ecommerce --force` (빌드 불필요) |
| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-ecommerce --force` |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-ecommerce --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-ecommerce --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-ecommerce --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan module:update sirsoft-ecommerce --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**주문 생성 (장바구니 → 결제 완료)**: `User\CheckoutController` / `User\OrderController` →
FormRequest → `CheckoutDataService`(주문서에 필요한 배송지·쿠폰·마일리지·결제수단 조립) →
`OrderCalculationService::calculate()` 9단계로 최종 결제금액 산출 → `TempOrderService` 가
임시 주문(`ecommerce_temp_orders`)으로 그 계산 결과를 보관 → PG 결제창 왕복 →
`OrderProcessingService` 가 임시 주문을 실제 주문(`Order` + `OrderOption` + `OrderAddress` +
`OrderPayment` + `OrderShipping`)으로 변환합니다. 이때 **결제 직전 계산을 한 번 더 돌려
금액이 그대로인지 검증**하고(`OrderAmountChangedException` /
`PaymentAmountMismatchException`), 주문번호는 `SequenceService` 가 DB UNIQUE 제약으로 원자
채번합니다. 상태 흐름은 `pending_order → pending_payment → payment_complete → …`
(`OrderStatusEnum` 10 케이스)입니다.
**취소·환불**: `Admin\OrderCancelController` / `User\OrderController` → FormRequest →
`OrderCancellationService`(취소 단위는 주문이 아니라 **옵션**입니다 — `OrderCancel` +
`OrderCancelOption`) → `OrderAdjustmentService` 가 이미 적용된 쿠폰·마일리지의 안분을 되돌리고
(`adjustment.filter_restore_promotions` 필터로 그 안분 규칙을 확장할 수 있습니다) →
`OrderRefund` + `OrderRefundOption` 생성 → 환불 수단(`RefundMethodEnum`: `pg`/`bank`/`points`)
에 따라 PG 플러그인 또는 마일리지 원장으로 실제 반환이 나갑니다. 쿠폰 복원·마일리지 복원 훅은
**호출자 트랜잭션과 함께 되돌아가야 하므로 `sync => true` 로 구독**합니다(기본값인 큐 래핑은
커밋 뒤에 실행되어 예외를 던져도 롤백되지 않습니다).
**상품 저장 → 색인·SEO**: `Admin\ProductController` → `StoreProductRequest`
(`product.create_validation_rules` 필터로 확장 지점 제공) → `ProductService`
(`before_create` → `filter_create_data` → `after_create`) → `ProductRepository`. 이후는 훅
리스너 레인입니다 — `SearchProductsListener`(검색 색인) · `SeoProductCacheListener`(봇 화면
캐시 무효화) · `ProductActivityLogListener`(활동 로그) · `SyncOptionGroupsListener` /
`SyncProductFromOptionListener`(옵션 ↔ 상품 대표값 동기화)가 각자 받아 처리하고, Service 는
이 부가효과를 알지 못합니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 508개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 142개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 33개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 12개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 3개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 9개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 10개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
발행 훅 508종은 도메인별로 같은 모양을 반복합니다 — `before_{동작}` (action) →
`filter_{동작}_data` (filter) → 실행 → `after_{동작}` (action), 그리고 FormRequest 쪽의
`{동작}_validation_rules` (filter). `brand` 19종을 한 번 읽으면 `product` 44 · `order` 35 ·
`coupon` 32 · `category` 28 · `cart` 25 · `shipping_policy` 24 에 그대로 적용됩니다. 도메인
CRUD 를 바꾸고 싶으면 이 4종 중 하나를 잡으면 되고, 이 모듈의 소스를 고칠 일은 없습니다.
그 규칙에서 벗어나는 것이 실제로 중요한 확장점입니다:
| 목적 | 잡을 훅 |
|---|---|
| 금액 계산 단계에 개입 | `calculation.after_item_subtotals` · `calculation.after_final_result` 등 `calculation.*` 18종 (9단계 사이사이) |
| 취소 시 쿠폰·마일리지 안분 되돌리기 규칙 변경 | `adjustment.filter_restore_promotions` |
| 새 PG·결제수단 추가 | 결제수단 카탈로그에 능력 선언 + `payment.*` 6종 (이 모듈 수정 불필요) |
| 결제 직전/취소/입금확인에 본인인증 강제 | `getIdentityPolicies()` 가 선언한 4개 정책의 target 훅 (`checkout.before_payment` · `payment.before_cancel` · `payment.before_approve` · `payment.before_confirm_deposit`) — 기본 `enabled: false` |
| 상품 문의를 다른 저장소로 | `inquiry.store_validation_rules` · `inquiry.update_validation_rules` + 게시판 측 `sirsoft-board.post.after_*` (`ProductInquiryBoardListener` 가 선례) |
| 재고 차감/복원 시점 개입 | `stock.*` 4종 |
**금전이 움직이는 훅을 구독할 때는 `'sync' => true` 를 붙입니다.** 쿠폰 차감·복원, 마일리지
차감·복원이 여기 해당합니다 — 기본값(큐 래핑 + `afterCommit`)으로 두면 커밋 뒤에 실행되어
리스너가 예외를 던져도 주문은 이미 확정된 뒤입니다.
브로드캐스트 채널은 0개입니다. 실시간 반영이 필요한 화면은 이 모듈이 아니라 소비하는
템플릿·모듈 쪽에서 폴링·재조회로 해결합니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-ecommerce --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-ecommerce` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] 금액 계산 경로를 건드렸다면 여섯 소비 지점(상품 상세·장바구니·체크아웃·주문 생성·결제 검증·부분 취소)에 같은 결과가 도달하는지 확인
- [ ] 통화가 관여하는 표시·기록을 추가할 때 다국어 문구는 `:amount` 로 중립, 과거 거래 표기는 `currency_snapshot` 경유
- [ ] 금전이 움직이는 훅(쿠폰·마일리지 차감/복원)을 구독·발행할 때 `'sync' => true` 확인
- [ ] 마일리지 원장에 기록하는 경로를 추가하면 같은 트랜잭션 마지막에 잔액 캐시 재계산 동반
- [ ] 목록 응답에 하위 컬렉션(옵션·이미지)을 실을 때 화면이 실제로 그리는 것만 — Repository 의 `relations:` 와 Resource 의 `whenLoaded` 를 함께 본다
- [ ] 결제수단·PG 관련 선언을 바꾸면 기설치본 `order_settings.json` 을 정정하는 업그레이드 스텝 동반 (자기 접두사만, 멱등)
- [ ] 새 관리자 화면을 추가하면 그 화면의 권한(`getPermissions()`)·메뉴(`getAdminMenus()`)·라우트 이름이 서로 가리키는 대상이 일치하는지 확인
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 화면·서비스마다 금액을 다시 계산 (`합계 = 단가 × 수량` 재구현) | `OrderCalculationService::calculate()` 결과만 사용 | 계산이 여섯 지점에 흩어지면 화면 금액과 결제 금액이 갈라지고, 그 어긋남은 결제 완료 검증에서 `PaymentAmountMismatchException` 으로 뒤늦게 나타난다 |
| 마일리지 잔액을 `ecommerce_mileage_balances` 에서 읽어 차감 가능 여부 판정 | 원장(`ecommerce_mileage_transactions`) `FOR UPDATE` 재검증 | 캐시는 단방향 파생물이라 동시 요청에서 뒤처질 수 있다 — 캐시를 근거로 차감하면 잔액이 음수가 된다 |
| 마일리지 잔액 캐시를 서비스에서 직접 증감 | 원장에 기록한 뒤 같은 트랜잭션 마지막에 캐시 재계산 | 두 곳을 각각 갱신하면 원장 합계와 캐시가 어긋나고, 정합 교정 스케줄(`reconcile-mileage-balance`)이 매번 되돌린다 |
| 과거 주문 표시에 현재 통화 설정(`getDecimalPlaces($code)`)을 조회 | 주문의 `currency_snapshot` 을 함께 넘긴다 | 운영자가 그 통화를 삭제하면 폴백(2자리)이 적용되어 `¥14,835` 가 `¥14,835.00` 이 된다. 금액 계산은 스냅샷을 쓰는데 표기만 현재 설정을 따르면 같은 화면 안에서 근거가 갈린다 |
| 다국어 문구에 `:amount원` / `:amount円` 처럼 통화 기호를 박기 | 문구는 `:amount` 로 중립, 호출부가 `ecommerce_format_price($amount, $currency)` 로 포맷 | UI 언어가 통화를 결정하게 되어 기본 통화가 다른 상점에서 단위만 틀린 금액이 나간다 |
| 쿠폰·마일리지 복원 훅을 기본 설정(큐)으로 구독 | `'sync' => true` | 커밋 뒤 실행이라 예외를 던져도 롤백되지 않는다 — 오류 응답만 나가고 차감된 쿠폰은 그대로 남는다 |
| 특정 PG 이름을 이 모듈 코드에 분기로 넣기 (`if ($pg === 'tosspayments')`) | 결제수단 카탈로그 선언(`needs_pg`/`pg_locked`/`pg_provider`)과 `payment.*` 훅 | PG 가 늘 때마다 이 모듈이 커지고, 플러그인만 설치하면 되는 구조가 깨진다 |
| 주문번호·상품코드를 `max(id)+1` 이나 타임스탬프 조합으로 직접 생성 | `SequenceService::generateCode()` | 채번은 DB UNIQUE 제약과 함께 원자적으로 수행된다 — 직접 생성은 동시 주문에서 중복을 만든다 |
| 취소·환불을 주문 단위로 처리 | 옵션 단위(`OrderCancelOption`/`OrderRefundOption`) | 부분 취소가 이 도메인의 기본이며, 주문 단위로 처리하면 남은 옵션의 안분 금액이 계산되지 않는다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 400개 | `modules/_bundled/sirsoft-ecommerce/tests` |
| Vitest | 140개 | `vitest.config.ts` |
| Playwright | 42개 | `tests/Playwright` |
| 시나리오 매니페스트 | 91개 | `tests/scenarios` |
기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd modules/_bundled/sirsoft-ecommerce && powershell -Command "npm run test:run -- <대상>"
# Playwright E2E (Bash)
npx playwright test modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/<대상>.spec.ts
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @generated:test-commands END -->
## 8. 문서 목차
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
@@ -6,6 +6,10 @@
## [1.2.1] - 2026-08-28
### Added
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
### Fixed
- 상세설명을 편집기(HTML)로 작성한 상품을 등록하거나 수정할 때 저장이 실패하던 문제를 수정했습니다. 상품 설명의 보안 정화에 쓰는 구성요소가 모듈 설치 폴더 안에 자기 캐시 파일을 만들려 했기 때문에, 보안상 모듈 폴더에 쓰기를 막아 둔 서버에서는 저장이 항상 오류로 끝났고 다시 시도해도 같은 결과였습니다. 이제 이 캐시는 `storage` 폴더 아래에 만들어지며, 그 위치마저 쓸 수 없는 경우에는 캐시 없이 정화만 수행해 저장이 실패하지 않습니다(설명은 종전과 똑같이 정화됩니다). (#125 @lyg-kaban 님께서 제보해주셨습니다.)
@@ -0,0 +1,225 @@
# 이커머스
**G7 모듈 · sirsoft-ecommerce**
그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.2.1-0066FF?style=flat-square" alt="version 1.2.1">
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.10-1F883D?style=flat-square" alt="G7 &gt;=7.0.10">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
온라인 상점 운영에 필요한 상품·주문·결제·배송·취소/환불·쿠폰·마일리지·리뷰·문의를 한곳에서
관리하는 모듈입니다. 관리자 화면에서 상품을 등록하고 주문을 처리하면, 방문자가 보는 상점
화면은 템플릿(`sirsoft-basic`)이 이 모듈의 데이터를 받아 그립니다.
여러 나라·여러 통화를 동시에 다루도록 설계되어 있습니다. 상품 가격을 저장하는 **기본 통화**,
구매자가 화면에서 고르는 **표시 통화**, 결제사에 청구되는 **결제 통화**를 각각 따로 설정할 수
있고, 주문이 만들어지는 순간의 통화 정보가 그 주문에 그대로 남습니다 — 나중에 통화 설정을
바꿔도 지난 주문의 금액 표기는 변하지 않습니다.
결제사(PG) 연동은 이 모듈에 들어 있지 않습니다. KG이니시스·NHN KCP·나이스페이먼츠·토스페이먼츠는
각각 별도 플러그인이며, 쓰려는 결제사의 플러그인을 설치·활성화한 뒤 환경설정에서 고르면 됩니다.
상품 문의 게시판도 마찬가지로 게시판 모듈이 글을 보관하고, 이 모듈은 "어떤 상품의 문의인가"만
연결합니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 상품 | 상품·옵션·추가옵션·이미지 등록, 카테고리/브랜드/라벨 분류, 상품정보제공고시와 공통정보 템플릿, 진열·판매 상태 관리 |
| 주문 | 주문 목록·상세, 상태 변경(입금대기 → 결제완료 → 배송준비 → 배송중 → 배송완료 → 구매확정), 관리자 수기 결제, 엑셀 내려받기 |
| 결제 | 카드·가상계좌·계좌이체·무통장·휴대폰·마일리지 등 결제수단 관리, 현금영수증·세금계산서 발행 이력, 입금 확인 |
| 배송 | 배송정책(국가별 요금·무료배송 기준·구간 요금 14종)·배송사·배송유형·추가배송비 템플릿, 송장 등록과 배송 추적 |
| 취소·환불 | 주문 전체/부분 취소, 환불 수단(PG·계좌·마일리지) 선택, 클레임 사유 관리, 이미 적용된 쿠폰·마일리지 자동 되돌림 |
| 쿠폰 | 상품/카테고리/주문금액/배송비 대상 쿠폰, 정액·정률 할인, 발급 방식(직접·다운로드·자동), 가입·첫구매·생일 자동 발급 |
| 마일리지 | 적립률·적립 시점(배송완료/구매확정)·지연 적립·자동 소멸과 소멸 예정 알림, 통화별 적립 규칙, 관리자 수동 지급·차감 |
| 리뷰·문의 | 구매자 리뷰(이미지 첨부·작성 기한·노출 관리), 상품 1:1 문의(게시판 모듈에 글로 보관) |
| 회원 | 회원별 배송지, 결제 통화·배송 국가 지정, 장바구니(비로그인 → 로그인 시 자동 병합), 찜 목록 |
| 대시보드·통계 | 매출·주문 현황 집계, 미처리 주문 요약, 관리자 대시보드에 커머스 위젯 주입 |
| 다국어·다통화 | 기본/표시/결제 통화 분리, 통화별 소수 자릿수·절사 규칙, 주문 시점 통화 정보 보존 |
| SEO | 상품·카테고리·검색·상점 첫 화면의 메타 정보와 구조화 데이터 자동 생성 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
V[구매자] -->|공개 API| T[템플릿 상점 화면]
T --> CART[장바구니]
CART --> CALC[주문 계산]
CALC --> TMP[임시 주문]
TMP -->|결제창| PG[PG 플러그인]
PG -->|승인 결과| ORD[주문 확정]
ORD --> SHIP[배송]
A[운영자] -->|관리자 화면| ADM[상품·주문 관리]
ADM --> ORD
```
구매자가 보는 화면은 템플릿이 그리고, 금액 계산·주문 확정은 이 모듈이 합니다. 결제창 왕복만
결제사 플러그인이 담당하며, 승인 결과가 돌아오면 이 모듈이 다시 금액을 검증한 뒤 주문을
확정합니다.
```mermaid
flowchart LR
P1[입금대기] --> P2[결제완료]
P2 --> P3[배송준비]
P3 --> P4[배송중]
P4 --> P5[배송완료]
P5 --> P6[구매확정]
P2 -.취소.-> C[취소/환불]
P3 -.취소.-> C
```
주문 상태는 위 순서로 진행하며, 결제 완료 이후 배송 시작 전까지는 취소가 가능합니다(어느
상태까지 취소를 허용할지는 환경설정에서 조정합니다). 취소하면 그 주문에 쓰인 쿠폰과 마일리지가
자동으로 되돌아갑니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan module:install sirsoft-ecommerce
# 활성화
php artisan module:activate sirsoft-ecommerce
# 업데이트 (번들 소스 기준 강제 반영)
php artisan module:update sirsoft-ecommerce --force
```
저장소: https://github.com/gnuboard/g7-module-sirsoft-ecommerce
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_별도의 관리자 설정 항목이 없습니다._
<!-- @generated:settings-summary END -->
<!-- @intent START -->
위 표가 비어 있는 이유는 이 모듈의 환경설정이 코드 선언이 아니라 설정 파일
(`config/settings/defaults.json`)에서 오기 때문입니다. 실제 설정은 `/admin/ecommerce/settings`
한 화면에 9개 탭으로 모여 있습니다.
| 탭 | 언제 바꾸는가 | 바꾸면 달라지는 것 |
|---|---|---|
| 기본 정보 | 개점 준비 시 1회 | 상점명·사업자 정보·상점 주소 경로가 상점 화면과 주문서에 반영됩니다 |
| 언어·통화 | 판매 국가를 늘릴 때 | 기본 통화와 취급 통화 목록. 기본 통화를 바꿔도 **이미 만들어진 주문의 표기는 그대로**입니다 |
| 주문 설정 | 결제사를 도입·교체할 때 | 기본 PG·현금영수증 발행처·결제수단 노출·무통장 계좌·미입금 자동취소 기한·취소 허용 상태 |
| 배송 | 해외 배송을 시작할 때 | 기본 배송 국가·취급 국가·무료배송 기준·주소 검증 사용 여부 |
| SEO | 검색 노출을 조정할 때 | 상품·카테고리·검색·상점 첫 화면의 제목/설명 서식과 구조화 데이터 사용 여부 |
| 리뷰 | 리뷰 정책을 바꿀 때 | 작성 가능 기한(구매 후 N일)·이미지 개수와 용량 제한 |
| 문의 | 문의 게시판을 지정할 때 | 상품 문의가 저장될 게시판. **게시판 모듈이 설치·활성화되어 있어야 합니다** |
| 알림 | 알림 채널을 조정할 때 | 주문·배송·문의 알림을 메일/앱 내 알림 중 어디로 보낼지 |
| 마일리지 | 적립 제도를 운영할 때 | 사용 여부·적립률·적립 시점(배송완료/구매확정)·지연 적립일·통화별 규칙·소멸 기한과 사전 알림 |
결제수단 목록은 **설치된 결제사 플러그인이 스스로 등록**합니다. 그래서 플러그인을 삭제하거나
비활성화하면 그 결제수단은 구매자 화면에서 자동으로 사라집니다 — 설정에서 따로 지울 필요가
없습니다.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**개점 준비**: `/admin/ecommerce/settings` 에서 기본 정보와 기본 통화를 정하고, 쓰려는 결제사
플러그인을 설치·활성화한 뒤 "주문 설정" 탭에서 기본 PG 와 노출할 결제수단을 고릅니다. 그다음
"배송" 탭에서 기본 배송 국가를 정하고 `/admin/ecommerce/shipping-policies` 에서 배송정책을
하나 이상 만듭니다 — 배송정책이 없으면 상품을 등록해도 배송비가 계산되지 않습니다. 마지막으로
`/admin/ecommerce/categories` 에서 카테고리를 만든 뒤 상품을 등록합니다.
**주문 처리**: 새 주문이 들어오면 `/admin/ecommerce/orders` 에 뜨고 담당자에게 알림이 갑니다.
무통장 입금 주문은 입금을 확인해 "결제완료"로 바꾸고(현금영수증 발행 설정이 켜져 있으면 이때
자동 발행됩니다), 상품을 준비한 뒤 송장 번호를 등록하면 상태가 "배송중"으로 넘어가면서 구매자
알림이 나갑니다. 배송완료 후 구매확정되면 마일리지 적립 시점 설정에 따라 적립이 이루어집니다.
**부분 취소·환불**: 주문 상세에서 취소할 **옵션(품목)을 골라** 취소를 진행합니다. 주문 전체가
아니라 품목 단위라서, 세 개 중 하나만 취소하면 나머지 두 개에 걸린 할인과 배송비가 자동으로
다시 안분됩니다. 환불 수단은 PG 취소·계좌 입금·마일리지 반환 중에서 고르며, 그 주문에 쓰인
쿠폰과 마일리지는 취소 처리와 같은 시점에 되돌아갑니다.
**쿠폰 발행**: `/admin/ecommerce/promotion-coupons` 에서 대상(전체/특정 상품/특정 카테고리)과
할인 방식(정액/정률), 적용 대상(상품금액/주문금액/배송비)을 정합니다. 발급 방식을 "자동"으로
두고 조건을 가입·첫구매·생일 중에서 고르면 해당 시점에 자동 발급됩니다.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `sirsoft-pay_kginicis` | 플러그인 | `>=1.1.0` |
| `sirsoft-pay_nhnkcp` | 플러그인 | `>=1.1.0` |
| `sirsoft-pay_nicepayments` | 플러그인 | `>=1.1.0` |
| `sirsoft-tosspayments` | 플러그인 | `>=1.1.0` |
| `sirsoft-basic` | 템플릿 | `>=1.1.0` |
<!-- @generated:integrations END -->
## 문서
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
## 트러블슈팅
<!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 결제 단계에서 결제수단이 하나도 보이지 않음 | 결제사 플러그인이 설치·활성화되지 않았거나, 환경설정 "주문 설정" 탭에서 노출이 꺼져 있음 | 플러그인을 활성화한 뒤 주문 설정 탭에서 해당 결제수단을 켭니다. 플러그인을 지웠다면 그 결제수단은 자동으로 목록에서 빠집니다 |
| 상품을 등록했는데 장바구니에서 배송비가 0원 | 그 상품에 배송정책이 지정되지 않았거나, 정책에 현재 배송 국가 설정이 없음 | 배송정책을 만들고 상품 편집 화면에서 지정한 뒤, 정책의 국가별 설정에 해당 국가를 추가합니다 |
| 상품 문의 메뉴가 동작하지 않음 | 게시판 모듈이 없거나, 환경설정 "문의" 탭의 게시판이 지정되지 않음 | 게시판 모듈을 활성화하고 문의용 게시판을 만든 뒤 문의 탭에서 그 게시판을 고릅니다 |
| 통화 설정을 바꿨는데 지난 주문의 금액 표기가 그대로 | 주문 시점의 통화 정보가 그 주문에 보존됨 | 정상 동작입니다. 지난 거래의 표기가 나중 설정 변경으로 달라지면 정산 근거가 바뀌므로 의도적으로 고정합니다 |
| 마일리지 잔액이 내역 합계와 어긋나 보임 | 표시용 잔액이 아직 재계산되지 않음 | 정합 교정 스케줄이 주기적으로 맞춥니다. 즉시 맞추려면 `php artisan sirsoft-ecommerce:reconcile-mileage-balance` 를 실행합니다 |
| 소멸 예정 마일리지 알림이 오지 않음 | 스케줄러가 동작하지 않거나 알림 채널이 꺼져 있음 | 서버의 스케줄러 등록을 확인하고, 환경설정 "알림" 탭에서 해당 알림의 채널을 켭니다 |
| 미입금 주문이 계속 남아 있음 | 자동 취소가 꺼져 있거나 기한이 길게 설정됨 | 주문 설정 탭의 "미입금 자동취소" 사용 여부와 기한(일)을 확인합니다 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -0,0 +1,22 @@
# 이커머스 개발자 문서
> modules/_bundled/sirsoft-ecommerce · 모듈
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 508 · **구독 훅 수**: 142 · **라우트 수**: 239 · **모델 수**: 47 · **테이블 수**: 51 · **마이그레이션 수**: 102 · **레이아웃 수**: 206 · **핸들러 수**: 160
<!-- @generated:stats END -->
## 문서 목차
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 |
|---|---|
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [api/](api/README.md) | API 레퍼런스 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,106 @@
# 이커머스 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
이 모듈의 설계는 "커머스 도메인에서 **변하는 것**과 **변하지 않는 것**을 갈라 두는 것"에
집중되어 있습니다. 변하지 않는 것은 금액 계산 규칙·주문 상태 전이·원장 기록이고, 변하는 것은
결제사·화면 디자인·나라별 배송 규칙·프로모션 정책입니다. 변하는 축은 전부 이 모듈 **밖**으로
빼거나 데이터로 내려서, 새 결제사·새 나라·새 화면이 추가될 때 이 모듈의 소스를 고치지 않아도
되게 했습니다.
- **결제사**: 플러그인이 이 모듈에 의존하지, 이 모듈이 플러그인을 알지 않습니다. 코어
`PaymentMethodEnum` 도 확장 결제수단 ID 를 모르므로, 능력(`needs_pg`/`pg_locked`/
`pg_provider`)은 등록하는 플러그인이 카탈로그에 선언하고 화면과 서버는 그 선언만 읽습니다.
- **화면**: 레이아웃 206개가 전부 관리자 화면입니다. 방문자 상점 화면은 템플릿이 공개 API 를
소비해 그리므로, 상점 디자인이 여러 벌 필요해도 이 모듈은 하나로 유지됩니다.
- **나라·통화·배송**: `ShippingPolicy` + `ShippingPolicyCountrySetting` 조합으로 국가별 요금
규칙을 데이터로 표현하고(`ChargePolicyEnum` 14종), 통화는 설정에서 읽습니다. 코드에 통화
코드나 국가 코드를 박지 않는 것이 규칙입니다.
- **프로모션**: 쿠폰의 대상 범위(`CouponTargetScope`)·대상 금액(`CouponTargetType`)·할인 방식
(`CouponDiscountType`)·발급 방식(`CouponIssueMethod`)이 전부 Enum + 데이터 조합이라, 새
프로모션 유형 대부분은 코드 없이 관리자 화면에서 만들어집니다.
그 대가로 **계산기 하나가 무거워집니다.** `OrderCalculationService` 는 9단계(옵션 금액 → 상품·
카테고리 쿠폰 → 배송비 → 배송비 쿠폰 → 주문금액 쿠폰 → 적립 마일리지 → 결제금액 → 마일리지
사용 → 최종 지불금액)를 한 번에 수행하며, 상품 상세·장바구니·체크아웃·주문 생성·결제 완료
검증·부분 취소 여섯 지점이 모두 이 하나를 부릅니다. 이 집중은 의도된 것입니다 — 계산이 흩어지면
화면 금액과 청구 금액이 갈라지고, 그 어긋남은 결제가 끝난 뒤에야 예외로 드러납니다.
**의도적으로 하지 않는 것**: 실시간 브로드캐스트(채널 0개)·PG 통신·방문자 화면 소유·문의 본문
저장. 문의는 게시판 모듈이 글로 보관하고 이 모듈은 상품↔글 피벗만 갖는데, manifest 의존에는
게시판이 없습니다. 연결이 코드 결합이 아니라 훅 구독이라 게시판이 없으면 문의 기능만 비고
나머지는 그대로 동작합니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
Http/Controllers (Admin/ 관리자, User/ 구매자, Guest/ 비회원 주문조회)
│
▼
FormRequest (검증 + *_validation_rules 필터 훅으로 확장 지점 제공)
│
▼
Services 48종
│ ├─ 계산 레인: OrderCalculationService(9단계) · CurrencyConversionService
│ │ · ShippingPolicyResolver · OrderAdjustmentService
│ ├─ 흐름 레인: CheckoutDataService → TempOrderService → OrderProcessingService
│ │ → OrderCancellationService
│ └─ 도메인 CRUD 레인: Product/Category/Brand/Coupon/Review/... Service
│ before_* → filter_*_data → 실행 → after_* (도메인 공통 3단 훅 패턴)
▼
Repositories (Interface 경유 — 목록은 컬럼 프루닝·정렬 화이트리스트)
│
▼
Models 47종 (Order/OrderOption/Product/... — 주문 계열은 SoftDeletes)
```
Support 클래스(`src/Support/`)는 계층이 아니라 **규칙의 단일 출처**입니다 — `VatCalculator`
(과세/면세 안분) · `MileageRounding`(적립 절사) · `ShippingPolicySnapshot`(주문 시점 배송정책
동결) · `CurrencySettingsCache`(통화 설정 조회) · `ReviewWritePolicy`(리뷰 작성 가능 판정) ·
`ShopPathResolver`(상점 경로). 같은 규칙을 서비스마다 다시 구현하지 않기 위한 자리이므로,
새 서비스가 반올림·안분·경로 조립을 직접 하고 있으면 여기로 올려야 하는 신호입니다.
Listeners 33종은 이 흐름과 **별도 레인**입니다. Service 가 발행한 훅을 받아 활동 로그·검색
색인·SEO 캐시 무효화·카테고리 트리 캐시·마일리지 적립·현금영수증 발행·알림 데이터 추출·장바구니
병합을 수행하며, Service 자신은 이 부가효과를 알지 못합니다. 그래서 새 부가효과는 Service 를
건드리지 않고 리스너 추가만으로 끝납니다 — 단, 금전이 되돌아가야 하는 리스너(쿠폰·마일리지
복원)는 `'sync' => true` 로 구독해야 호출자 트랜잭션과 함께 롤백됩니다.
<!-- @intent END -->
## 디렉토리
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-ecommerce --force` (빌드 불필요) |
| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-ecommerce --force` |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-ecommerce --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-ecommerce --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-ecommerce --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan module:update sirsoft-ecommerce --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,468 @@
# 이커머스 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
| `Brand` | `ecommerce_brands` | 7 | creator→User, updater→User, products→Product | SoftDeletes, 검색 색인 |
| `Cart` | `ecommerce_carts` | 6 | user→User, product→Product, productOption→ProductOption | - |
| `Category` | `ecommerce_categories` | 10 | parent→self, children→self, descendants→self, images→CategoryImage, products→Product | 검색 색인 |
| `CategoryImage` | `ecommerce_category_images` | 15 | category→Category, creator→User | SoftDeletes |
| `ClaimReason` | `ecommerce_claim_reasons` | 10 | creator→User, updater→User | HasUserOverrides |
| `Coupon` | `ecommerce_promotion_coupons` | 22 | issues→CouponIssue, products→Product, includedProducts→Product, excludedProducts→Product, categories→Category, includedCategories→Category, 외 2개 | SoftDeletes, 검색 색인 |
| `CouponIssue` | `ecommerce_promotion_coupon_issues` | 9 | coupon→Coupon, user→User, order→Order | - |
| `EcommerceStat` | `ecommerce_stats` | 4 | - | - |
| `EcommerceUserProfile` | `ecommerce_user_profiles` | 3 | user→User | - |
| `ExtraFeeTemplate` | `ecommerce_shipping_policy_extra_fee_templates` | 7 | creator→User, updater→User | - |
| `HasDirectAssetUrl` | (규약) | - | - | - |
| `MileageBalance` | `ecommerce_mileage_balances` | 9 | user→User | - |
| `MileageTransaction` | `ecommerce_mileage_transactions` | 16 | user→User, order→Order, orderOption→OrderOption, grantedByUser→User, sourceTransaction→self | - |
| `Order` | `ecommerce_orders` | 65 | user→User, options→OrderOption, firstOption→OrderOption, addresses→OrderAddress, shippingAddress→OrderAddress, billingAddress→OrderAddress, 외 8개 | SoftDeletes |
| `OrderAddress` | `ecommerce_order_addresses` | 23 | order→Order | - |
| `OrderCancel` | `ecommerce_order_cancels` | 10 | order→Order, cancelOptions→OrderCancelOption, refund→OrderRefund, cancelledByUser→User | - |
| `OrderCancelOption` | `ecommerce_order_cancel_options` | 10 | orderCancel→OrderCancel, order→Order, orderOption→OrderOption, processedByUser→User | - |
| `OrderCashReceipt` | `ecommerce_order_cash_receipts` | 16 | order→Order, payment→OrderPayment | - |
| `OrderOption` | `ecommerce_order_options` | 57 | order→Order, parentOption→self, childOptions→self, splitOptions→self, product→Product, productOption→ProductOption, 외 4개 | - |
| `OrderPayment` | `ecommerce_order_payments` | 58 | order→Order, taxInvoices→OrderTaxInvoice, cashReceipts→OrderCashReceipt | - |
| `OrderRefund` | `ecommerce_order_refunds` | 25 | order→Order, orderCancel→OrderCancel, refundOptions→OrderRefundOption, processedByUser→User | - |
| `OrderRefundOption` | `ecommerce_order_refund_options` | 12 | orderRefund→OrderRefund, order→Order, orderOption→OrderOption, processedByUser→User | - |
| `OrderShipping` | `ecommerce_order_shippings` | 32 | order→Order, orderOption→OrderOption, shippingPolicy→ShippingPolicy, carrier→ShippingCarrier | - |
| `OrderTaxInvoice` | `ecommerce_order_tax_invoices` | 21 | order→Order, payment→OrderPayment | - |
| `Product` | `ecommerce_products` | 34 | options→ProductOption, images→ProductImage, categories→Category, brand→Brand, commonInfo→ProductCommonInfo, shippingPolicy→ShippingPolicy, 외 6개 | SoftDeletes, 검색 색인 |
| `ProductAdditionalOption` | `ecommerce_product_additional_options` | 4 | product→Product, values→ProductAdditionalOptionValue | - |
| `ProductAdditionalOptionValue` | `ecommerce_product_additional_option_values` | 8 | additionalOption→ProductAdditionalOption | - |
| `ProductCommonInfo` | `ecommerce_product_common_infos` | 6 | products→Product | 검색 색인 |
| `ProductImage` | `ecommerce_product_images` | 16 | product→Product, creator→User | SoftDeletes |
| `ProductInquiry` | `ecommerce_product_inquiries` | 7 | product→Product, user→User, inquirable→? | SoftDeletes |
| `ProductLabel` | `ecommerce_product_labels` | 4 | assignments→ProductLabelAssignment | - |
| `ProductLabelAssignment` | `ecommerce_product_label_assignments` | 4 | product→Product, label→ProductLabel | - |
| `ProductNotice` | `ecommerce_product_notices` | 2 | product→Product | - |
| `ProductNoticeTemplate` | `ecommerce_product_notice_templates` | 5 | - | - |
| `ProductOption` | `ecommerce_product_options` | 18 | product→Product | - |
| `ProductReview` | `ecommerce_product_reviews` | 13 | product→Product, orderOption→OrderOption, user→User, replyAdmin→User, images→ProductReviewImage | SoftDeletes |
| `ProductReviewImage` | `ecommerce_product_review_images` | 15 | review→ProductReview, creator→User | SoftDeletes |
| `ProductWishlist` | `ecommerce_product_wishlists` | 2 | user→User, product→Product | - |
| `SearchPreset` | `ecommerce_search_presets` | 6 | user→User | - |
| `Sequence` | `ecommerce_sequences` | 12 | - | - |
| `SequenceCode` | `ecommerce_sequence_codes` | 2 | - | - |
| `ShippingCarrier` | `ecommerce_shipping_carriers` | 9 | creator→User, updater→User | HasUserOverrides |
| `ShippingPolicy` | `ecommerce_shipping_policies` | 6 | countrySettings→ShippingPolicyCountrySetting, listCountrySettings→ShippingPolicyCountrySetting | - |
| `ShippingPolicyCountrySetting` | `ecommerce_shipping_policy_country_settings` | 17 | shippingPolicy→ShippingPolicy | - |
| `ShippingType` | `ecommerce_shipping_types` | 8 | creator→User, updater→User | HasUserOverrides |
| `TempOrder` | `ecommerce_temp_orders` | 6 | user→User | - |
| `UserAddress` | `ecommerce_user_addresses` | 16 | user→User | - |
<!-- @generated:models END -->
<!-- @intent START -->
47개 모델은 다섯 계열로 읽습니다.
| 계열 | 모델 | 읽는 요령 |
|---|---|---|
| 카탈로그 | `Product` · `ProductOption` · `ProductAdditionalOption(Value)` · `ProductImage` · `Category` · `CategoryImage` · `Brand` · `ProductLabel(Assignment)` · `ProductCommonInfo` · `ProductNotice(Template)` | 판매 단위는 `Product` 가 아니라 **`ProductOption`** 입니다. 재고·가격·주문 연결이 전부 옵션에 걸립니다 |
| 주문 | `Order` · `OrderOption` · `OrderAddress` · `OrderPayment` · `OrderShipping` · `OrderCashReceipt` · `OrderTaxInvoice` | `Order` fillable 65 · `OrderOption` 57 · `OrderPayment` 58 — 이 셋이 큰 이유는 **주문 시점의 값을 전부 스냅샷으로 복사**하기 때문입니다. 상품명·가격·배송정책·통화 정보가 원본을 참조하지 않고 복제됩니다 |
| 취소·환불 | `OrderCancel(Option)` · `OrderRefund(Option)` · `ClaimReason` | 단위가 주문이 아니라 **옵션**입니다. `*Option` 쪽이 실제 처리 단위이고 상위는 묶음입니다 |
| 프로모션·적립 | `Coupon` · `CouponIssue` · `MileageTransaction` · `MileageBalance` | `MileageTransaction` 이 원장(SSoT), `MileageBalance` 는 단방향 파생 캐시입니다 |
| 배송·회원·기타 | `ShippingPolicy(CountrySetting)` · `ShippingCarrier` · `ShippingType` · `ExtraFeeTemplate` · `UserAddress` · `Cart` · `TempOrder` · `ProductWishlist` · `ProductReview(Image)` · `ProductInquiry` · `SearchPreset` · `Sequence(Code)` · `EcommerceStat` · `EcommerceUserProfile` | `TempOrder` 는 결제창 왕복 동안만 사는 임시 저장소이며 스케줄이 정리합니다 |
`HasDirectAssetUrl` 은 모델이 아니라 **규약(trait)** 입니다 — 이미지 계열 모델이 저장소 종류에
관계없이 같은 방식으로 자산 URL 을 내도록 묶습니다. 표에 모델처럼 잡힌 것은 수집기가 클래스
파일 단위로 세기 때문이며, 테이블이 `(규약)` 인 것이 그 표식입니다.
**`HasUserOverrides` 를 쓰는 셋**(`ClaimReason` · `ShippingCarrier` · `ShippingType`)은 시더가
기본값을 넣지만 운영자가 고칠 수 있는 테이블입니다. 시더를 다시 돌려도 운영자 수정분은
보존되므로, 이 셋의 기본 데이터를 바꿀 때는 시더만 고쳐서는 기설치본에 반영되지 않습니다.
**검색 색인 대상**은 `Product` · `Category` · `Brand` · `Coupon` · `ProductCommonInfo` 다섯이며,
색인 갱신은 서비스가 아니라 `SearchProductsListener` 가 훅으로 받아 처리합니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 테이블 | 모델 |
|---|---|
| `ecommerce_brands` | `Brand` |
| `ecommerce_carts` | `Cart` |
| `ecommerce_categories` | `Category` |
| `ecommerce_category_images` | `CategoryImage` |
| `ecommerce_claim_reasons` | `ClaimReason` |
| `ecommerce_mail_templates` | - |
| `ecommerce_mileage_balances` | `MileageBalance` |
| `ecommerce_mileage_transactions` | `MileageTransaction` |
| `ecommerce_order_addresses` | `OrderAddress` |
| `ecommerce_order_cancel_options` | `OrderCancelOption` |
| `ecommerce_order_cancels` | `OrderCancel` |
| `ecommerce_order_cash_receipts` | `OrderCashReceipt` |
| `ecommerce_order_options` | `OrderOption` |
| `ecommerce_order_payments` | `OrderPayment` |
| `ecommerce_order_refund_options` | `OrderRefundOption` |
| `ecommerce_order_refunds` | `OrderRefund` |
| `ecommerce_order_shippings` | `OrderShipping` |
| `ecommerce_order_tax_invoices` | `OrderTaxInvoice` |
| `ecommerce_orders` | `Order` |
| `ecommerce_product_additional_option_values` | `ProductAdditionalOptionValue` |
| `ecommerce_product_additional_options` | `ProductAdditionalOption` |
| `ecommerce_product_categories` | - |
| `ecommerce_product_common_infos` | `ProductCommonInfo` |
| `ecommerce_product_images` | `ProductImage` |
| `ecommerce_product_inquiries` | `ProductInquiry` |
| `ecommerce_product_label_assignments` | `ProductLabelAssignment` |
| `ecommerce_product_labels` | `ProductLabel` |
| `ecommerce_product_logs` | - |
| `ecommerce_product_notice_templates` | `ProductNoticeTemplate` |
| `ecommerce_product_notices` | `ProductNotice` |
| `ecommerce_product_options` | `ProductOption` |
| `ecommerce_product_review_images` | `ProductReviewImage` |
| `ecommerce_product_reviews` | `ProductReview` |
| `ecommerce_product_wishlists` | `ProductWishlist` |
| `ecommerce_products` | `Product` |
| `ecommerce_promotion_coupon_categories` | - |
| `ecommerce_promotion_coupon_issues` | `CouponIssue` |
| `ecommerce_promotion_coupon_products` | - |
| `ecommerce_promotion_coupons` | `Coupon` |
| `ecommerce_search_presets` | `SearchPreset` |
| `ecommerce_sequence_codes` | `SequenceCode` |
| `ecommerce_sequences` | `Sequence` |
| `ecommerce_shipping_carriers` | `ShippingCarrier` |
| `ecommerce_shipping_policies` | `ShippingPolicy` |
| `ecommerce_shipping_policy_country_settings` | `ShippingPolicyCountrySetting` |
| `ecommerce_shipping_policy_extra_fee_templates` | `ExtraFeeTemplate` |
| `ecommerce_shipping_types` | `ShippingType` |
| `ecommerce_stats` | `EcommerceStat` |
| `ecommerce_temp_orders` | `TempOrder` |
| `ecommerce_user_addresses` | `UserAddress` |
| `ecommerce_user_profiles` | `EcommerceUserProfile` |
<!-- @generated:tables END -->
<!-- @intent START -->
51개 테이블은 전부 `ecommerce_` 접두사를 갖습니다. 모델 열이 `-` 인 다섯은 각각 이유가 있습니다:
| 테이블 | 왜 모델이 없는가 |
|---|---|
| `ecommerce_product_categories` | 상품↔카테고리 다대다 피벗 (관계로만 접근) |
| `ecommerce_promotion_coupon_products` · `ecommerce_promotion_coupon_categories` | 쿠폰의 적용/제외 대상 피벗 — 한 쿠폰이 포함·제외 두 방향을 함께 갖습니다 |
| `ecommerce_product_logs` | 상품 변경 이력 적재 전용 (읽기는 집계 쿼리로) |
| `ecommerce_mail_templates` | 알림 본문 템플릿. 알림 정의 10종이 여기서 본문을 찾습니다 |
테이블을 추가·변경할 때 **DB CASCADE 에 삭제를 맡기지 않습니다.** 주문·상품 삭제는 훅 발행·
파일 정리·활동 로그가 함께 일어나야 하므로 Service 가 명시적으로 지웁니다 — CASCADE 로 지우면
그 부가 처리가 통째로 건너뛰어지고 아무 오류도 남지 않습니다.
주문 계열(`ecommerce_orders` · `ecommerce_order_*`)과 상품·리뷰·문의 계열은 SoftDeletes 를
씁니다. 목록 쿼리를 새로 만들 때 `withTrashed()` 를 습관적으로 붙이면 취소·삭제된 행이 매출
집계에 섞이므로 주의합니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
마이그레이션 102개.
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|---|---|---|---|
| `2026_04_01_000001_create_ecommerce_categories_table.php` | `ecommerce_categories` | - | ✅ |
| `2026_04_01_000002_create_ecommerce_category_images_table.php` | `ecommerce_category_images` | - | ✅ |
| `2026_04_01_000003_create_ecommerce_brands_table.php` | `ecommerce_brands` | - | ✅ |
| `2026_04_01_000004_create_ecommerce_search_presets_table.php` | `ecommerce_search_presets` | - | ✅ |
| `2026_04_01_000005_create_ecommerce_products_table.php` | `ecommerce_products` | `ecommerce_products` | ✅ |
| `2026_04_01_000006_create_ecommerce_product_images_table.php` | `ecommerce_product_images` | - | ✅ |
| `2026_04_01_000007_create_ecommerce_product_options_table.php` | `ecommerce_product_options` | - | ✅ |
| `2026_04_01_000008_create_ecommerce_product_additional_options_table.php` | `ecommerce_product_additional_options` | - | ✅ |
| `2026_04_01_000009_create_ecommerce_product_labels_table.php` | `ecommerce_product_labels` | - | ✅ |
| `2026_04_01_000010_create_ecommerce_product_label_assignments_table.php` | `ecommerce_product_label_assignments` | - | ✅ |
| `2026_04_01_000011_create_ecommerce_product_logs_table.php` | `ecommerce_product_logs` | - | ✅ |
| `2026_04_01_000012_create_ecommerce_product_notice_templates_table.php` | `ecommerce_product_notice_templates` | - | ✅ |
| `2026_04_01_000013_create_ecommerce_product_notices_table.php` | `ecommerce_product_notices` | - | ✅ |
| `2026_04_01_000014_create_ecommerce_product_common_infos_table.php` | `ecommerce_product_common_infos` | - | ✅ |
| `2026_04_01_000015_create_ecommerce_product_categories_table.php` | `ecommerce_product_categories` | - | ✅ |
| `2026_04_01_000016_create_ecommerce_product_wishlists_table.php` | `ecommerce_product_wishlists` | - | ✅ |
| `2026_04_01_000017_create_ecommerce_orders_table.php` | `ecommerce_orders` | - | ✅ |
| `2026_04_01_000018_create_ecommerce_order_options_table.php` | `ecommerce_order_options` | - | ✅ |
| `2026_04_01_000019_create_ecommerce_order_addresses_table.php` | `ecommerce_order_addresses` | - | ✅ |
| `2026_04_01_000020_create_ecommerce_order_payments_table.php` | `ecommerce_order_payments` | - | ✅ |
| `2026_04_01_000021_create_ecommerce_order_shippings_table.php` | `ecommerce_order_shippings` | - | ✅ |
| `2026_04_01_000022_create_ecommerce_order_tax_invoices_table.php` | `ecommerce_order_tax_invoices` | - | ✅ |
| `2026_04_01_000023_create_ecommerce_carts_table.php` | `ecommerce_carts` | - | ✅ |
| `2026_04_01_000024_create_ecommerce_temp_orders_table.php` | `ecommerce_temp_orders` | - | ✅ |
| `2026_04_01_000025_create_ecommerce_shipping_policies_table.php` | `ecommerce_shipping_policies` | - | ✅ |
| `2026_04_01_000026_create_ecommerce_shipping_policy_extra_fee_templates_table.php` | `ecommerce_shipping_policy_extra_fee_templates` | - | ✅ |
| `2026_04_01_000027_create_ecommerce_shipping_policy_country_settings_table.php` | `ecommerce_shipping_policy_country_settings` | - | ✅ |
| `2026_04_01_000028_create_ecommerce_shipping_carriers_table.php` | `ecommerce_shipping_carriers` | - | ✅ |
| `2026_04_01_000029_create_ecommerce_promotion_coupons_table.php` | `ecommerce_promotion_coupons` | - | ✅ |
| `2026_04_01_000030_add_vat_amount_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
| `2026_04_01_000031_create_ecommerce_promotion_coupon_issues_table.php` | `ecommerce_promotion_coupon_issues` | - | ✅ |
| `2026_04_01_000032_create_ecommerce_promotion_coupon_products_table.php` | `ecommerce_promotion_coupon_products` | - | ✅ |
| `2026_04_01_000033_create_ecommerce_promotion_coupon_categories_table.php` | `ecommerce_promotion_coupon_categories` | - | ✅ |
| `2026_04_01_000034_create_ecommerce_sequences_table.php` | `ecommerce_sequences` | - | ✅ |
| `2026_04_01_000035_create_ecommerce_sequence_codes_table.php` | `ecommerce_sequence_codes` | - | ✅ |
| `2026_04_01_000036_create_ecommerce_user_addresses_table.php` | `ecommerce_user_addresses` | - | ✅ |
| `2026_04_01_000037_create_ecommerce_mail_templates_table.php` | `ecommerce_mail_templates` | - | ✅ |
| `2026_04_01_000038_change_ecommerce_user_addresses_name_to_string.php` | - | `ecommerce_user_addresses` | ✅ |
| `2026_04_01_000039_create_ecommerce_product_reviews_table.php` | `ecommerce_product_reviews` | `ecommerce_product_reviews` | ✅ |
| `2026_04_01_000040_create_ecommerce_product_review_images_table.php` | `ecommerce_product_review_images` | `ecommerce_product_review_images` | ✅ |
| `2026_04_01_000041_create_ecommerce_order_cancels_table.php` | `ecommerce_order_cancels` | - | ✅ |
| `2026_04_01_000042_create_ecommerce_order_cancel_options_table.php` | `ecommerce_order_cancel_options` | - | ✅ |
| `2026_04_01_000043_create_ecommerce_order_refunds_table.php` | `ecommerce_order_refunds` | - | ✅ |
| `2026_04_01_000044_create_ecommerce_order_refund_options_table.php` | `ecommerce_order_refund_options` | - | ✅ |
| `2026_04_01_000045_add_cancellation_columns_to_ecommerce_orders_and_options.php` | - | `ecommerce_orders`, `ecommerce_order_options` | ✅ |
| `2026_04_01_000046_add_mc_refund_columns_to_ecommerce_order_refunds_table.php` | - | `ecommerce_order_refunds` | ✅ |
| `2026_04_01_000047_modify_i18n_columns_in_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
| `2026_04_01_000048_create_ecommerce_claim_reasons_table.php` | `ecommerce_claim_reasons` | - | ✅ |
| `2026_04_01_000049_add_confirmed_at_to_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
| `2026_04_01_000050_drop_ecommerce_product_logs_table.php` | `ecommerce_product_logs` | - | ✅ |
| `2026_04_01_000051_remove_url_from_ecommerce_product_images_and_review_images_table.php` | - | `ecommerce_product_images`, `ecommerce_product_review_images` | ✅ |
| `2026_04_01_000052_create_ecommerce_product_inquiries_table.php` | `ecommerce_product_inquiries` | `ecommerce_product_inquiries` | ✅ |
| `2026_04_01_000053_add_indexes_to_ecommerce_products_table.php` | - | `ecommerce_products` | ✅ |
| `2026_04_01_000054_add_indexes_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
| `2026_04_01_000055_add_indexes_to_ecommerce_order_addresses_table.php` | - | `ecommerce_order_addresses` | ✅ |
| `2026_04_01_000056_drop_carrier_name_from_ecommerce_order_shippings_table.php` | - | `ecommerce_order_shippings` | ✅ |
| `2026_04_01_000057_add_fulltext_indexes_to_ecommerce_products_table.php` | - | `ecommerce_products` | ✅ |
| `2026_04_01_000058_add_fulltext_indexes_to_ecommerce_categories_table.php` | - | `ecommerce_categories` | ✅ |
| `2026_04_01_000059_add_fulltext_indexes_to_ecommerce_brands_table.php` | - | `ecommerce_brands` | ✅ |
| `2026_04_01_000060_add_fulltext_indexes_to_ecommerce_promotion_coupons_table.php` | - | `ecommerce_promotion_coupons` | ✅ |
| `2026_04_01_000061_add_fulltext_indexes_to_ecommerce_product_common_infos_table.php` | - | `ecommerce_product_common_infos` | ✅ |
| `2026_04_01_000062_create_ecommerce_shipping_types_table.php` | `ecommerce_shipping_types` | - | ✅ |
| `2026_04_01_000063_add_custom_shipping_name_to_country_settings.php` | - | `ecommerce_shipping_policy_country_settings` | ✅ |
| `2026_04_13_000001_drop_ecommerce_mail_templates_table.php` | `ecommerce_mail_templates` | - | ✅ |
| `2026_04_13_000002_add_user_overrides_to_ecommerce_claim_reasons_table.php` | - | `ecommerce_claim_reasons` | ✅ |
| `2026_04_19_000000_add_user_overrides_to_ecommerce_shipping_types_table.php` | - | `ecommerce_shipping_types` | ✅ |
| `2026_04_20_000001_add_user_overrides_to_ecommerce_shipping_carriers_table.php` | - | `ecommerce_shipping_carriers` | ✅ |
| `2026_05_16_000001_add_unique_index_to_transaction_id_on_ecommerce_order_payments_table.php` | - | - | ✅ |
| `2026_06_02_000001_add_guest_lookup_password_hash_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
| `2026_06_11_000001_create_ecommerce_mileage_transactions_table.php` | `ecommerce_mileage_transactions` | - | ✅ |
| `2026_06_11_000002_add_delivered_at_to_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
| `2026_06_11_000003_add_mc_subtotal_earned_points_amount_to_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
| `2026_06_11_000004_create_ecommerce_mileage_balances_table.php` | `ecommerce_mileage_balances` | - | ✅ |
| `2026_06_16_000001_add_is_mileage_deducted_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
| `2026_06_16_000001_create_ecommerce_stats_table.php` | `ecommerce_stats` | - | ✅ |
| `2026_06_22_000001_add_api_config_to_ecommerce_shipping_policy_country_settings_table.php` | - | `ecommerce_shipping_policy_country_settings` | ✅ |
| `2026_06_22_000001_add_cancelled_at_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
| `2026_06_23_000001_add_seo_sync_flags_to_ecommerce_products_table.php` | - | `ecommerce_products` | ✅ |
| `2026_06_24_000001_convert_seo_meta_to_multilingual_json.php` | - | - | ✅ |
| `2026_06_24_000001_create_ecommerce_user_profiles_table.php` | `ecommerce_user_profiles` | - | ✅ |
| `2026_06_24_000010_create_ecommerce_product_additional_option_values_table.php` | `ecommerce_product_additional_option_values` | - | ✅ |
| `2026_06_24_000011_add_additional_option_selections_to_ecommerce_carts_table.php` | - | `ecommerce_carts` | ✅ |
| `2026_06_24_000012_add_additional_options_columns_to_ecommerce_order_options_table.php` | - | `ecommerce_order_options` | ✅ |
| `2026_06_25_000001_add_preferred_shipping_country_to_ecommerce_user_profiles_table.php` | - | `ecommerce_user_profiles` | ✅ |
| `2026_06_25_000001_change_ecommerce_product_prices_to_decimal.php` | - | `ecommerce_products`, `ecommerce_product_options` | ✅ |
| `2026_06_25_000002_add_shipping_snapshot_to_ecommerce_order_cancels_table.php` | - | `ecommerce_order_cancels` | ✅ |
| `2026_06_25_000013_add_allow_custom_text_to_ecommerce_product_additional_option_values_table.php` | - | `ecommerce_product_additional_option_values` | ✅ |
| `2026_06_26_000001_add_delivery_memo_label_to_ecommerce_order_addresses_table.php` | - | `ecommerce_order_addresses` | ✅ |
| `2026_06_26_000002_add_orderer_locale_to_ecommerce_order_addresses_table.php` | - | `ecommerce_order_addresses` | ✅ |
| `2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php` | `ecommerce_order_cash_receipts` | - | ✅ |
| `2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php` | - | `ecommerce_orders`, `ecommerce_order_options` | ✅ |
| `2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php` | - | `ecommerce_order_payments` | ✅ |
| `2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php` | - | `ecommerce_order_payments` | ✅ |
| `2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php` | - | `ecommerce_orders` | ✅ |
| `2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php` | - | `ecommerce_product_reviews` | ✅ |
| `2026_07_31_000001_add_shipped_at_index_to_ecommerce_order_shippings_table.php` | - | `ecommerce_order_shippings` | ✅ |
| `2026_08_01_000001_add_list_sort_indexes_to_ecommerce_tables.php` | - | - | ✅ |
| `2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php` | - | - | ✅ |
| `2026_08_11_000001_fix_weight_volume_unit_comments_in_ecommerce_order_tables.php` | - | `ecommerce_orders`, `ecommerce_order_options` | ✅ |
| `2026_08_17_000001_add_soft_deletes_to_ecommerce_product_inquiries_table.php` | - | `ecommerce_product_inquiries` | ✅ |
| `2026_08_21_000001_add_unique_purchase_earn_lot_to_ecommerce_mileage_transactions_table.php` | - | - | ✅ |
| `2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php` | - | `ecommerce_products` | ✅ |
<!-- @generated:migrations END -->
<!-- @intent START -->
102개는 초기 스키마 한 벌이 아니라 **누적된 변경 이력**입니다. 새 컬럼을 추가할 때 초기
`create_*` 파일을 고치는 것이 아니라 새 `add_*`/`change_*` 파일을 더합니다 — 이미 설치된
사이트는 초기 마이그레이션을 다시 실행하지 않기 때문입니다.
같은 이유로 **소스만 고쳐서는 기설치본이 낫지 않습니다.** 컬럼 기본값·comment·데이터 형태를
바로잡는 변경은 마이그레이션과 함께 `upgrades/` 의 업그레이드 스텝에 백필을 써야 이미 운영
중인 사이트에 반영됩니다.
작성 규칙 셋(코어 공통이지만 이 모듈에서 특히 자주 걸립니다):
- 모든 컬럼에 한국어 `comment` 와 `down()` 구현
- FK 컬럼의 `->comment()` 는 `->constrained()` **앞**에 둡니다 (뒤에 두면 comment 가 컬럼이
아니라 FK 정의에 붙어 조용히 사라집니다)
- 데이터를 순회하며 그 행을 갱신·삭제하는 백필은 `chunkById()` — `chunk()` 계열은 OFFSET
기반이라 처리된 행이 필터에서 이탈한 만큼 커서가 밀려 미처리 행을 조용히 건너뜁니다
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| Enum | backing | case 수 | case |
|---|---|---|---|
| `AdjustmentType` | `string` | 1 | `cancel` |
| `CancelOptionStatusEnum` | `string` | 2 | `requested`, `completed` |
| `CancelStatusEnum` | `string` | 2 | `requested`, `completed` |
| `CancelTypeEnum` | `string` | 2 | `full`, `partial` |
| `CashReceiptIdentifierType` | `string` | 3 | `phone`, `card`, `business` |
| `CashReceiptIssueStatus` | `string` | 3 | `IN_PROGRESS`, `COMPLETED`, `FAILED` |
| `CashReceiptTransactionType` | `string` | 2 | `issue`, `cancel` |
| `CashReceiptType` | `string` | 2 | `income`, `expense` |
| `ChargePolicyEnum` | `string` | 14 | `free`, `fixed`, `conditional_free`, `range_amount`, `range_quantity`, `range_weight`, `range_volume`, `range_volume_weight`, `외 6개` |
| `ClaimReasonFaultTypeEnum` | `string` | 3 | `customer`, `seller`, `carrier` |
| `ClaimReasonTypeEnum` | `string` | 1 | `refund` |
| `CouponDiscountType` | `string` | 2 | `fixed`, `rate` |
| `CouponIssueCondition` | `string` | 4 | `manual`, `signup`, `first_purchase`, `birthday` |
| `CouponIssueMethod` | `string` | 3 | `direct`, `download`, `auto` |
| `CouponIssueRecordStatus` | `string` | 4 | `available`, `used`, `expired`, `cancelled` |
| `CouponIssueStatus` | `string` | 2 | `issuing`, `stopped` |
| `CouponTargetScope` | `string` | 3 | `all`, `products`, `categories` |
| `CouponTargetType` | `string` | 3 | `product_amount`, `order_amount`, `shipping_fee` |
| `DeliveryMemoPresetEnum` | `string` | 4 | `door`, `security`, `parcel_box`, `call` |
| `DeviceTypeEnum` | `string` | 6 | `pc`, `mobile`, `app_ios`, `app_android`, `admin`, `api` |
| `MileageEarnTriggerEnum` | `string` | 2 | `delivered`, `confirmed` |
| `MileageTransactionTypeEnum` | `string` | 8 | `purchase_earn`, `admin_earn`, `order_use`, `admin_deduct`, `expired`, `refund_restore`, `order_cancel_restore`, `earn_cancel` |
| `OrderDateTypeEnum` | `string` | 5 | `ordered_at`, `paid_at`, `confirmed_at`, `delivered_at`, `cancelled_at` |
| `OrderOptionSourceTypeEnum` | `string` | 3 | `order`, `exchange`, `split` |
| `OrderStatusEnum` | `string` | 10 | `pending_order`, `pending_payment`, `payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered`, `외 2개` |
| `PaymentMethodEnum` | `string` | 8 | `card`, `vbank`, `dbank`, `bank`, `phone`, `point`, `deposit`, `free` |
| `PaymentStatusEnum` | `string` | 8 | `ready`, `in_progress`, `waiting_deposit`, `paid`, `partial_cancelled`, `cancelled`, `failed`, `expired` |
| `ProductDateType` | `string` | 2 | `created_at`, `updated_at` |
| `ProductDisplayStatus` | `string` | 2 | `visible`, `hidden` |
| `ProductImageCollection` | `string` | 3 | `main`, `detail`, `additional` |
| `ProductPriceType` | `string` | 3 | `selling_price`, `supply_price`, `list_price` |
| `ProductSalesStatus` | `string` | 4 | `on_sale`, `suspended`, `sold_out`, `coming_soon` |
| `ProductTaxStatus` | `string` | 2 | `taxable`, `tax_free` |
| `RefundMethodEnum` | `string` | 3 | `pg`, `bank`, `points` |
| `RefundOptionStatusEnum` | `string` | 6 | `requested`, `approved`, `processing`, `on_hold`, `completed`, `rejected` |
| `RefundPriorityEnum` | `string` | 2 | `pg_first`, `points_first` |
| `RefundStatusEnum` | `string` | 6 | `requested`, `approved`, `processing`, `on_hold`, `completed`, `rejected` |
| `ReviewStatus` | `string` | 2 | `visible`, `hidden` |
| `SearchPresetTargetScreen` | `string` | 3 | `products`, `orders`, `customers` |
| `SequenceAlgorithm` | `string` | 5 | `hybrid`, `sequential`, `daily`, `timestamp`, `nanoid` |
| `SequenceType` | `string` | 5 | `product`, `order`, `shipping`, `cancel`, `refund` |
| `ShippingApiAuthType` | `string` | 3 | `none`, `bearer`, `custom_header` |
| `ShippingApiHttpMethod` | `string` | 2 | `GET`, `POST` |
| `ShippingApiRequestField` | `string` | 5 | `policy_id`, `country_code`, `items`, `group_total`, `total_quantity` |
| `ShippingApiResponseType` | `string` | 2 | `json`, `text` |
| `ShippingCountryEnum` | `string` | 4 | `KR`, `US`, `CN`, `JP` |
| `ShippingFeeTaxPolicy` | `string` | 3 | `proportional`, `taxable`, `follow_main_item` |
| `ShippingStatusEnum` | `string` | 11 | `pending`, `preparing`, `ready`, `shipped`, `in_transit`, `out_for_delivery`, `delivered`, `failed`, `외 3개` |
| `TaxInvoiceStatusEnum` | `string` | 5 | `pending`, `processing`, `issued`, `failed`, `cancelled` |
<!-- @generated:enums END -->
<!-- @intent START -->
Enum 49종이 이 도메인의 **어휘 전체**입니다. 상태·분류를 문자열 리터럴로 비교하는 코드가 있으면
그 자리는 Enum 으로 바꿔야 하는 신호입니다 — 화면 필터 옵션·검증 게이트·실제 기록 값 셋이
같은 Enum 에서 파생되지 않으면, 빠진 값으로 기록된 행이 어떤 필터로도 도달할 수 없게 됩니다.
먼저 읽어야 하는 것들:
| Enum | 왜 중요한가 |
|---|---|
| `OrderStatusEnum` (10) | 주문 상태 전이의 SSoT. 어느 상태까지 취소를 허용할지는 설정(`cancellable_statuses`)이 이 케이스 이름으로 정합니다 |
| `PaymentStatusEnum` (8) · `PaymentMethodEnum` (8) | 결제 상태와 **코어 기본 결제수단**. 플러그인이 추가하는 결제수단(`kginicis_naverpay` 등)은 여기에 없고 카탈로그 선언으로만 존재합니다 — 이 Enum 을 결제수단의 전체 목록으로 오해하지 않습니다 |
| `ChargePolicyEnum` (14) | 배송비 산정 방식. 무료·정액·조건부무료·금액/수량/무게/부피 구간 등 국가별 요금 규칙이 전부 이 하나로 표현됩니다 |
| `MileageTransactionTypeEnum` (8) | 원장 기록의 종류. 적립·사용·소멸·환불복원·취소복원이 모두 별개 케이스라 원장만 보고 잔액을 재구성할 수 있습니다 |
| `RefundMethodEnum` (3) · `RefundPriorityEnum` (2) | 환불을 어디로 돌려줄지와 그 우선순위 |
| `SequenceType` (5) · `SequenceAlgorithm` (5) | 채번 대상과 방식. 주문번호·상품코드 형식을 바꾸는 자리입니다 |
| `ShippingStatusEnum` (11) · `ShippingCountryEnum` (4) | 배송 진행 상태와 기본 제공 국가 |
`ShippingCountryEnum` 이 4개뿐인 것은 **기본 제공 목록**이기 때문입니다. 취급 국가는 환경설정과
배송정책 국가별 설정이 정하므로, 나라를 늘리는 것은 이 Enum 을 고치는 일이 아닙니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `BrandRepository` | 구현 | 브랜드 Repository 구현체 |
| `BrandRepositoryInterface` | 인터페이스 | 브랜드 Repository 인터페이스 |
| `CartRepository` | 구현 | 장바구니 Repository 구현체 |
| `CartRepositoryInterface` | 인터페이스 | 장바구니 Repository 인터페이스 |
| `CategoryImageRepository` | 구현 | 카테고리 이미지 Repository 구현체 |
| `CategoryImageRepositoryInterface` | 인터페이스 | 카테고리 이미지 Repository 인터페이스 |
| `CategoryRepository` | 구현 | 카테고리 Repository 구현체 |
| `CategoryRepositoryInterface` | 인터페이스 | 카테고리 Repository 인터페이스 |
| `ClaimReasonRepository` | 구현 | 클레임 사유 Repository 구현체 |
| `ClaimReasonRepositoryInterface` | 인터페이스 | 클레임 사유 Repository 인터페이스 |
| `CouponIssueRepository` | 구현 | 쿠폰 발급 Repository 구현체 |
| `CouponIssueRepositoryInterface` | 인터페이스 | 쿠폰 발급 Repository 인터페이스 |
| `CouponRepository` | 구현 | 쿠폰 Repository 구현체 |
| `CouponRepositoryInterface` | 인터페이스 | 쿠폰 Repository 인터페이스 |
| `EcommerceStatRepository` | 구현 | 이커머스 일별 판매 집계 Repository |
| `EcommerceStatRepositoryInterface` | 인터페이스 | 이커머스 일별 판매 집계 Repository 계약 |
| `EcommerceUserProfileRepository` | 구현 | 이커머스 사용자 프로필 Repository 구현체 (A3) |
| `EcommerceUserProfileRepositoryInterface` | 인터페이스 | 이커머스 사용자 프로필 Repository 인터페이스 (A3) |
| `ExtraFeeTemplateRepository` | 구현 | 추가배송비 템플릿 Repository 구현체 |
| `ExtraFeeTemplateRepositoryInterface` | 인터페이스 | 추가배송비 템플릿 Repository 인터페이스 |
| `MileageBalanceRepository` | 구현 | 마일리지 잔액 캐시 Repository 구현체 (단방향 파생 — 원장/옵션 → 캐시) |
| `MileageBalanceRepositoryInterface` | 인터페이스 | 마일리지 잔액 캐시 Repository 인터페이스 (파생 캐시) |
| `MileageTransactionRepository` | 구현 | 마일리지 거래(원장) Repository 구현체 |
| `MileageTransactionRepositoryInterface` | 인터페이스 | 마일리지 거래(원장) Repository 인터페이스 |
| `OrderCancelOptionRepository` | 구현 | 주문 취소 옵션 리포지토리 구현체 |
| `OrderCancelOptionRepositoryInterface` | 인터페이스 | 주문 취소 옵션 리포지토리 인터페이스 |
| `OrderCancelRepository` | 구현 | 주문 취소 리포지토리 구현체 |
| `OrderCancelRepositoryInterface` | 인터페이스 | 주문 취소 리포지토리 인터페이스 |
| `OrderCashReceiptRepository` | 구현 | 주문 현금영수증 이력 Repository 구현체 |
| `OrderCashReceiptRepositoryInterface` | 인터페이스 | 주문 현금영수증 이력 Repository 인터페이스 |
| `OrderOptionRepository` | 구현 | 주문 옵션 리포지토리 |
| `OrderOptionRepositoryInterface` | 인터페이스 | 주문 옵션 리포지토리 인터페이스 |
| `OrderPaymentRepository` | 구현 | 주문 결제 Repository 구현체 |
| `OrderPaymentRepositoryInterface` | 인터페이스 | 주문 결제 Repository 인터페이스 |
| `OrderRefundOptionRepository` | 구현 | 주문 환불 옵션 리포지토리 구현체 |
| `OrderRefundOptionRepositoryInterface` | 인터페이스 | 주문 환불 옵션 리포지토리 인터페이스 |
| `OrderRefundRepository` | 구현 | 주문 환불 리포지토리 구현체 |
| `OrderRefundRepositoryInterface` | 인터페이스 | 주문 환불 리포지토리 인터페이스 |
| `OrderRepository` | 구현 | 주문 Repository 구현체 |
| `OrderRepositoryInterface` | 인터페이스 | 주문 Repository 인터페이스 |
| `OrderShippingRepository` | 구현 | 주문 배송 리포지토리 구현체 |
| `OrderShippingRepositoryInterface` | 인터페이스 | 주문 배송 리포지토리 인터페이스 |
| `ProductAdditionalOptionValueRepository` | 구현 | 상품 추가옵션 선택지 Repository 구현체 |
| `ProductAdditionalOptionValueRepositoryInterface` | 인터페이스 | 상품 추가옵션 선택지 Repository 인터페이스 |
| `ProductCommonInfoRepository` | 구현 | 공통정보 Repository 구현체 |
| `ProductCommonInfoRepositoryInterface` | 인터페이스 | 공통정보 Repository 인터페이스 |
| `ProductImageRepository` | 구현 | 상품 이미지 Repository 구현체 |
| `ProductImageRepositoryInterface` | 인터페이스 | 상품 이미지 Repository 인터페이스 |
| `ProductInquiryRepository` | 구현 | 상품 1:1 문의 Repository 구현체 |
| `ProductInquiryRepositoryInterface` | 인터페이스 | 상품 1:1 문의 Repository 인터페이스 |
| `ProductLabelRepository` | 구현 | 상품 라벨 Repository 구현체 |
| `ProductLabelRepositoryInterface` | 인터페이스 | 상품 라벨 Repository 인터페이스 |
| `ProductNoticeTemplateRepository` | 구현 | 상품정보제공고시 템플릿 Repository 구현체 |
| `ProductNoticeTemplateRepositoryInterface` | 인터페이스 | 상품정보제공고시 템플릿 Repository 인터페이스 |
| `ProductOptionRepository` | 구현 | 상품 옵션 Repository 구현체 |
| `ProductOptionRepositoryInterface` | 인터페이스 | 상품 옵션 Repository 인터페이스 |
| `ProductRepository` | 구현 | 상품 Repository 구현체 |
| `ProductRepositoryInterface` | 인터페이스 | 상품 Repository 인터페이스 |
| `ProductReviewImageRepository` | 구현 | 상품 리뷰 이미지 Repository 구현체 |
| `ProductReviewImageRepositoryInterface` | 인터페이스 | 상품 리뷰 이미지 Repository 인터페이스 |
| `ProductReviewRepository` | 구현 | 상품 리뷰 Repository 구현체 |
| `ProductReviewRepositoryInterface` | 인터페이스 | 상품 리뷰 Repository 인터페이스 |
| `ProductWishlistRepository` | 구현 | 상품 찜 Repository 구현체 |
| `ProductWishlistRepositoryInterface` | 인터페이스 | 상품 찜 Repository 인터페이스 |
| `SearchPresetRepository` | 구현 | 검색 프리셋 Repository 구현체 |
| `SearchPresetRepositoryInterface` | 인터페이스 | 검색 프리셋 Repository 인터페이스 |
| `SequenceRepository` | 구현 | 시퀀스 Repository 구현체 |
| `SequenceRepositoryInterface` | 인터페이스 | 시퀀스 Repository 인터페이스 |
| `ShippingCarrierRepository` | 구현 | 배송사 Repository 구현체 |
| `ShippingCarrierRepositoryInterface` | 인터페이스 | 배송사 Repository 인터페이스 |
| `ShippingPolicyRepository` | 구현 | 배송정책 Repository 구현체 |
| `ShippingPolicyRepositoryInterface` | 인터페이스 | 배송정책 Repository 인터페이스 |
| `ShippingTypeRepository` | 구현 | 배송유형 Repository 구현체 |
| `ShippingTypeRepositoryInterface` | 인터페이스 | 배송유형 Repository 인터페이스 |
| `TempOrderRepository` | 구현 | 임시 주문 Repository 구현체 |
| `TempOrderRepositoryInterface` | 인터페이스 | 임시 주문 Repository 인터페이스 |
| `UserAddressRepository` | 구현 | 사용자 배송지 Repository 구현체 |
| `UserAddressRepositoryInterface` | 인터페이스 | 사용자 배송지 Repository 인터페이스 |
<!-- @generated:repositories END -->
<!-- @intent START -->
Repository 는 인터페이스와 구현이 1:1 로 짝을 이루며, 서비스는 **인터페이스만 주입**받습니다
(구체 클래스 타입힌트 금지). 바인딩은 모듈 서비스 프로바이더가 담당합니다.
이 모듈에서 Repository 를 손댈 때 특히 걸리는 것 셋:
- **목록 쿼리의 컬럼 프루닝** — `paginate()` 에 컬럼 목록을 주고, 목록이 실제로 그리는 것만
싣습니다. 상품 목록에 옵션 전체를 실으면 상품 100건 × 옵션 20건이 한 응답에 나갑니다.
- **정렬 컬럼 화이트리스트** — 요청에서 온 정렬 컬럼을 그대로 `orderBy` 에 넘기지 않습니다.
화면의 정렬 옵션 ⊆ FormRequest 게이트 ⊆ Repository 화이트리스트 순서로 포함 관계가
유지되어야 하며, 어긋나면 422 뒤에 직전 목록이 남아 **정렬된 것처럼 보입니다.**
- **마일리지 두 Repository 의 역할 차이** — `MileageTransactionRepository` 는 원장이고
`MileageBalanceRepository` 는 파생 캐시입니다. 차감 가능 여부 판정은 반드시 원장
`FOR UPDATE` 로 하고, 캐시는 같은 트랜잭션 마지막에 재계산합니다. 캐시를 근거로 차감하면
동시 요청에서 잔액이 음수가 됩니다.
`EcommerceStatRepository` 만 성격이 다릅니다 — 대시보드용 일별 집계 테이블을 읽고 쓰며, 원본
주문에서 매번 집계하지 않기 위한 자리입니다. 집계를 채우는 것은 `aggregate-stats` 스케줄입니다.
<!-- @intent END -->
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,483 @@
# 이커머스 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 206개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 206개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `admin_ecommerce_brand_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_category_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_deposit_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_excel_download_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_main_banner_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_mileage_transaction_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_order_detail` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_order_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_order_list` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_payment_failure_history` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_personal_payment` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_personal_payment_create` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_personal_payment_detail` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_product_common_info_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_product_form` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_product_list` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_product_notice_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_product_review_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_promotion_coupon_form` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_promotion_coupon_list` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_promotion_discount_code_create` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_promotion_discount_code_index` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_settings` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_shipping_policy_form` | `admin` | 화면 | `_admin_base` |
| `admin_ecommerce_shipping_policy_list` | `admin` | 화면 | `_admin_base` |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_editing_confirm` | `admin` | partial | - |
| `_panel_brand_list` | `admin` | partial | - |
| `_panel_detail` | `admin` | partial | - |
| `_panel_form` | `admin` | partial | - |
| `_panel_view` | `admin` | partial | - |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_image_preview` | `admin` | partial | - |
| `_panel_category_list` | `admin` | partial | - |
| `_panel_detail` | `admin` | partial | - |
| `_panel_form` | `admin` | partial | - |
| `_panel_view` | `admin` | partial | - |
| `_modal_bulk_match` | `admin` | partial | - |
| `_modal_manual_match` | `admin` | partial | - |
| `_modal_process_history` | `admin` | partial | - |
| `_modal_download_history` | `admin` | partial | - |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_edit_cancel` | `admin` | partial | - |
| `_modal_status_change` | `admin` | partial | - |
| `_partial_banner_detail` | `admin` | partial | - |
| `_partial_banner_form` | `admin` | partial | - |
| `_partial_banner_list` | `admin` | partial | - |
| `_partial_preview_slider` | `admin` | partial | - |
| `_filters` | `admin` | partial | - |
| `_modal_edit_transaction` | `admin` | partial | - |
| `_modal_extend_expiry` | `admin` | partial | - |
| `_modal_manual_transaction` | `admin` | partial | - |
| `_transactions_table` | `admin` | partial | - |
| `_modal_batch_change_confirm` | `admin` | partial | - |
| `_modal_cancel_order` | `admin` | partial | - |
| `_modal_confirm_deposit` | `admin` | partial | - |
| `_modal_issue_cash_receipt` | `admin` | partial | - |
| `_modal_reset_guest_password` | `admin` | partial | - |
| `_modal_send_email` | `admin` | partial | - |
| `_modal_send_sms` | `admin` | partial | - |
| `_partial_activity_log` | `admin` | partial | - |
| `_partial_claim_history` | `admin` | partial | - |
| `_partial_order_info` | `admin` | partial | - |
| `_partial_payment_info` | `admin` | partial | - |
| `_tab_claim_exchange` | `admin` | partial | - |
| `_tab_claim_refund` | `admin` | partial | - |
| `_tab_claim_return` | `admin` | partial | - |
| `_modal_excel_download` | `admin` | partial | - |
| `_modal_preset_manage` | `admin` | partial | - |
| `_modal_preset_save` | `admin` | partial | - |
| `_modal_bulk_confirm` | `admin` | partial | - |
| `_modal_excel_download` | `admin` | partial | - |
| `_modal_preset_delete_confirm` | `admin` | partial | - |
| `_modal_preset_edit` | `admin` | partial | - |
| `_modal_preset_manage` | `admin` | partial | - |
| `_modal_preset_save` | `admin` | partial | - |
| `_partial_bulk_action_section` | `admin` | partial | - |
| `_partial_filter_section` | `admin` | partial | - |
| `_partial_order_datagrid` | `admin` | partial | - |
| `_partial_preset_section` | `admin` | partial | - |
| `_modal_member_search` | `admin` | partial | - |
| `_modal_order_search` | `admin` | partial | - |
| `_modal_content_mode_change` | `admin` | partial | - |
| `_modal_copy_confirm` | `admin` | partial | - |
| `_modal_default_confirm` | `admin` | partial | - |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_editing_confirm` | `admin` | partial | - |
| `_modal_set_default_confirm` | `admin` | partial | - |
| `_panel_detail` | `admin` | partial | - |
| `_panel_form` | `admin` | partial | - |
| `_panel_list` | `admin` | partial | - |
| `_panel_view` | `admin` | partial | - |
| `_partial_common_info_detail` | `admin` | partial | - |
| `_partial_common_info_form` | `admin` | partial | - |
| `_modal_add_language` | `admin` | partial | - |
| `_modal_additional_options_clear` | `admin` | partial | - |
| `_modal_confirm_regenerate` | `admin` | partial | - |
| `_modal_copy_product` | `admin` | partial | - |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_label_delete_confirm` | `admin` | partial | - |
| `_modal_label_form` | `admin` | partial | - |
| `_modal_label_uncheck_confirm` | `admin` | partial | - |
| `_modal_multilingual_tag_edit` | `admin` | partial | - |
| `_modal_notice_bulk_change` | `admin` | partial | - |
| `_modal_notice_template_confirm` | `admin` | partial | - |
| `_modal_save_template` | `admin` | partial | - |
| `_partial_activity_log` | `admin` | partial | - |
| `_partial_basic_info` | `admin` | partial | - |
| `_partial_common_info` | `admin` | partial | - |
| `_partial_description` | `admin` | partial | - |
| `_partial_identification_codes` | `admin` | partial | - |
| `_partial_image_upload` | `admin` | partial | - |
| `_partial_other_info` | `admin` | partial | - |
| `_partial_product_notice` | `admin` | partial | - |
| `_partial_product_options` | `admin` | partial | - |
| `_partial_sales_info` | `admin` | partial | - |
| `_partial_seo_settings` | `admin` | partial | - |
| `_partial_shipping` | `admin` | partial | - |
| `_partial_shopping_integration` | `admin` | partial | - |
| `_modal_bulk_confirm` | `admin` | partial | - |
| `_modal_bulk_price` | `admin` | partial | - |
| `_modal_bulk_stock` | `admin` | partial | - |
| `_modal_copy_product` | `admin` | partial | - |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_excel_download` | `admin` | partial | - |
| `_modal_preset_delete_confirm` | `admin` | partial | - |
| `_modal_preset_edit` | `admin` | partial | - |
| `_modal_preset_manage` | `admin` | partial | - |
| `_modal_preset_save` | `admin` | partial | - |
| `_partial_filter_section` | `admin` | partial | - |
| `_partial_product_datagrid` | `admin` | partial | - |
| `_modal_bulk_change` | `admin` | partial | - |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_editing_confirm` | `admin` | partial | - |
| `_panel_detail` | `admin` | partial | - |
| `_panel_form` | `admin` | partial | - |
| `_panel_list` | `admin` | partial | - |
| `_panel_view` | `admin` | partial | - |
| `_modal_image_preview` | `admin` | partial | - |
| `_modal_reply_delete` | `admin` | partial | - |
| `_modal_status_change` | `admin` | partial | - |
| `_partial_basic_info` | `admin` | partial | - |
| `_partial_benefit_settings` | `admin` | partial | - |
| `_partial_issue_settings` | `admin` | partial | - |
| `_partial_usage_conditions` | `admin` | partial | - |
| `_modal_cancel_issue_confirm` | `admin` | partial | - |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_direct_issue` | `admin` | partial | - |
| `_modal_issue_history` | `admin` | partial | - |
| `_modal_status_change_confirm` | `admin` | partial | - |
| `_partial_coupon_datagrid` | `admin` | partial | - |
| `_partial_filter_section` | `admin` | partial | - |
| `_modal_delete_confirm` | `admin` | partial | - |
| `_modal_status_change` | `admin` | partial | - |
| `_bank_accounts_cards` | `admin` | partial | - |
| `_bank_accounts_table` | `admin` | partial | - |
| `_bank_management_modal` | `admin` | partial | - |
| `_currency_exchange_cards` | `admin` | partial | - |
| `_currency_exchange_table` | `admin` | partial | - |
| `_disable_international_shipping_modal` | `admin` | partial | - |
| `_modal_clear_seo_cache` | `admin` | partial | - |
| `_modal_identity_policy_delete` | `admin` | partial | - |
| `_modal_identity_policy_form` | `admin` | partial | - |
| `_modal_mail_template_edit` | `admin` | partial | - |
| `_modal_notification_definition_reset` | `admin` | partial | - |
| `_modal_notification_template_edit` | `admin` | partial | - |
| `_modal_notification_template_preview` | `admin` | partial | - |
| `_payment_methods_cards` | `admin` | partial | - |
| `_payment_methods_list` | `admin` | partial | - |
| `_refund_reason_cards` | `admin` | partial | - |
| `_refund_reason_section` | `admin` | partial | - |
| `_shipping_carrier_cards` | `admin` | partial | - |
| `_shipping_carrier_section` | `admin` | partial | - |
| `_shipping_country_cards` | `admin` | partial | - |
| `_shipping_country_table` | `admin` | partial | - |
| `_shipping_type_cards` | `admin` | partial | - |
| `_shipping_type_section` | `admin` | partial | - |
| `_tab_basic_info` | `admin` | partial | - |
| `_tab_claim` | `admin` | partial | - |
| `_tab_identity_policies` | `admin` | partial | - |
| `_tab_language_currency` | `admin` | partial | - |
| `_tab_mileage` | `admin` | partial | - |
| `_tab_mileage_basic_card` | `admin` | partial | - |
| `_tab_mileage_currency_cards` | `admin` | partial | - |
| `_tab_mileage_currency_table` | `admin` | partial | - |
| `_tab_mileage_expiry_card` | `admin` | partial | - |
| `_tab_mileage_notification_card` | `admin` | partial | - |
| `_tab_notification_definitions` | `admin` | partial | - |
| `_tab_order_settings` | `admin` | partial | - |
| `_tab_review_settings` | `admin` | partial | - |
| `_tab_seo` | `admin` | partial | - |
| `_tab_shipping` | `admin` | partial | - |
| `_modal_extra_fee_template` | `admin` | partial | - |
| `_partial_basic_info` | `admin` | partial | - |
| `_partial_charge_settings` | `admin` | partial | - |
| `_partial_country_basic_fields` | `admin` | partial | - |
| `_partial_country_tabs` | `admin` | partial | - |
| `_partial_extra_fee` | `admin` | partial | - |
| `_modal_bulk_delete` | `admin` | partial | - |
| `_modal_bulk_toggle` | `admin` | partial | - |
| `_modal_copy` | `admin` | partial | - |
| `_modal_delete` | `admin` | partial | - |
| `_modal_set_default` | `admin` | partial | - |
| `_partial_bulk_actions` | `admin` | partial | - |
| `_partial_datagrid` | `admin` | partial | - |
| `_partial_filter` | `admin` | partial | - |
<!-- @generated:layouts END -->
<!-- @intent START -->
206개가 **전부 `admin` 그룹**입니다(화면 25 + 부분 레이아웃 181). `resources/layouts/user/`
디렉토리는 있지만 비어 있습니다 — 이 모듈이 방문자 쇼핑 화면을 소유하지 않는다는 설계가
디렉토리 구조에 그대로 드러난 자리입니다. 상품 목록·상세·장바구니·주문서·마이페이지는
템플릿(`sirsoft-basic`)의 레이아웃이며, 그 화면들은 이 모듈의 공개 API 와 아래 액션 핸들러를
씁니다.
화면 25개에 부분 레이아웃 181개가 붙는 비율(1:7)은 화면이 크기 때문입니다. 상품 등록 폼·주문
상세·환경설정처럼 탭이 여러 개인 화면은 탭마다 파일을 나눠 두었습니다
(`partials/{화면이름}/_tab_*.json`). 화면 하나를 고칠 때는 그 화면 이름의 partials 디렉토리를
함께 열어야 전체가 보입니다.
부분 레이아웃에서는 `{{props.*}}` 를 쓰지 않고 데이터소스 ID 를 직접 참조합니다. 그리고
부모–자식 레이아웃 사이에 데이터소스 ID 가 겹치면 안 됩니다 — 겹치면 한쪽이 조용히 다른 쪽의
응답을 덮어씁니다.
레이아웃 JSON 만 고쳤다면 빌드는 필요 없고 `php artisan module:update sirsoft-ecommerce --force`
로 활성 디렉토리에 반영합니다. 다만 **새로 쓴 Tailwind 클래스가 빌드된 CSS 에 없으면** 그
스타일만 조용히 빠지므로, 기존 레이아웃에 쓰이지 않던 클래스를 도입할 때는 확인이 필요합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 160개 (정의: `resources/js/handlers/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `updateProductField` | `sirsoft-ecommerce.updateProductField` |
| `updateOptionField` | `sirsoft-ecommerce.updateOptionField` |
| `calculateCurrencyPrices` | `sirsoft-ecommerce.calculateCurrencyPrices` |
| `initPreferredCurrency` | `sirsoft-ecommerce.initPreferredCurrency` |
| `initPreferredShippingCountry` | `sirsoft-ecommerce.initPreferredShippingCountry` |
| `setDateRange` | `sirsoft-ecommerce.setDateRange` |
| `setDefaultOption` | `sirsoft-ecommerce.setDefaultOption` |
| `toggleOption` | `sirsoft-ecommerce.toggleOption` |
| `toggleProductOptions` | `sirsoft-ecommerce.toggleProductOptions` |
| `toggleAllOptionsInRow` | `sirsoft-ecommerce.toggleAllOptionsInRow` |
| `getProductOptionStates` | `sirsoft-ecommerce.getProductOptionStates` |
| `syncProductSelection` | `sirsoft-ecommerce.syncProductSelection` |
| `loadExpandedOptions` | `sirsoft-ecommerce.loadExpandedOptions` |
| `retryExpandedOptions` | `sirsoft-ecommerce.retryExpandedOptions` |
| `generateCopyProductCode` | `sirsoft-ecommerce.generateCopyProductCode` |
| `copyProduct` | `sirsoft-ecommerce.copyProduct` |
| `selectCategory` | `sirsoft-ecommerce.selectCategory` |
| `selectCategoryMobile` | `sirsoft-ecommerce.selectCategoryMobile` |
| `addCategoryToSelection` | `sirsoft-ecommerce.addCategoryToSelection` |
| `removeCategoryFromSelection` | `sirsoft-ecommerce.removeCategoryFromSelection` |
| `getCategoryBreadcrumb` | `sirsoft-ecommerce.getCategoryBreadcrumb` |
| `validateCategoryPath` | `sirsoft-ecommerce.validateCategoryPath` |
| `initCategoryInfosFromProduct` | `sirsoft-ecommerce.initCategoryInfosFromProduct` |
| `getBrandName` | `sirsoft-ecommerce.getBrandName` |
| `getBrandDescription` | `sirsoft-ecommerce.getBrandDescription` |
| `updatePrice` | `sirsoft-ecommerce.updatePrice` |
| `calculateTotalOptionStock` | `sirsoft-ecommerce.calculateTotalOptionStock` |
| `validatePriceRelation` | `sirsoft-ecommerce.validatePriceRelation` |
| `addOptionInput` | `sirsoft-ecommerce.addOptionInput` |
| `removeOptionInput` | `sirsoft-ecommerce.removeOptionInput` |
| `updateOptionInput` | `sirsoft-ecommerce.updateOptionInput` |
| `generateOptions` | `sirsoft-ecommerce.generateOptions` |
| `deleteOption` | `sirsoft-ecommerce.deleteOption` |
| `applyOptionAddTool` | `sirsoft-ecommerce.applyOptionAddTool` |
| `addRequiredItem` | `sirsoft-ecommerce.addRequiredItem` |
| `updateRequiredItem` | `sirsoft-ecommerce.updateRequiredItem` |
| `removeRequiredItem` | `sirsoft-ecommerce.removeRequiredItem` |
| `reorderRequiredItems` | `sirsoft-ecommerce.reorderRequiredItems` |
| `addAdditionalOption` | `sirsoft-ecommerce.addAdditionalOption` |
| `updateAdditionalOption` | `sirsoft-ecommerce.updateAdditionalOption` |
| `removeAdditionalOption` | `sirsoft-ecommerce.removeAdditionalOption` |
| `reorderAdditionalOptions` | `sirsoft-ecommerce.reorderAdditionalOptions` |
| `clearAdditionalOptions` | `sirsoft-ecommerce.clearAdditionalOptions` |
| `addAdditionalOptionValue` | `sirsoft-ecommerce.addAdditionalOptionValue` |
| `updateAdditionalOptionValue` | `sirsoft-ecommerce.updateAdditionalOptionValue` |
| `removeAdditionalOptionValue` | `sirsoft-ecommerce.removeAdditionalOptionValue` |
| `uploadImages` | `sirsoft-ecommerce.uploadImages` |
| `setThumbnail` | `sirsoft-ecommerce.setThumbnail` |
| `reorderImages` | `sirsoft-ecommerce.reorderImages` |
| `updateDescription` | `sirsoft-ecommerce.updateDescription` |
| `confirmSelectNoticeTemplate` | `sirsoft-ecommerce.confirmSelectNoticeTemplate` |
| `selectNoticeTemplate` | `sirsoft-ecommerce.selectNoticeTemplate` |
| `updateNoticeItem` | `sirsoft-ecommerce.updateNoticeItem` |
| `removeNoticeItem` | `sirsoft-ecommerce.removeNoticeItem` |
| `reorderNoticeItems` | `sirsoft-ecommerce.reorderNoticeItems` |
| `fillNoticeWithValue` | `sirsoft-ecommerce.fillNoticeWithValue` |
| `switchNoticeMode` | `sirsoft-ecommerce.switchNoticeMode` |
| `updateNewTemplateName` | `sirsoft-ecommerce.updateNewTemplateName` |
| `addNoticeItem` | `sirsoft-ecommerce.addNoticeItem` |
| `updateNoticeItemName` | `sirsoft-ecommerce.updateNoticeItemName` |
| `saveAsNoticeTemplate` | `sirsoft-ecommerce.saveAsNoticeTemplate` |
| `confirmSaveNoticeTemplate` | `sirsoft-ecommerce.confirmSaveNoticeTemplate` |
| `fillTemplateFieldsWithDetailReference` | `sirsoft-ecommerce.fillTemplateFieldsWithDetailReference` |
| `fillNoticeItemsWithDetailReference` | `sirsoft-ecommerce.fillNoticeItemsWithDetailReference` |
| `toggleLabel` | `sirsoft-ecommerce.toggleLabel` |
| `generateProductCode` | `sirsoft-ecommerce.generateProductCode` |
| `getShippingPolicyInfo` | `sirsoft-ecommerce.getShippingPolicyInfo` |
| `getCommonInfoContent` | `sirsoft-ecommerce.getCommonInfoContent` |
| `updateShoppingIntegration` | `sirsoft-ecommerce.updateShoppingIntegration` |
| `updateShippingType` | `sirsoft-ecommerce.updateShippingType` |
| `updateIdentificationCode` | `sirsoft-ecommerce.updateIdentificationCode` |
| `openLabelPeriodModal` | `sirsoft-ecommerce.openLabelPeriodModal` |
| `saveLabelPeriod` | `sirsoft-ecommerce.saveLabelPeriod` |
| `removeLabelPeriod` | `sirsoft-ecommerce.removeLabelPeriod` |
| `updateActivityLogSort` | `sirsoft-ecommerce.updateActivityLogSort` |
| `updateActivityLogPerPage` | `sirsoft-ecommerce.updateActivityLogPerPage` |
| `setDefaultShippingPolicy` | `sirsoft-ecommerce.setDefaultShippingPolicy` |
| `setLabelDatePreset` | `sirsoft-ecommerce.setLabelDatePreset` |
| `toggleDefaultShippingPolicy` | `sirsoft-ecommerce.toggleDefaultShippingPolicy` |
| `toggleLabelAssignment` | `sirsoft-ecommerce.toggleLabelAssignment` |
| `saveLabelSettings` | `sirsoft-ecommerce.saveLabelSettings` |
| `deleteLabel` | `sirsoft-ecommerce.deleteLabel` |
| `updateLabelPeriodInline` | `sirsoft-ecommerce.updateLabelPeriodInline` |
| `setLabelDatePresetInline` | `sirsoft-ecommerce.setLabelDatePresetInline` |
| `confirmUncheckLabel` | `sirsoft-ecommerce.confirmUncheckLabel` |
| `removeDescriptionLocale` | `sirsoft-ecommerce.removeDescriptionLocale` |
| `showAddLocaleModal` | `sirsoft-ecommerce.showAddLocaleModal` |
| `addDescriptionLocale` | `sirsoft-ecommerce.addDescriptionLocale` |
| `setDefaultOptionFromGrid` | `sirsoft-ecommerce.setDefaultOptionFromGrid` |
| `addOptionRow` | `sirsoft-ecommerce.addOptionRow` |
| `updateFormOptionField` | `sirsoft-ecommerce.updateFormOptionField` |
| `recalculateOptionPriceAdjustments` | `sirsoft-ecommerce.recalculateOptionPriceAdjustments` |
| `bulkUpdate` | `sirsoft-ecommerce.bulkUpdate` |
| `buildConfirmData` | `sirsoft-ecommerce.buildConfirmData` |
| `buildOrderColumns` | `sirsoft-ecommerce.buildOrderColumns` |
| `toggleArrayValue` | `sirsoft-ecommerce.toggleArrayValue` |
| `toggleVisibleFilter` | `sirsoft-ecommerce.toggleVisibleFilter` |
| `syncOrderSelection` | `sirsoft-ecommerce.syncOrderSelection` |
| `handleOrderRowAction` | `sirsoft-ecommerce.handleOrderRowAction` |
| `processOrderBulkAction` | `sirsoft-ecommerce.processOrderBulkAction` |
| `buildOrderBulkConfirmData` | `sirsoft-ecommerce.buildOrderBulkConfirmData` |
| `executeOrderBulkAction` | `sirsoft-ecommerce.executeOrderBulkAction` |
| `downloadOrderExcel` | `sirsoft-ecommerce.downloadOrderExcel` |
| `saveVisibleColumns` | `sirsoft-ecommerce.saveVisibleColumns` |
| `loadVisibleColumns` | `sirsoft-ecommerce.loadVisibleColumns` |
| `loadVisibleFilters` | `sirsoft-ecommerce.loadVisibleFilters` |
| `handleProductRowAction` | `sirsoft-ecommerce.handleProductRowAction` |
| `initOrderDetailForm` | `sirsoft-ecommerce.initOrderDetailForm` |
| `toggleProductSelection` | `sirsoft-ecommerce.toggleProductSelection` |
| `toggleAllProducts` | `sirsoft-ecommerce.toggleAllProducts` |
| `buildOrderDetailBulkConfirmData` | `sirsoft-ecommerce.buildOrderDetailBulkConfirmData` |
| `processOrderDetailBulkChange` | `sirsoft-ecommerce.processOrderDetailBulkChange` |
| `saveAdminMemo` | `sirsoft-ecommerce.saveAdminMemo` |
| `updateChangeQuantity` | `sirsoft-ecommerce.updateChangeQuantity` |
| `openConfirmDepositModal` | `sirsoft-ecommerce.openConfirmDepositModal` |
| `confirmDeposit` | `sirsoft-ecommerce.confirmDeposit` |
| `initShippingPolicyForm` | `sirsoft-ecommerce.initShippingPolicyForm` |
| `addCountrySetting` | `sirsoft-ecommerce.addCountrySetting` |
| `removeCountrySetting` | `sirsoft-ecommerce.removeCountrySetting` |
| `switchCountryTab` | `sirsoft-ecommerce.switchCountryTab` |
| `updateCountryField` | `sirsoft-ecommerce.updateCountryField` |
| `onChargePolicyChange` | `sirsoft-ecommerce.onChargePolicyChange` |
| `addRangeTier` | `sirsoft-ecommerce.addRangeTier` |
| `removeRangeTier` | `sirsoft-ecommerce.removeRangeTier` |
| `updateRangeTierField` | `sirsoft-ecommerce.updateRangeTierField` |
| `validateRangeTiers` | `sirsoft-ecommerce.validateRangeTiers` |
| `addExtraFeeRow` | `sirsoft-ecommerce.addExtraFeeRow` |
| `removeExtraFeeRow` | `sirsoft-ecommerce.removeExtraFeeRow` |
| `applyExtraFeeTemplate` | `sirsoft-ecommerce.applyExtraFeeTemplate` |
| `updateUnitValue` | `sirsoft-ecommerce.updateUnitValue` |
| `addApiRequestField` | `sirsoft-ecommerce.addApiRequestField` |
| `updateApiRequestField` | `sirsoft-ecommerce.updateApiRequestField` |
| `removeApiRequestField` | `sirsoft-ecommerce.removeApiRequestField` |
| `toggleApiRequestField` | `sirsoft-ecommerce.toggleApiRequestField` |
| `updateApiConfigField` | `sirsoft-ecommerce.updateApiConfigField` |
| `updateApiFieldMap` | `sirsoft-ecommerce.updateApiFieldMap` |
| `testShippingApi` | `sirsoft-ecommerce.testShippingApi` |
| `updateExtraFeeField` | `sirsoft-ecommerce.updateExtraFeeField` |
| `updateCancelQuantity` | `sirsoft-ecommerce.updateCancelQuantity` |
| `estimateRefundAmount` | `sirsoft-ecommerce.estimateRefundAmount` |
| `changeRefundPriority` | `sirsoft-ecommerce.changeRefundPriority` |
| `executeCancelOrder` | `sirsoft-ecommerce.executeCancelOrder` |
| `clearCancelOrderTimers` | `sirsoft-ecommerce.clearCancelOrderTimers` |
| `toggleItemSelection` | `sirsoft-ecommerce.toggleItemSelection` |
| `toggleSelectAllItems` | `sirsoft-ecommerce.toggleSelectAllItems` |
| `initUserCancelItems` | `sirsoft-ecommerce.initUserCancelItems` |
| `toggleUserCancelItem` | `sirsoft-ecommerce.toggleUserCancelItem` |
| `toggleUserCancelSelectAll` | `sirsoft-ecommerce.toggleUserCancelSelectAll` |
| `updateUserCancelQuantity` | `sirsoft-ecommerce.updateUserCancelQuantity` |
| `estimateUserRefund` | `sirsoft-ecommerce.estimateUserRefund` |
| `changeUserRefundPriority` | `sirsoft-ecommerce.changeUserRefundPriority` |
| `executeUserCancelOrder` | `sirsoft-ecommerce.executeUserCancelOrder` |
| `clearUserCancelOrderTimers` | `sirsoft-ecommerce.clearUserCancelOrderTimers` |
| `confirmOrderOption` | `sirsoft-ecommerce.confirmOrderOption` |
| `changeShippingAddress` | `sirsoft-ecommerce.changeShippingAddress` |
| `submitReview` | `sirsoft-ecommerce.submitReview` |
| `initCategoryFromUrl` | `sirsoft-ecommerce.initCategoryFromUrl` |
| `initBrandFromUrl` | `sirsoft-ecommerce.initBrandFromUrl` |
| `initCommonInfoFromUrl` | `sirsoft-ecommerce.initCommonInfoFromUrl` |
| `initNoticeFromUrl` | `sirsoft-ecommerce.initNoticeFromUrl` |
<!-- @generated:handlers END -->
<!-- @intent START -->
핸들러 160개는 레이아웃 JSON 에서 `sirsoft-ecommerce.{이름}` 으로 부릅니다. 관리자 화면 전용이
아니라 **템플릿의 방문자 화면도 이 핸들러를 씁니다** — `initUserCancelItems` ·
`toggleUserCancelItem` · `estimateUserRefund` · `submitReview` · `changeShippingAddress` ·
`confirmOrderOption` 처럼 `User`/`user` 가 붙은 것들이 그 무리입니다. 그래서 이 핸들러들의
이름·시그니처는 템플릿과의 계약이며, 바꾸면 템플릿 화면이 조용히 무반응이 됩니다.
역할별로 네 무리입니다:
| 무리 | 예 | 하는 일 |
|---|---|---|
| 상품 편집 | `updateProductField` · `toggleOption` · `loadExpandedOptions` | 옵션이 많은 상품 폼의 부분 상태 갱신 |
| 통화 | `calculateCurrencyPrices` · `initPreferredCurrency` | 표시 통화 전환과 통화별 가격 재계산 |
| 취소·환불 | `updateCancelQuantity` · `estimateRefundAmount` · `changeRefundPriority` | 옵션 단위 취소 수량 조정과 예상 환불액 조회 (관리자·구매자 두 벌) |
| 배송정책·주소 | `testShippingApi` · `updateExtraFeeField` · `changeShippingAddress` | 외부 배송비 API 시험 호출과 주소 변경 |
핸들러 TS 를 고치면 **빌드가 필요합니다** — `php artisan module:build` 후
`module:update --force`. 커밋되는 `dist/` 는 배포 산출물이므로 `--production` 으로 굽고
`sourceMappingURL` 이 남지 않아야 합니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftEcommerce` |
| 재등록 진입점 | `initModule()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`window.__SirsoftEcommerce.initModule()` 이 재등록 진입점입니다. 로케일을 전환하면 코어가 이
함수를 다시 불러 핸들러를 재등록하는데, **이 함수가 없거나 이름이 다르면 로케일 전환 직후
이 모듈의 액션 160개가 전부 무반응이 됩니다** — 오류도 토스트도 없이 버튼만 동작하지 않습니다.
그래서 이 진입점은 **핸들러 재등록만** 수행합니다. 1회성 부팅 작업(초기 상태 시드·전역 이벤트
구독 등)을 여기 넣으면 로케일을 바꿀 때마다 다시 실행되어 상태가 초기화되거나 리스너가
중복 등록됩니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/css/module.css` | 빌드 산출물 (커밋 대상) |
| `dist/js/module.iife.js` | 빌드 산출물 (커밋 대상) |
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
로딩 전략이 `global` 이라 이 모듈의 JS·CSS 는 **모든 페이지에서 로드**됩니다. 관리자 화면
전용이 아니라 템플릿의 방문자 화면도 이 모듈의 핸들러를 쓰기 때문입니다. `priority: 100` 은
확장 번들 안에서의 실행 순서로, 다른 확장이 이보다 먼저 나가야 한다면 그쪽이 더 작은 값을
선언합니다 — 특정 확장 이름을 지목하는 분기를 두지 않는 것이 규칙입니다.
`dist/` 는 **커밋되는 배포 산출물**입니다. 소스(`resources/js/**`)를 고치면 `--production`
으로 다시 굽고 그 결과를 함께 커밋합니다. 새 소스 리터럴이 `dist/` 에 없으면 stale 빌드이며,
브라우저가 받는 것은 커밋된 `dist/` 이므로 소스만 고친 변경은 사이트에 반영되지 않습니다.
구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다. CDN 도달 실패는 예외도
서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다. 자산 URL 을 문자열로 조립하지
않고 `G7Core.asset.module` 을 쓰는 것도 같은 이유입니다 — 확장자를 정적 location 이 가로채는
서버에서는 조립한 URL 만 404 가 됩니다.
<!-- @intent END -->
@@ -0,0 +1,179 @@
# 이커머스 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_`getSettingsSchema()` 선언이 없습니다._
기본값 파일: `config/settings/defaults.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
`getSettingsSchema()` 선언이 없는 것은 누락이 아닙니다. 이 모듈의 설정은 코드가 아니라
`config/settings/defaults.json` 이 SSoT 이며, 그 파일 하나가 세 가지를 함께 담습니다:
| 키 | 역할 |
|---|---|
| `_meta.categories` | 설정 그룹 9개의 목록과 순서 (`basic_info` · `language_currency` · `order_settings` · `shipping` · `seo` · `review_settings` · `inquiry` · `notifications` · `mileage`) |
| `defaults` | 그룹별 기본값. 설치 시 `storage/app/settings/` 로 동기화되어 `module_setting()` 이 읽는 값이 됩니다 |
| `frontend_schema` | 관리자 화면이 자동으로 그리는 입력 폼 정의 |
**`frontend_schema` 는 8개 그룹뿐이고 `mileage` 가 없습니다.** 자동 생성 폼으로는 표현할 수 없는
입력(통화별 적립 규칙 표 등)이 있어서 마일리지 탭만 레이아웃 JSON 으로 직접 그리기 때문입니다
(`resources/layouts/admin/partials/admin_ecommerce_settings/_tab_mileage*.json` 6개). 새 그룹을
추가할 때는 이 셋 중 어디까지 손댈지를 먼저 정합니다 — `_meta.categories` 에만 넣고 `defaults`
를 빠뜨리면 그 그룹은 화면에 뜨지만 저장할 값이 없습니다.
설정 값을 코드에서 읽을 때는 `EcommerceSettingsService` 를 거칩니다. 통화 설정처럼 요청마다
여러 번 읽히는 값은 `CurrencySettingsCache` 가 따로 캐시하며, 설정 저장 후 캐시를 비우는 것은
`core.module_settings.after_save` 훅을 받는 리스너들입니다 — 설정을 직접 파일에서 읽으면 그
무효화 경로를 타지 않아 화면과 서버가 서로 다른 값을 봅니다.
결제수단 목록(`order_settings.payment_methods`)만은 성격이 다릅니다. **저장값과 플러그인이
등록한 카탈로그의 병합**이라, 플러그인을 삭제·비활성화하면 저장값은 남아 있는데 카탈로그에서
사라지는 고아 항목이 생깁니다. 공개 응답은 고아 항목을 걸러 내보내고 관리자 응답은 그대로
노출하는 것이 규칙입니다 — 운영자는 그것을 보고 지워야 하기 때문입니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 카테고리 | 이름 | 액션 | 라우트 키 |
|---|---|---|---|
| `products` | 상품 관리 | `read`, `create`, `update`, `delete` | `product` |
| `orders` | 주문 관리 | `read`, `update` | `order` |
| `categories` | 카테고리 관리 | `read`, `create`, `update`, `delete` | - |
| `brands` | 브랜드 관리 | `read`, `create`, `update`, `delete` | `brand` |
| `product-notice-templates` | 상품정보제공고시 관리 | `read`, `create`, `update`, `delete` | - |
| `product-common-infos` | 공통정보 관리 | `read`, `create`, `update`, `delete` | - |
| `settings` | 환경설정 | `read`, `update` | - |
| `promotion-coupon` | 쿠폰 관리 | `read`, `create`, `update`, `delete` | `coupon` |
| `shipping-policies` | 배송정책 관리 | `read`, `create`, `update`, `delete` | `shippingPolicy` |
| `product-labels` | 상품 라벨 관리 | `read`, `create`, `update`, `delete` | - |
| `identity.policies` | 이커머스 본인인증 정책 | `read`, `update` | - |
| `reviews` | 리뷰 관리 | `read`, `update`, `delete` | `review` |
| `inquiries` | 문의 관리 | `update`, `delete` | - |
| `dashboard` | 대시보드 | `view` | - |
| `user-products` | 사용자 상품 | `read` | - |
| `user-orders` | 사용자 주문 | `create`, `cancel`, `confirm` | - |
| `user-reviews` | 사용자 리뷰 | `write` | - |
| `mileage` | 마일리지 관리 | `read`, `manage` | `mileage-transaction` |
| `user-currency` | 회원 결제 통화 관리 | `manage` | - |
| `user-shipping-country` | 회원 배송국가 관리 | `manage` | - |
<!-- @generated:permissions END -->
<!-- @intent START -->
권한 20종은 세 무리로 갈립니다.
- **관리자 CRUD 12종** (`products` · `orders` · `categories` · `brands` ·
`product-notice-templates` · `product-common-infos` · `promotion-coupon` ·
`shipping-policies` · `product-labels` · `reviews` · `inquiries` · `mileage`): 관리자 화면과
1:1 대응하며, 라우트 키가 있는 것은 그 라우트에 스코프 미들웨어가 걸립니다.
- **사용자 측 5종** (`user-products` · `user-orders` · `user-reviews` · `user-currency` ·
`user-shipping-country`): 구매자가 자기 자원에 대해 갖는 권한입니다. 관리자 권한과 이름이
겹치지 않도록 `user-` 접두사를 씁니다.
- **횡단 3종** (`settings` · `dashboard` · `identity.policies`): 화면 하나에 대응합니다.
`orders` 에 `create`/`delete` 가 없는 것은 의도입니다 — 주문은 구매자 결제로 생기고
(`user-orders.create`), 삭제 대신 취소·환불로 처리합니다. `inquiries` 에 `read` 가 없는 것도
같은 성격입니다: 문의 **본문은 게시판 모듈이 소유**하므로 읽기 권한은 그 게시판의 권한이
정하고, 이 모듈은 답변·삭제만 관장합니다.
새 관리자 화면을 추가하면 권한·메뉴·라우트 이름 셋이 서로를 정확히 가리키는지 확인합니다.
권한만 추가하고 메뉴를 빠뜨리면 화면에 도달할 길이 없고, 반대면 눌러도 403 입니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 구분 | slug | 이름 | URL | 하위 |
|---|---|---|---|---|
| 관리자 | `sirsoft-ecommerce` | 이커머스 | - | 11개 |
<!-- @generated:menus END -->
<!-- @intent START -->
최상위 `sirsoft-ecommerce` 아래 11개 하위 메뉴가 붙습니다 — 환경설정 · 상품 · 카테고리 ·
브랜드 · 상품정보제공고시 · 공통정보 · 주문 · 쿠폰 · 배송정책 · 리뷰 · 마일리지 내역.
메뉴는 **권한과 짝을 이룰 때만 보입니다.** 운영자에게 역할이 부여되어도 그 역할에 해당 권한이
없으면 메뉴가 렌더되지 않으므로, 새 화면을 추가할 때는 `getPermissions()` 와 `getAdminMenus()`
를 함께 바꿉니다.
권한 표에는 있는데 메뉴가 없는 것들(`dashboard` · `identity.policies` · `product-labels` 등)은
독립 메뉴가 아니라 다른 화면 안에 들어 있기 때문입니다 — 대시보드는 코어 관리자 첫 화면에
레이아웃 조각으로 주입되고, 본인인증 정책은 코어 IDV 설정 화면에서 함께 다뤄지며, 상품 라벨은
상품 관리 화면 안에 있습니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/modules/sirsoft-ecommerce/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
라우트 239개가 파일 하나(`src/routes/api.php`)에 모여 있고 전부 `/api/modules/sirsoft-ecommerce/`
아래로 나갑니다. 화면용 라우트는 없습니다 — 관리자 화면은 레이아웃 JSON 이 이 API 를 호출해
그리고, 방문자 화면은 템플릿이 같은 API 를 씁니다.
대상별로 셋으로 갈립니다:
| 무리 | 인증 | 비고 |
|---|---|---|
| `admin.*` | 관리자 인증 + 권한 스코프 | 라우트 키가 선언된 권한이 여기에 걸립니다 |
| `user.*` · 공개 조회 | Sanctum(일부는 `optional.sanctum`) | 비로그인도 상품·카테고리는 봅니다 |
| `guest.orders.*` | `VerifyGuestOrderToken` | 비회원 주문 조회·취소. 토큰이 곧 신원이므로 **새 라우트를 추가하면 미들웨어 선언(`getMiddleware()`)에도 그 이름을 반드시 추가**합니다 |
라우트를 추가·변경한 뒤에는 라우트 캐시를 다시 구워야 합니다. 확장 라우트는 **활성 상태인
확장의 것만** 등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 그대로 404 가 됩니다.
모든 라우트에 `name()` 이 필요합니다 — 이름이 없으면 미들웨어 self-gate 의 `targets` 패턴과
IDV 정책의 라우트명 인덱스가 그 라우트를 찾지 못해, 보호가 걸린 것처럼 보이지만 실제로는
통과합니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `sirsoft-pay_kginicis` | 플러그인 | `>=1.1.0` |
| `sirsoft-pay_nhnkcp` | 플러그인 | `>=1.1.0` |
| `sirsoft-pay_nicepayments` | 플러그인 | `>=1.1.0` |
| `sirsoft-tosspayments` | 플러그인 | `>=1.1.0` |
| `sirsoft-basic` | 템플릿 | `>=1.1.0` |
<!-- @generated:dependencies END -->
<!-- @intent START -->
이 모듈은 **아무 확장에도 의존하지 않습니다.** 코어만 있으면 동작하며, 관계는 전부 한 방향으로
들어옵니다 — 결제 플러그인 4종과 템플릿 `sirsoft-basic` 이 이 모듈을 요구합니다.
그 방향이 뒤집히지 않게 유지하는 것이 이 모듈 설계의 핵심입니다. PG 이름을 이 모듈 코드에 넣는
순간 의존이 양방향이 되고, 새 PG 를 붙일 때마다 이 모듈을 고쳐야 합니다.
manifest 에는 없지만 **실제로 맞물리는 확장이 둘 더** 있습니다:
| 확장 | 무엇으로 연결되는가 | 없으면 |
|---|---|---|
| `sirsoft-board` | 훅 3종 구독 (문의 글 삭제·복원·일괄삭제 시 피벗 정리) + 설정 `inquiry.board_slug` | 상품 문의 기능만 비고 나머지는 정상 |
| `sirsoft-ckeditor5` | 훅 1종 (편집기가 참조할 이커머스 리소스 목록 제공) | 편집기에서 상품 링크를 고를 수 없을 뿐 |
훅 구독은 상대가 없으면 발화하지 않으므로 이 둘을 manifest 의존으로 올리지 않는 것이 맞습니다.
다만 그 대가로 **상대 확장이 훅 이름을 바꾸면 예외 없이 조용히 연동이 끊깁니다** — 상대의
`docs/extension-points.md` 를 함께 확인해야 하는 이유입니다.
이 모듈의 공개 표면(Service·Repository·Contracts·라우트·발행 훅)을 바꿀 때는 위 4+1 개 확장의
`dependencies` 최소 버전 상향이 필요한지 검토합니다.
<!-- @intent END -->
+207
View File
@@ -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. 이 확장은 무엇인가
<!-- @intent START -->
회사소개·이용약관·개인정보처리방침처럼 **고정된 주소를 갖는 단일 문서**를 관리하는 모듈입니다.
게시판이 "여러 글이 목록을 이루는 것"이라면 이 모듈은 "글 하나가 곧 하나의 주소"이며, 그
차이가 설계의 대부분을 설명합니다 — 목록·댓글·신고·카테고리가 없고 대신 **slug 와 버전 이력**이
있습니다.
**소유 범위는 관리자 CRUD + 공개 조회 API 까지입니다.** 레이아웃 3개가 전부 관리자 화면이며,
방문자가 보는 페이지 화면은 템플릿(`sirsoft-basic`)이 `GET /pages/{slug}` 를 호출해 그립니다.
**설계 원칙 셋**:
1. **모든 수정이 버전을 남긴다.** 저장할 때마다 `page_versions` 에 스냅샷이 쌓이고
`current_version` 이 올라갑니다. 과거 버전으로 되돌리는 것도 **덮어쓰기가 아니라 새 버전
생성**입니다(복원 후 `current_version` 이 또 1 증가) — 되돌린 사실 자체가 이력에 남아야
하기 때문입니다.
2. **소프트 삭제를 쓰지 않는다.** 초기 스키마에는 있었지만 마이그레이션 두 개
(`2026_06_29_*`)로 걷어냈습니다. slug 가 주소이므로, 지운 페이지가 보이지 않게 남아 있으면
같은 slug 를 다시 쓸 수 없습니다. 되돌리기의 책임은 삭제가 아니라 버전 이력이 집니다.
3. **검색·SEO 는 코어에 붙는다.** 이 모듈은 자기 검색 화면을 만들지 않고 코어 통합 검색
(`core.search.*` 훅 3종)에 페이지를 얹으며, 봇 화면 캐시도 코어 SEO 가 관리합니다.
**의도적으로 하지 않는 것**: 관리자 설정 화면(첨부 제한은 파일 설정이며 UI 가 없습니다)·
알림·브로드캐스트·미들웨어·레이아웃 확장. 이 모듈은 다른 확장 화면에 무엇도 주입하지 않습니다.
<!-- @intent END -->
## 2. 디렉토리 지도
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-page --force` (빌드 불필요) |
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan module:update sirsoft-page --force` |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-page --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-page --force` |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**페이지 저장 → 버전 적재**: `Admin\PageController` → `Store`/`UpdatePageRequest`(slug 유일성·
다국어 제목) → `PageService::create()`/`update()`(`before_*` → `filter_*_data` → `after_*`) →
`PageRepository` 로 `pages` 갱신 + **같은 트랜잭션에서 `page_versions` 스냅샷 적재 +
`current_version` 증가**. 이후는 리스너 레인입니다 — `PageActivityLogListener`(활동 로그) ·
`SeoPageCacheListener`(봇 화면 캐시 무효화)가 `after_*` 를 받아 처리합니다.
**버전 복원**: `POST /admin/pages/{page}/versions/{versionId}/restore` →
`PageService::restoreVersion()` → 그 버전의 `title`/`content`/`content_mode`/`seo_meta` 를
현재 페이지에 쓰고 **`current_version` 을 다시 +1** 한 뒤 스냅샷을 한 번 더 남깁니다. 그래서
"3번 버전으로 되돌림"은 3번이 되는 것이 아니라 3번의 내용을 담은 5번이 생기는 것입니다.
**첨부 업로드 → 공개 서빙**: `Admin\PageAttachmentController` → `UploadPageAttachmentRequest`
(설정 `attachment.max_size_mb` · `allowed_types`) → `PageAttachmentService`
(`before_upload` → `filter_upload_file` → `after_upload`, 개수 상한 `attachment.max_count`
초과 시 `AttachmentLimitExceededException`) → 저장. 공개 서빙은 `PublicPageAttachmentController`
가 **해시**로 받습니다(`/pages/attachment/{hash}`, `/preview`) — 순번 ID 를 노출하지 않기
위한 선택이며, 그래서 이 두 경로는 각각 발행 상태 게이트를 **자기 자리에서** 확인해야 합니다.
**공개 조회**: `PublicPageController::show(slug)` — `optional.sanctum` 이라 비로그인도
접근하며, 미발행 페이지는 **읽기 권한을 가진 운영자에게만 미리보기로** 열리고 그 외에는
404 입니다. 첨부 서빙 두 경로도 같은 판정을 각자 재적용합니다. 목록 API 는 없습니다(페이지는
목록을 이루지 않습니다). 통합 검색 결과에 페이지가 섞이는 것은 `SearchPagesListener` 가 코어
검색 훅에 응답을 얹기 때문입니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 21개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 17개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 5개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 0개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 1개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
발행 훅 21종은 두 도메인(`page` 14 · `attachment` 7)의 3단 패턴
(`before_*` → `filter_*_data` → `after_*`)이 거의 전부입니다. 그 밖의 것 셋만 성격이 다릅니다:
| 훅 | 무엇을 열어 주는가 |
|---|---|
| `page.filter_content_thumbnail` | 본문에서 대표 이미지를 뽑는 규칙. 본문 형식이 특이한 사이트가 자기 방식으로 바꿀 수 있습니다 |
| `search.page.index_should_update` | 어떤 변경에 검색 색인을 다시 태울지. 색인 비용이 큰 설치가 조건을 좁히는 자리입니다 |
| `attachment.filter_upload_file` | 업로드 파일을 저장 전에 가공(리사이즈·변환) |
**구독 방향이 이 모듈의 성격을 더 잘 보여줍니다.** 17개 구독 중 12개는 자기 훅이고, 나머지
5개가 바깥을 향합니다 — 코어 검색 3종(`core.search.results` · `build_response` ·
`index_validation_rules`)에 페이지 결과를 얹고, 코어 활동 로그 1종에 설명 변수를 제공하며,
`sirsoft-ckeditor5.image.filter_reference_sources` 로 편집기가 고를 수 있는 이미지 출처에
페이지 첨부를 더합니다.
이 셋은 전부 **상대가 없으면 발화하지 않을 뿐**이라 manifest 의존에 없습니다. 대신 상대가 훅
이름을 바꾸면 예외 없이 조용히 끊기므로, 코어 검색이나 ckeditor5 를 손댈 때는 이 구독이 함께
확인 대상입니다.
미들웨어·브로드캐스트 채널·알림·레이아웃 확장은 0개입니다. 이 모듈은 다른 화면에 개입하지
않습니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-page --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-page` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] 페이지 저장 경로를 추가·변경했다면 버전 스냅샷 적재와 `current_version` 증가가 같은 트랜잭션에 있는지 확인
- [ ] 첨부를 내보내는 경로를 추가하면 발행 상태 게이트를 그 자리에서 재적용 (부모에서 한 번 판정하고 끝나지 않는다)
- [ ] 코어 검색·SEO·ckeditor5 의 훅 이름이 바뀌면 이 모듈의 구독 5종이 조용히 끊기므로 함께 확인
- [ ] 첨부 제한(`attachment.*`)은 `config/settings/defaults.json` 이 SSoT — 서비스에서 리터럴로 재클램프하지 않는다
- [ ] 활동 로그 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨·description 과 번들 ja 팩까지 동반
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 페이지를 저장하면서 버전 스냅샷 적재를 건너뛰기 | `PageService` 의 저장 경로를 거친다 (스냅샷 + `current_version` 증가가 같은 트랜잭션) | 버전이 빠진 수정은 되돌릴 수 없다. 소프트 삭제를 걷어낸 뒤로 **되돌리기 수단이 버전 이력뿐**이다 |
| 버전 복원을 현재 행 덮어쓰기로 구현 | 복원도 새 버전을 만든다 (`current_version` +1 후 스냅샷) | 되돌린 사실이 이력에서 사라지면 "누가 언제 무엇으로 되돌렸는가"를 추적할 수 없다 |
| 첨부 공개 서빙(`download`)에만 발행 상태를 확인하고 `preview` 는 그대로 노출 | 두 경로 모두 같은 게이트를 재적용 | 한쪽만 막으면 같은 파일이 형제 엔드포인트로 새어나간다 |
| 첨부 URL 을 순번 ID 로 조립 | 해시 경로(`/pages/attachment/{hash}`) | ID 노출은 다른 페이지의 첨부를 훑을 수 있는 열쇠가 된다 |
| 소프트 삭제를 다시 도입 | 삭제는 실삭제, 되돌리기는 버전 이력 | 지운 페이지가 남아 있으면 같은 slug 를 다시 쓸 수 없고, slug 는 이 도메인에서 주소 그 자체다 |
| 첨부 개수·용량 상한을 서비스에 리터럴로 재클램프 | 설정(`attachment.*`) 을 읽고 검증은 FormRequest 에 둔다 | 이중 클램프가 생기면 설정을 올려도 반영되지 않는다 |
| 페이지 목록을 만들기 위해 공개 목록 API 를 추가 | 목록이 필요하면 게시판 모듈을 쓴다 | 페이지는 "주소 하나 = 문서 하나" 도메인이다. 목록을 들이면 게시판과 역할이 겹치면서 둘 다 애매해진다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 30개 | `modules/_bundled/sirsoft-page/tests` |
| Vitest | 4개 | `vitest.config.ts` |
| Playwright | 7개 | `tests/Playwright` |
| 시나리오 매니페스트 | 9개 | `tests/scenarios` |
기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit modules/_bundled/sirsoft-page/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd modules/_bundled/sirsoft-page && powershell -Command "npm run test:run -- <대상>"
# Playwright E2E (Bash)
npx playwright test modules/_bundled/sirsoft-page/tests/Playwright/specs/<대상>.spec.ts
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @generated:test-commands END -->
## 8. 문서 목차
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
@@ -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
+182
View File
@@ -0,0 +1,182 @@
# 페이지
**G7 모듈 · sirsoft-page**
정적 페이지(정보/정책/안내) 관리 모듈
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.1.1-0066FF?style=flat-square" alt="version 1.1.1">
<img src="https://img.shields.io/badge/type-%EB%AA%A8%EB%93%88-555555?style=flat-square" alt="type 모듈">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.10-1F883D?style=flat-square" alt="G7 &gt;=7.0.10">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
회사소개·이용약관·개인정보처리방침처럼 **주소가 고정된 문서 한 장**을 만들고 관리하는
모듈입니다. 관리자 화면에서 주소(slug)와 내용을 정해 저장하면 `/{slug}` 로 공개됩니다.
게시판과 헷갈리기 쉬운데 역할이 다릅니다. 게시판은 여러 글이 목록을 이루고 댓글·검색·신고가
따라오지만, 페이지는 **글 하나가 곧 주소 하나**입니다. 목록도 댓글도 없고, 대신 수정할 때마다
이전 내용이 자동으로 보관되어 언제든 되돌릴 수 있습니다.
방문자가 보는 페이지 화면은 템플릿(`sirsoft-basic`)이 그립니다. 이 모듈은 내용을 관리하고
넘겨주는 역할까지 맡습니다.
의도적으로 두지 않은 것: 관리자 환경설정 화면(첨부 제한은 설정 파일에서 조정합니다)·알림·
페이지 목록 API. 여러 글을 목록으로 보여줘야 한다면 게시판 모듈이 맞는 선택입니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 페이지 관리 | 주소(slug)·제목·본문 작성, 다국어 제목, 발행/미발행 전환, 여러 페이지 한 번에 발행 |
| 버전 이력 | 저장할 때마다 자동 스냅샷, 이전 버전 내용 확인과 되돌리기 |
| 첨부파일 | 파일 업로드·순서 변경·삭제, 개수/용량/형식 제한, 공개 내려받기와 미리보기 |
| 미리보기 | 아직 발행하지 않은 페이지를 운영자만 실제 화면으로 확인 |
| 검색 노출 | 사이트 통합 검색 결과에 페이지가 함께 나옴 |
| SEO | 페이지별 메타 정보 설정, 내용이 바뀌면 검색엔진용 화면 캐시 자동 갱신 |
| 본문 대표 이미지 | 본문에서 첫 이미지를 자동으로 뽑아 목록·공유 미리보기에 사용 |
| 편집기 연동 | 편집기에서 이미지를 고를 때 페이지 첨부를 함께 제시 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
A[운영자] -->|작성·수정| ADM[페이지 관리]
ADM --> SAVE[저장]
SAVE --> PAGE[(현재 내용)]
SAVE --> VER[(버전 이력)]
VER -.되돌리기.-> SAVE
V[방문자] -->|/slug 접속| T[템플릿 화면]
T --> PAGE
```
저장할 때마다 현재 내용과 버전 이력이 함께 갱신됩니다. 되돌리기도 "예전으로 덮어쓰기"가 아니라
**그 내용으로 다시 한 번 저장**하는 것이라, 되돌린 사실 자체가 이력에 남습니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan module:install sirsoft-page
# 활성화
php artisan module:activate sirsoft-page
# 업데이트 (번들 소스 기준 강제 반영)
php artisan module:update sirsoft-page --force
```
저장소: https://github.com/gnuboard/g7-module-sirsoft-page
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_별도의 관리자 설정 항목이 없습니다._
<!-- @generated:settings-summary END -->
<!-- @intent START -->
위 표가 비어 있는 것은 이 모듈에 **관리자 환경설정 화면이 없기** 때문입니다. 조정할 수 있는
값은 첨부 제한 셋뿐이고, 설정 파일(`config/settings/defaults.json`)에 들어 있습니다.
| 항목 | 기본값 | 바꾸면 달라지는 것 |
|---|---|---|
| `attachment.max_count` | 5 | 페이지 하나에 붙일 수 있는 파일 개수 |
| `attachment.max_size_mb` | 10 | 파일 하나의 최대 용량(MB) |
| `attachment.allowed_types` | JPEG·PNG·GIF·WebP·PDF·ZIP | 업로드를 허용할 파일 형식 |
값을 바꾸려면 설치된 모듈의 설정 파일을 고친 뒤 모듈 캐시를 비웁니다. 화면 입력이 없는 이유는
이 셋이 개점 후 거의 바뀌지 않는 값이라 판단했기 때문이며, 조정이 잦아지면 그때 설정 화면을
추가하는 것이 맞습니다.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**페이지 만들기**: `/admin/pages` → "페이지 추가" → 주소(slug)와 제목·본문을 입력합니다. 주소는
저장 전에 중복 여부를 확인해 주며, 한번 공개한 주소를 바꾸면 기존 링크가 끊기므로 신중히
정합니다. 작성 중에는 "미발행" 으로 두고 미리보기로 확인한 뒤 발행합니다.
**예전 내용으로 되돌리기**: 페이지 상세의 버전 목록에서 원하는 시점을 골라 내용을 확인한 뒤
복원합니다. 복원해도 그 사이의 버전이 지워지지 않고 **새 버전이 하나 더 생기므로**, 되돌린
것을 다시 되돌릴 수 있습니다.
**약관 개정 공지처럼 여러 페이지를 동시에 여는 경우**: 각 페이지를 미발행 상태로 준비해 두고
목록에서 대상을 체크한 뒤 일괄 발행합니다.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `sirsoft-marketing` | 플러그인 | `>=1.0.0` |
| `sirsoft-basic` | 템플릿 | `>=1.1.0` |
<!-- @generated:integrations END -->
## 문서
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
## 트러블슈팅
<!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 페이지 주소로 들어가면 404 | 아직 발행하지 않았거나 주소를 바꿈 | 관리자 화면에서 발행 상태와 현재 주소를 확인합니다. 운영자 계정으로는 미발행 페이지도 미리보기로 열립니다 |
| 첨부 파일이 내려받아지지 않음 | 그 페이지가 미발행 상태 | 페이지를 발행하면 첨부도 함께 공개됩니다. 미발행 상태의 첨부는 권한 있는 운영자에게만 열립니다 |
| 파일 업로드가 거부됨 | 개수·용량·형식 제한에 걸림 | 기본값은 5개·10MB·이미지/PDF/ZIP 입니다. 설정 파일에서 조정할 수 있습니다 |
| 내용을 고쳤는데 검색 결과가 예전 그대로 | 검색 색인이 아직 갱신되지 않음 | 잠시 후 다시 확인하고, 계속 그렇다면 코어 검색 색인 점검을 실행합니다 |
| 페이지를 지웠는데 되돌릴 수 없음 | 이 모듈은 삭제를 실제 삭제로 처리 | 삭제 전 되돌리기 수단은 버전 이력뿐입니다. 삭제 대신 "미발행" 으로 두면 언제든 되살릴 수 있습니다 |
| 공유했을 때 미리보기 이미지가 나오지 않음 | 본문에 이미지가 없거나 대표 이미지를 뽑지 못함 | 본문 첫머리에 이미지를 넣거나 SEO 설정에서 직접 지정합니다 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-page",
"description": "Page module for Gnuboard7",
"type": "library",
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -0,0 +1,22 @@
# 페이지 개발자 문서
> modules/_bundled/sirsoft-page · 모듈
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 21 · **구독 훅 수**: 17 · **라우트 수**: 17 · **모델 수**: 3 · **테이블 수**: 3 · **마이그레이션 수**: 8 · **레이아웃 수**: 3 · **핸들러 수**: 0
<!-- @generated:stats END -->
## 문서 목차
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 |
|---|---|
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [api/](api/README.md) | API 레퍼런스 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,86 @@
# 페이지 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
"문서 하나 = 주소 하나" 라는 전제 하나가 이 모듈의 모든 선택을 설명합니다.
- **목록이 없다.** 공개 API 는 `GET /pages/{slug}` 뿐이고 목록 엔드포인트가 없습니다. 여러 글을
목록으로 다루는 것은 게시판 모듈의 역할이며, 두 모듈이 그 역할을 나눠 갖지 않으면 둘 다
애매해집니다. 방문자가 페이지를 찾는 통로는 사이트 메뉴와 통합 검색입니다.
- **삭제가 실삭제다.** 초기 스키마의 SoftDeletes 를 마이그레이션 두 개로 걷어냈습니다. slug 가
주소이므로 지운 페이지가 보이지 않게 남아 있으면 같은 주소를 다시 쓸 수 없습니다. 되돌리기의
책임은 삭제 플래그가 아니라 **버전 이력**이 집니다.
- **모든 수정이 버전을 남긴다.** 그래서 되돌리기도 덮어쓰기가 아니라 새 버전 생성입니다 —
되돌린 사실 자체가 이력에 남아야 하기 때문입니다.
- **검색·SEO 를 스스로 만들지 않는다.** 코어 검색 훅에 결과를 얹고 코어 SEO 캐시에 무효화를
통지할 뿐, 자기 검색 화면이나 자기 캐시를 두지 않습니다.
- **관리자 설정 화면이 없다.** 조정 가능한 값은 첨부 제한 셋뿐이고 개점 후 거의 바뀌지 않아
설정 파일에 두었습니다. 조정이 잦아지면 그때 화면을 더하는 것이 맞습니다.
**의도적으로 하지 않는 것**: 알림·브로드캐스트·미들웨어·레이아웃 확장·프론트 액션 핸들러.
이 모듈은 다른 확장의 화면이나 요청 흐름에 개입하지 않습니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
Http/Controllers (Admin/ 관리자 CRUD, User/ 공개 조회·첨부 서빙)
│
▼
FormRequest (slug 유일성 · 다국어 제목 · 첨부 용량/형식)
│
▼
Services 3종
│ ├─ PageService : CRUD + 발행 + 버전 스냅샷·복원
│ ├─ PageAttachmentService : 업로드·순서·삭제 (개수 상한 판정)
│ └─ PageSettingsService : 설정 파일 해석
│ before_* → filter_*_data → 실행 → after_*
▼
Repositories 3종 (Interface 경유)
│
▼
Models 3종 (Page ─1:N─ PageVersion / PageAttachment)
```
`PageService` 안에 **정렬 이름 → 컬럼 선언**(`SEARCH_SORT_MAP`)이 상수로 있습니다. 코어
`SearchPagePolicy` 가 이 선언을 읽어 커서 페이지네이션 적용 여부를 판정하므로, 정렬 이름을
추가할 때는 이 상수부터 손댑니다 — 여기 없는 정렬(관련도순 등)은 계산값이라 커서 경계로 쓸 수
없어 offset 을 유지합니다.
Listeners 5종은 별도 레인입니다 — 활동 로그·검색 결과 편입·SEO 캐시 무효화·편집기 이미지
출처 제공. Service 는 이 부가효과를 알지 못하며, 그래서 새 부가효과는 리스너 추가만으로
끝납니다.
<!-- @intent END -->
## 디렉토리
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-page --force` (빌드 불필요) |
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan module:update sirsoft-page --force` |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-page --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-page --force` |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,138 @@
# 페이지 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
| `Page` | `pages` | 11 | creator→User, updater→User, versions→PageVersion, attachments→PageAttachment | 검색 색인 |
| `PageAttachment` | `page_attachments` | 13 | page→Page, creator→User | - |
| `PageVersion` | `page_versions` | 8 | page→Page, creator→User | - |
<!-- @generated:models END -->
<!-- @intent START -->
세 모델의 관계는 `Page` ─1:N─ `PageVersion` / `PageAttachment` 하나뿐입니다.
- **`Page`** — `slug` 가 사실상의 주소이고 `current_version` 이 이력의 현재 위치입니다.
`title` 과 `content` 는 `AsUnicodeJson` 캐스팅이라 다국어 값을 담습니다(로케일별 문자열
맵). `content_thumbnail_url` 은 본문에서 뽑은 대표 이미지를 **저장해 둔 것**이라, 본문을
고치면 함께 갱신되어야 합니다.
- **`PageVersion`** — 저장 시점의 `title`/`content`/`content_mode`/`seo_meta` 스냅샷입니다.
복원은 이 값을 현재 페이지에 쓰고 `current_version` 을 **또 1 올린** 뒤 스냅샷을 한 번 더
남깁니다.
- **`PageAttachment`** — 공개 서빙은 순번 ID 가 아니라 **해시**로 합니다. 부모 페이지의 발행
상태가 곧 첨부의 공개 여부이며, 내려받기와 미리보기 **두 경로가 각자** 그 판정을 합니다.
셋 다 **SoftDeletes 를 쓰지 않습니다.** `Page` 의 소프트 삭제는 마이그레이션
`2026_06_29_000001` 이, `PageAttachment` 는 `2026_06_29_000002` 가 걷어냈습니다. 삭제된
페이지가 보이지 않게 남아 있으면 같은 slug 를 다시 쓸 수 없기 때문이며, 되돌리기의 책임은
버전 이력이 집니다.
`Page` 만 검색 색인 대상입니다. 색인에 실리는 컬럼과 가중치는 모델의 `searchableColumns()` ·
`searchableWeights()` 가 선언하고, 다시 태울지 여부는 `searchIndexShouldBeUpdated()` 와
`search.page.index_should_update` 필터가 정합니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 테이블 | 모델 |
|---|---|
| `page_attachments` | `PageAttachment` |
| `page_versions` | `PageVersion` |
| `pages` | `Page` |
<!-- @generated:tables END -->
<!-- @intent START -->
세 테이블 모두 모델과 1:1 이며 피벗이 없습니다. 접두사가 `page_` 로 짧은 것은 이 모듈이
코어에 가까운 기본 기능이라는 초기 판단 때문이며, 다른 확장이 같은 이름을 쓰지 않도록
주의합니다.
`pages` 에는 FULLTEXT 인덱스(`2026_04_01_000004`)와 발행 정렬 인덱스(`2026_08_02_000001`)가
따로 붙어 있습니다. 목록·검색 쿼리를 새로 만들 때 이 두 인덱스를 쓰는 형태인지 확인합니다 —
컬럼에 함수를 씌우거나(`whereDate` 등) 정렬 컬럼을 바꾸면 인덱스가 쓰이지 않습니다.
삭제는 **DB CASCADE 에 맡기지 않습니다.** 페이지를 지울 때 `PageService` 가 첨부를 하나씩
`PageAttachmentService::deleteAttachment()` 로 지웁니다 — 물리 파일 삭제와 훅 발행이 함께
일어나야 하는데, CASCADE 로 지우면 그 둘이 통째로 건너뛰어지고 아무 오류도 남지 않습니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
마이그레이션 8개.
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|---|---|---|---|
| `2026_04_01_000001_create_pages_table.php` | `pages` | `pages` | ✅ |
| `2026_04_01_000002_create_page_versions_table.php` | `page_versions` | `page_versions` | ✅ |
| `2026_04_01_000003_create_page_attachments_table.php` | `page_attachments` | `page_attachments` | ✅ |
| `2026_04_01_000004_add_fulltext_indexes_to_pages_table.php` | - | `pages` | ✅ |
| `2026_06_29_000001_drop_soft_deletes_from_pages_table.php` | - | `pages` | ✅ |
| `2026_06_29_000002_drop_soft_deletes_from_page_attachments_table.php` | - | `page_attachments` | ✅ |
| `2026_08_02_000001_add_published_sort_index_to_pages_table.php` | - | - | ✅ |
| `2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php` | - | `pages` | ✅ |
<!-- @generated:migrations END -->
<!-- @intent START -->
8개 중 3개가 초기 스키마이고 5개는 이후의 변경입니다. 그 5개가 이 모듈이 겪은 설계 변경을
그대로 보여줍니다:
| 마이그레이션 | 무엇이 바뀌었나 |
|---|---|
| `add_fulltext_indexes_to_pages_table` | 통합 검색 편입을 위해 FULLTEXT 인덱스 추가 |
| `drop_soft_deletes_from_pages_table` · `..._page_attachments_table` | 소프트 삭제 철회 — slug 재사용을 막기 때문 |
| `add_published_sort_index_to_pages_table` | 발행 목록 정렬의 인덱스 확보 |
| `add_content_thumbnail_url_to_pages_table` | 본문 대표 이미지를 조회 때마다 뽑지 않고 저장 |
새 컬럼을 더할 때 초기 `create_*` 파일을 고치지 않습니다 — 이미 설치된 사이트는 그 파일을
다시 실행하지 않으므로 반영되지 않습니다. 컬럼 기본값·comment·데이터 형태를 바로잡는 변경은
마이그레이션과 함께 `upgrades/` 의 업그레이드 스텝 백필이 필요합니다.
한국어 `comment` 와 `down()` 은 필수이고, FK 컬럼의 `->comment()` 는 `->constrained()` **앞**에
둡니다(뒤에 두면 comment 가 FK 정의에 붙어 조용히 사라집니다).
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
없습니다. 이 도메인의 상태는 `published` 불리언 하나뿐이라 분류 어휘가 생기지 않았습니다.
`content_mode` 는 문자열 컬럼입니다 — 편집기(위지윅/평문)가 무엇을 저장했는지를 나타내며,
편집기 확보에 실패했을 때의 폴백 계약(`text`)과 짝을 이룹니다. 값의 가짓수가 늘어나
분기가 생기기 시작하면 그때 Enum 으로 올리는 것이 맞습니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `PageAttachmentRepository` | 구현 | 페이지 첨부파일 Repository |
| `PageAttachmentRepositoryInterface` | 인터페이스 | 페이지 첨부파일 Repository 인터페이스 |
| `PageRepository` | 구현 | 페이지 Repository |
| `PageRepositoryInterface` | 인터페이스 | 페이지 Repository 인터페이스 |
| `PageVersionRepository` | 구현 | 페이지 버전 Repository |
| `PageVersionRepositoryInterface` | 인터페이스 | 페이지 버전 Repository 인터페이스 |
<!-- @generated:repositories END -->
<!-- @intent START -->
세 Repository 모두 인터페이스와 1:1 이며 서비스는 **인터페이스만 주입**받습니다(구체 클래스
타입힌트 금지).
이 모듈에서 특히 걸리는 것 둘:
- **버전 조회는 반드시 페이지 스코프로.** `PageVersionRepository::findForPage($pageId, $versionId)`
처럼 상위 리소스 ID 를 where 절에 반영합니다. 버전 ID 만으로 찾으면 다른 페이지의 버전을
현재 페이지에 복원할 수 있는 교차 접근 경로가 생기는데, 정상 응답이 나가므로 오류도 로그도
남지 않습니다.
- **목록 쿼리의 컬럼 프루닝과 정렬 화이트리스트.** 페이지 본문은 큰 컬럼이라 목록에 실으면
오버플로 페이지 읽기가 발생합니다. 정렬은 `PageService::SEARCH_SORT_MAP` 이 닫힌 집합을
정하며, 화면 정렬 옵션 ⊆ 검증 게이트 ⊆ 이 선언 순서로 포함 관계가 유지되어야 합니다.
<!-- @intent END -->
@@ -0,0 +1,238 @@
# 페이지 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 21종 / 호출 지점 23곳. 이 중 21종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `sirsoft-page.attachment.after_delete` | action | — | `src/Services/PageAttachmentService.php:208` |
| `sirsoft-page.attachment.after_reorder` | action | — | `src/Services/PageAttachmentService.php:335` |
| `sirsoft-page.attachment.after_upload` | action | — | `src/Services/PageAttachmentService.php:139` |
| `sirsoft-page.attachment.before_delete` | action | — | `src/Services/PageAttachmentService.php:200` |
| `sirsoft-page.attachment.before_reorder` | action | — | `src/Services/PageAttachmentService.php:331` |
| `sirsoft-page.attachment.before_upload` | action | — | `src/Services/PageAttachmentService.php:90` |
| `sirsoft-page.attachment.filter_upload_file` | filter | — | `src/Services/PageAttachmentService.php:92` |
| `sirsoft-page.page.after_create` | action | — | `src/Services/PageService.php:106` |
| `sirsoft-page.page.after_delete` | action | — | `src/Services/PageService.php:190` |
| `sirsoft-page.page.after_publish` | action | — | `src/Services/PageService.php:223` 외 1곳 |
| `sirsoft-page.page.after_restore` | action | — | `src/Services/PageService.php:322` |
| `sirsoft-page.page.after_update` | action | — | `src/Services/PageService.php:159` |
| `sirsoft-page.page.before_create` | action | — | `src/Services/PageService.php:75` |
| `sirsoft-page.page.before_delete` | action | — | `src/Services/PageService.php:179` |
| `sirsoft-page.page.before_publish` | action | — | `src/Services/PageService.php:209` 외 1곳 |
| `sirsoft-page.page.before_update` | action | — | `src/Services/PageService.php:127` |
| `sirsoft-page.page.filter_content_thumbnail` | filter | — | `src/Models/Page.php:128` |
| `sirsoft-page.page.filter_create_data` | filter | — | `src/Services/PageService.php:77` |
| `sirsoft-page.page.filter_list_query` | filter | — | `src/Services/PageService.php:60` |
| `sirsoft-page.page.filter_update_data` | filter | — | `src/Services/PageService.php:131` |
| `sirsoft-page.search.page.index_should_update` | filter | — | `src/Models/Page.php:270` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
21종은 두 도메인의 3단 패턴이 대부분입니다 — `page.*` 14종과 `attachment.*` 7종이
`before_{동작}`(action) → `filter_{동작}_data`(filter) → `after_{동작}`(action) 을 반복합니다.
CRUD 동작을 바꾸고 싶으면 이 셋 중 하나를 잡습니다.
패턴에서 벗어나는 셋이 이 모듈 고유의 확장점입니다:
| 훅 | 무엇을 열어 주는가 |
|---|---|
| `page.filter_content_thumbnail` | 본문에서 대표 이미지를 뽑는 규칙. 본문 형식이 특이한 사이트가 자기 방식으로 바꿉니다 (모델에서 발행되므로 조회 경로 전체에 걸립니다) |
| `search.page.index_should_update` | 어떤 변경에 검색 색인을 다시 태울지. 색인 비용이 큰 설치가 조건을 좁히는 자리입니다 |
| `attachment.filter_upload_file` | 저장 직전 파일 가공(리사이즈·형식 변환) |
`page.after_restore` 는 소프트 삭제 복원이 아니라 **버전 복원**입니다. 이 모듈은 소프트 삭제를
쓰지 않으므로 "복원" 이라는 말이 나오면 언제나 버전 이력 쪽입니다.
발행 훅이 `getHooks()` 선언에 없어 소스에서 자동 감지된 상태입니다. 선언에 추가하면 유형과
설명이 표에 함께 실리며, 이 모듈처럼 훅 수가 적은 확장은 선언을 채우는 비용이 낮습니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `core.activity_log.filter_description_params` | filter | `ActivityLogDescriptionResolver` | `resolveDescriptionParams` | 10 |
| `core.search.build_response` | filter | `SearchPagesListener` | `buildPagesResponse` | 10 |
| `core.search.index_validation_rules` | filter | `SearchPagesListener` | `addValidationRules` | 10 |
| `core.search.results` | filter | `SearchPagesListener` | `searchPages` | 10 |
| `sirsoft-ckeditor5.image.filter_reference_sources` | filter | `Ckeditor5ReferenceSourcesListener` | `addPageSources` | 10 |
| `sirsoft-page.attachment.after_delete` | action (미선언) | `PageActivityLogListener` | `handleAttachmentAfterDelete` | 20 |
| `sirsoft-page.attachment.after_upload` | action (미선언) | `PageActivityLogListener` | `handleAttachmentAfterUpload` | 20 |
| `sirsoft-page.page.after_create` | action (미선언) | `PageActivityLogListener` | `handlePageAfterCreate` | 20 |
| `sirsoft-page.page.after_create` | action (미선언) | `SeoPageCacheListener` | `onPageChange` | 20 |
| `sirsoft-page.page.after_delete` | action (미선언) | `PageActivityLogListener` | `handlePageAfterDelete` | 20 |
| `sirsoft-page.page.after_delete` | action (미선언) | `SeoPageCacheListener` | `onPageDelete` | 20 |
| `sirsoft-page.page.after_publish` | action (미선언) | `PageActivityLogListener` | `handlePageAfterPublish` | 20 |
| `sirsoft-page.page.after_publish` | action (미선언) | `SeoPageCacheListener` | `onPageChange` | 20 |
| `sirsoft-page.page.after_restore` | action (미선언) | `PageActivityLogListener` | `handlePageAfterRestore` | 20 |
| `sirsoft-page.page.after_restore` | action (미선언) | `SeoPageCacheListener` | `onPageChange` | 20 |
| `sirsoft-page.page.after_update` | action (미선언) | `PageActivityLogListener` | `handlePageAfterUpdate` | 20 |
| `sirsoft-page.page.after_update` | action (미선언) | `SeoPageCacheListener` | `onPageChange` | 20 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
17개 중 12개는 자기 훅입니다(활동 로그 7 + SEO 캐시 5). 바깥을 향한 5개가 이 모듈이 다른
확장과 맞물리는 전부입니다:
| 상대 훅 | 리스너 | 무엇을 위해 |
|---|---|---|
| `core.search.results` · `core.search.build_response` · `core.search.index_validation_rules` | `SearchPagesListener` | 사이트 통합 검색 결과에 페이지를 섞고, 검색 요청의 검증 규칙에 페이지 축을 더합니다 |
| `core.activity_log.filter_description_params` | `ActivityLogDescriptionResolver` | 활동 로그 문장의 치환 변수(페이지 제목 등)를 ID 에서 표시명으로 해석합니다 |
| `sirsoft-ckeditor5.image.filter_reference_sources` | `Ckeditor5ReferenceSourcesListener` | 편집기가 이미지를 고를 때 페이지 첨부를 출처 목록에 더합니다 |
`sirsoft-ckeditor5` 는 **manifest 의존에 없습니다.** 훅 구독은 상대가 없으면 발화하지 않으므로
편집기 플러그인이 없어도 이 모듈은 정상 동작하고 그 기능만 비어 있습니다. 대신 상대가 훅
이름을 바꾸면 예외 없이 조용히 끊기므로, 코어 검색이나 ckeditor5 를 손댈 때 이 구독이 함께
확인 대상입니다.
리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 를 직접 부르지 않습니다 — 데이터
접근은 Repository 인터페이스 주입으로만 합니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `ActivityLogDescriptionResolver` | 1개 | 명시 등록 | ✅ | `src/Listeners/ActivityLogDescriptionResolver.php` |
| `Ckeditor5ReferenceSourcesListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/Ckeditor5ReferenceSourcesListener.php` |
| `PageActivityLogListener` | 7개 | 명시 등록 | ✅ | `src/Listeners/PageActivityLogListener.php` |
| `SearchPagesListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SearchPagesListener.php` |
| `SeoPageCacheListener` | 5개 | 명시 등록 | ✅ | `src/Listeners/SeoPageCacheListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
5개 전부 `HookListenerInterface` 를 구현하고 `getSubscribedHooks()` 로 자기 구독을 선언합니다.
| 리스너 | 역할 |
|---|---|
| `PageActivityLogListener` | 페이지·첨부 변경을 코어 `activity_logs` 에 기록 |
| `ActivityLogDescriptionResolver` | 그 기록의 설명 변수(ID → 표시명) 해석 |
| `SeoPageCacheListener` | 내용이 바뀌면 봇 화면 캐시 무효화 |
| `SearchPagesListener` | 코어 통합 검색에 페이지 결과 편입 |
| `Ckeditor5ReferenceSourcesListener` | 편집기 이미지 출처에 페이지 첨부 제공 |
새 활동 로그 항목을 더할 때는 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description
본문이 함께 필요합니다 — **모듈 lang 파일에 넣으면 해석되지 않습니다.** 번들 일본어 팩도 같은
작업 단위에서 동기화합니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_레이아웃 확장이 없습니다._
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
없습니다. 이 모듈은 다른 확장·템플릿의 화면에 조각을 주입하지 않습니다.
페이지 내용을 다른 화면에 노출하고 싶다면 그 화면을 소유한 쪽(템플릿 또는 그 모듈)이 이
모듈의 공개 API 를 호출하는 것이 맞는 방향입니다. 여기에 조각을 더하면 대상 화면이 슬롯을
없앨 때 오류 없이 사라지는 결합이 생깁니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
없습니다. 이 모듈의 라우트는 코어가 제공하는 인증 미들웨어(`auth:sanctum` ·
`optional.sanctum`)와 요율 제한만 씁니다.
발행 상태·열람 권한 판정은 미들웨어가 아니라 **컨트롤러 안에서** 이루어집니다. 페이지 본문과
첨부 두 종류의 응답에 서로 다른 판정이 필요하고(첨부 미리보기는 서명 링크도 인정), 그 차이를
미들웨어 하나로 표현하면 어느 쪽이든 과하거나 모자라기 때문입니다. 그 대신 **경로마다 게이트를
재적용해야 한다**는 의무가 생깁니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
없습니다. 페이지는 실시간 갱신이 필요한 콘텐츠가 아닙니다 — 발행 시점이 운영자의 조작이고,
방문자는 그 시점 이후의 접속에서 새 내용을 봅니다.
실시간이 필요한 화면이 생기면 이 모듈에 채널을 더하는 것이 아니라, `page.after_publish` 를
구독하는 쪽에서 자기 채널로 내보냅니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 스케줄 | 주기 | 설명 |
|---|---|---|
| `sirsoft-page:prune-temp-attachments` | `daily` | 미연결 임시 페이지 첨부 자동 삭제 |
<!-- @generated:schedules END -->
<!-- @intent START -->
하나뿐입니다. `prune-temp-attachments` 는 **업로드했지만 페이지 저장까지 이어지지 않은 파일**을
정리합니다 — 편집 중 창을 닫은 세션의 부산물이라 운영 데이터가 아니며, 그래서 설정 토글 없이
상시 동작합니다(보존 기간은 커맨드 옵션).
이미 페이지에 연결된 첨부는 이 스케줄의 대상이 아닙니다. 페이지를 지우면 그 첨부는 삭제 흐름
안에서 함께 정리됩니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
없습니다. 페이지 발행은 특정 수신자를 향한 사건이 아니라 사이트 전체에 대한 게시라, 누구에게
보내야 할지가 정해지지 않습니다.
약관 개정 안내처럼 발행을 계기로 알림을 보내야 한다면 `page.after_publish` 를 구독해 코어
`GenericNotification` 으로 발송하는 리스너를 **그 알림을 필요로 하는 확장 쪽에** 둡니다.
수신자 범위가 사이트마다 다르므로 이 모듈이 정할 수 없습니다.
<!-- @intent END -->
## 활동 로그 훅
> 이 확장이 코어 활동 로그(`activity_logs`)에 기록을 남기기 위해 구독하는 훅 7개입니다.
> 코어 `docs/backend/activity-log-hooks.md` 에 있던 목록을 이 확장 소유로 옮긴 것입니다(#601) —
> 확장이 훅을 더할 때 코어 문서를 고쳐야 하던 역방향 의존을 없애기 위해서입니다. 코어 문서에는
> 총계와 이 문서로의 링크만 남습니다.
> 새 항목을 추가하면 코어 `lang/{ko,en}/activity_log.php` 의 action 라벨과 description 본문,
> 그리고 번들 일본어 팩까지 함께 정의해야 합니다 — **모듈 lang 파일에 넣으면 해석되지
> 않습니다.**
### 페이지 모듈 훅 (PageActivityLogListener)
**파일**: `modules/_bundled/sirsoft-page/src/Listeners/PageActivityLogListener.php`
**총 7훅**
> 이 표에 `before_*` 훅이 없는 것은 누락이 아닙니다. 수정 전 스냅샷은 이 리스너가
> `before_*` 훅으로 직접 잡지 않고 **Service 가 잡아 `after_*` 훅의 인자로 넘깁니다**
> (`ChangeDetector::detect($model, $snapshot)`). `before_*` 훅 자체는 발행되며 그 목록은
> 위 「발행 훅」 절에 있습니다.
#### Page (5훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-page.page.after_create` | `handlePageAfterCreate` | `page.create` | Admin | Page |
| `sirsoft-page.page.after_update` | `handlePageAfterUpdate` | `page.update` | Admin | Page |
| `sirsoft-page.page.after_delete` | `handlePageAfterDelete` | `page.delete` | Admin | Page |
| `sirsoft-page.page.after_publish` | `handlePageAfterPublish` | `page.publish` / `page.unpublish` | Admin | Page |
| `sirsoft-page.page.after_restore` | `handlePageAfterRestore` | `page.restore` | Admin | Page |
#### PageAttachment (2훅)
| 훅 이름 | Listener 메서드 | Action (DB) | LogType | Loggable |
|---------|----------------|-------------|---------|----------|
| `sirsoft-page.attachment.after_upload` | `handleAttachmentAfterUpload` | `page_attachment.upload` | Admin | PageAttachment |
| `sirsoft-page.attachment.after_delete` | `handleAttachmentAfterDelete` | `page_attachment.delete` | Admin | PageAttachment |
@@ -0,0 +1,85 @@
# 페이지 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 3개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 3개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `admin_page_detail` | `admin` | 화면 | `_admin_base` |
| `admin_page_form` | `admin` | 화면 | `_admin_base` |
| `admin_page_list` | `admin` | 화면 | `_admin_base` |
<!-- @generated:layouts END -->
<!-- @intent START -->
3개 전부 관리자 화면입니다 — 목록(`admin_page_list`) · 작성/수정(`admin_page_form`) ·
상세(`admin_page_detail`). 부분 레이아웃이 없을 만큼 화면이 단순합니다.
방문자가 보는 페이지 화면은 여기에 없습니다. 템플릿(`sirsoft-basic`)이 `GET /pages/{slug}` 를
호출해 그리므로, 페이지의 **보이는 모습**을 바꾸는 작업은 이 모듈이 아니라 그 템플릿 쪽입니다.
레이아웃 JSON 만 고쳤다면 빌드는 필요 없고 `php artisan module:update sirsoft-page --force`
로 반영합니다. 새로 쓴 Tailwind 클래스가 빌드된 CSS 에 없으면 그 스타일만 조용히 빠지므로,
기존 레이아웃에 없던 클래스를 도입할 때는 확인이 필요합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 액션 핸들러가 없습니다._
<!-- @generated:handlers END -->
<!-- @intent START -->
없습니다. 이 모듈의 관리자 화면은 코어 엔진의 기본 핸들러(`apiCall` · `navigate` · `setState`
등)만으로 충분해서 자체 핸들러를 두지 않았습니다.
그래서 **전역 진입점(`initModule`)도 없고 빌드 산출물(`dist/`)도 없습니다.** 핸들러를 처음
추가할 때는 셋이 함께 필요합니다 — 엔트리 파일, `window.__SirsoftPage.initModule()` 재등록
진입점, 그리고 `module:build --production` 으로 구운 `dist/` 커밋. 진입점을 빠뜨리면 로케일
전환 직후 그 핸들러들이 오류 없이 무반응이 됩니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_프론트 엔트리포인트가 없습니다._
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
없습니다 — 등록할 액션 핸들러가 없기 때문입니다.
핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록
진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부
무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅
작업을 포함하지 않습니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
JS·CSS 산출물이 없고 `editor-spec.json` 하나만 있습니다 — 레이아웃 편집기가 이 모듈의 화면을
편집할 때 쓰는 팔레트·중첩 규칙 선언이며, 실행 코드가 아니라 manifest 입니다.
로딩 설정(`strategy: global`, `priority: 100`)은 골격 기본값이 그대로 남은 것입니다. 실을
자산이 없으므로 현재는 아무 영향이 없지만, 나중에 JS 를 더하면 이 선언이 확장 번들 안에서의
순서를 정하게 됩니다.
`editor-spec.json` 을 고친 뒤에는 빌드 없이 `php artisan module:update sirsoft-page --force`
만 실행합니다. 편집기는 활성 디렉토리 기준으로 서빙하므로 `_bundled` 만 고치면 반영되지
않습니다.
<!-- @intent END -->
@@ -0,0 +1,130 @@
# 페이지 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_`getSettingsSchema()` 선언이 없습니다._
기본값 파일: `config/settings/defaults.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
`getSettingsSchema()` 도 관리자 설정 화면도 없습니다. 조정 가능한 값은 첨부 제한 셋뿐이며
`config/settings/defaults.json` 이 SSoT 입니다.
| 키 | 기본값 | 쓰이는 곳 |
|---|---|---|
| `attachment.max_count` | 5 | `PageAttachmentService` — 초과 시 `AttachmentLimitExceededException` |
| `attachment.max_size_mb` | 10 | `UploadPageAttachmentRequest` 검증 규칙 |
| `attachment.allowed_types` | 이미지 4종 + PDF + ZIP | 같은 FormRequest (미설정 시 클래스 상수 폴백) |
파일 형태가 다른 확장과 다릅니다 — 이커머스·게시판은 `{_meta, defaults, frontend_schema}` 3단
구조지만 여기는 **평평한 값 트리**입니다. 관리자 화면이 없어 `frontend_schema` 가 필요 없고,
그래서 `_meta` 도 두지 않았습니다. 읽기는 `g7_module_settings('sirsoft-page', 'attachment.…')`
로 하고, `PageSettingsService` 가 설정이 아직 동기화되지 않은 환경을 위해 파일 직접 읽기를
폴백으로 갖습니다.
**서비스에서 상한을 리터럴로 재클램프하지 않습니다.** 계산은 서비스가, 상한 검증은
FormRequest 가 단일 책임으로 갖습니다 — 이중 클램프가 생기면 설정을 올려도 반영되지 않습니다.
설정 화면을 나중에 추가한다면 `_meta.categories` 와 `frontend_schema` 를 더하는 방식이며, 그때
값의 위치(`attachment.*`)는 바꾸지 않아야 기존 설치의 값이 유지됩니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 카테고리 | 이름 | 액션 | 라우트 키 |
|---|---|---|---|
| `pages` | 페이지 관리 | `read`, `create`, `update`, `delete` | `page` |
<!-- @generated:permissions END -->
<!-- @intent START -->
`pages` 하나에 `read`/`create`/`update`/`delete` 네 액션이 전부입니다. 라우트 키 `page` 가
선언되어 있어 관리자 라우트에 스코프 미들웨어가 걸립니다.
`read` 가 관장하는 범위에 주의가 필요합니다 — 관리자 목록·상세뿐 아니라 **미발행 페이지의
공개 화면 미리보기**와 **미발행 페이지 첨부의 서빙**까지 이 권한이 판정합니다. 그래서 이
권한을 넓게 주면 아직 공개하지 않은 문서가 그 계정에 열립니다.
역할(`getRoles()`)은 선언하지 않습니다. 게시판처럼 대상마다 담당자가 갈리는 도메인이 아니라
페이지 전체를 한 사람이 관리하는 경우가 대부분이므로, 코어 역할에 이 권한을 부여하는 것으로
충분하다고 보았습니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 구분 | slug | 이름 | URL | 하위 |
|---|---|---|---|---|
| 관리자 | `sirsoft-page` | 페이지 관리 | `/admin/pages` | - |
<!-- @generated:menus END -->
<!-- @intent START -->
최상위 메뉴 하나(`/admin/pages`)뿐이고 하위 메뉴가 없습니다. 화면이 목록·작성/수정·상세 셋뿐이며
셋 다 목록에서 이어지므로 별도 진입점이 필요 없습니다.
메뉴는 **권한과 짝을 이룰 때만 보입니다.** `pages.read` 가 없는 역할에는 이 메뉴가 렌더되지
않습니다. 새 화면을 더한다면 권한·메뉴·라우트 이름 셋이 서로를 정확히 가리키는지 함께
확인합니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/modules/sirsoft-page/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
17개가 파일 하나(`src/routes/api.php`)에 있고 세 무리로 갈립니다:
| 무리 | prefix | 인증 |
|---|---|---|
| 관리자 페이지 CRUD·버전 | `admin/pages` | `auth:sanctum` + 권한 스코프 |
| 관리자 첨부 | `admin/attachments` | `auth:sanctum` |
| 공개 조회·첨부 서빙 | `pages` | `optional.sanctum` (비로그인 접근, 발행 상태는 컨트롤러가 판정) |
화면용 라우트는 없습니다 — 관리자 화면은 레이아웃 JSON 이 이 API 를 호출해 그리고, 방문자
화면은 템플릿이 `GET /pages/{slug}` 를 씁니다.
**공개 첨부 경로가 해시 기반**(`/pages/attachment/{hash}`, `.../preview`)인 것에 주의합니다.
새 서빙 경로를 더하면 그 자리에서 발행 상태·권한 게이트를 **다시** 걸어야 합니다 — 한쪽만
막으면 같은 파일이 형제 엔드포인트로 새어나가고, 정상 응답이라 오류도 로그도 남지 않습니다.
모든 라우트에 `name()` 이 필요하고, 라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 캐시에
없는 라우트는 예외도 경고도 없이 404 가 됩니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `sirsoft-marketing` | 플러그인 | `>=1.0.0` |
| `sirsoft-basic` | 템플릿 | `>=1.1.0` |
<!-- @generated:dependencies END -->
<!-- @intent START -->
이 모듈은 아무 확장에도 의존하지 않습니다. 관계는 한 방향으로 들어옵니다 —
`sirsoft-marketing` 플러그인과 `sirsoft-basic` 템플릿이 이 모듈을 요구합니다.
manifest 에는 없지만 **훅으로 맞물리는 확장이 하나 더** 있습니다: `sirsoft-ckeditor5` 가
없으면 편집기 이미지 출처 제공만 비고 나머지는 정상 동작하므로, 의존으로 올리지 않는 것이
맞습니다.
이 모듈의 공개 표면(Service·Repository·Contracts·라우트·발행 훅)을 바꿀 때는 위 확장들의
`dependencies` 최소 버전 상향이 필요한지 검토합니다. 특히 공개 조회 API 의 응답 형태는
템플릿이 그대로 화면에 그리므로, 필드를 빼면 그 템플릿의 페이지 화면이 빈 채로 렌더됩니다.
<!-- @intent END -->
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "페이지",
"en": "Page"
},
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"description": {
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-page",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-page",
"version": "1.1.0",
"version": "1.1.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-page",
"version": "1.1.0",
"version": "1.1.1",
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -0,0 +1,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. 이 확장은 무엇인가
<!-- @intent START -->
**학습용 최소 샘플 플러그인**입니다. 플러그인의 핵심 역할인 **훅 구독**을 두 종류로 시연하는
것이 유일한 목적입니다 — 부가 작업을 수행하는 Action 리스너 하나와, 흐름 중간에서 값을 가공하는
Filter 리스너 하나.
대상은 학습용 모듈(`gnuboard7-hello_module`)의 메모입니다. 메모가 생성되면 로그를 남기고(Action),
메모 제목이 화면에 나가기 전에 접두사를 붙입니다(Filter). **모듈 코드는 한 줄도 고치지
않습니다** — 그것이 훅 시스템이 존재하는 이유입니다.
**모듈과 플러그인의 경계**도 함께 보여줍니다. 플러그인은 완전한 페이지 레이아웃을 등록할 수
없고, 설정 화면(`plugin_settings.json`)과 `layout_extensions`(다른 화면에 끼워 넣는 조각)만
허용됩니다. 이 샘플에는 설정 화면 하나가 있습니다.
`manifest.hidden = true` 라 관리자 UI 의 플러그인 목록에 나타나지 않습니다. artisan CLI 로는
정상 설치·활성화됩니다.
**의도적으로 하지 않는 것**: 모델·테이블·마이그레이션·API 라우트. 플러그인이 자기 데이터를 가질
수는 있지만(다른 플러그인들이 그렇습니다), 이 샘플은 **훅만** 보이면 되므로 두지 않았습니다.
<!-- @intent END -->
## 2. 디렉토리 지도
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update gnuboard7-hello_plugin --force` (빌드 불필요) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**Action 구독** — 부가 작업: 학습용 모듈의 `MemoService::create()` 가
`gnuboard7-hello_module.memo.created` 를 발행 → `LogMemoCreatedListener::onMemoCreated()`
가 그것을 받아 로그를 기록 → 기록 직후 자기 훅
`gnuboard7-hello_plugin.log.written` 을 발행합니다. **구독한 플러그인이 다시 발행하는** 이
연쇄가 훅 시스템의 확장 방식입니다 — 또 다른 확장이 이 플러그인의 동작에 반응할 수 있습니다.
로그 기록 여부는 설정(`log_enabled`)이 정합니다. **설정으로 끌 수 있게 만드는 것**이 부가
동작의 규약입니다 — 리스너가 무조건 동작하면 그 확장을 설치한 사이트는 끌 방법이 없습니다.
**Filter 구독** — 값 가공: `gnuboard7-hello_module.memo.title.filter` 가 발행되면
`FilterMemoTitleListener::prependHelloPrefix()` 가 그 값을 받아 접두사를 붙여 **반환**합니다.
Action 과 달리 Filter 는 **반환값이 흐름에 다시 들어갑니다.**
이 훅은 **학습용 모듈이 실제로 발행하지 않습니다.** 리스너 docblock 이 "발행한다고 가정하고"
라고 밝히고 있으며, 그 자체가 학습 포인트입니다 — **훅이 발행되지 않아도 리스너 등록은
유효하고**, 나중에 발행 지점이 생기면 그때부터 자동으로 호출됩니다. 구독은 발행자에게 아무런
부담을 주지 않으므로 확장이 서로를 몰라도 됩니다.
Filter 구독에는 `'type' => 'filter'` 선언이 반드시 필요합니다. 빠뜨리면 코어가 그것을 Action
으로 취급해 **반환값을 버립니다** — 리스너는 정상 실행되고 오류도 없는데 가공만 반영되지
않습니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 1개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 2개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 2개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 0개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
이 샘플이 보여주는 것은 **구독 쪽**이지만, 발행도 하나 있습니다.
| 방향 | 훅 | 무엇을 보여주는가 |
|---|---|---|
| 구독 (Action) | `gnuboard7-hello_module.memo.created` | 다른 확장의 흐름에 부가 작업을 붙이는 법 |
| 구독 (Filter) | `gnuboard7-hello_module.memo.title.filter` | 흐름 중간의 값을 가공하는 법 (`'type' => 'filter'` 필수). **모듈이 실제로 발행하지는 않는 가상의 훅** — 미발행 훅 구독도 유효함을 함께 보인다 |
| 발행 (Action) | `gnuboard7-hello_plugin.log.written` | 구독한 확장이 **다시 발행**해 연쇄를 잇는 법 |
발행 훅에는 `getHooks()` 선언이 있어 표에 유형과 설명이 함께 실립니다 — 발행 훅을 선언하면
구독하려는 쪽에 계약이 드러납니다.
**의존 방향에 주의합니다.** 이 플러그인은 `gnuboard7-hello_module` 에 manifest 의존을
선언합니다. 구독 대상이 없으면 훅이 발화하지 않을 뿐이지만, 이 샘플은 **그 모듈의 훅을 보는
것 자체가 목적**이라 모듈 없이는 존재 이유가 없습니다. 실제 플러그인에서는 "없으면 그 기능만
비는" 관계인지 "없으면 성립하지 않는" 관계인지를 보고 의존 선언 여부를 정합니다.
레이아웃 확장·미들웨어·브로드캐스트·스케줄·알림·권한·메뉴는 없습니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update gnuboard7-hello_plugin --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:gnuboard7-hello_plugin` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] Filter 훅을 구독한다면 `'type' => 'filter'` 를 선언했는지 확인 — 누락 시 반환값이 조용히 버려진다
- [ ] 부가 동작은 설정 토글 뒤에 둔다 (`log_enabled` 가 그 본보기)
- [ ] `manifest.hidden = true` 를 유지 (복제본에서만 제거)
- [ ] 구독 대상 모듈의 훅 이름이 바뀌면 이 플러그인이 조용히 아무 일도 하지 않게 된다
- [ ] `docs/extension/sample-extensions.md` 의 계층 표와 어긋나지 않는지 확인 (파일을 추가·삭제했다면 그 표도 갱신)
- [ ] 플러그인은 완전한 페이지 레이아웃을 등록할 수 없다 — 설정 화면과 `layout_extensions` 만
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| Filter 훅을 구독하면서 `'type' => 'filter'` 를 빠뜨리기 | 선언 필수 | 코어가 Action 으로 취급해 **반환값을 버린다** — 리스너는 실행되고 오류도 없는데 가공만 반영되지 않는다 |
| 대상 모듈의 코드를 직접 고쳐 부가 동작을 넣기 | 훅 구독 | 모듈이 업그레이드될 때마다 충돌하고, 플러그인을 꺼도 그 동작이 남는다 |
| 부가 동작을 설정 없이 무조건 수행 | 설정 토글(`log_enabled`) 뒤에 둔다 | 설치한 사이트가 끌 방법이 없다 |
| 리스너에서 `Model::query()` · `DB::table()` · `$row->save()` 직접 호출 | Repository 인터페이스 주입 | 리스너가 데이터 접근 규약의 예외가 되면 그 예외가 번진다 |
| 플러그인에 완전한 페이지 레이아웃을 등록 | 설정 화면(`plugin_settings.json`)과 `layout_extensions` 만 | 페이지 소유권은 모듈·템플릿에 있다 — 경로를 다투면 설치 순서에 따라 화면이 바뀐다 |
| 이 샘플에 기능을 더해 "쓸모 있게" 만들기 | 짧게 유지하고, 필요한 기능은 별도 확장으로 | 샘플의 가치는 한눈에 읽히는 것이다 |
| `manifest.hidden` 을 제거 | 그대로 둔다 (복제본에서만 제거) | 학습용 플러그인이 운영 사이트의 목록에 섞인다 |
| 금전이 오가는 훅을 기본 설정(큐)으로 구독 | `'sync' => true` | 커밋 뒤 실행이라 예외를 던져도 롤백되지 않는다 (이 샘플에는 해당 없으나 실제 플러그인에서 자주 걸린다) |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 2개 | `plugins/_bundled/gnuboard7-hello_plugin/tests` |
| Vitest | 0개 | — |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 0개 | — |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit plugins/_bundled/gnuboard7-hello_plugin/tests --filter='<대상클래스>'
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @generated:test-commands END -->
## 8. 문서 목차
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
@@ -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
@@ -0,0 +1,182 @@
# Hello 플러그인
**G7 플러그인 · gnuboard7-hello_plugin**
학습용 최소 샘플 플러그인 (Hello 모듈 훅 소비)
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-0.1.2-0066FF?style=flat-square" alt="version 0.1.2">
<img src="https://img.shields.io/badge/type-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-555555?style=flat-square" alt="type 플러그인">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.0-1F883D?style=flat-square" alt="G7 &gt;=7.0.0">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
<img src="https://img.shields.io/badge/requires-gnuboard7--hello__module-BF8700?style=flat-square" alt="requires gnuboard7-hello_module">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
그누보드7 **플러그인이 어떻게 생겼는지 보여주는 학습용 샘플**입니다. 실제 업무에 쓰는 기능은
없습니다.
플러그인의 핵심 역할은 **다른 확장의 코드를 고치지 않고 그 동작에 끼어드는 것**입니다. 이
샘플은 그 두 가지 방식을 하나씩 보여줍니다 — 학습용 모듈에 메모가 등록되면 기록을 남기고,
메모 제목이 화면에 나가기 전에 앞에 표시를 붙이는 것입니다.
관리자 화면의 플러그인 목록에는 나타나지 않습니다(학습용이 운영 목록에 섞이지 않도록). 명령줄로
설치·활성화할 수 있으며, 학습용 모듈이 함께 설치되어 있어야 동작을 확인할 수 있습니다.
플러그인은 자기 페이지를 가질 수 없습니다 — 설정 화면과 "다른 화면에 끼워 넣는 조각" 만
허용됩니다. 이 샘플에는 설정 화면 하나가 있습니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 기록 남기기 | 학습용 모듈에 메모가 등록되면 로그 파일에 기록 (설정으로 끌 수 있음) |
| 제목 가공 | 메모 제목 앞에 표시를 붙이는 예시 |
| 설정 화면 | 기록 사용 여부를 켜고 끄는 관리자 설정 |
| 연결점 제공 | 기록을 남긴 직후 다른 확장이 반응할 수 있는 연결점 |
| 다국어 | 한국어·영어 화면 문구 |
| 테스트 | 모듈이 신호를 보내고 이 플러그인이 받는지 확인하는 예시 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
M[학습용 모듈<br/>메모 등록] -->|생성 신호| P[이 플러그인]
P -->|설정이 켜져 있으면| LOG[(로그 기록)]
LOG -->|기록 완료 신호| X[다른 확장]
M -.제목 가공 요청.-> P2[제목 앞에 표시 붙이기]
```
모듈은 이 플러그인의 존재를 모릅니다. 모듈이 "메모가 등록되었다" 는 신호를 보내면, 그 신호를
듣고 있던 이 플러그인이 자기 일을 합니다. 그래서 플러그인을 꺼도 모듈은 그대로 동작합니다.
기록을 남긴 뒤에는 이 플러그인도 신호를 보냅니다 — 신호를 받은 확장이 다시 신호를 보내며
이어지는 것이 확장 시스템의 기본 구조입니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.0` |
| PHP | `^8.2` |
| 의존 모듈 | `gnuboard7-hello_module` `>=0.1.0` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan plugin:install gnuboard7-hello_plugin
# 활성화
php artisan plugin:activate gnuboard7-hello_plugin
# 업데이트 (번들 소스 기준 강제 반영)
php artisan plugin:update gnuboard7-hello_plugin --force
```
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `log_enabled` | 로그 기록 사용 | `true` |
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
<!-- @generated:settings-summary END -->
<!-- @intent START -->
설정 항목은 하나뿐입니다.
| 항목 | 기본값 | 바꾸면 달라지는 것 |
|---|---|---|
| 로그 기록 사용 | 켜짐 | 끄면 메모가 등록되어도 기록을 남기지 않습니다 (모듈 동작에는 영향 없음) |
부가 동작을 **설정으로 끌 수 있게 만드는 것**이 이 항목의 학습 포인트입니다. 설정 없이 무조건
동작하면 그 플러그인을 설치한 사이트는 동작을 멈출 방법이 없습니다.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**설치해 보기**: 학습용 모듈을 먼저 설치한 뒤 이 플러그인을 설치합니다.
```bash
php artisan module:install gnuboard7-hello_module
php artisan module:activate gnuboard7-hello_module
php artisan plugin:install gnuboard7-hello_plugin
php artisan plugin:activate gnuboard7-hello_plugin
```
관리자에서 "Hello 메모" 를 등록하면 로그에 기록이 남습니다. 플러그인 설정에서 기록을 끈 뒤
다시 등록해 보면 기록이 남지 않는 것을 확인할 수 있습니다 — 모듈 동작 자체는 그대로입니다.
**새 플러그인의 출발점으로 쓰기**: 이 디렉토리를 복제한 뒤 식별자·네임스페이스를 모두 바꾸고
학습용 표시를 지우면 새 플러그인이 됩니다. 자세한 절차는 확장 시스템 문서의 "학습용 샘플 확장"
항목을 참고합니다.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `gnuboard7-hello_module` | 모듈 | `>=0.1.0` | ✅ |
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:integrations END -->
## 문서
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
## 트러블슈팅
<!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 관리자 플러그인 목록에 이 플러그인이 없음 | 학습용이라 목록에서 제외됨 | 정상입니다. 명령줄로 설치·활성화합니다 |
| 메모를 등록해도 기록이 남지 않음 | 설정에서 기록이 꺼져 있거나 학습용 모듈이 비활성 | 플러그인 설정과 모듈 활성화 상태를 확인합니다 |
| 제목 가공이 반영되지 않음 | 이 예시가 기대하는 가공 요청 지점이 모듈에 없음 | 정상입니다. 연결점이 없어도 등록 자체는 유효하며, 그 지점이 생기면 자동으로 동작합니다 |
| 복제해서 만든 플러그인이 관리자 목록에 안 보임 | 복제본에 학습용 표시가 남아 있음 | 복제본의 `hidden` 표시를 지웁니다 |
| 복제한 플러그인에서 값 가공이 무시됨 | 가공용 구독에 종류 표시가 빠짐 | 가공(Filter) 구독에는 종류를 명시해야 반환값이 반영됩니다 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -2,7 +2,7 @@
"name": "plugins/gnuboard7-hello_plugin",
"description": "Learning minimal sample plugin for Gnuboard7 platform (consumes Hello module hooks)",
"type": "library",
"version": "0.1.1",
"version": "0.1.2",
"autoload": {
"psr-4": {
"Plugins\\Gnuboard7\\HelloPlugin\\": ["src/", "./"]
@@ -0,0 +1,21 @@
# Hello 플러그인 개발자 문서
> plugins/_bundled/gnuboard7-hello_plugin · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 1 · **구독 훅 수**: 2 · **라우트 수**: 0 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 0
<!-- @generated:stats END -->
## 문서 목차
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 |
|---|---|
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,71 @@
# Hello 플러그인 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
"플러그인은 무엇을 하는가" 에 대한 답을 **가장 짧게** 보이는 것이 목표입니다. 플러그인의 핵심은
**다른 확장의 코드를 고치지 않고 그 동작에 끼어드는 것**이며, 그 방식이 둘(Action·Filter)
이므로 리스너도 둘입니다.
거기에 두 가지를 덧붙였습니다:
- **부가 동작은 설정으로 끌 수 있어야 한다** — `log_enabled` 가 그 본보기입니다. 리스너가
무조건 동작하면 그 확장을 설치한 사이트는 멈출 방법이 없습니다.
- **구독한 확장이 다시 발행할 수 있다** — `log.written` 이 그 예입니다. 훅은 한 번 받고 끝나는
것이 아니라 연쇄를 이룹니다.
**플러그인의 경계**도 구조로 드러납니다. 완전한 페이지 레이아웃을 등록할 수 없고, 설정 화면
(`plugin_settings.json`)과 `layout_extensions`(다른 화면에 끼워 넣는 조각)만 허용됩니다. 이
샘플에는 설정 화면 하나가 있고 `layout_extensions` 는 없습니다.
**의도적으로 하지 않는 것**: 모델·테이블·마이그레이션·API 라우트·권한·메뉴. 플러그인이 자기
데이터를 가질 수는 있지만(실제 플러그인들이 그렇습니다), 이 샘플은 훅만 보이면 되므로 두지
않았습니다. `manifest.hidden = true` 로 관리자 UI 목록에서 제외되며 CLI 로는 정상 동작합니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
plugin.php 진입 클래스 — 설정 스키마 · 발행 훅 선언 · 리스너 등록
│
├─ Listeners/LogMemoCreatedListener Action 구독
│ gnuboard7-hello_module.memo.created 를 받아
│ 설정(log_enabled) 확인 → 로그 기록 → log.written 발행
│
└─ Listeners/FilterMemoTitleListener Filter 구독 ('type' => 'filter')
gnuboard7-hello_module.memo.title.filter 의 값을 가공해 반환
config/settings/defaults.json 설정 기본값
resources/layouts/admin/plugin_settings.json 설정 화면 (파일 이름이 계약)
src/routes/web.php web 라우트 — 플러그인도 라우트를 가질 수 있음을 보이는 예시
resources/lang/{ko,en}.json 프론트 다국어 (백엔드 PHP 다국어는 없음)
```
**계층이 얕은 것이 정상**입니다. 플러그인은 자기 도메인을 갖지 않고 남의 흐름에 붙으므로,
Controller → Service → Repository → Model 사슬이 필요 없습니다. 실제 플러그인 중 자기 데이터를
갖는 것들(결제·GDPR 등)은 그 사슬을 갖지만, 그것은 플러그인의 필수 구조가 아니라 그 도메인의
필요입니다.
`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를
찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다.
<!-- @intent END -->
## 디렉토리
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update gnuboard7-hello_plugin --force` (빌드 불필요) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,76 @@
# Hello 플러그인 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 모델이 없습니다._
<!-- @generated:models END -->
<!-- @intent START -->
없습니다. 이 샘플은 자기 데이터를 갖지 않습니다.
플러그인이 모델과 테이블을 가질 수는 있고 실제로 그런 플러그인이 많습니다(결제 이력·동의 기록·
메시지 발송 기록 등). 다만 이 샘플의 목적은 **훅 구독**을 보이는 것이라, 데이터 계층을 두면
읽어야 할 코드만 늘어납니다.
모델·Repository·마이그레이션이 있는 플러그인 예시가 필요하면 실제 도메인 플러그인의 문서를
참고합니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 테이블이 없습니다._
<!-- @generated:tables END -->
<!-- @intent START -->
없습니다. 저장하는 데이터가 없습니다.
플러그인이 테이블을 가질 때는 **확장 식별자를 접두사로** 붙입니다 — 확장은 같은 데이터베이스를
공유하므로 짧은 이름을 쓰면 다른 확장과 충돌합니다. 그리고 플러그인 제거 시 정리 대상임을
`getDynamicTables()` 로 코어에 알립니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_마이그레이션이 없습니다._
<!-- @generated:migrations END -->
<!-- @intent START -->
없습니다. 스키마가 없으므로 마이그레이션도 없습니다.
플러그인이 마이그레이션을 가질 때의 규약은 모듈과 같습니다 — 한국어 `comment` 와 `down()`
필수, 초기 `create_*` 파일을 나중에 고치지 않기, 기존 행을 손봐야 하는 변경에는 `upgrades/`
업그레이드 스텝 백필 동반.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
없습니다. 이 샘플에는 상태도 분류도 없습니다.
실제 확장에서 상태·타입·분류를 다룰 때는 문자열 리터럴이 아니라 Enum 을 단일 출처로 둡니다 —
화면 필터 옵션·검증 게이트·실제 기록 값 셋이 같은 Enum 에서 파생되지 않으면, 빠진 값으로
기록된 행이 어떤 필터로도 도달할 수 없게 됩니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Repository 가 없습니다._
<!-- @generated:repositories END -->
<!-- @intent START -->
없습니다. 데이터 접근 자체가 없습니다.
리스너에서 데이터에 접근해야 한다면 `Model::query()` · `DB::table()` · `$row->save()` 를 직접
부르지 않고 **Repository 인터페이스를 주입**받습니다. 리스너가 데이터 접근 규약의 예외가 되면
그 예외가 다른 리스너로 번집니다.
<!-- @intent END -->
@@ -0,0 +1,146 @@
# Hello 플러그인 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 1종 / 호출 지점 1곳.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `gnuboard7-hello_plugin.log.written` | action | Hello 플러그인이 로그 파일에 기록을 남긴 직후 실행되는 액션 훅 | `src/Listeners/LogMemoCreatedListener.php:83` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
하나이며, **구독한 플러그인이 다시 발행하는** 형태를 보이기 위한 것입니다.
`LogMemoCreatedListener` 가 로그를 기록한 직후 `gnuboard7-hello_plugin.log.written` 을
발행합니다. 훅은 한 번 받고 끝나는 것이 아니라 연쇄를 이루며, 또 다른 확장이 이 플러그인의
동작에 반응할 수 있습니다.
`getHooks()` 선언이 있어 표에 유형과 설명이 함께 실립니다 — 발행 훅을 선언하면 구독하려는
쪽에 계약이 드러납니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `gnuboard7-hello_module.memo.created` | action (미선언) | `LogMemoCreatedListener` | `onMemoCreated` | 10 |
| `gnuboard7-hello_module.memo.title.filter` | filter | `FilterMemoTitleListener` | `prependHelloPrefix` | 10 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
둘이며 **두 종류를 하나씩** 보여줍니다.
| 훅 | 종류 | 무엇을 보여주는가 |
|---|---|---|
| `gnuboard7-hello_module.memo.created` | Action | 흐름에 부가 작업을 붙인다. 반환값은 흐름에 영향을 주지 않는다 |
| `gnuboard7-hello_module.memo.title.filter` | Filter | 흐름 중간의 값을 가공해 **반환**한다. 반환값이 다시 흐름에 들어간다 |
**Filter 구독에는 `'type' => 'filter'` 선언이 반드시 필요합니다.** 빠뜨리면 코어가 Action 으로
취급해 반환값을 버립니다 — 리스너는 정상 실행되고 오류도 없는데 가공만 반영되지 않습니다.
Filter 쪽 훅은 **학습용 모듈이 실제로 발행하지 않습니다.** 리스너 docblock 이 "발행한다고
가정하고" 라고 밝히고 있으며, 그 자체가 학습 포인트입니다 — 훅이 발행되지 않아도 리스너 등록은
유효하고, 나중에 발행 지점이 생기면 그때부터 자동으로 호출됩니다. 구독은 발행자에게 아무 부담을
주지 않으므로 확장이 서로를 몰라도 됩니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `FilterMemoTitleListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/FilterMemoTitleListener.php` |
| `LogMemoCreatedListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/LogMemoCreatedListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
둘 다 `HookListenerInterface` 를 구현하고 `getSubscribedHooks()` 로 자기 구독을 선언합니다
(명시 등록).
`LogMemoCreatedListener` 는 **설정을 먼저 확인**한 뒤 동작합니다 —
`log_enabled` 가 `false` 면 조용히 건너뜁니다. 부가 동작을 설정 뒤에 두는 것이 규약이며, 설정
없이 무조건 동작하면 그 확장을 설치한 사이트가 멈출 방법이 없습니다.
`FilterMemoTitleListener` 는 `'type' => 'filter'` 를 선언합니다. 이 선언이 없으면 반환값이
버려집니다.
두 리스너 모두 데이터에 직접 접근하지 않습니다. 접근이 필요하면 `Model::query()` 나
`DB::table()` 이 아니라 Repository 인터페이스를 주입받습니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_레이아웃 확장이 없습니다._
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
없습니다. 이 샘플은 다른 화면에 조각을 주입하지 않습니다.
플러그인이 화면에 관여하는 통로는 둘뿐입니다 — 설정 화면(`plugin_settings.json`)과
`layout_extensions`(다른 확장·템플릿 화면에 끼워 넣는 조각). **완전한 페이지 레이아웃은 등록할
수 없습니다** — 페이지 소유권은 모듈·템플릿에 있고, 경로를 다투면 어느 쪽이 이기는지가 설치
순서에 좌우됩니다.
주입 예시가 필요하면 실제로 그렇게 하는 플러그인(마케팅의 회원가입 동의 항목, 편집기의 본문
입력 자리)의 문서를 참고합니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
없습니다. 이 샘플은 요청 흐름에 개입하지 않습니다.
플러그인이 미들웨어를 등록할 때는 `getMiddleware()` 로 **부착 대상(targets)을 스스로 선언**
합니다(self-gate). 커널 미들웨어 그룹을 직접 조작하거나 라우트 파일에 FQCN 을 붙이지 않습니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
없습니다. 샘플에 넣으면 훅을 보러 온 사람이 읽어야 할 코드가 늘어납니다.
채널을 등록할 때는 `getChannels()` 를 오버라이드합니다 — `routes/channels.php` 에 하드코딩
하지 않습니다. 채널명에는 확장 프리픽스(`plugin.{id}.*`)를 붙입니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
없습니다. 샘플에는 시간 축 동작이 없습니다.
스케줄 선언 형태(`command` · `schedule` · `description` · `enabled_config`)는 실제로 스케줄을
쓰는 확장의 문서를 참고합니다. `schedule` 키를 빠뜨리면 코어 등록부가 그 항목을 건너뛰는데
예외도 경고도 남지 않습니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
없습니다. 알림은 수신자 해석·채널 게이트·템플릿까지 함께 필요해 "하나씩만" 원칙으로 담기
어렵습니다.
알림이 필요한 예시는 실제로 알림을 발송하는 확장의 문서를 참고합니다. 코어
`GenericNotification` 범용 클래스 하나로 처리하며 개별 Notification 클래스를 만들지 않습니다.
<!-- @intent END -->
@@ -0,0 +1,79 @@
# Hello 플러그인 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 1개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 1개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `plugin_settings` | `admin` | 화면 | `_admin_base` |
<!-- @generated:layouts END -->
<!-- @intent START -->
설정 화면(`plugin_settings`) 하나뿐입니다. **플러그인은 완전한 페이지 레이아웃을 등록할 수
없습니다** — 설정 화면과 `layout_extensions`(다른 화면에 끼워 넣는 조각)만 허용됩니다.
`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를
찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다.
이 레이아웃은 설정 자동 바인딩 패턴의 예시이기도 합니다 — 입력 항목의 `name` 이 설정 키와
맞으면 값 로드·저장이 자동으로 배선됩니다.
레이아웃 JSON 만 고쳤다면 빌드 없이
`php artisan plugin:update gnuboard7-hello_plugin --force` 로 반영합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 액션 핸들러가 없습니다._
<!-- @generated:handlers END -->
<!-- @intent START -->
없습니다. 설정 화면은 코어 엔진의 기본 핸들러(`apiCall` · `setState` 등)만으로 충분합니다.
핸들러를 처음 추가할 때는 셋이 함께 필요합니다 — 엔트리 파일,
`window.__[Name].initPlugin()` 재등록 진입점, 그리고 `--production` 으로 구운 `dist/` 커밋.
진입점을 빠뜨리면 로케일 전환 직후 그 핸들러들이 오류 없이 무반응이 됩니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_프론트 엔트리포인트가 없습니다._
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
없습니다 — 등록할 액션 핸들러가 없기 때문입니다.
핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록
진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부
무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅
작업을 포함하지 않습니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_프론트 에셋이 없습니다._
<!-- @generated:assets END -->
<!-- @intent START -->
없습니다. 이 샘플의 프론트엔드는 설정 화면 레이아웃 JSON 하나와 다국어 JSON 뿐이라 빌드할
실행 코드가 없습니다.
그래서 반영이 `php artisan plugin:update gnuboard7-hello_plugin --force` 하나로 끝납니다.
JS 를 더하면 그때 빌드(`plugin:build --production`)·`dist/` 커밋·전역 진입점 셋이 함께
필요해집니다.
구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도
서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다. 부득이 외부 호스트가 필요하면
`trusted_script_hosts` 와 **그 사유**를 manifest 에 함께 선언합니다.
<!-- @intent END -->
@@ -0,0 +1,110 @@
# Hello 플러그인 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `log_enabled` | `boolean` | `true` | 로그 기록 사용 |
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
하나뿐이며 그 하나가 규약의 본보기입니다.
`log_enabled` 는 이 플러그인의 부가 동작(로그 기록)을 끄는 토글입니다. **부가 동작은 설정으로
끌 수 있어야 한다**는 것이 규약이며, 설정 없이 무조건 동작하면 그 확장을 설치한 사이트는 멈출
방법이 없습니다.
읽기는 `plugin_setting()`(또는 `PluginSettingsService`)으로 하며, 리스너가 동작 **직전에**
확인합니다 — 등록 시점에 확인하면 설정을 바꿔도 다음 재부팅까지 반영되지 않습니다.
설정 화면은 `resources/layouts/admin/plugin_settings.json` 이 그립니다. **파일 이름이
계약**이므로 코어가 이 고정 경로를 찾으며, 이름을 바꾸면 설정 화면 자체가 사라집니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_선언된 권한이 없습니다._
<!-- @generated:permissions END -->
<!-- @intent START -->
선언하지 않습니다. 이 샘플에는 접근을 나눌 화면도 데이터도 없습니다.
설정 변경은 코어의 플러그인 설정 권한이 관장합니다. 플러그인이 자기 권한을 선언할 때는
`{확장식별자}.{카테고리}.{액션}` 으로 이름이 조립되므로 다른 확장과 겹칠 걱정이 없습니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 메뉴가 없습니다._
<!-- @generated:menus END -->
<!-- @intent START -->
등록하지 않습니다. 이 샘플에는 자체 관리 화면이 없습니다.
설정은 코어의 플러그인 목록에서 이 플러그인의 설정으로 들어가는 공통 경로를 씁니다 — 코어가
`resources/layouts/admin/plugin_settings.json` 을 찾아 그리므로 자체 메뉴가 필요 없습니다.
메뉴를 등록할 때는 권한과 짝을 이뤄야 합니다. 권한만 추가하고 메뉴를 빠뜨리면 화면에 도달할
길이 없고, 반대면 눌러도 403 입니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `web` | `src/routes/web.php` | `/plugins/gnuboard7-hello_plugin/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
`web.php` 하나뿐이며, **플러그인도 라우트를 가질 수 있다**는 사실을 보이기 위한 예시입니다.
URL prefix 가 `/plugins/{식별자}/` 로 고정되는 것에 주의합니다. 확장이 다른 확장이나 코어의
경로를 침범하지 않도록 코어가 강제하는 규칙이며, 이 네임스페이스 밖의 경로를 선언하면 어느
쪽이 이기는지가 설치 순서에 좌우됩니다.
모든 라우트에 `name()` 이 필요합니다. 라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다 —
확장 라우트는 활성 상태인 확장의 것만 등록되고, 캐시에 없는 라우트는 예외도 경고도 없이
404 가 됩니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `gnuboard7-hello_module` | 모듈 | `>=0.1.0` | ✅ |
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
`gnuboard7-hello_module` 에 의존합니다(`>=0.1.0`).
**이 의존 선언 자체가 학습 포인트**입니다. 훅 구독은 상대가 없으면 발화하지 않을 뿐이라 보통은
manifest 의존으로 올리지 않습니다 — "없으면 그 기능만 비는" 관계이기 때문입니다. 그런데 이
샘플은 **그 모듈의 훅을 보는 것 자체가 목적**이라 모듈 없이는 존재 이유가 없습니다.
실제 플러그인에서는 이 둘을 구분해 판단합니다:
| 관계 | 의존 선언 |
|---|---|
| 없으면 그 기능만 비고 나머지는 정상 | 선언하지 않는다 (훅 구독으로 충분) |
| 없으면 확장이 성립하지 않는다 | manifest 의존으로 선언 |
의존을 과하게 선언하면 그 확장을 비활성화할 때 이쪽까지 함께 막히고, 부족하게 선언하면 상대가
없을 때 조용히 아무 일도 하지 않습니다.
<!-- @intent END -->
@@ -5,7 +5,7 @@
"ko": "Hello 플러그인",
"en": "Hello Plugin"
},
"version": "0.1.1",
"version": "0.1.2",
"license": "MIT",
"description": {
"ko": "학습용 최소 샘플 플러그인 (Hello 모듈 훅 소비)",
@@ -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. 이 확장은 무엇인가
<!-- @intent START -->
코어가 정의한 두 확장점(`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)도 이 플러그인의 일이 아닙니다 — 저장측 검증과 봇 화면 정화는 코어가 담당합니다.
<!-- @intent END -->
## 2. 디렉토리 지도
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/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 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**편집기 장착**: 어떤 화면이 `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일)이 지난 것만 대상이며,
비활성 설치 모듈이 있으면 판정 자체를 중단합니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 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#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
발행 훅은 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개입니다.
<!-- @intent END -->
## 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. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 편집기 자산을 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 가 되는데 빌드와 테스트는 통과한다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| 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
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @generated:test-commands END -->
## 8. 문서 목차
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
@@ -10,6 +10,7 @@
- CKEditor 5 본체·스타일·번역 파일을 플러그인에 함께 담았습니다. 이제 외부 CDN 에 연결하지 않고 사이트 자신의 서버에서 불러오므로, 폐쇄망이나 외부 접속이 제한된 환경에서도 에디터가 동작합니다.
- 에디터를 불러오지 못한 경우 안내와 함께 임시 입력창으로 자동 전환됩니다. 작성한 내용은 그대로 저장되고, 이미 저장된 글을 수정할 때는 기존 본문이 임시 입력창에 그대로 실립니다. [다시 시도] 로 편집기를 되살리면 입력해 둔 내용이 그대로 이어집니다.
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
### Fixed
@@ -0,0 +1,204 @@
# CKEditor 5 WYSIWYG 에디터
**G7 플러그인 · sirsoft-ckeditor5**
CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다.
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.3-0066FF?style=flat-square" alt="version 1.0.3">
<img src="https://img.shields.io/badge/type-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-555555?style=flat-square" alt="type 플러그인">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.10-1F883D?style=flat-square" alt="G7 &gt;=7.0.10">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
글을 쓰는 자리에 **위지윅 편집기**를 제공하는 플러그인입니다. 설치·활성화하면 게시판 글쓰기,
상품 설명, 페이지 내용처럼 본문을 입력하는 화면이 자동으로 CKEditor 5 로 바뀝니다 — 각
화면을 따로 설정할 필요가 없습니다.
편집기 프로그램은 외부 서버에서 받아오지 않고 이 플러그인 안에 함께 들어 있습니다. 인터넷이
차단된 사내망이나 광고 차단 프로그램을 쓰는 환경에서도 편집기가 정상적으로 뜨게 하기 위한
선택입니다. 혹시 편집기를 불러오지 못하더라도 **일반 입력창으로 자동 전환되어 글은 계속 쓸 수
있고**, 작성 중이던 내용도 그대로 유지됩니다.
편집기로 올린 이미지는 따로 관리됩니다. 어느 글에서도 더 이상 쓰이지 않는 이미지를 찾아
정리하는 기능이 있으며, 실수로 지우는 일이 없도록 여러 안전장치를 두고 기본값은 꺼져
있습니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 위지윅 편집 | 서식·표·목록·링크 등 문서 편집 기능. 툴바 구성을 간단형·표준형·전체 중에서 선택 |
| 이미지 업로드 | 편집기에서 바로 이미지 붙여넣기·끌어놓기, 용량 제한 설정 |
| 업로드 이미지 관리 | 관리자 화면에서 올린 이미지 목록 확인과 개별·일괄 삭제 |
| 미사용 이미지 정리 | 어느 글에서도 쓰이지 않는 이미지를 보존기간 경과 후 자동 정리 (기본 꺼짐) |
| 다국어 본문 | 언어별로 본문을 따로 작성 |
| 자동 폴백 | 편집기를 불러오지 못하면 일반 입력창으로 전환하고 알림, 재시도 시 내용 승계 |
| 저장소 선택 | 이미지를 어느 디스크에 둘지 지정 (로컬·외부 저장소) |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```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[정리]
```
미사용 이미지 정리는 "본문 어디에도 그 이미지 주소가 없고, 올린 지 보존기간이 지났을 때"만
동작합니다. 게다가 **비활성 상태인 모듈이 하나라도 있으면 검사 자체를 멈춥니다** — 그 모듈의
글을 확인할 수 없어 쓰이는 이미지를 안 쓰인다고 오판할 수 있기 때문입니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```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
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `imageUpload` | 이미지 업로드 | `true` |
| `imageMaxSizeMb` | 이미지 최대 크기 (MB) | `2` |
| `editorHeight` | 에디터 높이 (px) | `400` |
| `toolbar` | 툴바 유형 | `standard` |
| `public_asset_disk` | 공개 자산 디스크 | - |
| `unusedImageCleanup` | 미사용 이미지 자동 정리 | `false` |
| `unusedImageRetentionDays` | 미사용 이미지 보존기간 (일) | `30` |
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
<!-- @generated:settings-summary END -->
<!-- @intent START -->
설정은 관리자의 플러그인 목록에서 이 플러그인의 설정으로 들어가 조정합니다.
| 항목 | 언제 바꾸는가 | 바꾸면 달라지는 것 |
|---|---|---|
| 이미지 업로드 | 편집기에서 이미지 첨부를 막고 싶을 때 | 끄면 편집기 툴바에서 이미지 버튼이 사라집니다 |
| 이미지 최대 크기 (MB) | 큰 사진을 그대로 올려야 할 때 | 이 값을 넘는 파일은 업로드가 거부됩니다 (기본 2MB) |
| 에디터 높이 (px) | 본문이 긴 화면에서 | 편집 영역의 기본 높이 (기본 400px) |
| 툴바 유형 | 필요한 기능만 남기고 싶을 때 | 툴바 버튼 구성 — 간단형(minimal)·표준형(standard)·전체(full) |
| 공개 자산 디스크 | 이미지를 외부 저장소·CDN 에 둘 때 | 업로드 이미지가 저장되고 서빙되는 위치 |
| 미사용 이미지 자동 정리 | 저장 공간을 관리할 때 | **기본은 꺼짐.** 켜면 하루 한 번 미사용 이미지를 정리합니다 |
| 미사용 이미지 보존기간 (일) | 정리 시점을 조정할 때 | 올린 지 이 기간이 지난 것만 정리 대상 (기본 30일) |
미사용 이미지 정리를 켜기 전에 확인할 것: 본문에 이미지를 담는 모듈이 **모두 활성 상태**여야
합니다. 설치만 되어 있고 꺼진 모듈이 있으면 그 모듈의 글을 검사할 수 없어, 안전을 위해 정리가
수행되지 않습니다.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**도입**: 플러그인을 설치·활성화하면 끝입니다. 본문을 입력하는 화면들이 자동으로 위지윅
편집기로 바뀝니다. 편집기 플러그인은 **하나만 활성화**합니다 — 둘 이상 켜면 어느 것이 표시될지
설치 순서에 좌우됩니다.
**업로드 이미지 관리**: `/admin/plugins/sirsoft-ckeditor5/uploads` 에서 편집기로 올린 이미지를
목록으로 보고, 필요 없는 것을 개별 또는 일괄로 지웁니다. 여기서 지운 이미지가 아직 본문에
쓰이고 있으면 그 자리가 깨지므로, 삭제 전에 사용처를 확인합니다.
**저장 공간 정리**: 이미지가 계속 쌓여 용량이 부담되면 설정에서 "미사용 이미지 자동 정리" 를
켜고 보존기간을 정합니다. 처음 켤 때는 보존기간을 넉넉히(예: 90일) 잡아 한 주기 동안 어떤
것이 정리되는지 확인한 뒤 줄이는 편이 안전합니다.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:integrations END -->
## 문서
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
## 트러블슈팅
<!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 본문 자리에 편집기 대신 일반 입력창이 뜨고 안내가 나옴 | 편집기 자산을 불러오지 못함 | 안내의 재시도를 누릅니다. 작성한 내용은 유지됩니다. 반복되면 플러그인 파일이 온전히 설치되었는지 확인합니다 |
| 편집기가 두 번 뜨거나 엉뚱한 편집기가 나옴 | 편집기 플러그인이 둘 이상 활성화됨 | 하나만 남기고 나머지를 비활성화합니다 |
| 이미지 업로드가 거부됨 | 용량 제한 초과 또는 업로드 기능이 꺼짐 | 설정에서 "이미지 업로드" 사용 여부와 최대 크기를 확인합니다 (기본 2MB) |
| 본문의 이미지가 깨져 보임 | 그 이미지를 관리 화면에서 지웠거나 저장 디스크 설정이 바뀜 | 업로드 관리 화면에서 삭제 여부를 확인하고, 저장 디스크를 바꿨다면 이전 위치의 파일이 접근 가능한지 확인합니다 |
| 미사용 이미지 정리를 켰는데 아무것도 정리되지 않음 | 비활성 상태의 모듈이 있어 검사가 중단됨, 또는 보존기간이 아직 지나지 않음 | 설치된 모듈을 모두 활성화하거나 쓰지 않는 모듈은 삭제합니다. 보존기간도 함께 확인합니다 |
| 편집기 툴바에 원하는 버튼이 없음 | 툴바 유형이 간단형(minimal) | 설정에서 툴바 유형을 표준형(standard) 또는 전체(full)로 바꿉니다 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -0,0 +1,22 @@
# CKEditor 5 WYSIWYG 에디터 개발자 문서
> plugins/_bundled/sirsoft-ckeditor5 · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 4 · **구독 훅 수**: 0 · **라우트 수**: 5 · **모델 수**: 1 · **테이블 수**: 1 · **마이그레이션 수**: 2 · **레이아웃 수**: 2 · **핸들러 수**: 3
<!-- @generated:stats END -->
## 문서 목차
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 |
|---|---|
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [api/](api/README.md) | API 레퍼런스 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,96 @@
# CKEditor 5 WYSIWYG 에디터 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
편집기는 **교체 가능해야 한다**는 전제에서 출발합니다. 그래서 본문 입력 화면들은 편집기를
직접 알지 않고 코어 확장점(`html_editor` · `html_content`)만 열어 두고, 이 플러그인이
`mode: replace` 로 그 자리를 차지합니다. 편집기를 바꾸는 일은 화면들을 고치는 것이 아니라
플러그인을 교체하는 것입니다.
그 구조의 대가로 **같은 확장점을 노리는 편집기 플러그인이 둘이면 승자가 설치 순서에
좌우됩니다.** 편집기 플러그인은 하나만 활성화하는 것이 전제이며, 이는 규칙이 아니라 구조적
성질입니다.
나머지 설계는 전부 "**조용한 실패를 만들지 않는다**" 로 수렴합니다:
- **자산을 자체 제공한다** — CKEditor 5 를 `dist/vendor/ckeditor5/43.3.1/` 에 동봉해
same-origin 으로 서빙합니다. CDN 도달 실패는 서버 로그에 흔적이 없고 브라우저에서 편집기만
사라지므로, 운영자가 원인을 특정할 수 없습니다.
- **실패해도 글은 쓸 수 있다** — 자산 확보에 실패하면 평문 입력창으로 내려가되 저장 계약
(`{name}_mode`)을 유지하고, 사용자에게 사실과 재시도 통로를 제시합니다. 빈 컨테이너를 남기는
것은 "글을 쓸 수 없다" 인데 화면에는 아무 설명이 없는 상태입니다.
- **이미지 정리는 fail-closed** — 참조 판정에 필요한 소스를 다 모으지 못한 정황(비활성 설치
모듈)이 있으면 정리를 아예 하지 않습니다. 잘못 지운 이미지는 되돌릴 수 없기 때문입니다.
**의도적으로 하지 않는 것**: 본문 정화(sanitize)·훅 구독·리스너·미들웨어·브로드캐스트·알림.
저장측 검증과 봇 화면 정화는 코어의 일이며, 이 플러그인이 정화까지 맡으면 편집기를 교체하는
순간 그 방어가 함께 사라집니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
[프론트] 레이아웃 확장 조각 (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` 를 선언하는 것은 이 테이블이 플러그인
제거와 함께 정리되는 대상임을 코어에 알리기 위해서입니다.
<!-- @intent END -->
## 디렉토리
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/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 팩 동기화 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,104 @@
# CKEditor 5 WYSIWYG 에디터 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
| `Ckeditor5ImageUpload` | `ckeditor5_image_uploads` | 7 | uploader→User | - |
<!-- @generated:models END -->
<!-- @intent START -->
모델 하나뿐입니다. `Ckeditor5ImageUpload` 는 편집기로 올린 이미지의 **기록**이며, 파일 자체는
설정된 디스크(`public_asset_disk`)에 있습니다.
이 기록이 존재하는 이유는 두 가지입니다 — 관리자 화면에서 업로드 이미지를 목록으로 보여주기
위해서, 그리고 미참조 정리 판정의 대상 목록을 얻기 위해서입니다. 본문은 이 기록의 ID 를
참조하지 않고 **URL 문자열**을 담으므로, 기록을 지운다고 본문의 이미지 태그가 사라지지는
않습니다(그 자리가 깨질 뿐입니다).
`uploader→User` 관계 하나만 있고 콘텐츠와의 관계는 없습니다. 이미지가 어느 글에 쓰이는지는
관계가 아니라 **본문 문자열 검색**으로 판정합니다 — 본문을 가진 확장이 늘 때마다 이 플러그인이
그 관계를 알아야 한다면 결합이 무한히 늘어나기 때문입니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 테이블 | 모델 |
|---|---|
| `ckeditor5_image_uploads` | `Ckeditor5ImageUpload` |
<!-- @generated:tables END -->
<!-- @intent START -->
`ckeditor5_image_uploads` 하나입니다. `plugin.php` 의 `getDynamicTables()` 가 이 이름을
선언하는데, 플러그인 제거 시 정리 대상임을 코어에 알리기 위해서입니다.
기록을 지우는 것과 **파일을 지우는 것은 별개**입니다. 관리 화면의 삭제는 둘 다 수행하지만,
DB 행만 사라지고 파일이 남는 경로를 만들지 않도록 주의합니다 — 남은 파일은 어떤 목록에도
뜨지 않아 영영 정리되지 않습니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
마이그레이션 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` | ✅ |
<!-- @generated:migrations END -->
<!-- @intent START -->
2개입니다. 초기 테이블 생성 하나와 `created_at` 인덱스 추가 하나.
인덱스가 나중에 추가된 것은 정리 커맨드가 보존기간으로 대상을 고르기 때문입니다 —
`created_at` 범위 조건이 인덱스를 타지 못하면 업로드가 쌓일수록 정리 배치가 느려집니다.
새 컬럼을 더할 때 초기 `create_*` 파일을 고치지 않습니다. 이미 설치된 사이트는 그 파일을 다시
실행하지 않으므로 반영되지 않으며, 기존 행을 손봐야 하는 변경은 `upgrades/` 의 업그레이드 스텝
백필이 함께 필요합니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
없습니다. 이 플러그인에는 상태 전이가 없습니다 — 이미지는 올라오거나 지워질 뿐입니다.
설정의 `toolbar` 만 닫힌 어휘(`standard`/`minimal`/`full`)를 갖는데, 이는 설정 스키마의
`enum` 타입으로 선언되어 있어 별도 PHP Enum 을 두지 않았습니다. 이 어휘를 코드에서 분기로
비교하는 자리가 늘어나면 그때 Enum 으로 올리는 것이 맞습니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `ImageReferenceSourceRepository` | 구현 | 에디터 이미지 참조 소스 조회 Repository 구현체 |
| `ImageReferenceSourceRepositoryInterface` | 인터페이스 | 에디터 이미지 참조 소스 조회 Repository 인터페이스 |
| `ImageUploadRepository` | 구현 | CKEditor5 이미지 업로드 Repository 구현체 |
| `ImageUploadRepositoryInterface` | 인터페이스 | CKEditor5 이미지 업로드 Repository 인터페이스 |
<!-- @generated:repositories END -->
<!-- @intent START -->
두 갈래입니다.
- **`ImageUploadRepository`** — 자기 테이블(`ckeditor5_image_uploads`) 접근.
- **`ImageReferenceSourceRepository`** — **다른 확장의 콘텐츠 테이블**을 읽습니다. 이 플러그인이
소유하지 않은 테이블을 훑는 유일한 자리이며, 그래서 소스 목록의 유효성(테이블·컬럼이 실제로
존재하는가)을 스스로 검증합니다.
두 번째 Repository 의 쿼리는 **본문 문자열 검색**이라 비용이 큽니다. 대상은 보존기간이 지난
업로드로 한정되고, 검색 토큰은 해시와 저장 파일명 **두 개를 OR** 로 겁니다 — 본문에 박히는
URL 형태가 둘(API 폴백형·디스크 직접형)이라 한쪽만 보면 다른 형태를 미참조로 오판합니다.
서비스는 인터페이스만 주입받습니다(구체 클래스 타입힌트 금지).
<!-- @intent END -->
@@ -0,0 +1,155 @@
# CKEditor 5 WYSIWYG 에디터 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 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` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
4종 중 셋은 업로드 파이프라인(`before_upload` → `filter_upload_file` → `after_upload`)이고,
나머지 하나가 이 플러그인에서 가장 중요한 훅입니다.
**`image.filter_reference_sources`** — 업로드 이미지가 어느 콘텐츠에서 쓰이는지 판정할 때
훑을 테이블·컬럼 목록을 만드는 자리입니다. 본문에 이미지를 담는 확장(게시판 글·상품 설명·
페이지 내용)은 **반드시 자기 테이블·컬럼을 여기에 등록**합니다. 등록하지 않으면 그 확장의
콘텐츠가 판정에서 통째로 빠지고, 화면에 멀쩡히 보이는 이미지가 "미참조" 로 분류되어 정리
대상이 됩니다 — 오류 없이 이미지가 깨지는 형태로만 드러납니다.
등록 시 **로그 사본 테이블을 소스로 삼지 않습니다.** 알림 발송 로그·메일 로그·신고 스냅샷·
레이아웃 미리보기는 자체 보존기간으로 지워지는 사본이라, 소스로 넣으면 "로그가 지워지는 순간
이미지가 고아가 되는" 역전이 생깁니다. 코어가 그 넷을 명시적으로 제외한 이유입니다.
`before_upload` 는 본인인증 강제·업로드 쿼터 같은 게이트를 붙이는 자리이고,
`filter_upload_file` 은 저장 직전 압축·리사이즈·형식 변환 자리입니다. `after_upload` 는 기록
생성 후이므로 외부 저장소 미러링처럼 사후 처리에 씁니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_이 확장은 훅을 구독하지 않습니다._
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
하나도 구독하지 않습니다. 이 플러그인은 다른 확장의 흐름에 개입하지 않고, 자기 확장점 안에서만
동작합니다.
관계는 반대 방향으로 흐릅니다 — 다른 확장이 **이 플러그인의 훅을 구독**합니다. 게시판·페이지·
이커머스가 `image.filter_reference_sources` 에 자기 콘텐츠 테이블을 등록하는 것이 그
예입니다. 그래서 이 플러그인의 훅 이름을 바꾸면 그 확장들의 등록이 예외 없이 조용히 끊기고,
결과는 "쓰이는 이미지가 정리 대상이 되는" 형태로 나타납니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_훅 리스너가 없습니다._
<!-- @generated:listeners END -->
<!-- @intent START -->
없습니다. 구독하는 훅이 없으므로 리스너도 필요하지 않습니다.
이미지 정리는 훅이 아니라 **스케줄 커맨드**가 수행합니다. 콘텐츠 변경마다 반응하는 것이 아니라
주기적으로 전체를 훑는 방식인데, 본문에서 이미지 주소가 빠지는 사건을 훅으로 잡으려면 본문을
가진 모든 확장이 그 사실을 발행해야 하기 때문입니다. 주기 검사는 그 협조 없이도 성립합니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/html-content.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/html-editor.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
두 조각이 이 플러그인의 **본체**입니다. 백엔드가 아니라 이 조각들이 편집기를 화면에 올립니다.
| 조각 | 확장점 | 하는 일 |
|---|---|---|
| `html-editor.json` | `html_editor` | 동봉 CKEditor 5 UMD 를 로드하고 컨테이너 `onMount` 에서 `initEditor` 실행 |
| `html-content.json` | `html_content` | 저장된 본문을 읽기 화면에 렌더 |
둘 다 `mode: replace` 입니다 — 확장점 자리를 비우고 대신 들어갑니다. 같은 확장점을 노리는
다른 편집기 플러그인이 함께 활성화되면 어느 쪽이 이기는지가 설치 순서에 좌우되므로, 편집기
플러그인은 하나만 켭니다.
조각 안의 `scripts.src` 에 **동봉 자산의 버전 경로가 문자열로 박혀 있습니다.** CKEditor 5
버전을 올릴 때 디렉토리명만 바꾸고 이 값을 빠뜨리면 그 자산이 404 가 되는데, 빌드도 테스트도
통과합니다. 버전 기재는 디렉토리명 · 이 조각 · 소스 상수 · 테스트 단언이 **한 벌**로 움직여야
합니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
없습니다. 업로드·서빙 라우트는 코어 인증 미들웨어만 씁니다.
업로드 게이트가 필요하면 미들웨어가 아니라 `image.before_upload` 훅을 잡습니다 — 그 편이
확장에 열려 있고, 미들웨어 부착 대상(targets) 선언을 늘리지 않아도 됩니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
없습니다. 편집기 동작은 각 사용자의 브라우저 안에서 끝나므로 서버가 다른 접속자에게 알릴
사건이 없습니다.
여러 사람이 같은 문서를 동시에 편집하는 협업 기능은 CKEditor 5 상용 부가 기능의 영역이며 이
플러그인의 범위 밖입니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 스케줄 | 주기 | 설명 |
|---|---|---|
| `sirsoft-ckeditor5:prune-unused-images --scheduled` | `daily` | 미참조 에디터 업로드 이미지 정리 |
<!-- @generated:schedules END -->
<!-- @intent START -->
하나뿐입니다. `prune-unused-images --scheduled` 는 어느 콘텐츠에서도 참조되지 않고 보존기간
(`unusedImageRetentionDays`, 기본 30일)이 지난 업로드 이미지를 정리합니다.
**기본값이 꺼짐**(`unusedImageCleanup: false`)인 것이 이 스케줄의 핵심입니다. 잘못 지운
이미지는 되돌릴 수 없고, 참조 판정은 다른 확장들의 협조(훅 등록)에 의존하므로 사이트마다
정확도가 다를 수 있습니다. 운영자가 자기 사이트에서 무엇이 정리되는지 확인한 뒤 켜는 것이
전제입니다.
켜져 있어도 **비활성 설치 모듈이 하나라도 있으면 판정을 중단**합니다. 그 모듈의 콘텐츠가
소스 목록에 등록되지 않아 실제로 쓰이는 이미지를 미참조로 오판하기 때문입니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
없습니다. 편집기 사용은 알릴 사건이 아니고, 이미지 정리는 운영자가 설정으로 켠 배치 작업이라
그 결과는 커맨드 출력과 로그로 남습니다.
정리된 건수를 운영자에게 통지해야 한다면 `prune-unused-images` 를 감싸는 별도 확장에서
코어 `GenericNotification` 으로 보내는 것이 맞습니다 — 수신자 범위를 이 플러그인이 정할 수
없습니다.
<!-- @intent END -->
@@ -0,0 +1,118 @@
# CKEditor 5 WYSIWYG 에디터 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 2개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 2개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `ckeditor5_uploads` | `admin` | 화면 | `_admin_base` |
| `plugin_settings` | `admin` | 화면 | `_admin_base` |
<!-- @generated:layouts END -->
<!-- @intent START -->
관리자 화면 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`
로 반영합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 3개 (정의: `resources/js/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `initEditor` | `sirsoft-ckeditor5.initEditor` |
| `destroyEditor` | `sirsoft-ckeditor5.destroyEditor` |
| `injectContentCss` | `sirsoft-ckeditor5.injectContentCss` |
<!-- @generated:handlers END -->
<!-- @intent START -->
셋뿐이지만 이 플러그인의 동작 대부분이 여기 있습니다.
| 핸들러 | 하는 일 |
|---|---|
| `initEditor` | 컨테이너에 편집기를 붙이고 `form.{name}_mode = 'html'` 설정. **자산 확보 실패 시 평문 입력창 폴백 + 사용자 통지 + 재시도 통로** |
| `destroyEditor` | 화면을 떠날 때 인스턴스 해제 (누수 방지) |
| `injectContentCss` | 읽기 화면에 본문 스타일 주입 |
`initEditor` 의 폴백 경로가 이 플러그인에서 가장 조심스러운 코드입니다. 편집기를 못 불러왔을
때 **빈 컨테이너를 남기면 안 되고**(글을 쓸 수 없는데 화면에 설명이 없습니다), 폴백으로 내려간
뒤에도 저장 계약을 지켜야 하며(`{name}_mode = 'text'`), 재시도로 편집기가 뜨면 그때 `_mode` 를
`'html'` 로 되돌리면서 **그 사이에 쓴 내용을 승계**해야 합니다. 이 셋 중 하나라도 빠지면 사용자
입력이 사라집니다.
핸들러 TS 를 고치면 빌드가 필요합니다 — `php artisan plugin:build` 후
`plugin:update --force`. 그리고 프론트엔드 변경은 Playwright 위지윅 spec 을 함께 갱신·실행
합니다. 편집기 장착 회귀는 단위 테스트가 초록인 상태에서도 브라우저에서만 드러납니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftCkeditor5` |
| 재등록 진입점 | `initPlugin()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`window.__SirsoftCkeditor5.initPlugin()` 이 재등록 진입점입니다. 로케일을 전환하면 코어가 이
함수를 다시 불러 핸들러를 재등록하는데, 없거나 이름이 다르면 **로케일 전환 직후 편집기가
장착되지 않습니다** — 오류도 토스트도 없이 본문 자리만 비게 됩니다.
진입점은 핸들러 재등록만 수행합니다. 편집기 인스턴스 생성·자산 로드 같은 1회성 작업을 여기
넣으면 로케일을 바꿀 때마다 다시 실행되어 인스턴스가 중복되거나 작성 중인 내용이 날아갑니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
| `dist/vendor/ckeditor5/43.3.1` | 동봉 제3자 자산 (자체 제공) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
두 항목의 성격이 다릅니다.
| 경로 | 성격 |
|---|---|
| `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` 을 씁니다.
<!-- @intent END -->
@@ -0,0 +1,135 @@
# CKEditor 5 WYSIWYG 에디터 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `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`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
7개 항목이 세 무리입니다.
| 무리 | 항목 | 성격 |
|---|---|---|
| 편집기 표현 | `toolbar` · `editorHeight` | 화면에만 영향. 잘못 설정해도 데이터는 안전합니다 |
| 업로드 | `imageUpload` · `imageMaxSizeMb` · `public_asset_disk` | 저장 위치와 허용 범위 |
| 정리 | `unusedImageCleanup` · `unusedImageRetentionDays` | **파일을 지우는 설정** — 기본이 꺼짐입니다 |
`public_asset_disk` 만 `enum` 이 아니라 `string` 인 이유가 있습니다. 선택지가 코어 카탈로그 +
플러그인이 훅으로 등록한 디스크로 **동적**이라 스키마 단계에서 열거할 수 없습니다. 존재하지
않는 디스크 값이 들어오면 `resolvePublicAssetDisk()` 가 스트리밍 서빙으로 안전하게 폴백하므로,
설정 오타가 이미지 소실로 이어지지는 않습니다.
`unusedImageCleanup` 의 기본값이 `false` 인 것은 **의도적인 보수 설정**입니다. 참조 판정이
다른 확장들의 훅 등록에 의존하므로 사이트마다 정확도가 다를 수 있고, 잘못 지운 이미지는
되돌릴 수 없습니다.
설정 화면은 `resources/layouts/admin/plugin_settings.json` 이 그립니다 — 코어가 플러그인
디렉토리의 이 고정 경로를 찾으므로 파일 이름을 바꾸면 설정 화면 자체가 사라집니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 카테고리 | 이름 | 액션 | 라우트 키 |
|---|---|---|---|
| `uploads` | 에디터 업로드 이미지 | `read`, `delete` | - |
<!-- @generated:permissions END -->
<!-- @intent START -->
`uploads` 하나에 `read`/`delete` 두 액션뿐입니다. 업로드 이미지 **관리 화면**에 대한 권한이며,
편집기를 쓰는 권한이 아닙니다 — 편집기는 본문을 쓸 수 있는 사람이면 누구나 씁니다.
`create` 가 없는 것은 이미지가 관리 화면이 아니라 **편집기에서** 올라오기 때문입니다. 그
경로의 게이트는 권한이 아니라 훅(`image.before_upload`)이 담당합니다 — 업로드 제한 정책이
사이트마다 다르고(회원 등급별 쿼터, 본인인증 요구 등) 권한 하나로 표현되지 않습니다.
`delete` 는 파일을 실제로 지우는 권한입니다. 본문에서 아직 쓰이는 이미지를 지우면 그 자리가
깨지므로 넓게 부여하지 않습니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 구분 | slug | 이름 | URL | 하위 |
|---|---|---|---|---|
| 관리자 | `sirsoft-ckeditor5-uploads` | 에디터 업로드 이미지 | `/admin/plugins/sirsoft-ckeditor5/uploads` | - |
<!-- @generated:menus END -->
<!-- @intent START -->
관리자 메뉴 하나(`/admin/plugins/sirsoft-ckeditor5/uploads`)뿐이며 업로드 이미지 목록으로
갑니다.
설정 화면은 이 메뉴에 없습니다 — 코어의 플러그인 목록에서 이 플러그인의 설정으로 들어가는
공통 경로를 씁니다. 플러그인 설정은 코어가 `resources/layouts/admin/plugin_settings.json` 을
찾아 그리므로 자체 메뉴가 필요하지 않습니다.
메뉴는 권한과 짝을 이룰 때만 보입니다 — `sirsoft-ckeditor5.uploads.read` 가 없는 역할에는
렌더되지 않습니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-ckeditor5/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
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 가 됩니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
양방향 모두 비어 있습니다. 이 플러그인은 코어만으로 동작하고, manifest 상 이 플러그인을
요구하는 확장도 없습니다.
**그런데 실제 관계는 표가 보여주는 것보다 많습니다.** 게시판·페이지·이커머스가 이 플러그인의
`image.filter_reference_sources` 를 구독해 자기 콘텐츠 테이블을 등록합니다. manifest 의존이
아닌 이유는 편집기가 없어도 그 확장들이 정상 동작하기 때문이며(본문을 평문으로 쓸 뿐),
그 판단은 맞습니다.
대신 그 대가로 **이 플러그인이 훅 이름을 바꾸면 구독하던 확장들의 등록이 예외 없이 조용히
끊깁니다.** 그 결과는 "쓰이는 이미지가 정리 대상이 되는" 형태로 나타나므로, 훅 이름·페이로드
스키마를 바꿀 때는 구독 확장을 전수 확인하고 그 확장들의 `dependencies` 최소 버전 상향이
필요한지 검토합니다.
<!-- @intent END -->
@@ -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. 이 확장은 무엇인가
<!-- @intent START -->
주소 입력 자리에 **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 장애)
사용자에게 사실을 알리고 재시도 통로를 남기며, 주소를 직접 입력할 수 있게 합니다 — 검색이
안 된다고 주문을 못 하게 되면 안 되기 때문입니다.
**의도적으로 하지 않는 것**: 백엔드 저장·주소 검증·좌표 변환·해외 주소. 선택된 주소를 어디에
어떻게 저장할지는 그 화면을 소유한 확장(이커머스 등)의 일입니다.
<!-- @intent END -->
## 2. 디렉토리 지도
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `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` 재실행 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
백엔드 흐름이 없으므로 전부 프론트에서 일어납니다.
**장착**: 어떤 화면이 `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 가 막힌 환경에서 주소를 아예 넣을 수 없습니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 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#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
발행 훅 2종은 **선택된 주소를 가로채는** 자리입니다.
| 훅 | 언제 쓰는가 |
|---|---|
| `filter_address_data` | 주소 데이터를 필드에 쓰기 **전**에 가공. 도로명/지번 중 어느 것을 기본으로 쓸지, 건물명을 상세주소에 미리 넣을지 등 |
| `address.selected` | 주소가 확정된 **후**. 배송비 재계산·배송 가능 지역 판정 같은 후속 동작 |
두 훅 모두 `getHooks()` 에 파라미터까지 선언되어 있습니다 —
`zonecode`(우편번호) · `address`(기본) · `roadAddress`(도로명) · `jibunAddress`(지번) ·
`buildingName`(건물명). 발행 위치가 "선언(호출 위치 미확인)" 인 것은 실제 발행이 **프론트
핸들러**에서 이루어져 PHP 소스 스캔에 잡히지 않기 때문입니다.
레이아웃 조각 하나(`ecommerce-address-search.json`)가 이 플러그인의 UI 전부입니다. 파일
이름에 `ecommerce` 가 붙어 있지만 **확장점 이름(`address_search_slot`)으로 매칭**되므로,
그 확장점을 여는 화면이면 어디든 붙습니다 — 이커머스 전용이 아닙니다.
구독 훅·리스너·미들웨어·브로드캐스트 채널·스케줄·알림·권한·메뉴·라우트는 전부 0개입니다.
<!-- @intent END -->
## 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. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| `trusted_script_hosts` 만 선언하고 사유를 생략 | `trusted_script_hosts_reason` 에 호스트별 사유 동반 | 자체 제공이 원칙이고 외부 호스트는 예외다 — 예외의 근거가 코드에 남지 않으면 다음 사람이 무심코 CDN 을 늘린다 |
| SDK 확보 실패 시 검색 버튼만 죽이고 끝내기 | 사용자 통지 + 재시도 + **필드를 편집 가능하게 남겨 직접 입력 폴백** | 검색이 막힌 환경에서 주소를 아예 넣을 수 없게 되면 그 화면 전체(주문·배송지 등록)가 불능이 된다 |
| 스크립트 로드에 `onerror` 를 걸지 않거나 실패를 `resolve()` 로 삼키기 | 실패를 명시적으로 감지해 폴백으로 분기 | 삼키면 "버튼을 눌러도 아무 일이 없다" 가 되고 콘솔 외에는 흔적이 없다 |
| SDK 확보를 확인하지 않고 필드를 먼저 읽기 전용으로 만들기 | 확보 확인 후에만 읽기 전용 적용 (해제는 확인 없이) | 읽기 전용 + 검색 불가 = 입력 불가. 순서가 뒤집히면 실패 환경에서 필드가 잠긴 채 남는다 |
| 선택된 주소를 이 플러그인이 직접 저장 | 필드에 쓰고 `address.selected` 발행까지 | 저장 위치·형식은 화면을 소유한 확장이 정한다. 여기서 저장하면 그 확장마다 분기가 늘어난다 |
| 도로명/지번 중 하나를 코드에 고정 | `filter_address_data` 로 소비처가 고르게 | 사이트마다 표기 정책이 다르다 |
| 자산 URL 을 문자열로 조립 | `G7Core.asset.plugin` | 확장자를 정적 location 이 가로채는 서버에서 조립한 URL 만 404 가 된다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| 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 -- <대상>"
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @generated:test-commands END -->
## 8. 문서 목차
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
@@ -6,6 +6,10 @@
## [1.0.3] - 2026-08-25
### Added
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
### Fixed
- 주소 검색 서비스를 불러오지 못한 상태에서 우편번호·주소 입력란이 읽기 전용으로 고정되어, 검색도 직접 입력도 할 수 없던 문제를 고쳤습니다. 이제 검색을 쓸 수 있을 때만 읽기 전용이 되고, 그렇지 않으면 직접 입력할 수 있습니다.
@@ -0,0 +1,183 @@
# Daum 우편번호
**G7 플러그인 · sirsoft-daum_postcode**
Daum 우편번호 서비스를 통한 주소 검색 기능을 제공하는 플러그인입니다. API 키 없이 무료로 사용할 수 있습니다.
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.3-0066FF?style=flat-square" alt="version 1.0.3">
<img src="https://img.shields.io/badge/type-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-555555?style=flat-square" alt="type 플러그인">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.10-1F883D?style=flat-square" alt="G7 &gt;=7.0.10">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
주소를 입력하는 자리에 **우편번호 검색 창**을 붙여 주는 플러그인입니다. 설치·활성화하면
배송지 입력 같은 주소 입력 화면에 검색 버튼이 생기고, 검색해서 고른 주소가 우편번호·기본
주소·도로명·지번 칸에 자동으로 채워집니다.
Daum(카카오)이 제공하는 무료 서비스를 사용하므로 **API 키 발급이나 별도 계약이 필요 없습니다.**
다만 주소 검색 창 자체는 Daum 서버에서 내려받으므로, 인터넷이 차단된 환경에서는 검색이
동작하지 않습니다. 그럴 때는 안내가 뜨고 **주소를 직접 입력**할 수 있으므로 주문이나 배송지
등록이 막히지는 않습니다.
이 플러그인은 주소를 찾아 칸에 넣어 주는 데까지만 합니다. 그 주소를 어디에 어떻게 저장할지는
주소 입력 화면을 가진 확장(예: 이커머스)이 정합니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 주소 검색 | 도로명·지번·건물명·우편번호로 검색해 정확한 주소 선택 |
| 자동 입력 | 선택한 주소를 우편번호·기본주소·도로명·지번 칸에 자동으로 채움 |
| 오타 방지 | 검색으로 채우는 칸은 직접 수정할 수 없게 잠금 (상세주소는 직접 입력) |
| 표시 방식 | 화면 안에 겹쳐 띄우는 레이어 방식과 별도 창 팝업 방식 중 선택 |
| 모양 조정 | 팝업 크기와 테마 색상을 사이트에 맞게 설정 |
| 연결 실패 대비 | 검색을 불러오지 못하면 안내 후 직접 입력 허용, 재시도 제공 |
| 연동 지점 | 주소 선택 시점에 다른 확장이 반응할 수 있는 확장점 제공 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
F[주소 입력 화면] -->|검색 자리| P[이 플러그인]
P -->|검색 창 열기| D[Daum 우편번호 서비스]
D -->|선택한 주소| P
P --> FIELD[우편번호·주소 칸 채움]
P -.연결 실패.-> M[안내 + 직접 입력]
```
주소 입력 화면은 "검색 버튼이 들어갈 자리" 만 비워 두고 이 플러그인이 그 자리를 채웁니다.
그래서 이커머스 배송지든 다른 확장의 주소 입력이든 같은 방식으로 동작합니다.
검색 창을 불러오지 못하면 잠겨 있던 주소 칸이 **편집 가능한 상태로 남아** 직접 입력할 수
있습니다. 검색이 안 된다고 화면 전체가 막히지 않도록 한 것입니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
| 외부 스크립트 호스트 | `t1.daumcdn.net` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```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
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `display_mode` | 표시 방식 | `layer` |
| `popup_width` | 팝업 너비 (px) | `500` |
| `popup_height` | 팝업 높이 (px) | `600` |
| `theme_color` | 테마 색상 | `#1D4ED8` |
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
<!-- @generated:settings-summary END -->
<!-- @intent START -->
설정은 관리자의 플러그인 목록에서 이 플러그인의 설정으로 들어가 조정합니다.
| 항목 | 언제 바꾸는가 | 바꾸면 달라지는 것 |
|---|---|---|
| 표시 방식 | 팝업 차단 프로그램 사용자가 많을 때 | `layer`(기본)는 화면 안에 겹쳐 띄우고, `popup`은 별도 창을 엽니다 |
| 팝업 너비 / 높이 (px) | 팝업 방식일 때 창이 작거나 클 때 | 별도 창의 크기 (기본 500 × 600) |
| 테마 색상 | 사이트 색과 맞출 때 | 검색 창의 강조 색 (기본 `#1D4ED8`) |
표시 방식은 `layer` 를 기본값으로 둡니다 — 팝업은 브라우저나 확장 프로그램에 의해 차단될 수
있고, 차단되면 사용자에게는 "버튼을 눌러도 아무 일이 없는" 것으로 보이기 때문입니다.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**도입**: 플러그인을 설치·활성화하면 끝입니다. 주소 입력 자리를 제공하는 화면(이커머스 배송지
입력 등)에 검색 버튼이 자동으로 나타납니다. 별도의 키 발급이나 신청 절차는 없습니다.
**주소 입력**: 검색 버튼을 눌러 도로명·건물명·지번 중 아는 것으로 검색하고 결과를 고릅니다.
우편번호와 주소 칸이 자동으로 채워지며, 상세주소(동·호수)만 직접 입력하면 됩니다.
**팝업이 뜨지 않을 때**: 설정에서 표시 방식을 `layer` 로 바꿉니다. 화면 안에 겹쳐 뜨는 방식이라
팝업 차단의 영향을 받지 않습니다.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `sirsoft-basic` | 템플릿 | `>=1.0.0` |
<!-- @generated:integrations END -->
## 문서
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
## 트러블슈팅
<!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 검색 버튼을 눌러도 창이 뜨지 않음 | 표시 방식이 팝업인데 브라우저가 팝업을 차단 | 설정에서 표시 방식을 `layer` 로 바꿉니다 |
| "주소 검색을 불러오지 못했습니다" 안내가 뜸 | 인터넷 차단·방화벽·광고차단 프로그램이 Daum 서버 접속을 막음 | 안내의 재시도를 눌러 봅니다. 계속 실패하면 주소를 직접 입력하면 되며, 사내망이라면 `t1.daumcdn.net` 접속을 허용합니다 |
| 주소 칸을 직접 고칠 수 없음 | 오타 방지를 위해 검색으로만 채우도록 잠금 | 정상 동작입니다. 상세주소 칸은 직접 입력할 수 있습니다 |
| 검색이 안 되는 환경인데 주소 칸도 잠겨 있음 | 정상이라면 발생하지 않는 상태 | 검색을 불러오지 못하면 칸이 편집 가능한 상태로 남습니다. 잠겨 있다면 플러그인이 온전히 설치되었는지 확인합니다 |
| 주소 입력 화면에 검색 버튼이 없음 | 그 화면이 주소 검색 자리를 제공하지 않음 | 해당 화면을 가진 확장이 주소 검색 확장 자리를 지원하는지 확인합니다 |
| 해외 주소를 검색할 수 없음 | 국내 우편번호 서비스 | 해외 주소는 직접 입력해야 합니다 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -0,0 +1,21 @@
# Daum 우편번호 개발자 문서
> plugins/_bundled/sirsoft-daum_postcode · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 2 · **구독 훅 수**: 0 · **라우트 수**: 0 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 2
<!-- @generated:stats END -->
## 문서 목차
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 |
|---|---|
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,73 @@
# Daum 우편번호 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
백엔드가 없는 것이 이 플러그인의 설계 그 자체입니다 — 라우트 0 · 모델 0 · 테이블 0 ·
마이그레이션 0 · 리스너 0. 주소 검색은 브라우저에서 시작해 브라우저에서 끝나는 일이고,
선택된 주소를 저장하는 것은 그 화면을 소유한 확장의 책임이라 서버에 남길 것이 없습니다.
**외부 호스트 예외.** 코어 규정은 "구동 자산을 자체 제공한다" 입니다. 이 플러그인은 그
예외이며, 근거는 대상이 라이브러리가 아니라 **서비스**라는 점입니다 — Daum 우편번호 SDK 는
자체 호스팅해도 Daum 서버와 통신하지 않으면 주소 데이터를 얻을 수 없습니다. 그래서
`trusted_script_hosts` 와 **호스트별 사유**를 manifest 에 함께 선언합니다. 사유 없는 외부
호스트 선언은 금지이며, 이 플러그인이 그 예외 기재의 선례입니다.
**예외의 대가는 실패 경로다.** 외부 호스트에 의존하는 순간 그 도달 실패가 가능해지고, 그
실패는 예외도 서버 로그도 남기지 않습니다. 그래서 세 가지를 갖춥니다 — 스크립트 로드에
`onerror` 를 걸어 실패를 **감지**하고, `G7Core.assets.notifyFailure` 로 사용자에게 **통지**
하며, 필드를 편집 가능한 채로 남겨 **직접 입력**을 허용합니다. 특히 마지막이 중요합니다:
읽기 전용은 SDK 확보를 확인한 **뒤에만** 적용하고, 해제는 확인 없이 즉시 수행합니다.
**의도적으로 하지 않는 것**: 주소 저장·주소 검증·좌표 변환·해외 주소·자체 화면. 이 플러그인의
UI 는 다른 화면에 끼워 넣는 조각 하나와 관리자 설정 화면 하나뿐입니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
[주입] 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`)뿐입니다. 서버가 하는 일이 설정 제공과 훅 선언밖에 없다는
사실이 그대로 드러납니다.
<!-- @intent END -->
## 디렉토리
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `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` 재실행 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,71 @@
# Daum 우편번호 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 모델이 없습니다._
<!-- @generated:models END -->
<!-- @intent START -->
없습니다. 이 플러그인은 아무것도 저장하지 않습니다.
선택된 주소는 화면의 입력 칸에 채워질 뿐이며, 그 값을 어디에 어떻게 저장할지는 주소 입력
화면을 소유한 확장(이커머스의 배송지 등)이 정합니다. 여기서 저장을 맡으면 소비하는 확장마다
저장 형식 분기가 늘어나고, 그 확장의 데이터 소유권도 흐려집니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 테이블이 없습니다._
<!-- @generated:tables END -->
<!-- @intent START -->
없습니다. 저장하는 데이터가 없으므로 테이블도 없습니다.
이 플러그인을 삭제해도 정리할 데이터가 없다는 뜻이기도 합니다 — 주소 검색만 사라지고 이미
입력된 주소는 그 주소를 소유한 확장에 그대로 남습니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_마이그레이션이 없습니다._
<!-- @generated:migrations END -->
<!-- @intent START -->
없습니다. 스키마가 없으므로 마이그레이션도 없습니다.
나중에 저장할 것이 생긴다면(예: 검색 사용 통계) 먼저 "그것이 정말 이 플러그인의 데이터인가"를
따져야 합니다 — 이 플러그인이 상태를 갖지 않는 것은 누락이 아니라 설계입니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
없습니다. 설정의 `display_mode`(`layer` / `popup`)만 닫힌 어휘를 갖는데, 설정 스키마의 `enum`
타입으로 선언되어 있어 별도 PHP Enum 을 두지 않았습니다.
이 값을 코드에서 분기로 비교하는 자리가 프론트 핸들러 한 곳뿐이라 어휘가 갈라질 여지가
없습니다. 비교 지점이 늘어나기 시작하면 그때 Enum 으로 올리는 것이 맞습니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Repository 가 없습니다._
<!-- @generated:repositories END -->
<!-- @intent START -->
없습니다. 데이터 접근 자체가 없습니다.
이 플러그인의 PHP 코드는 `plugin.php` 하나이며, 메서드 넷(`getMetadata` ·
`getSettingsSchema` · `getConfigValues` · `getHooks`)이 전부입니다 — 설정 제공과 훅 선언
외에 서버가 하는 일이 없다는 사실이 그대로 드러납니다.
<!-- @intent END -->
@@ -0,0 +1,128 @@
# Daum 우편번호 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 2종 / 호출 지점 0곳.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `sirsoft-daum_postcode.address.selected` | action | 주소 선택 완료 시 실행되는 액션 훅 | 선언 (호출 위치 미확인) |
| `sirsoft-daum_postcode.filter_address_data` | filter | 선택된 주소 데이터를 필터링하는 훅 | 선언 (호출 위치 미확인) |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
2종이며 **선택된 주소를 가로채는 전/후 한 쌍**입니다.
| 훅 | 시점 | 파라미터 |
|---|---|---|
| `filter_address_data` | 필드에 쓰기 **전** | `data`(주소 배열) → 가공된 배열 반환 |
| `address.selected` | 확정 **후** | `zonecode` · `address` · `roadAddress` · `jibunAddress` · `buildingName` |
앞의 것은 "무엇을 어느 칸에 넣을 것인가" 를 사이트가 정하는 자리입니다 — 도로명과 지번 중
어느 것을 기본 주소로 쓸지, 건물명을 상세주소 칸에 미리 채울지는 사이트마다 다르므로 코드에
고정하지 않습니다. 뒤의 것은 "주소가 정해졌으니 이제 무엇을 할 것인가" 로, 배송비 재계산이나
배송 가능 지역 판정을 붙이는 자리입니다.
발행 위치가 "선언(호출 위치 미확인)" 인 것은 실제 발행이 **프론트 핸들러**에서 이루어져 PHP
소스 스캔에 잡히지 않기 때문입니다. `getHooks()` 에 파라미터까지 선언되어 있으므로 계약은
그 선언이 SSoT 입니다.
구독 훅은 없습니다 — 이 플러그인은 다른 확장의 흐름에 개입하지 않습니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_이 확장은 훅을 구독하지 않습니다._
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
없습니다. 이 플러그인은 자기 확장점 안에서만 동작하며 다른 확장의 흐름에 끼어들지 않습니다.
관계는 반대 방향입니다 — 주소를 다루는 확장이 **이 플러그인의 훅을 구독**합니다. 그래서 이
플러그인이 훅 이름이나 파라미터를 바꾸면 그쪽 배선이 예외 없이 조용히 끊깁니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_훅 리스너가 없습니다._
<!-- @generated:listeners END -->
<!-- @intent START -->
없습니다. 구독하는 훅이 없고 서버에서 하는 일도 없으므로 리스너가 필요하지 않습니다.
이 플러그인의 동작은 전부 프론트 핸들러 둘(`setFieldReadOnly` · `openPostcode`)에 있습니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/ecommerce-address-search.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
조각 하나가 이 플러그인의 **UI 본체**입니다.
파일 이름은 `ecommerce-address-search.json` 이지만 **확장점 이름(`address_search_slot`)으로
매칭**되므로 이커머스 전용이 아닙니다. 그 확장점을 여는 화면이면 어디든 붙습니다 — 이름은
최초 도입 맥락이 남은 것일 뿐입니다.
조각의 `scripts` 에 **외부 호스트 URL 이 문자열로 박혀 있습니다.** 같은 URL 이
`resources/js/handlers/postcodeSdk.ts` 의 `DAUM_POSTCODE_SDK_URL` 상수에도 있으므로, 주소가
바뀌면 **두 곳을 함께** 고쳐야 합니다. 한쪽만 고치면 조각이 로드한 스크립트와 핸들러가 찾는
스크립트가 달라져 확보 판정이 어긋납니다.
대상 화면을 소유한 쪽이 그 확장점을 없애면 조각은 오류 없이 사라집니다 — 증상은 "주소 입력
화면에 검색 버튼이 없다" 뿐입니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
없습니다. 이 플러그인에는 라우트가 없으므로 요청 흐름 자체가 없습니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
없습니다. 주소 검색은 한 사용자의 브라우저 안에서 끝나는 일이라 다른 접속자에게 알릴 사건이
없습니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
없습니다. 저장하는 데이터가 없으므로 주기적으로 정리하거나 갱신할 대상이 없습니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
없습니다. 주소 선택은 사용자 자신의 조작이므로 통지할 사건이 아닙니다.
SDK 확보 실패 통지는 알림 시스템이 아니라 **화면 안의 자산 실패 통지**
(`G7Core.assets.notifyFailure`)로 처리합니다. 지금 이 화면에서 무엇을 할 수 없는지를 그
자리에서 알려야 하고, 메일이나 앱 알림으로 보낼 성질이 아니기 때문입니다.
<!-- @intent END -->
@@ -0,0 +1,108 @@
# Daum 우편번호 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 1개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 1개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `plugin_settings` | `admin` | 화면 | `_admin_base` |
<!-- @generated:layouts END -->
<!-- @intent START -->
관리자 설정 화면(`plugin_settings`) 하나뿐입니다. **이 플러그인의 실제 UI 는 레이아웃이 아니라
확장 조각**(`resources/extensions/ecommerce-address-search.json`)이며, 다른 화면 안에
들어갑니다.
`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를
찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다.
레이아웃·조각 JSON 만 고쳤다면 빌드는 필요 없고
`php artisan plugin:update sirsoft-daum_postcode --force` 로 반영합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 2개 (정의: `resources/js/handlers/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `setFieldReadOnly` | `sirsoft-daum_postcode.setFieldReadOnly` |
| `openPostcode` | `sirsoft-daum_postcode.openPostcode` |
<!-- @generated:handlers END -->
<!-- @intent START -->
둘뿐이고 역할이 명확히 갈립니다.
| 핸들러 | 하는 일 |
|---|---|
| `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` 를 함께 갱신·실행합니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftDaumPostcode` |
| 재등록 진입점 | `initPlugin()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`window.__SirsoftDaumPostcode.initPlugin()` 이 재등록 진입점입니다. 로케일을 전환하면 코어가
이 함수를 다시 불러 핸들러를 재등록하는데, 없거나 이름이 다르면 **로케일 전환 직후 검색
버튼이 무반응**이 됩니다 — 오류도 토스트도 남지 않습니다.
진입점은 핸들러 재등록만 수행합니다. SDK 로드 같은 1회성 작업을 여기 넣으면 로케일을 바꿀
때마다 스크립트를 다시 붙이게 됩니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
커밋되는 산출물은 `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 가 됩니다).
<!-- @intent END -->
@@ -0,0 +1,107 @@
# Daum 우편번호 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `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`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
4개 전부 **표시 방식**에 관한 것입니다. 기능을 켜고 끄는 토글이 없는 것은 플러그인 활성화
자체가 곧 기능 활성화이기 때문입니다.
| 키 | 기본값 | 왜 그 기본값인가 |
|---|---|---|
| `display_mode` | `layer` | 팝업은 브라우저·확장 프로그램에 차단될 수 있고, 차단되면 사용자에게는 "버튼을 눌러도 아무 일이 없는" 것으로 보입니다. 레이어는 그 위험이 없습니다 |
| `popup_width` · `popup_height` | 500 × 600 | 팝업 모드에서만 쓰입니다 |
| `theme_color` | `#1D4ED8` | 검색 창의 강조 색 |
`getConfigValues()` 가 같은 값을 한 번 더 선언합니다 — 스키마의 `default` 는 설정 화면의
초기값이고, 이쪽은 설정이 아직 저장되지 않은 상태에서 코드가 읽는 폴백입니다. **두 곳이
어긋나면** 설정을 한 번도 저장하지 않은 사이트와 저장한 사이트의 동작이 달라지므로 함께
고칩니다.
설정 화면은 `resources/layouts/admin/plugin_settings.json` 이 그립니다 — 코어가 플러그인
디렉토리의 이 고정 경로를 찾으므로 파일 이름을 바꾸면 설정 화면 자체가 사라집니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_선언된 권한이 없습니다._
<!-- @generated:permissions END -->
<!-- @intent START -->
선언하지 않습니다. 주소 검색은 그 화면을 볼 수 있는 사람이면 누구나 쓰는 보조 기능이고,
저장하는 데이터가 없어 접근을 나눌 대상 자체가 없습니다.
설정 변경은 코어의 플러그인 설정 권한이 이미 관장합니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 메뉴가 없습니다._
<!-- @generated:menus END -->
<!-- @intent START -->
등록하지 않습니다. 자체 관리 화면이 없기 때문입니다.
설정은 코어의 플러그인 목록에서 이 플러그인의 설정으로 들어가는 공통 경로를 씁니다 — 코어가
`resources/layouts/admin/plugin_settings.json` 을 찾아 그리므로 자체 메뉴가 필요 없습니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_라우트 파일이 없습니다._
<!-- @generated:routes END -->
<!-- @intent START -->
없습니다. `resources/routes.json` 의 `routes` 가 빈 배열이고 서버 라우트 파일도 없습니다.
이 플러그인은 서버와 통신하지 않습니다 — 주소 데이터는 브라우저가 Daum 서버에서 직접
받아옵니다. 그래서 라우트 캐시·미들웨어·인증 같은 서버측 관심사가 전부 해당하지 않습니다.
만약 서버 라우트가 필요해진다면(예: 검색 결과 프록시) 그 순간 이 플러그인의 성격이 바뀝니다 —
외부 서비스 호출이 서버에서 일어나면 타임아웃·요율 제한·자격 증명 관리가 따라옵니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `sirsoft-basic` | 템플릿 | `>=1.0.0` |
<!-- @generated:dependencies END -->
<!-- @intent START -->
양방향 모두 비어 있습니다. 코어만으로 동작하고, manifest 상 이 플러그인을 요구하는 확장도
없습니다.
**실제 관계는 확장점으로 맺어집니다.** 이커머스의 배송지 입력 화면이 `address_search_slot`
을 열어 두고 있고, 이 플러그인이 그 자리를 채웁니다. manifest 의존이 아닌 것은 방향이
맞습니다 — 이 플러그인이 없어도 그 화면은 주소를 직접 입력받아 정상 동작합니다.
대신 그 대가로 **대상 화면이 확장점을 없애면 이 플러그인은 오류 없이 무력해집니다.** 조각이
붙는 확장점 이름은 상대가 소유하므로, 상대 확장을 업그레이드한 뒤에는 검색 버튼이 여전히
보이는지 확인합니다.
manifest 의 `trusted_script_hosts` 는 의존이 아니라 **외부 서비스에 대한 신뢰 선언**입니다.
이 목록을 늘릴 때는 반드시 `trusted_script_hosts_reason` 에 사유를 함께 적습니다.
<!-- @intent END -->
@@ -6,6 +6,10 @@
## [1.0.4] - 2026-08-31
### Added
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
### Fixed
- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
@@ -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. 이 확장은 무엇인가
<!-- @intent START -->
회원의 **마케팅 정보 수신 동의**를 항목별로 받고, 그 동의·철회 이력을 남기는 플러그인입니다.
이메일·SMS 같은 수신 채널을 운영자가 자유롭게 추가할 수 있고, 제3자 제공 동의·정보 공개
동의처럼 채널이 아닌 항목도 같은 구조로 다룹니다.
**자기 화면이 없습니다.** 관리자 설정 화면 하나를 빼면 이 플러그인의 UI 는 전부 **다른 화면에
끼워 넣는 조각**입니다 — 회원가입 폼·회원 상세·회원 수정 폼·마이페이지 프로필에 동의 항목이
나타나는 것이 그것입니다. 코어 회원 화면을 고치지 않고 동의 항목을 늘리기 위한 구조입니다.
**설계 원칙 셋**:
1. **EAV 구조로 항목을 데이터화한다.** 동의 항목마다 컬럼을 만들면 항목을 늘릴 때 스키마
변경이 필요합니다. 대신 `user_marketing_consents` 한 테이블에 `consent_key` 별 행을 두어,
채널 추가가 **설정 변경만으로** 끝나게 했습니다.
2. **코어 회원 흐름에 훅으로 붙는다.** 구독 훅 11개가 전부 코어 것입니다 — 가입·생성·수정·
삭제·조회 각 지점의 검증 규칙과 데이터에 동의 항목을 얹습니다. 코어 `User` 모델이나 회원
컨트롤러를 고치지 않습니다.
3. **철회는 동의만큼 쉬워야 한다.** 마이페이지 조각이 항상 노출되고, 동의·철회가 모두
`user_marketing_consent_histories` 에 기록됩니다(행위·출처·IP). 동의를 받은 경로와 철회
경로가 대칭이 아니면 그 동의는 법적 근거로 쓸 수 없습니다.
**의도적으로 하지 않는 것**: 실제 발송(메일·SMS)·권한 선언·관리자 메뉴·프론트 액션 핸들러.
이 플러그인은 "누가 무엇에 동의했는가"만 답하고, 그 동의를 근거로 무엇을 보낼지는 발송을
담당하는 확장의 일입니다.
<!-- @intent END -->
## 2. 디렉토리 지도
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/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 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**회원가입 시 동의 수집**: 템플릿의 가입 폼이 `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` 에서 그 회원의 동의 기록을 정리합니다. 코어 회원
삭제가 이 플러그인의 테이블을 알지 못하므로, 이 훅이 없으면 고아 행이 남습니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 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#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
발행 훅 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개입니다.
<!-- @intent END -->
## 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. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 동의 항목을 추가하려고 `user_marketing_consents` 에 컬럼을 더하기 | `channels` 설정에 항목을 추가 (EAV 구조) | 항목마다 컬럼을 만들면 운영자가 채널을 늘릴 때마다 배포가 필요해진다 — 이 플러그인이 존재하는 이유가 사라진다 |
| 동의 상태만 갱신하고 이력을 남기지 않기 | 상태 갱신과 이력 적재를 같은 트랜잭션에 | 이력이 없는 동의는 법적 근거로 쓸 수 없다. "언제 어느 경로로 동의했는가"가 동의 그 자체다 |
| 마이페이지 철회 조각을 설정 토글로 감추기 | 동의 이력이 있는 회원에게는 항상 노출 | 동의를 받은 경로와 철회 경로가 대칭이 아니면 그 동의는 무효가 된다 |
| 코어 `User` 모델·회원 컨트롤러를 고쳐 동의 필드를 넣기 | `core.user.*` 훅 11종 | 코어 수정은 업그레이드마다 충돌하고, 이 플러그인을 비활성화해도 필드가 남는다 |
| 회원 삭제 시 동의 기록 정리를 DB CASCADE 에 맡기기 | `core.user.before_delete` 구독 | CASCADE 는 훅 발행·이력 처리를 건너뛰고, 아무 오류도 남기지 않는다 |
| 동의 여부를 근거로 이 플러그인이 직접 메일·SMS 를 보내기 | 발송은 발송 담당 확장이, 이 플러그인은 `user.subscribed`/`unsubscribed` 발행까지 | 동의 관리와 발송이 한 확장에 묶이면 발송 수단을 바꿀 때 동의 이력까지 흔들린다 |
| 약관 slug 를 코드에 리터럴로 박기 | 설정(`*_terms_slug`)을 읽어 페이지 모듈에서 조회 | 약관 문서는 운영자가 만들고 고치는 콘텐츠다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| 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 -- <대상>"
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @generated:test-commands END -->
## 8. 문서 목차
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
@@ -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
@@ -0,0 +1,200 @@
# 마케팅 동의
**G7 플러그인 · sirsoft-marketing**
이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.4-0066FF?style=flat-square" alt="version 1.0.4">
<img src="https://img.shields.io/badge/type-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-555555?style=flat-square" alt="type 플러그인">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.0-1F883D?style=flat-square" alt="G7 &gt;=7.0.0">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
<img src="https://img.shields.io/badge/requires-sirsoft--page-BF8700?style=flat-square" alt="requires sirsoft-page">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
회원에게 **마케팅 정보 수신 동의**를 받고 그 이력을 남기는 플러그인입니다. 이메일·SMS 같은
수신 채널을 관리자 화면에서 원하는 만큼 추가할 수 있고, 제3자 제공 동의·정보 공개 동의처럼
채널이 아닌 법정 동의 항목도 함께 다룹니다.
동의 항목은 회원가입 폼과 마이페이지, 관리자의 회원 상세·수정 화면에 자동으로 나타납니다.
이 플러그인은 자기 화면을 갖지 않고 **기존 화면에 항목을 얹는** 방식이라, 도입해도 회원
관리 흐름이 달라지지 않습니다.
동의와 철회는 모두 기록됩니다 — 언제, 어느 경로로, 어느 IP 에서 이루어졌는지가 남습니다.
동의 여부만 남기면 나중에 "동의를 받았다" 는 사실을 증명할 수 없기 때문입니다.
의도적으로 하지 않는 것: **실제 발송**. 이 플러그인은 "누가 무엇에 동의했는가" 까지만
답하고, 그 동의를 근거로 메일이나 문자를 보내는 것은 발송 담당 확장의 몫입니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 수신 채널 관리 | 이메일·SMS 등 수신 채널을 관리자 화면에서 추가·수정·사용중지 |
| 법정 동의 항목 | 마케팅 활용 동의·제3자 제공 동의·정보 공개 동의를 각각 켜고 끔 |
| 약관 연결 | 동의 항목마다 약관 페이지를 지정해 회원이 내용을 확인하고 동의 |
| 가입 시 수집 | 회원가입 폼에 동의 항목이 자동으로 나타남 |
| 마이페이지 관리 | 회원이 언제든 스스로 동의·철회 |
| 관리자 조회·수정 | 회원 상세·수정 화면에서 동의 상태 확인과 변경 |
| 동의 이력 | 동의·철회 행위마다 시각·경로·IP 기록, 동의 횟수 누적 |
| 연동 지점 | 동의·철회 시점에 다른 확장이 반응할 수 있는 확장점 제공 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
R[회원가입 폼] -->|동의 항목 주입| P[이 플러그인]
M[마이페이지] -->|동의/철회| P
A[관리자 회원 화면] -->|조회·변경| P
P --> S[(현재 동의 상태)]
P --> H[(동의 이력)]
P -.동의/철회 알림.-> X[발송·연동 확장]
```
이 플러그인은 회원 화면들을 고치지 않고 **그 화면에 항목만 얹습니다.** 어느 경로로 동의가
바뀌든 현재 상태와 이력이 함께 기록되고, 그 변화를 다른 확장이 받아 수신 목록에 반영할 수
있습니다.
수신 채널을 새로 추가하는 것은 **설정 변경만으로** 끝납니다. 프로그램을 다시 배포하거나
데이터베이스를 고칠 필요가 없습니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.0` |
| PHP | `^8.2` |
| 의존 모듈 | `sirsoft-page` `>=1.0.0` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```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
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `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#설정-스키마) 를 보세요.
<!-- @generated:settings-summary END -->
<!-- @intent START -->
설정은 관리자의 플러그인 목록에서 이 플러그인의 설정으로 들어가 조정합니다.
| 항목 | 언제 바꾸는가 | 바꾸면 달라지는 것 |
|---|---|---|
| 마케팅 동의 사용 | 마케팅 수신 동의를 받지 않을 때 | 끄면 가입 폼·마이페이지에서 마케팅 동의 항목이 사라집니다 |
| 마케팅 동의 약관 페이지 | 약관 문서를 만든 뒤 | 동의 항목 옆 "내용 보기" 가 가리키는 페이지 (기본 `marketing-terms`) |
| 채널 목록 | 수신 수단을 늘리거나 줄일 때 | 이메일·SMS 등 개별 수신 채널. 여기서 추가하면 즉시 가입 폼과 마이페이지에 나타납니다 |
| 제3자 제공 동의 사용 / 약관 페이지 | 개인정보를 제휴사에 제공할 때 | 해당 동의 항목의 노출 여부와 약관 링크 |
| 정보 공개 동의 사용 / 약관 페이지 | 회원 정보를 공개 영역에 노출할 때 | 해당 동의 항목의 노출 여부와 약관 링크 |
약관 페이지는 **페이지 모듈에서 만든 문서**를 가리킵니다. 슬러그만 지정하면 되고, 문서가
없으면 링크가 열리지 않으므로 약관을 먼저 작성합니다.
채널을 **사용중지**로 바꾸면 새 가입자에게는 보이지 않지만, 이미 동의한 회원의 기록은
남습니다 — 나중에 다시 켰을 때 그 회원이 다시 동의할 필요가 없도록 하기 위해서입니다.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**도입**: 페이지 모듈로 마케팅 수신 동의 약관 문서를 먼저 만듭니다(예: 슬러그
`marketing-terms`). 그다음 이 플러그인의 설정에서 약관 페이지 슬러그를 지정하고, 수신 채널을
필요한 만큼 추가합니다. 저장하면 회원가입 폼과 마이페이지에 동의 항목이 바로 나타납니다.
**수신 채널 늘리기**: 예를 들어 이메일만 받다가 카카오 알림톡을 추가하려면, 설정의 채널
목록에 항목을 하나 더하고 표시할 이름과 약관 페이지를 지정합니다. 기존 회원은 새 항목에
대해 미동의 상태로 시작하며, 마이페이지에서 개별적으로 동의할 수 있습니다.
**동의 현황 확인**: 관리자의 회원 상세 화면에서 그 회원의 항목별 동의 상태를 볼 수 있습니다.
운영자가 대신 변경할 수도 있지만, 그 변경도 이력에 "관리자에 의한 변경" 으로 남습니다.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-page` | 모듈 | `>=1.0.0` | ✅ |
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:integrations END -->
## 문서
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
## 트러블슈팅
<!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 가입 폼에 동의 항목이 보이지 않음 | 해당 동의 항목이 꺼져 있거나 채널이 하나도 없음 | 설정에서 사용 여부를 확인하고 채널을 하나 이상 추가합니다 |
| 동의 항목의 "내용 보기" 를 눌러도 약관이 열리지 않음 | 지정한 슬러그의 페이지가 없거나 미발행 | 페이지 모듈에서 그 슬러그의 문서를 만들고 발행합니다 |
| 템플릿을 바꾸거나 업데이트한 뒤 동의 항목이 사라짐 | 새 화면에 항목이 들어갈 자리가 없음 | 해당 템플릿이 회원 화면의 확장 자리를 제공하는지 확인합니다 |
| 채널을 지웠는데 이미 동의한 회원 기록이 남아 있음 | 기록은 의도적으로 보존됨 | 정상 동작입니다. 채널을 다시 켜면 그 회원은 다시 동의할 필요가 없습니다 |
| 동의했는데 마케팅 메일이 오지 않음 | 이 플러그인은 동의만 관리하고 발송은 하지 않음 | 발송을 담당하는 확장의 설정과 발송 대상 조건을 확인합니다 |
| 회원을 지웠는데 동의 이력이 남아 있는지 확인하고 싶음 | 회원 삭제 시 함께 정리됨 | 정상 동작입니다. 삭제된 회원의 동의 기록은 삭제 흐름에서 함께 정리됩니다 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -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/", "./"]
@@ -0,0 +1,22 @@
# 마케팅 동의 개발자 문서
> plugins/_bundled/sirsoft-marketing · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 4 · **구독 훅 수**: 11 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 3 · **레이아웃 수**: 1 · **핸들러 수**: 0
<!-- @generated:stats END -->
## 문서 목차
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 |
|---|---|
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [api/](api/README.md) | API 레퍼런스 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,86 @@
# 마케팅 동의 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
"동의 항목은 운영자가 늘린다" 는 전제 하나가 이 플러그인의 구조를 결정했습니다.
- **EAV 구조.** 동의 항목마다 컬럼을 만들면 채널을 하나 늘릴 때마다 마이그레이션과 배포가
필요합니다. `user_marketing_consents` 에 `consent_key` 별 행을 두어, 채널 추가가 **설정
변경만으로** 끝나게 했습니다. 그 대가로 "회원의 이메일 동의 여부"를 SQL 한 줄로 얻기가
덜 직관적이지만, 항목이 데이터인 이상 그 편이 맞습니다.
- **자기 화면을 갖지 않는다.** 관리자 설정 화면 하나를 빼면 UI 는 전부 다른 화면에 끼워 넣는
조각 5개입니다. 코어 회원 화면을 고치지 않고 필드를 더하려면 이 방법뿐입니다.
- **코어에 훅으로만 붙는다.** 구독 11종이 전부 코어 회원·가입 흐름이며, 코어 `User` 모델이나
회원 컨트롤러는 한 줄도 건드리지 않습니다. 이 플러그인을 비활성화하면 동의 항목이 화면과
응답에서 함께 사라집니다.
- **상태와 이력을 분리한다.** `user_marketing_consents` 는 "지금 어떤가", `user_marketing_
consent_histories` 는 "어떻게 여기까지 왔는가" 입니다. 동의 여부만 남기면 나중에 "동의를
받았다" 는 사실을 증명할 수 없습니다.
**의도적으로 하지 않는 것**: 실제 발송·권한 선언·관리자 메뉴·프론트 액션 핸들러. 동의 관리와
발송이 한 확장에 묶이면 발송 수단을 바꿀 때 동의 이력까지 흔들립니다. 발송 확장은
`user.subscribed`/`user.unsubscribed` 를 구독해 자기 수신 목록을 관리합니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
[주입] 레이아웃 조각 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개 훅을 전부 받는 구조는 의도적입니다. 훅마다 리스너를
나누면 "회원 도메인에 필드를 더하려면 어디를 봐야 하는가" 의 답이 흩어집니다.
<!-- @intent END -->
## 디렉토리
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/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 팩 동기화 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,111 @@
# 마케팅 동의 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
| `MarketingConsent` | `user_marketing_consents` | 7 | user→User | - |
| `MarketingConsentHistory` | `user_marketing_consent_histories` | 5 | user→User | - |
<!-- @generated:models END -->
<!-- @intent START -->
두 모델의 역할이 **상태와 이력**으로 갈립니다.
- **`MarketingConsent`** — "지금 어떤가". 회원 × 동의 항목(`consent_key`) 하나가 한 행이며,
현재 동의 여부(`is_consented`) · 동의/철회 시각 · 누적 동의 횟수(`consent_count`) · 마지막
변경 출처(`last_source`)를 갖습니다. **EAV 구조**이므로 항목이 늘어도 스키마는 그대로입니다.
- **`MarketingConsentHistory`** — "어떻게 여기까지 왔는가". 변경 한 건이 한 행이며 행위
(`action`) · 출처(`source`) · IP(`ip_address`)를 남깁니다.
둘을 나눈 이유는 조회 성질이 다르기 때문입니다. 현재 상태는 화면을 그릴 때마다 읽히므로 회원당
항목 수만큼만 있어야 하고, 이력은 계속 쌓이지만 평소에는 읽히지 않습니다. 한 테이블에 두면
"현재 상태" 조회가 이력 전체를 훑게 됩니다.
`consent_count` 는 이력에서 세도 되는 값이지만 상태에 함께 둡니다 — 이 값을 보려고 이력
테이블을 조회하게 하면 화면 조회가 이력 크기에 묶입니다. 대신 **상태와 이력을 같은 트랜잭션에서
갱신**해야 둘이 어긋나지 않습니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 테이블 | 모델 |
|---|---|
| `user_marketing_consent_histories` | `MarketingConsentHistory` |
| `user_marketing_consents` | `MarketingConsent` |
<!-- @generated:tables END -->
<!-- @intent START -->
두 테이블 모두 `user_` 로 시작합니다 — 이 플러그인의 데이터가 회원에 종속된다는 뜻이며,
회원이 사라지면 함께 사라져야 합니다.
그 정리는 **DB CASCADE 가 아니라 `core.user.before_delete` 훅**이 합니다. 코어 회원 삭제는
이 플러그인의 테이블을 알지 못하므로, 이 구독이 빠지면 고아 행이 조용히 쌓입니다. 반대로
CASCADE 로 처리하면 훅 발행과 이력 처리가 통째로 건너뛰어집니다.
이력 테이블에는 인덱스 추가 마이그레이션이 따로 있습니다(`2026_04_01_000003`). 이력은 계속
쌓이는 테이블이라 회원별·채널별 조회가 인덱스를 타야 합니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
마이그레이션 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` | ✅ |
<!-- @generated:migrations END -->
<!-- @intent START -->
3개입니다 — 상태 테이블 · 이력 테이블 · 이력 인덱스.
**항목이 늘어도 마이그레이션이 필요 없는 것**이 이 설계의 목표입니다. 채널을 추가하려는데
마이그레이션을 쓰고 있다면 EAV 구조를 벗어나고 있다는 신호이므로, 그 변경을 설정으로 표현할
수 없는지 먼저 검토합니다.
새 컬럼을 더할 때 초기 `create_*` 파일을 고치지 않습니다 — 이미 설치된 사이트는 그 파일을
다시 실행하지 않으므로 반영되지 않으며, 기존 행을 손봐야 하는 변경은 `upgrades/` 의 업그레이드
스텝 백필이 함께 필요합니다. 한국어 `comment` 와 `down()` 은 필수입니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
없습니다. 동의는 참/거짓 하나이고 항목 목록은 **설정 데이터**라 코드의 닫힌 어휘가 아닙니다 —
Enum 으로 만들면 채널 추가가 다시 배포 작업이 됩니다.
닫힌 어휘가 하나 있긴 합니다: 이력의 `source`(`admin` / `profile`)와 `action`. 이 값들은
`detectSource()` 와 서비스가 문자열로 다루는데, 새 변경 경로가 늘어 분기가 생기기 시작하면
그때 Enum 으로 올리는 것이 맞습니다. 지금은 판정 지점이 한 곳뿐이라 어휘가 갈라질 여지가
없습니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `MarketingConsentRepository` | 구현 | 마케팅 동의 Repository 구현체 |
| `MarketingConsentRepositoryInterface` | 인터페이스 | 마케팅 동의 Repository 인터페이스 |
<!-- @generated:repositories END -->
<!-- @intent START -->
`MarketingConsentRepository` 하나이며 인터페이스를 통해 주입됩니다(구체 클래스 타입힌트 금지).
상태와 이력을 **한 Repository 가 함께** 다룹니다. 둘이 같은 트랜잭션에서 갱신되어야 하는데
Repository 를 나누면 그 원자성을 호출부가 조립하게 되고, 조립을 빠뜨린 경로에서 상태만 바뀌고
이력이 없는 행이 생깁니다.
회원 삭제 정리(`deleteByUserId`)도 여기 있습니다. 이 메서드는 `core.user.before_delete` 에서만
호출되며, 두 테이블을 함께 지웁니다.
<!-- @intent END -->
@@ -0,0 +1,183 @@
# 마케팅 동의 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 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)로 변경될 때 실행되는 액션 훅 | 선언 (호출 위치 미확인) |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
4종 전부 **동의 상태 변화를 바깥에 알리는** 용도입니다. 이 플러그인은 동의를 관리할 뿐 발송을
하지 않으므로, 실제 수신 목록 반영은 이 훅을 구독하는 쪽이 합니다.
| 훅 | 언제 쓰는가 |
|---|---|
| `user.consent_changed` | 동의 상태가 바뀔 때마다. 외부 마케팅 도구와 동기화하는 지점 |
| `user.subscribed` | 미동의 → 동의 전이. 수신 목록에 **추가**하는 자리 |
| `user.unsubscribed` | 동의 → 철회 전이. 수신 목록에서 **제거**하는 자리 |
| `filter_consent_data` | 동의 데이터를 다른 확장이 가공해야 할 때 |
`subscribed`/`unsubscribed` 가 `consent_changed` 와 별도로 있는 이유는, 대부분의 소비자가
"바뀌었다" 가 아니라 "켜졌다/꺼졌다" 에 따라 **다른 동작**을 하기 때문입니다. 한 훅에서
전후 값을 비교하게 하면 그 비교 코드가 소비자마다 복제됩니다.
발행 위치가 "선언(호출 위치 미확인)" 인 셋은 훅 이름이 상수·변수로 조립되어 정적 수집에
잡히지 않은 것입니다 — 선언에는 있으므로 실제로 발행됩니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `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 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
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` 구독이 빠지면 회원을 지워도 동의 행이 남습니다. 코어 회원 삭제는 이 플러그인의
테이블을 알지 못하므로 아무 오류도 나지 않고, 고아 행만 조용히 쌓입니다.
코어 회원·가입 흐름의 훅 이름이나 페이로드가 바뀌면 이 플러그인이 예외 없이 조용히 끊깁니다 —
증상은 "가입 폼에서 동의를 체크했는데 저장되지 않는다" 로만 나타납니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `MarketingConsentListener` | 11개 | 명시 등록 | ✅ | `src/Listeners/MarketingConsentListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
`MarketingConsentListener` 하나가 11개 훅을 전부 받습니다. 훅마다 리스너를 나누지 않은 것은
의도적입니다 — 나누면 "회원 도메인에 필드를 더하려면 어디를 봐야 하는가" 의 답이 흩어집니다.
리스너는 판정과 기록을 직접 하지 않고 `MarketingConsentService` 에 위임합니다. 데이터 접근은
Repository 인터페이스 주입으로만 하며, `Model::query()` · `DB::table()` · `$row->save()` 를
직접 부르지 않습니다.
`detectSource()` 가 현재 라우트로 출처(`admin` / `profile`)를 판정해 이력에 남깁니다. 새 변경
경로(예: 일괄 처리·외부 연동)를 추가하면 그 출처도 여기서 구분해야 합니다 — 모든 변경이
`profile` 로 기록되면 이력이 "누가 바꿨는가" 를 답하지 못합니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `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` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
조각 5개가 이 플러그인의 **UI 전부**입니다. 관리자 설정 화면 하나를 빼면 자기 레이아웃이
없습니다.
| 조각 | 들어가는 자리 |
|---|---|
| `user-marketing-register.json` | 회원가입 폼 |
| `user-marketing-form.json` | 관리자 회원 수정 폼 |
| `user-marketing-detail.json` | 관리자 회원 상세 |
| `user-marketing-profile.json` · `user-marketing-profile-view.json` | 마이페이지 (수정·보기) |
대상 화면을 소유한 쪽(템플릿·코어)이 그 자리(슬롯)를 없애면 조각은 **오류 없이 사라집니다.**
증상은 "가입 폼에 동의 항목이 안 보인다" 뿐이고 로그에는 아무것도 남지 않으므로, 템플릿이나
코어 회원 화면을 업그레이드한 뒤에는 다섯 자리를 눈으로 확인합니다.
마이페이지 조각은 **동의 이력이 있는 회원에게 항상 노출**되어야 합니다. 동의를 받은 경로와
철회 경로가 대칭이 아니면 그 동의는 법적 근거로 쓸 수 없습니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
없습니다. 이 플러그인은 요청 흐름에 개입하지 않고 코어 회원 흐름의 훅 지점에서만 동작합니다.
관리자 채널 저장 라우트는 미들웨어 대신 코어 권한 미들웨어
(`permission:admin,core.plugins.update`)를 직접 지정합니다 — 이 플러그인이 자기 권한을
선언하지 않고 "플러그인 설정을 고칠 수 있는 사람" 이라는 코어 권한에 얹는 방식입니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
없습니다. 동의 변경은 그 회원 자신의 조작이므로 다른 접속자에게 실시간으로 알릴 사건이
없습니다.
외부 마케팅 도구와의 실시간 동기화가 필요하면 `user.consent_changed` 를 구독해 그 확장에서
자기 채널이나 외부 API 호출로 처리합니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
없습니다. 동의는 회원의 조작으로만 바뀌므로 주기적으로 훑을 대상이 없습니다.
동의 만료(예: "2년마다 재동의")가 필요해지면 스케줄이 생길 자리입니다. 그때도 만료 판정은
`consented_at` 과 설정값으로 하고, 만료 처리 자체는 일반 철회와 같은 경로(상태 갱신 + 이력
적재 + `user.unsubscribed` 발행)를 타야 합니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
없습니다. 이 플러그인은 동의를 관리할 뿐 발송을 하지 않으므로, 자기 이름으로 보낼 알림이
없습니다.
"마케팅 수신 동의 처리 완료" 같은 확인 메일이 필요하면 `user.subscribed` 를 구독하는 확장이
코어 `GenericNotification` 으로 보냅니다. 동의 관리와 발송을 한 확장에 묶으면 발송 수단을
바꿀 때 동의 이력까지 함께 흔들립니다.
<!-- @intent END -->
@@ -0,0 +1,84 @@
# 마케팅 동의 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 1개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 1개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `plugin_settings` | `admin` | 화면 | `_admin_base` |
<!-- @generated:layouts END -->
<!-- @intent START -->
관리자 설정 화면(`plugin_settings`) 하나뿐입니다. **이 플러그인의 실제 UI 는 레이아웃이 아니라
확장 조각 5개**이며, 그것들은 다른 화면 안에 들어갑니다.
`plugin_settings.json` 은 **파일 이름이 계약**입니다. 코어가 플러그인 디렉토리의 이 고정 경로를
찾아 설정 화면을 그리므로, 이름을 바꾸면 설정 화면 자체가 사라집니다.
레이아웃 JSON 만 고쳤다면 빌드는 필요 없고 `php artisan plugin:update sirsoft-marketing --force`
로 반영합니다. 새로 쓴 Tailwind 클래스가 빌드된 CSS 에 없으면 그 스타일만 조용히 빠지므로,
기존 레이아웃에 없던 클래스를 도입할 때는 확인이 필요합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 액션 핸들러가 없습니다._
<!-- @generated:handlers END -->
<!-- @intent START -->
없습니다. 동의 항목은 일반 체크박스이고 저장은 코어 회원 API 를 타므로, 자체 핸들러가
필요하지 않습니다.
체크박스를 다룰 때 주의할 점이 하나 있습니다 — 저장값이 `null` 일 수 있는 체크박스는
`name` 자동바인딩만으로 묶으면 값이 `null` 로 고착됩니다. 조각에서 동의 체크박스를 손볼 때는
`autoBinding: false` + `checked` 표현식 + `change` 액션 형태를 유지합니다.
핸들러를 처음 추가한다면 전역 진입점(`window.__SirsoftMarketing.initPlugin()`)과 빌드 산출물
(`dist/`)이 함께 필요합니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_프론트 엔트리포인트가 없습니다._
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
없습니다 — 등록할 액션 핸들러가 없기 때문입니다.
핸들러를 도입하는 순간 이 진입점이 **필수**가 됩니다. 코어는 로케일 전환 시 확장의 재등록
진입점을 다시 부르는데, 그 함수가 없거나 이름이 다르면 전환 직후 그 확장의 액션이 전부
무반응이 되고 오류도 토스트도 남지 않습니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅
작업을 포함하지 않습니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
JS·CSS 산출물이 없고 `editor-spec.json` 하나만 있습니다 — 레이아웃 편집기가 이 플러그인의
화면을 편집할 때 쓰는 팔레트·중첩 규칙 선언이며 실행 코드가 아닙니다. 이 플러그인의 프론트엔드는
전부 **선언형 JSON**(레이아웃 조각 5개 + 설정 화면 1개 + 편집기 스펙)입니다.
그래서 빌드 단계가 없고, 변경 반영은 `php artisan plugin:update sirsoft-marketing --force`
하나로 끝납니다. 나중에 JS 를 더하면 그때 빌드·`dist/` 커밋·전역 진입점 셋이 함께 필요해집니다.
구동에 필요한 제3자 자산은 외부 CDN 에서 받지 않고 확장이 동봉합니다 — CDN 도달 실패는 예외도
서버 로그도 남기지 않고 화면 기능만 조용히 사라지기 때문입니다.
<!-- @intent END -->
@@ -0,0 +1,130 @@
# 마케팅 동의 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `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`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
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()`(전체)가 나뉘어 있는 이유입니다. 다시
켰을 때 그 회원이 재동의할 필요가 없도록 하기 위한 것입니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_선언된 권한이 없습니다._
<!-- @generated:permissions END -->
<!-- @intent START -->
선언하지 않습니다. 이 플러그인의 데이터는 **회원 자신의 것**이라 회원 권한 체계에 얹히고,
운영자 조작은 코어 회원 권한이 이미 관장합니다.
관리자 채널 저장 라우트만 코어 권한(`permission:admin,core.plugins.update`)을 직접 지정합니다 —
"플러그인 설정을 고칠 수 있는 사람" 이라는 기존 권한에 얹는 방식입니다.
자기 권한을 새로 만들지 않은 것은 의도입니다. 권한을 늘리면 운영자가 역할마다 그 권한을
배정해야 하는데, "마케팅 동의만 따로 관리하는 담당자" 라는 역할 구분이 실제로 필요해지기
전까지는 그 부담이 이득보다 큽니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 메뉴가 없습니다._
<!-- @generated:menus END -->
<!-- @intent START -->
등록하지 않습니다. 이 플러그인은 자기 관리 화면을 갖지 않고, 동의 상태는 **회원 관리 화면
안에서** 조각으로 보입니다.
설정은 코어의 플러그인 목록에서 이 플러그인의 설정으로 들어가는 공통 경로를 씁니다 — 코어가
`resources/layouts/admin/plugin_settings.json` 을 찾아 그리므로 자체 메뉴가 필요 없습니다.
동의 현황을 회원과 분리해 따로 보는 화면(예: 채널별 동의자 목록)이 필요해지면 그때 메뉴가
생길 자리입니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-marketing/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
2개뿐입니다.
| 경로 | 용도 |
|---|---|
| `GET /settings` | 프론트가 동의 항목 구성(활성 채널·약관 slug·사용 여부)을 조회 |
| `PUT admin/channels` | 운영자가 채널 목록을 저장 (`permission:admin,core.plugins.update`) |
**동의 값 자체를 읽고 쓰는 엔드포인트가 없습니다.** 그 일은 코어 회원 API 를 타고 이루어지며,
이 플러그인은 `core.user.filter_update_data` / `filter_resource_data` 훅으로 그 흐름에
끼어듭니다. 그래서 이 플러그인을 비활성화하면 회원 API 응답에서 동의 필드가 함께 사라집니다.
라우트를 바꾼 뒤에는 라우트 캐시를 다시 굽습니다. 확장 라우트는 활성 상태인 확장의 것만
등록되고, 캐시에 없는 라우트는 예외도 경고도 없이 404 가 됩니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-page` | 모듈 | `>=1.0.0` | ✅ |
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
`sirsoft-page` 모듈에 의존합니다(`>=1.0.0`). 동의 항목의 **약관 문서**가 페이지 모듈의
콘텐츠이기 때문입니다.
manifest 의존으로 올린 것은 약관 링크가 이 플러그인의 기능 일부가 아니라 **법적 요건**이기
때문입니다 — 회원이 동의 내용을 확인할 수 없으면 그 동의는 유효하지 않습니다. 훅 구독처럼
"없으면 그 기능만 비는" 관계가 아니라, 없으면 이 플러그인의 존재 이유가 성립하지 않습니다.
이 플러그인에 의존하는 확장은 없습니다. 다만 발행 훅
(`user.subscribed`/`unsubscribed`/`consent_changed`)을 구독해 수신 목록을 관리하는 확장이
생기면, 그 확장은 이 훅 이름에 묶입니다 — 훅 이름·페이로드를 바꿀 때는 구독 확장을 전수
확인하고 최소 버전 상향을 검토합니다.
<!-- @intent END -->
+2 -2
View File
@@ -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",
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-marketing",
"version": "1.0.3",
"version": "1.0.4",
"description": "G7 마케팅 동의 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",

Some files were not shown because too many files have changed in this diff Show More