docs(core,extensions): 확장 개발자 문서 9세트 집필과 생성기 정합 보강

S1 파일럿 3종(sirsoft-board · sirsoft-gdpr · sirsoft-admin_basic)에 이어
결제·본인인증 동형군 6종의 AGENTS.md · README.md · docs/ 5문서를 집필했다.
확장을 고치려는 쪽이 매번 src/ 를 훑어 구조를 재발견하지 않도록, 설계 의도와
확장점(발행·구독 훅)·수정 시 동반 의무·금지 패턴을 코드 근거로 서술했다.

생성기(ExtensionDocScaffolder)에서 표를 무의미하게 만들던 세 결함을 함께 고쳤다.
getLayoutExtensions 기본 구현이 돌려주는 절대경로가 파일 목록과 중복돼 로컬
머신 경로가 커밋 문서에 실리던 문제, getNotificationDefinitions 를 'key'/'event'
로 읽어 모든 행이 '-' 로 찍히던 문제, getSettingsLayout 절대경로가 정규화 없이
노출되던 문제다. 셋 다 예외를 남기지 않고 표만 조용히 망가뜨리므로
ExtensionDocContractTest 에 각각의 되돌림 red 를 확인한 단언을 두었다.
템플릿의 extensions/{id}/ 는 모듈·플러그인의 발행과 반대 방향(오버라이드)이라
별도 블록(template-overrides)으로 분리했다.

sirsoft-admin_basic 의 컴포넌트·핸들러·레이아웃 문서를 코어 docs/ 에서 그 템플릿
소유로 이관하고, 남은 참조 6축을 재는 가드를 추가했다. 이관 후 남은 옛 경로는
오류가 아니라 헛걸음으로만 나타나 드러나지 않는다. 의 상대 링크가
한 단계 얕아 공유 docs/ 대신 를 가리키던 문제도 함께 고쳤다.

README 상단의 확장명 이미지 배지를 평문 H1 로 바꿨다( 지시 2026-08-31).
루트 README.md · README.ko.md 도 같은 기준을 적용했다. 정보 배지는 유지한다.

sirsoft-gdpr: 회원탈퇴로 자동 철회된 동의가 관리자 동의 이력 화면의 출처 필터로
걸러지지 않던 문제를 고쳤다. Repository 가 'withdraw' 리터럴을 직접 UPDATE 에
싣는데 그 값이 ConsentSource enum 에 없어, 화면 필터 옵션·라벨 어느 쪽에도
도달하지 못했다. 어휘를 enum 단일 출처로 모으고 ko·en·ja 라벨과 필터 옵션을
함께 채웠으며, 어휘 대조 테스트의 모집단에 Repository 를 편입했다.
This commit is contained in:
HeuJung
2026-08-31 15:57:44 +09:00
parent 11175d35d6
commit 8328b1db77
113 changed files with 12002 additions and 1554 deletions
+18 -4
View File
@@ -47,7 +47,7 @@
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... | | [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) | | [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
### 프론트엔드 [frontend/](docs/frontend/) (50개) ### 프론트엔드 [frontend/](docs/frontend/) (47개)
| 문서 | 설명 | TL;DR 핵심 | | 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------| |------|------|-----------|
@@ -95,9 +95,6 @@
| [tailwind-safelist.md](docs/frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | Tailwind는 빌드 시 사용된 클래스만 CSS에 포함 | | [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-development.md](docs/frontend/template-development.md) | 템플릿 개발 가이드라인 | 디렉토리: templates/[vendor-template]/ (예: sirsoft-admin_basic) |
| [template-handlers.md](docs/frontend/template-handlers.md) | 템플릿 전용 핸들러 | setLocale: 앱 언어 변경 — 엔진 빌트인 (ActionDispatcher) | | [template-handlers.md](docs/frontend/template-handlers.md) | 템플릿 전용 핸들러 | setLocale: 앱 언어 변경 — 엔진 빌트인 (ActionDispatcher) |
| [components.md](docs/frontend/templates/sirsoft-admin_basic/components.md) | sirsoft-admin_basic 컴포넌트 | Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
| [handlers.md](docs/frontend/templates/sirsoft-admin_basic/handlers.md) | sirsoft-admin_basic 핸들러 | setLocale: 앱 언어 변경 (locale 파라미터) |
| [layouts.md](docs/frontend/templates/sirsoft-admin_basic/layouts.md) | sirsoft-admin_basic 레이아웃 | 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯) |
| [components.md](docs/frontend/templates/sirsoft-basic/components.md) | sirsoft-basic 컴포넌트 | Basic 26개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... | | [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과 동일 키 공유) | | [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 (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) | | [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
@@ -180,6 +177,23 @@
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 | | `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
### 확장 개발자 문서 (9개 확장, 자동 스캔)
> 확장을 수정하기 전에 읽는 문서. 설계 의도 · 디렉토리 지도 · 확장점(발행/구독 훅) · 수정 시 동반 의무 · 금지 패턴을 담는다. `php artisan ext:docgen` 이 실측 부분을 유지하며, 이 표는 `{modules,plugins,templates}/_bundled/*/docs/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
| 확장 | 유형 | 에이전트 가이드 | 문서 목차 | 실측 집계 |
|------|------|----------------|----------|----------|
| `sirsoft-board` | 모듈 | [AGENTS.md](modules/_bundled/sirsoft-board/AGENTS.md) | [docs/](modules/_bundled/sirsoft-board/docs/README.md) | 훅 90 · 라우트 80 · 모델 9 · 레이아웃 46 |
| `sirsoft-gdpr` | 플러그인 | [AGENTS.md](plugins/_bundled/sirsoft-gdpr/AGENTS.md) | [docs/](plugins/_bundled/sirsoft-gdpr/docs/README.md) | 훅 2 · 라우트 15 · 모델 3 · 레이아웃 4 |
| `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 |
| `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 |
<!-- AUTO-GENERATED-END: docs-quick-reference --> <!-- AUTO-GENERATED-END: docs-quick-reference -->
--- ---
+3 -7
View File
@@ -1,13 +1,9 @@
<p align="center"><a href="README.md">English</a> | 한국어</p> <p align="center"><a href="README.md">English</a> | 한국어</p>
<p align="center"> # 그누보드7
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
</p>
<p align="center"> **모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS**
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br> A modern, extensible CMS platform built with Laravel + React
A modern, extensible CMS platform built with Laravel + React
</p>
<p align="center"> <p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a> <a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
+3 -7
View File
@@ -1,13 +1,9 @@
<p align="center">English | <a href="README.ko.md">한국어</a></p> <p align="center">English | <a href="README.ko.md">한국어</a></p>
<p align="center"> # Gnuboard7
<img src="https://img.shields.io/badge/Gnuboard7-그누보드7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="Gnuboard7 (그누보드7)">
</p>
<p align="center"> **A modern, extensible CMS platform built with Laravel + React**
<strong>A modern, extensible CMS platform built with Laravel + React</strong><br> The next generation of Gnuboard — Korea's most widely used open-source CMS
The next generation of Gnuboard — Korea's most widely used open-source CMS
</p>
<p align="center"> <p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a> <a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
@@ -149,6 +149,11 @@ class ExtensionDocScaffolder
'blocks' => [ 'blocks' => [
'레이아웃 목록' => 'layouts', '레이아웃 목록' => 'layouts',
'라우트 매핑' => 'layout-map', '라우트 매핑' => 'layout-map',
// 템플릿의 `extensions/{module-identifier}/*.json` 은 그 모듈/플러그인이
// 발행한 레이아웃 확장 조각을 이 템플릿이 오버라이드한 것이다(모듈/플러그인
// 쪽 `resources/extensions/` 와는 반대 방향 — 발행이 아니라 대체). 모듈/플러그인
// 문서의 `docs/extension-points.md` 는 템플릿에 존재하지 않아 자리가 없었다.
'확장 오버라이드' => 'template-overrides',
], ],
], ],
'docs/handlers.md' => [ 'docs/handlers.md' => [
@@ -538,6 +543,7 @@ class ExtensionDocScaffolder
'hooks-subscribed' => $this->renderHooksSubscribed($ctx), 'hooks-subscribed' => $this->renderHooksSubscribed($ctx),
'listeners' => $this->renderListeners($ctx), 'listeners' => $this->renderListeners($ctx),
'layout-extensions' => $this->renderLayoutExtensions($ctx), 'layout-extensions' => $this->renderLayoutExtensions($ctx),
'template-overrides' => $this->renderLayoutExtensions($ctx),
'middleware' => $this->renderMiddleware($ctx), 'middleware' => $this->renderMiddleware($ctx),
'channels' => $this->renderChannels($ctx), 'channels' => $this->renderChannels($ctx),
'schedules' => $this->renderSchedules($ctx), 'schedules' => $this->renderSchedules($ctx),
@@ -963,6 +969,7 @@ class ExtensionDocScaffolder
['제공 컴포넌트', (string) $frontend['components']['total'].'개', $this->docLink($ctx, 'docs/components.md', '제공 컴포넌트')], ['제공 컴포넌트', (string) $frontend['components']['total'].'개', $this->docLink($ctx, 'docs/components.md', '제공 컴포넌트')],
['레이아웃', (string) count($frontend['layouts']).'개', $this->docLink($ctx, 'docs/layouts.md', '레이아웃 목록')], ['레이아웃', (string) count($frontend['layouts']).'개', $this->docLink($ctx, 'docs/layouts.md', '레이아웃 목록')],
['전용 핸들러', (string) count($frontend['handlers']['names']).'개', $this->docLink($ctx, 'docs/handlers.md', '템플릿 전용 핸들러')], ['전용 핸들러', (string) count($frontend['handlers']['names']).'개', $this->docLink($ctx, 'docs/handlers.md', '템플릿 전용 핸들러')],
['확장 오버라이드', (string) count($frontend['layoutExtensions']).'개', $this->docLink($ctx, 'docs/layouts.md', '확장 오버라이드')],
]; ];
} }
@@ -1250,23 +1257,37 @@ class ExtensionDocScaffolder
{ {
$files = $ctx['frontend']['layoutExtensions']; $files = $ctx['frontend']['layoutExtensions'];
$declared = $ctx['surface']['values']['getLayoutExtensions'] ?? []; $declared = $ctx['surface']['values']['getLayoutExtensions'] ?? [];
// 템플릿의 `extensions/{module-identifier}/*.json` 은 그 모듈/플러그인이 발행한
// 조각을 이 템플릿이 대체하는 것이지, 모듈/플러그인처럼 밖으로 발행하는 것이 아니다.
$isTemplate = ($ctx['record']['type'] ?? null) === ExtensionInventory::TYPE_TEMPLATE;
if ($files === [] && ! is_array($declared)) { if ($files === [] && ! is_array($declared)) {
return $this->none('레이아웃 확장이 없습니다.'); return $this->none($isTemplate ? '오버라이드하는 레이아웃 확장 조각이 없습니다.' : '레이아웃 확장이 없습니다.');
} }
if ($files === [] && $declared === []) { if ($files === [] && $declared === []) {
return $this->none('레이아웃 확장이 없습니다.'); return $this->none($isTemplate ? '오버라이드하는 레이아웃 확장 조각이 없습니다.' : '레이아웃 확장이 없습니다.');
} }
$known = array_flip($files);
$rows = []; $rows = [];
foreach ($files as $file) { foreach ($files as $file) {
$rows[] = [$this->code($file), '다른 확장/템플릿 레이아웃에 주입되는 조각']; $rows[] = [$this->code($file), $isTemplate ? '모듈/플러그인 확장 조각을 대체하는 오버라이드' : '다른 확장/템플릿 레이아웃에 주입되는 조각'];
} }
// `getLayoutExtensions()` 기본 구현(AbstractModule/AbstractPlugin)은 위 파일 목록과
// 같은 디렉토리를 glob() 한 절대경로라 100% 중복이다. 확장-상대 경로로 정규화한 뒤
// 이미 실린 파일은 건너뛰고, 확장이 오버라이드해 다른 대상을 선언한 경우만 싣는다.
if (is_array($declared)) { if (is_array($declared)) {
foreach ($declared as $key => $value) { foreach ($declared as $key => $value) {
$rows[] = [$this->code(is_string($key) ? $key : (string) $value), '`getLayoutExtensions()` 선언']; $raw = is_string($key) ? $key : (is_string($value) ? $value : (string) $value);
$rel = $this->relativeToExtension($ctx, $raw);
if (isset($known[$rel])) {
continue;
}
$known[$rel] = true;
$rows[] = [$this->code($rel), '`getLayoutExtensions()` 선언'];
} }
} }
@@ -1372,7 +1393,11 @@ class ExtensionDocScaffolder
$rows = []; $rows = [];
foreach ($definitions as $key => $definition) { foreach ($definitions as $key => $definition) {
$name = is_string($key) ? $key : (is_array($definition) ? ($definition['key'] ?? $definition['event'] ?? '-') : (string) $definition); // getNotificationDefinitions() 의 표준 계약(AbstractModule/AbstractPlugin 소비처인
// NotificationSyncHelper 기준)은 리스트 배열 + 각 원소의 'type' 키다. 'key'/'event'
// 는 어떤 확장도 쓰지 않아, 문자열 키가 아닌 한(list 배열이면 전부 정수 키) 이 표는
// 항상 '-' 만 찍고 있었다.
$name = is_string($key) ? $key : (is_array($definition) ? ($definition['type'] ?? $definition['key'] ?? $definition['event'] ?? '-') : (string) $definition);
$channels = is_array($definition) ? ($definition['channels'] ?? null) : null; $channels = is_array($definition) ? ($definition['channels'] ?? null) : null;
$rows[] = [ $rows[] = [
@@ -1590,7 +1615,7 @@ class ExtensionDocScaffolder
$extra[] = '기본값 파일: '.$this->code($this->relativeToExtension($ctx, $defaultsPath)); $extra[] = '기본값 파일: '.$this->code($this->relativeToExtension($ctx, $defaultsPath));
} }
if (is_string($layout) && $layout !== '') { if (is_string($layout) && $layout !== '') {
$extra[] = '설정 화면 레이아웃: '.$this->code($layout); $extra[] = '설정 화면 레이아웃: '.$this->code($this->relativeToExtension($ctx, $layout));
} }
if ($extra !== []) { if ($extra !== []) {
@@ -1624,7 +1649,7 @@ class ExtensionDocScaffolder
$layout = $ctx['surface']['values']['getSettingsLayout'] ?? null; $layout = $ctx['surface']['values']['getSettingsLayout'] ?? null;
if (is_string($layout) && $layout !== '') { if (is_string($layout) && $layout !== '') {
return '관리자 설정 화면이 있습니다 (레이아웃: '.$this->code($layout).'). 설정 항목은 화면에서 확인하세요.' return '관리자 설정 화면이 있습니다 (레이아웃: '.$this->code($this->relativeToExtension($ctx, $layout)).'). 설정 항목은 화면에서 확인하세요.'
.(is_string($route) && $route !== '' ? ' 경로: '.$this->code($route) : ''); .(is_string($route) && $route !== '' ? ' 경로: '.$this->code($route) : '');
} }
+6 -6
View File
@@ -10,7 +10,7 @@
| 카테고리 | 문서 수 | 링크 상태 | | 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------| |----------|---------|----------|
| [백엔드](backend/) | 37개 | 정상 | | [백엔드](backend/) | 37개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 | | [프론트엔드](frontend/) | 49개 | 정상 |
| [확장 시스템](extension/) | 32개 | 정상 | | [확장 시스템](extension/) | 32개 | 정상 |
| 공통 | 20개 | 정상 | | 공통 | 20개 | 정상 |
| [AI 도구](ai-tools/) | - | 정상 | | [AI 도구](ai-tools/) | - | 정상 |
@@ -43,7 +43,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| 4 | [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) | extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입) | | 4 | [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) | extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입) |
| 5 | [컴포넌트 개발 규칙](frontend/components.md) | HTML 태그 직접 사용 금지 | | 5 | [컴포넌트 개발 규칙](frontend/components.md) | HTML 태그 직접 사용 금지 |
| 6 | [컴포넌트 Props 레퍼런스](frontend/component-props.md) | - | | 6 | [컴포넌트 Props 레퍼런스](frontend/component-props.md) | - |
| 7 | [sirsoft-admin_basic 컴포넌트](frontend/templates/sirsoft-admin_basic/components.md) | Basic (37개), Composite (66개), Layout (8개) | | 7 | [sirsoft-basic 컴포넌트](frontend/templates/sirsoft-basic/components.md) | Basic (26개), Composite 등 |
| 8 | [데이터 바인딩 및 표현식](frontend/data-binding.md) | API 데이터: {{user.name}}, URL 파라미터: {{route.id}} | | 8 | [데이터 바인딩 및 표현식](frontend/data-binding.md) | API 데이터: {{user.name}}, URL 파라미터: {{route.id}} |
| 9 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - | | 9 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - |
| 10 | [액션 핸들러 가이드](frontend/actions.md) | 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션) | | 10 | [액션 핸들러 가이드](frontend/actions.md) | 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션) |
@@ -52,6 +52,8 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| 13 | [데이터 소스](frontend/data-sources.md) | data_sources 배열에 API 정의: id, endpoint, method | | 13 | [데이터 소스](frontend/data-sources.md) | data_sources 배열에 API 정의: id, endpoint, method |
| 14 | [다크 모드 지원](frontend/dark-mode.md) | Tailwind dark: variant 사용 | | 14 | [다크 모드 지원](frontend/dark-mode.md) | Tailwind dark: variant 사용 |
sirsoft-admin_basic 컴포넌트 문서는 확장이 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/components.md](../templates/_bundled/sirsoft-admin_basic/docs/components.md) 를 참고하세요.
### 컨트롤러 작성 ### 컨트롤러 작성
| 순서 | 문서 | TL;DR 핵심 | | 순서 | 문서 | TL;DR 핵심 |
@@ -167,7 +169,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | | [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) |
| [validation.md](backend/validation.md) | 검증 (Validation) | | [validation.md](backend/validation.md) | 검증 (Validation) |
### 프론트엔드 (51개) ### 프론트엔드 (49개)
| 문서 | 제목 | | 문서 | 제목 |
|------|------| |------|------|
@@ -216,9 +218,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [tailwind-safelist.md](frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | | [tailwind-safelist.md](frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 |
| [template-development.md](frontend/template-development.md) | 템플릿 개발 가이드라인 | | [template-development.md](frontend/template-development.md) | 템플릿 개발 가이드라인 |
| [template-handlers.md](frontend/template-handlers.md) | 템플릿 전용 핸들러 | | [template-handlers.md](frontend/template-handlers.md) | 템플릿 전용 핸들러 |
| [components.md](frontend/components.md) | sirsoft-admin_basic 컴포넌트 | | [README.md](frontend/README.md) | 템플릿별 컴포넌트·핸들러·레이아웃 문서 |
| [handlers.md](frontend/handlers.md) | sirsoft-admin_basic 핸들러 |
| [layouts.md](frontend/layouts.md) | sirsoft-admin_basic 레이아웃 |
| [components.md](frontend/components.md) | sirsoft-basic 컴포넌트 | | [components.md](frontend/components.md) | sirsoft-basic 컴포넌트 |
| [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 | | [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 |
| [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 | | [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 |
+1 -1
View File
@@ -173,7 +173,7 @@ global.window = {
- docs/frontend/template-development.md - docs/frontend/template-development.md
- docs/extension/template-basics.md - docs/extension/template-basics.md
- docs/extension/template-commands.md - docs/extension/template-commands.md
- docs/frontend/templates/sirsoft-admin_basic/components.md - templates/_bundled/sirsoft-admin_basic/docs/components.md
- docs/frontend/templates/sirsoft-basic/components.md - docs/frontend/templates/sirsoft-basic/components.md
## 테스트 실행 ## 테스트 실행
-1
View File
@@ -40,7 +40,6 @@
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 | | 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|--------------|---------|--------|--------| |--------------|---------|--------|--------|
| `sirsoft-admin_basic` | [components.md](templates/sirsoft-admin_basic/components.md) | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | [layouts.md](templates/sirsoft-admin_basic/layouts.md) |
| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) | | `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) |
### 컴포넌트 개발 ### 컴포넌트 개발
+2 -2
View File
@@ -1,6 +1,6 @@
# 컴포넌트 Props 레퍼런스 - Composite # 컴포넌트 Props 레퍼런스 - Composite
> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) > **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
--- ---
@@ -671,7 +671,7 @@ G7에서는 콘텐츠의 렌더링 모드를 DB의 `*_mode` 컬럼(`'text'` / `'
- [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) - [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md)
- [컴포넌트 개발 규칙](components.md) - [컴포넌트 개발 규칙](components.md)
- [컴포넌트 고급 기능](components-advanced.md) - [컴포넌트 고급 기능](components-advanced.md)
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
- [액션 핸들러 - 커스텀 콜백](actions.md#커스텀-이벤트-event-필드) - [액션 핸들러 - 커스텀 콜백](actions.md#커스텀-이벤트-event-필드)
- [보안 가이드 - HTML 렌더링](security.md#htmlcontent--htmleditor-html-렌더링이-필요한-경우) - [보안 가이드 - HTML 렌더링](security.md#htmlcontent--htmleditor-html-렌더링이-필요한-경우)
- [에디터 컴포넌트](editors.md) - [에디터 컴포넌트](editors.md)
+1 -1
View File
@@ -1144,7 +1144,7 @@ id prop 사용: scrollIntoView 등 DOM selector로 접근해야 할 때 필수
- [컴포넌트 개발 규칙](components.md) - basic, composite, layout 컴포넌트 - [컴포넌트 개발 규칙](components.md) - basic, composite, layout 컴포넌트
- [레이아웃 JSON 스키마](layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법 - [레이아웃 JSON 스키마](layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 (111개) - [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개) - [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개)
- [에디터 컴포넌트](editors.md) - HtmlEditor, CodeEditor 상세 가이드 - [에디터 컴포넌트](editors.md) - HtmlEditor, CodeEditor 상세 가이드
- [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어 - [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어
+2 -2
View File
@@ -1,7 +1,7 @@
# 컴포넌트 타입별 개발 규칙 # 컴포넌트 타입별 개발 규칙
> **메인 문서**: [components.md](components.md) > **메인 문서**: [components.md](components.md)
> **관련 문서**: [layout-json-components.md](layout-json-components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) > **관련 문서**: [layout-json-components.md](layout-json-components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
--- ---
@@ -452,5 +452,5 @@ export const Container: React.FC<ContainerProps> = ({ children }) => (
- [컴포넌트 개발 규칙 인덱스](components.md) - [컴포넌트 개발 규칙 인덱스](components.md)
- [컴포넌트 패턴](components-patterns.md) - [컴포넌트 패턴](components-patterns.md)
- [컴포넌트 고급 기능](components-advanced.md) - [컴포넌트 고급 기능](components-advanced.md)
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) - [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md)
+4 -2
View File
@@ -29,11 +29,13 @@
| 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 | | 템플릿 식별자 | 컴포넌트 | 핸들러 | 레이아웃 |
|--------------|---------|--------|--------| |--------------|---------|--------|--------|
| `sirsoft-admin_basic` | [components.md](templates/sirsoft-admin_basic/components.md) | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | [layouts.md](templates/sirsoft-admin_basic/layouts.md) |
| `sirsoft-basic` | [components.md](templates/sirsoft-basic/components.md) | [handlers.md](templates/sirsoft-basic/handlers.md) | [layouts.md](templates/sirsoft-basic/layouts.md) | | `sirsoft-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 --> <!-- AUTO-GENERATED-END: frontend-template-reference -->
> `sirsoft-admin_basic` 은 문서를 그 템플릿이 직접 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/](../../templates/_bundled/sirsoft-admin_basic/docs/README.md).
> 템플릿별 문서 위치는 [templates/README.md](templates/README.md) 를 따릅니다.
--- ---
## 목차 ## 목차
@@ -153,7 +155,7 @@ import { Icon, IconName } from '../basic/Icon';
- [g7core-api.md](g7core-api.md) - G7Core 전역 API 레퍼런스 - [g7core-api.md](g7core-api.md) - G7Core 전역 API 레퍼런스
- [레이아웃 JSON 스키마](./layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법 - [레이아웃 JSON 스키마](./layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법
- [데이터 바인딩](./data-binding.md) - props에서 데이터 바인딩 사용법 - [데이터 바인딩](./data-binding.md) - props에서 데이터 바인딩 사용법
- [sirsoft-admin_basic 컴포넌트](./templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 (111개) - [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
- [sirsoft-basic 컴포넌트](./templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개) - [sirsoft-basic 컴포넌트](./templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개)
- [다크 모드](./dark-mode.md) - 컴포넌트 다크 모드 지원 가이드 - [다크 모드](./dark-mode.md) - 컴포넌트 다크 모드 지원 가이드
- [상태 관리](./state-management.md) - 전역/로컬 상태 관리 및 동기화 패턴 - [상태 관리](./state-management.md) - 전역/로컬 상태 관리 및 동기화 패턴
+1 -1
View File
@@ -25,7 +25,7 @@
| `sirsoft-admin_basic` | 미선언 | ThemeToggle 존재, dark: variant 동작 | | `sirsoft-admin_basic` | 미선언 | ThemeToggle 존재, dark: variant 동작 |
| `sirsoft-basic` | `true` | template.json에 선언, 완전 지원 | | `sirsoft-basic` | `true` | template.json에 선언, 완전 지원 |
> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md) > 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md)
--- ---
+1 -1
View File
@@ -366,7 +366,7 @@ HtmlEditor는 내부적으로 **DOMPurify**를 사용하여 HTML을 정화합니
## 관련 문서 ## 관련 문서
- [컴포넌트 Props 레퍼런스](component-props.md) - Select, Input, Button Props - [컴포넌트 Props 레퍼런스](component-props.md) - Select, Input, Button Props
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 - [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록
- [레이아웃 JSON](layout-json.md) - 레이아웃 JSON 스키마 - [레이아웃 JSON](layout-json.md) - 레이아웃 JSON 스키마
- [데이터 바인딩](data-binding.md) - {{}} 표현식, $t: 다국어 - [데이터 바인딩](data-binding.md) - {{}} 표현식, $t: 다국어
- [상태 관리](state-management.md) - _local, setState - [상태 관리](state-management.md) - _local, setState
+1 -1
View File
@@ -23,7 +23,7 @@
| `sirsoft-admin_basic` | 미선언 | 데스크톱 중심, Tailwind responsive 클래스는 동작 | | `sirsoft-admin_basic` | 미선언 | 데스크톱 중심, Tailwind responsive 클래스는 동작 |
| `sirsoft-basic` | `true` | MobileNav 포함, portable preset 지원 | | `sirsoft-basic` | `true` | MobileNav 포함, portable preset 지원 |
> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md) > 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md)
--- ---
+4 -2
View File
@@ -22,11 +22,13 @@
<!-- AUTO-GENERATED-START: frontend-template-handlers --> <!-- AUTO-GENERATED-START: frontend-template-handlers -->
| 템플릿 식별자 | 핸들러 문서 | TL;DR 핵심 | | 템플릿 식별자 | 핸들러 문서 | TL;DR 핵심 |
|--------------|-----------|----------| |--------------|-----------|----------|
| `sirsoft-admin_basic` | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | setLocale: 앱 언어 변경 (locale 파라미터) |
| `sirsoft-basic` | [handlers.md](templates/sirsoft-basic/handlers.md) | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) | | `sirsoft-basic` | [handlers.md](templates/sirsoft-basic/handlers.md) | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
<!-- AUTO-GENERATED-END: frontend-template-handlers --> <!-- AUTO-GENERATED-END: frontend-template-handlers -->
> `sirsoft-admin_basic` 은 문서를 그 템플릿이 직접 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/](../../templates/_bundled/sirsoft-admin_basic/docs/README.md).
> 템플릿별 문서 위치는 [templates/README.md](templates/README.md) 를 따릅니다.
--- ---
## 엔진 빌트인 핸들러 ## 엔진 빌트인 핸들러
@@ -55,5 +57,5 @@
- [액션 핸들러 개요](actions-handlers.md) - [액션 핸들러 개요](actions-handlers.md)
- [템플릿 개발 가이드](template-development.md) - [템플릿 개발 가이드](template-development.md)
- [다크 모드 지원](dark-mode.md) - [다크 모드 지원](dark-mode.md)
- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
- [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) - [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md)
+13
View File
@@ -0,0 +1,13 @@
# 템플릿별 컴포넌트·핸들러·레이아웃 문서
번들 템플릿의 컴포넌트/핸들러/레이아웃 상세 문서는 그 템플릿이 소유합니다(#601). 어느
템플릿이 어디에 문서를 갖는지는 아래 표를 따릅니다 — 이관이 끝난 템플릿은 코어에 사본을
남기지 않습니다.
| 템플릿 | 상태 | 문서 위치 |
|---|---|---|
| `sirsoft-admin_basic` | 이관 완료 | [templates/_bundled/sirsoft-admin_basic/docs/](../../../templates/_bundled/sirsoft-admin_basic/docs/README.md) |
| `sirsoft-basic` | 이관 대기 (S3) | [components.md](sirsoft-basic/components.md) · [handlers.md](sirsoft-basic/handlers.md) · [layouts.md](sirsoft-basic/layouts.md) |
이관 배경·문서 체계 전반은 [extension-documentation.md](../../extension/extension-documentation.md)
를 참고하세요.
@@ -1,445 +0,0 @@
# sirsoft-admin_basic 핸들러
> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin)
> **관련 문서**: [액션 핸들러 개요](../../actions-handlers.md) | [컴포넌트](./components.md) | [레이아웃](./layouts.md)
---
## TL;DR (5초 요약)
```text
1. setLocale: 앱 언어 변경 (locale 파라미터)
2. setTheme/initTheme: 다크/라이트 모드 전환 및 초기화
3. scrollToSection: 특정 섹션으로 스크롤 이동 (offset 지원)
4. initMenuFromUrl: URL 기반 메뉴 활성 상태 초기화
5. filterVisibility 4종: 필터 패널 가시성 저장/토글/초기화
6. multilingualTag 3종: 다국어 태그 저장/취소/업데이트
```
---
## 목차
1. [setLocale](#setlocale)
2. [setTheme / initTheme](#settheme--inittheme)
3. [scrollToSection](#scrolltosection)
4. [initMenuFromUrl](#initmenuefromurl)
5. [필터 가시성 핸들러](#필터-가시성-핸들러)
6. [다국어 태그 핸들러](#다국어-태그-핸들러)
7. [핸들러 소스 파일 매핑](#핸들러-소스-파일-매핑)
---
## setLocale
앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다.
**소스**: `src/handlers/setLocaleHandler.ts`
```json
{
"type": "click",
"handler": "setLocale",
"params": {
"locale": "en"
}
}
```
### params
| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
### 동작
```text
1. 로케일 변경 → 번역 파일 다시 로드
2. _global.locale 자동 업데이트
3. 모든 $t: 표현식 재평가
```
### 사용 예시
```json
{
"id": "lang_en_btn",
"type": "basic",
"name": "Button",
"props": {
"text": "English"
},
"actions": [
{
"type": "click",
"handler": "setLocale",
"params": {
"locale": "en"
}
}
]
}
```
---
## setTheme / initTheme
### setTheme
다크/라이트 모드를 전환합니다.
**소스**: `src/handlers/setThemeHandler.ts`
```json
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "dark"
}
}
```
### setTheme params
| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
### 동작
```text
1. localStorage에 테마 설정 저장
2. document.documentElement에 class 적용 (dark/light)
3. Tailwind dark: variant 활성화/비활성화
```
### initTheme
앱 시작 시 저장된 테마 설정을 적용합니다. 주로 `init_actions`에서 사용합니다.
```json
{
"init_actions": [
{
"handler": "initTheme"
}
]
}
```
params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다.
### 사용 예시
```json
{
"id": "theme_toggle",
"type": "composite",
"name": "ThemeToggle",
"actions": [
{
"type": "click",
"handler": "setTheme",
"params": {
"theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
}
}
]
}
```
---
## scrollToSection
특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다.
**소스**: `src/handlers/scrollToSectionHandler.ts`
```json
{
"type": "click",
"handler": "scrollToSection",
"params": {
"selector": "#features",
"offset": -80
}
}
```
### params
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) |
| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 |
### 동작
```text
1. document.querySelector(selector)로 대상 요소 검색
2. 요소의 위치 계산 + offset 적용
3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤
```
### 사용 예시
```json
{
"id": "nav_features",
"type": "basic",
"name": "A",
"props": {
"text": "$t:common.features",
"className": "cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "scrollToSection",
"params": {
"selector": "#features",
"offset": -80
}
}
]
}
```
---
## initMenuFromUrl
현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다.
**소스**: `src/handlers/initMenuFromUrlHandler.ts`
```json
{
"init_actions": [
{
"handler": "initMenuFromUrl"
}
]
}
```
### params
없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다.
### 동작
```text
1. 현재 URL 경로 (window.location.pathname) 추출
2. 사이드바 메뉴 데이터에서 URL 매칭
3. 매칭된 메뉴 항목의 is_active 상태 설정
4. 부모 메뉴도 자동으로 펼침 상태 설정
```
### 사용 예시 (_admin_base.json)
```json
{
"init_actions": [
{ "handler": "initTheme" },
{ "handler": "initMenuFromUrl" }
]
}
```
---
## 필터 가시성 핸들러
목록 화면에서 필터 패널의 가시성(표시/숨김)을 관리합니다. localStorage에 상태를 저장하여 새로고침 후에도 유지합니다.
**소스**: `src/handlers/filterVisibilityHandler.ts`
### initFilterVisibility
저장된 필터 가시성 상태를 `_local`에 복원합니다.
```json
{
"init_actions": [
{
"handler": "initFilterVisibility"
}
]
}
```
### saveFilterVisibility
현재 필터 가시성 상태를 localStorage에 저장합니다.
```json
{
"handler": "saveFilterVisibility",
"params": {
"filters": "{{_local.filterVisibility}}"
}
}
```
### toggleFilterVisibility
특정 필터 키의 가시성을 토글합니다.
```json
{
"type": "click",
"handler": "toggleFilterVisibility",
"params": {
"key": "advancedFilters"
}
}
```
### resetFilterVisibility
모든 필터 가시성을 초기 상태로 리셋합니다.
```json
{
"type": "click",
"handler": "resetFilterVisibility"
}
```
### 핸들러 params 요약
| 핸들러 | params | 설명 |
|--------|--------|------|
| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 |
| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 |
| `resetFilterVisibility` | 없음 | 전체 초기화 |
### 사용 예시 (목록 페이지)
```json
{
"init_actions": [
{ "handler": "initFilterVisibility" }
],
"components": [
{
"id": "filter_toggle_btn",
"type": "basic",
"name": "Button",
"props": {
"text": "$t:common.toggle_filters"
},
"actions": [
{
"type": "click",
"handler": "toggleFilterVisibility",
"params": { "key": "advancedFilters" }
}
]
},
{
"id": "filter_section",
"type": "basic",
"name": "Div",
"if": "{{_local.filterVisibility?.advancedFilters}}",
"children": [
{ "comment": "필터 컴포넌트들" }
]
}
]
}
```
---
## 다국어 태그 핸들러
다국어 입력 컴포넌트(MultilingualInput)에서 사용하는 태그 관리 핸들러입니다.
**소스**: `src/handlers/multilingualTagHandler.ts`
### saveMultilingualTag
다국어 태그를 저장합니다.
```json
{
"handler": "saveMultilingualTag",
"params": {
"field": "tags",
"locale": "{{_global.locale}}"
}
}
```
### cancelMultilingualTag
다국어 태그 편집을 취소합니다.
```json
{
"handler": "cancelMultilingualTag"
}
```
### updateMultilingualTagValue
다국어 태그 값을 업데이트합니다.
```json
{
"handler": "updateMultilingualTagValue",
"params": {
"field": "tags",
"locale": "ko",
"value": "{{$event.target.value}}"
}
}
```
### 태그 핸들러 params 요약
| 핸들러 | params | 설명 |
|--------|--------|------|
| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
| `cancelMultilingualTag` | 없음 | 편집 취소 |
| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
---
## 핸들러 소스 파일 매핑
| 핸들러명 | 소스 파일 | 등록 함수 |
|---------|----------|----------|
| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` |
| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` |
| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` |
| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` |
| `initFilterVisibility`, `saveFilterVisibility`, `toggleFilterVisibility`, `resetFilterVisibility` | `src/handlers/filterVisibilityHandler.ts` | `initFilterVisibilityHandler` |
| `saveMultilingualTag`, `cancelMultilingualTag`, `updateMultilingualTagValue` | `src/handlers/multilingualTagHandler.ts` | `saveMultilingualTagHandler` |
---
## 주의사항
```text
이 핸들러들은 sirsoft-admin_basic 템플릿에서만 등록됨 (다른 템플릿에서 미지원 가능)
범용 핸들러(navigate, apiCall, setState 등)와 달리 템플릿 의존적
✅ 커스텀 핸들러이므로 template.json의 핸들러 등록 확인 필요
✅ 범용 핸들러는 actions-handlers.md 참조
```
---
## 관련 문서
- [액션 핸들러 개요](../../actions-handlers.md)
- [sirsoft-admin_basic 컴포넌트](./components.md)
- [sirsoft-admin_basic 레이아웃](./layouts.md)
- [sirsoft-basic 핸들러](../sirsoft-basic/handlers.md)
@@ -1,368 +0,0 @@
# sirsoft-admin_basic 레이아웃
> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin)
> **관련 문서**: [컴포넌트](./components.md) | [핸들러](./handlers.md) | [레이아웃 JSON 스키마](../../layout-json.md)
---
## TL;DR (5초 요약)
```text
1. 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯)
2. 코어 페이지 17개: 대시보드, 사용자, 역할, 설정, 확장 관리 등
3. Partial 70개+: 탭, 모달, 패널, 필터 등
4. 에러 페이지 6개: 401, 403, 404, 500, 503, maintenance
5. 패턴: 목록(DataGrid+Pagination), 상세(Card), 폼(Form+FormField), 설정(Tab)
```
---
## 목차
1. [페이지 맵 (트리 구조)](#페이지-맵-트리-구조)
2. [카테고리별 가이드](#카테고리별-가이드)
- [목록 페이지 패턴](#목록-페이지-패턴)
- [상세 페이지 패턴](#상세-페이지-패턴)
- [폼 페이지 패턴](#폼-페이지-패턴)
- [설정 페이지 패턴](#설정-페이지-패턴)
- [확장 관리 패턴](#확장-관리-패턴)
- [에러 페이지 패턴](#에러-페이지-패턴)
---
## 페이지 맵 (트리 구조)
```text
_admin_base.json (베이스 레이아웃)
│
├── admin_dashboard.json (대시보드)
├── admin_login.json (로그인 — _admin_base 미상속)
│
├── 사용자 관리
│ ├── admin_user_list.json (목록)
│ ├── admin_user_form.json (생성/수정)
│ └── admin_user_detail.json (상세)
│
├── 역할 관리
│ ├── admin_role_list.json (목록)
│ │ └── partials/admin_role_list/_modal_delete.json
│ └── admin_role_form.json (생성/수정)
│
├── 메뉴 관리
│ └── admin_menu_list.json (3패널 레이아웃)
│ └── partials/admin_menu_list/
│ ├── _panel_menu_list.json (좌측: 메뉴 트리)
│ ├── _panel_form.json (중앙: 편집 폼)
│ ├── _panel_detail.json (중앙: 상세 보기)
│ ├── _panel_view.json (우측: 미리보기)
│ └── _modal_delete.json
│
├── 환경 설정
│ └── admin_settings.json (탭 네비게이션)
│ └── partials/admin_settings/
│ ├── _tab_general.json (일반)
│ ├── _tab_mail.json (메일 발송 SMTP 설정)
│ ├── _tab_notification_definitions.json (알림 정의)
│ ├── _tab_security.json (보안)
│ ├── _tab_upload.json (업로드)
│ ├── _tab_drivers.json (드라이버)
│ ├── _tab_seo.json (SEO)
│ ├── _tab_advanced.json (고급)
│ ├── _tab_info.json (시스템 정보)
│ ├── _modal_cache_delete.json
│ ├── _modal_core_changelog.json
│ ├── _modal_core_update_guide.json
│ ├── _modal_core_update_result.json
│ ├── _modal_notification_template_form.json
│ ├── _modal_notification_template_preview.json
│ └── _modal_password_confirm.json
│
├── 모듈 관리
│ └── admin_module_list.json
│ └── partials/admin_module_list/
│ ├── _modal_detail.json
│ ├── _modal_install.json
│ ├── _modal_manual_install.json
│ ├── _modal_uninstall.json
│ ├── _modal_update.json
│ ├── _modal_deactivate_warning.json
│ ├── _modal_force_activate.json
│ ├── _modal_force_deactivate.json
│ ├── _modal_extension_license.json
│ └── _modal_refresh_layouts.json
│
├── 플러그인 관리
│ └── admin_plugin_list.json
│ └── partials/admin_plugin_list/
│ ├── (모듈과 동일 구조 — 10개 모달)
│ └── ...
│
├── 템플릿 관리
│ ├── admin_template_list.json
│ │ └── partials/admin_template_list/
│ │ ├── _tab_admin.json (Admin 템플릿 탭)
│ │ ├── _tab_user.json (User 템플릿 탭)
│ │ ├── _modal_detail.json
│ │ ├── _modal_install.json
│ │ ├── _modal_manual_install.json
│ │ ├── _modal_uninstall.json
│ │ ├── _modal_update.json
│ │ ├── _modal_activate.json
│ │ ├── _modal_deactivate.json
│ │ ├── _modal_force_activate.json
│ │ ├── _modal_extension_license.json
│ │ └── _modal_refresh_layouts.json
│ └── admin_template_layout_edit.json (레이아웃 편집기)
│ └── partials/admin_template_layout_edit/_modal_version_history.json
│
├── 스케줄 관리
│ └── admin_schedule_list.json
│ └── partials/admin_schedule_list/
│ ├── _tab_schedules.json
│ ├── _modal_form.json
│ ├── _modal_delete.json
│ ├── _modal_duplicate.json
│ ├── _modal_history.json
│ └── _modal_run.json
│
├── 메일 발송 로그
│ └── admin_mail_send_log_list.json
│ └── partials/admin_mail_send_log_list/
│ ├── _partial_datagrid.json
│ └── _partial_filter.json
│
├── 공통 Partial
│ ├── partials/_modal_changelog.json
│ └── partials/_modal_license.json
│
├── 에러 페이지
│ └── errors/
│ ├── 401.json (인증 필요)
│ ├── 403.json (접근 거부)
│ ├── 404.json (페이지 없음)
│ ├── 500.json (서버 오류)
│ ├── 503.json (서비스 불가)
│ └── maintenance.json (점검 중)
│
├── 오버라이드
│ └── overrides/sirsoft-sample/index.json
│
└── 테스트
└── template_partial_test.json
└── partials/template_partial_test/
├── _content_section.json
├── _header_section.json
└── _info_card.json
```
---
## 카테고리별 가이드
### 목록 페이지 패턴
**대표**: `admin_user_list.json`, `admin_role_list.json`
**구성**:
```text
extends: _admin_base
slots.content:
└── PageHeader (제목, 액션 버튼)
└── FilterGroup (선택적)
└── DataGrid (columns, data, pagination)
└── Pagination
```
**data_sources**:
```json
{
"id": "users",
"endpoint": "/api/admin/users",
"method": "GET",
"auto_fetch": true,
"params": { "page": "{{_local.page ?? 1}}", "per_page": "{{_local.per_page ?? 15}}" }
}
```
**핸들러 패턴**:
- `apiCall` — 삭제, 상태 변경
- `navigate` — 상세/수정 페이지 이동
- `setState` — 필터, 페이지네이션 상태
**Partial 구조**: 모달 (삭제 확인, 상세 보기 등)
---
### 상세 페이지 패턴
**대표**: `admin_user_detail.json`
**구성**:
```text
extends: _admin_base
data_sources: [상세 API (route.id 기반)]
slots.content:
└── PageHeader
└── Card (기본 정보 섹션)
└── Card (활동 내역 섹션)
└── Card (권한 정보 섹션)
```
**data_sources**:
```json
{
"id": "user",
"endpoint": "/api/admin/users/{{route.id}}",
"method": "GET",
"auto_fetch": true
}
```
**핸들러 패턴**:
- `apiCall` — 상태 변경 (활성화/비활성화, 역할 변경)
- `navigate` — 목록으로 이동, 수정 페이지 이동
---
### 폼 페이지 패턴
**대표**: `admin_user_form.json`, `admin_role_form.json`
**구성**:
```text
extends: _admin_base
data_sources: [상세 API (수정 시), 참조 데이터 (Select 옵션)]
slots.content:
└── PageHeader
└── Form
├── FormField + Input (텍스트)
├── FormField + Select (선택)
├── FormField + Toggle (토글)
└── Button (저장/취소)
```
**핸들러 패턴**:
- `apiCall` — 생성 (POST) / 수정 (PUT)
- `navigate` — 성공 후 목록/상세로 이동
**주의사항**:
```text
✅ Form 내 Button에 type="button" 명시 (submit 방지)
✅ 수정 폼은 route.id 존재 여부로 생성/수정 구분
✅ FormField에 error prop으로 서버 검증 에러 표시
```
---
### 설정 페이지 패턴
**대표**: `admin_settings.json`
**구성**:
```text
extends: _admin_base
data_sources: [설정 API]
slots.content:
└── PageHeader
└── TabNavigation (tabs)
└── Div (탭별 partial 조건부 렌더링)
├── if: activeTab === 'general' → partial: _tab_general.json
├── if: activeTab === 'mail' → partial: _tab_mail.json
├── if: activeTab === 'security' → partial: _tab_security.json
└── ...
```
**Partial 구조**: `partials/admin_settings/_tab_*.json` (9개 탭)
**핸들러 패턴**:
- `setState` — 탭 전환
- `apiCall` — 설정 저장
- `openModal` — 확인 다이얼로그
---
### 확장 관리 패턴
**대표**: `admin_module_list.json`, `admin_plugin_list.json`, `admin_template_list.json`
**구성**:
```text
extends: _admin_base
data_sources: [확장 목록 API]
slots.content:
└── PageHeader (새로고침, 수동 설치 버튼)
└── DataGrid/CardGrid (확장 목록)
├── StatusBadge (상태)
├── ActionMenu (설치/활성화/비활성화/삭제/업데이트)
└── ExtensionBadge (모듈 식별)
modals:
├── _modal_detail.json (상세 정보)
├── _modal_install.json (설치 확인)
├── _modal_uninstall.json (삭제 확인)
├── _modal_update.json (업데이트)
└── _modal_force_activate.json 등
```
**핸들러 패턴**:
- `apiCall` — 설치, 활성화, 비활성화, 삭제, 업데이트
- `openModal` — 확인 다이얼로그
- `setState` — 선택된 확장 정보 저장
**특수사항**:
- 템플릿 관리는 Admin/User 탭 분리 (`_tab_admin.json`, `_tab_user.json`)
- 레이아웃 편집기 (`admin_template_layout_edit.json`)는 CodeEditor + 실시간 미리보기
---
### 에러 페이지 패턴
**대표**: `errors/404.json`
**구성**:
```text
(extends 없음 — 독립 레이아웃)
components:
└── Div (전체 화면 중앙 정렬)
├── Icon (에러 아이콘)
├── H1 (에러 코드)
├── P (에러 메시지)
└── Button (홈으로 이동)
```
**핸들러 패턴**:
- `navigate` — 대시보드/홈으로 이동
---
## 베이스 레이아웃 구조
### _admin_base.json
모든 관리자 페이지의 공통 구조를 정의합니다.
```text
_admin_base.json
├── init_actions: [initTheme, initMenuFromUrl]
├── data_sources: [admin_menu, notifications]
├── components:
│ ├── AdminSidebar (menu: admin_menu.data)
│ ├── AdminHeader (user, notifications)
│ ├── Toast
│ ├── PageTransitionIndicator
│ └── Div (content area)
│ └── slot: "content" (← 하위 레이아웃이 채움)
└── AdminFooter
```
**슬롯**:
- `content` — 각 페이지의 메인 콘텐츠가 삽입되는 위치
---
## 관련 문서
- [sirsoft-admin_basic 컴포넌트](./components.md)
- [sirsoft-admin_basic 핸들러](./handlers.md)
- [레이아웃 JSON 스키마](../../layout-json.md)
- [레이아웃 상속](../../layout-json-inheritance.md)
- [sirsoft-basic 레이아웃](../sirsoft-basic/layouts.md)
@@ -242,6 +242,6 @@ AdminSidebar, AdminHeader, AdminFooter, PageHeader, DataGrid, CodeEditor, Dynami
- [sirsoft-basic 핸들러](./handlers.md) - [sirsoft-basic 핸들러](./handlers.md)
- [sirsoft-basic 레이아웃](./layouts.md) - [sirsoft-basic 레이아웃](./layouts.md)
- [sirsoft-admin_basic 컴포넌트](../sirsoft-admin_basic/components.md) - [sirsoft-admin_basic 컴포넌트](../../../../templates/_bundled/sirsoft-admin_basic/docs/components.md)
- [컴포넌트 개발 규칙](../../components.md) - [컴포넌트 개발 규칙](../../components.md)
- [컴포넌트 Props 레퍼런스](../../component-props.md) - [컴포넌트 Props 레퍼런스](../../component-props.md)
@@ -428,4 +428,4 @@ setLocale은 엔진 레벨(ActionDispatcher) 빌트인 — 별도 등록 불필
- [액션 핸들러 개요](../../actions-handlers.md) - [액션 핸들러 개요](../../actions-handlers.md)
- [sirsoft-basic 컴포넌트](./components.md) - [sirsoft-basic 컴포넌트](./components.md)
- [sirsoft-basic 레이아웃](./layouts.md) - [sirsoft-basic 레이아웃](./layouts.md)
- [sirsoft-admin_basic 핸들러](../sirsoft-admin_basic/handlers.md) - [sirsoft-admin_basic 핸들러](../../../../templates/_bundled/sirsoft-admin_basic/docs/handlers.md)
@@ -642,6 +642,6 @@ _user_base.json
- [sirsoft-basic 컴포넌트](./components.md) - [sirsoft-basic 컴포넌트](./components.md)
- [sirsoft-basic 핸들러](./handlers.md) - [sirsoft-basic 핸들러](./handlers.md)
- [sirsoft-admin_basic 레이아웃](../sirsoft-admin_basic/layouts.md) - [sirsoft-admin_basic 레이아웃](../../../../templates/_bundled/sirsoft-admin_basic/docs/layouts.md)
- [레이아웃 JSON 스키마](../../layout-json.md) - [레이아웃 JSON 스키마](../../layout-json.md)
- [레이아웃 상속](../../layout-json-inheritance.md) - [레이아웃 상속](../../layout-json-inheritance.md)
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-08-31
### Added
- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가
## [1.0.1] - 2026-08-10 ## [1.0.1] - 2026-08-10
### Added ### Added
@@ -203,6 +203,7 @@ return [
'register' => '会員登録', 'register' => '会員登録',
'mypage' => 'マイページ', 'mypage' => 'マイページ',
'mypage_renew_all' => 'マイページ一括再同意', 'mypage_renew_all' => 'マイページ一括再同意',
'withdraw' => '会員退会',
], ],
'col' => [ 'col' => [
'created_at' => '時点', 'created_at' => '時点',
@@ -238,7 +238,8 @@
"preference_center": "環境設定", "preference_center": "環境設定",
"mypage": "マイページ", "mypage": "マイページ",
"register": "会員登録", "register": "会員登録",
"mypage_renew_all": "マイページ一括再同意" "mypage_renew_all": "マイページ一括再同意",
"withdraw": "会員退会"
}, },
"col": { "col": {
"created_at": "時点", "created_at": "時点",
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-gdpr) Japanese language pack (bundled)", "en": "G7 plugin (sirsoft-gdpr) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-gdpr) 日本語 言語パック(バンドル)" "ja": "G7 プラグイン (sirsoft-gdpr) 日本語 言語パック(バンドル)"
}, },
"version": "1.0.1", "version": "1.0.2",
"license": "MIT", "license": "MIT",
"scope": "plugin", "scope": "plugin",
"target_identifier": "sirsoft-gdpr", "target_identifier": "sirsoft-gdpr",
+191
View File
@@ -0,0 +1,191 @@
# 게시판 — 에이전트 가이드
> 이 문서는 이 모듈을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
## TL;DR (5초 요약)
```text
1. 유형: 모듈 (sirsoft-board) — 게시판·게시글·댓글·신고·게시판별 알림설정 도메인. 관리자 CRUD + 공개 API 만 소유하고, 방문자 화면(목록/상세/글쓰기)은 템플릿이 그린다
2. 확장 방식: 발행 훅 90개(전량 action/filter) — 새 콘텐츠 타입 연동은 `EcommerceInquiryHookListener` 식 필터 훅 위임 패턴을 참고
3. 건드리면 안 되는 것: 비밀글 게이팅(`SecretContentGate`)을 우회하는 신규 조회 경로, `chunk()`(OFFSET) 로 삭제·갱신 순회, count 컬럼(posts_count 등) 직접 갱신
4. 작업 위치: `modules/_bundled/sirsoft-board` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan module:update sirsoft-board --force`
```
## 1. 이 확장은 무엇인가
<!-- @intent START -->
게시판·게시글·댓글·신고·게시판별 알림설정을 소유하는 콘텐츠 도메인 모듈입니다. 운영자가
`/admin/boards`에서 게시판을 자유롭게 생성(게시판 유형 `basic`/`gallery`/`card` 선택, 비밀글·
답변형·트리거 기반 관리자 알림 등 게시판별 세부 설정)하면, 그 게시판마다 동적 권한
(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`/`{slug}.step`)·메뉴가 자동 생성됩니다 —
게시판 하나하나가 사실상 독립된 작은 확장처럼 자기 권한 체계를 갖습니다.
**소유 범위는 관리자 CRUD + 공개 API 까지입니다.** 방문자가 실제로 보는 목록/상세/글쓰기
화면은 이 모듈이 그리지 않습니다 — `resources/layouts/` 46개가 전부 `admin` 그룹인 것이
그 증거입니다. 공개 조회·작성은 `routes/api.php` 의 `boards.*` 라우트(비회원도 접근하는
`optional.sanctum`)로만 나가고, 그 API 를 소비해 실제 화면을 그리는 것은 템플릿(`sirsoft-basic`)
쪽 책임입니다. 그래서 이 모듈에 의존하는 확장은 지금 템플릿 하나뿐입니다 — 새 방문자 화면이
필요하면 이 모듈이 아니라 그 화면을 쓰는 템플릿/모듈 쪽에 레이아웃을 추가합니다.
**설계 원칙**: 게시판은 이커머스 문의·후기처럼 "게시판을 흉내 낸" 다른 도메인의 콘텐츠 저장소로도
쓰입니다. 그래서 이 모듈을 다른 도메인이 재사용하는 지점은 코드 결합이 아니라 **필터 훅
위임**(`EcommerceInquiryHookListener`)입니다 — 이커머스 모듈이 자기 문의 게시판을 만들 때
`sirsoft-board` 를 직접 `use` 하지 않고, board 쪽 흐름 중간에서 자기 로직으로 갈아끼웁니다.
**의도적으로 하지 않는 것**: 게시판 콘텐츠에 대한 실시간 브로드캐스트(WebSocket)는 제공하지
않습니다 — 알림은 전부 코어 `GenericNotification`(mail/database) 경유이며, 새로고침 없이
갱신되는 목록 같은 기능은 이 모듈의 범위 밖입니다. 또한 비밀글 판정은 이 모듈이 API 계층에서
전량 서버측으로 강제합니다(`SecretContentGate`, KVE-2026-1914) — 클라이언트가 비밀글 여부를
판단해 화면만 가리는 방식은 쓰지 않습니다.
<!-- @intent END -->
## 2. 디렉토리 지도
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-board --force` (빌드 불필요) |
| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-board --force` |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-board --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-board --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-board --force` |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**게시판 생성**: `Admin\BoardController` → `StoreBoardRequest`(다국어 이름·slug 유일성·게시판
유형 검증) → `BoardService::createBoard()` → `BoardRepository` 로 `boards` 행 생성 후, 같은
트랜잭션 안에서 `BoardPermissionService`(동적 권한 3계층 생성) → 역할(manager/step) 생성 →
관리자 메뉴 등록까지 연쇄 실행됩니다. 이 연쇄 때문에 `getDynamicPermissionIdentifiers()` /
`getDynamicRoleIdentifiers()` / `getDynamicMenuSlugs()` 가 `boards` 테이블을 다시 조회해
전수를 재구성합니다 — 게시판 삭제·정리(clean-up) 시 "stale 판정"의 기준이 이 세 메서드입니다.
**게시글 작성 → 비밀글 게이팅**: `User\PostController` → `StorePostRequest`
(`sirsoft-board.post.store_validation_rules` 필터로 게시판별 커스텀 규칙 추가) →
`PostService::createPost()` (`before_create`→`filter_create_data`→`after_create` 훅 순서,
`sirsoft-board.post.user_create` IDV 정책 게이트 통과 후) → `PostRepository`. 조회 시에는
`PostResource` 가 `SecretContentGate` 로 비밀글 여부·열람 권한을 판정해 `content`/`title`/
`reply`/`attachments` 를 마스킹합니다 — 이 판정은 댓글 목록·첨부 다운로드에도 **개별
재적용**됩니다(부모 글에서 한 번 판정하고 끝나지 않음, KVE-2026-1914).
**신고 접수 → 처리**: `User\ReportController` → `StoreReportRequest` → `ReportService::create()`
(`before_create`→`filter_create_data`→`after_create`) → 신고 접수 관리자 알림 발송. 관리자가
`Admin\ReportController` 에서 처리(블라인드/삭제/복원)하면 `ReportService` 가 대상 게시글/댓글
서비스(`PostService`/`CommentService`)를 호출해 실제 콘텐츠 상태를 바꾸고, 그 결과가
`report_action`/`post_action` 알림으로 원 작성자에게 통지됩니다. 신고 삭제·일괄 처리는
관리자 민감 작업 IDV 정책(`report.delete`/`report.bulk_action`)이 걸려 있습니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 90개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 77개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 16개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 2개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 7개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
발행 훅 90개는 전부 `action`/`filter` 이며 코어 브로드캐스트 채널은 쓰지 않습니다(구독 훅
목록의 이커머스 8개가 예로 보여주듯, **콘텐츠를 다른 도메인이 재사용하는 자리는 훅이지 상속이
아닙니다**). 새 게시판 세부 검증 규칙을 추가하려면 `board.store_validation_rules`/
`update_validation_rules` filter 를, 게시판 생성 후 부가 리소스를 함께 만들려면
`board.after_create` action 을 잡습니다. 게시글/댓글의 `before_*`→`filter_*_data`→`after_*`
3단 패턴은 전 도메인(board/comment/report/attachment)에 동일하게 반복되므로, 하나를 배우면
나머지에 그대로 적용됩니다. 훅 리스너 16개 중 `EcommerceInquiryHookListener` 는 이 모듈의
CRUD 흐름 자체를 **자기 도메인으로 대체**하는 가장 무거운 형태의 확장 사례입니다 — 새로운
"게시판을 흉내 낸 도메인"을 만들 때 참고할 선례입니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-board --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-board` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] 비밀글이 관여하는 새 조회 경로(댓글·첨부·검색 결과 등)를 추가할 때 `SecretContentGate` 재적용 (KVE-2026-1914 — 부모에서 한 번 판정하고 끝나지 않는다)
- [ ] `boards`/`board_posts`/`board_comments` 의 count 컬럼(`posts_count`/`comments_count`/`replies_count`/`attachments_count`)은 훅 리스너(`*CountSyncListener`)가 갱신 — Service 에서 직접 증감 금지
- [ ] 게시판 삭제 시 `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()`/`getDynamicMenuSlugs()` 가 최신 상태를 반영하도록 동일 트랜잭션에서 정리 (module.php `uninstall()` 의 `chunkById` 패턴 참고 — OFFSET 순회로 삭제 금지)
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 비밀글 상세만 `SecretContentGate` 로 막고 댓글 목록·첨부 다운로드는 그대로 노출 | 댓글 목록은 부모 글 비밀 여부 확인 후 빈 배열, 첨부 서빙은 403 — 두 경로 모두 게이트 재적용 | 상세 API 하나만 막으면 같은 정보가 형제 엔드포인트로 새어나간다 (KVE-2026-1914) |
| 게시글/댓글 삭제·복원 시 `posts_count`/`comments_count` 를 Service 에서 `increment()`/`decrement()` | 훅(`after_create`/`after_delete`/`after_restore`) 리스너의 count 동기화에 맡긴다 | 직접 증감은 훅 기반 동기화와 이중 집계되어 카운트가 어긋난다 |
| 게시판별 동적 권한/역할을 board 삭제와 별도 시점에 정리 | `BoardService` 삭제 흐름 안에서 즉시 정리(또는 stale cleanup 이 `getDynamicPermissionIdentifiers()` 로 정확히 판정하게 유지) | 정리가 늦으면 존재하지 않는 게시판의 권한이 역할에 남아 관리 화면에 유령 항목이 뜬다 |
| 새 콘텐츠 타입을 board 코드에 `if ($type === 'inquiry')` 로 직접 분기 | `EcommerceInquiryHookListener` 처럼 필터 훅으로 CRUD 를 위임 | board 코드가 알지 못하는 도메인이 늘어날수록 분기가 무한 증식한다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 155개 | `modules/_bundled/sirsoft-board/tests` |
| Vitest | 33개 | `vitest.config.ts` |
| Playwright | 26개 | `tests/Playwright` |
| 시나리오 매니페스트 | 34개 | `tests/scenarios` |
기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit modules/_bundled/sirsoft-board/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd modules/_bundled/sirsoft-board && powershell -Command "npm run test:run -- <대상>"
# Playwright E2E (Bash)
npx playwright test modules/_bundled/sirsoft-board/tests/Playwright/specs/<대상>.spec.ts
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @generated:test-commands END -->
## 8. 문서 목차
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
+171
View File
@@ -0,0 +1,171 @@
# 게시판
**G7 모듈 · sirsoft-board**
게시판 관리를 위한 모듈
<!-- @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/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 -->
게시판·게시글·댓글·신고를 관리하는 콘텐츠 모듈입니다. 운영자가 관리자 화면에서 자유형(가로형/
갤러리형/카드형) 게시판을 원하는 개수만큼 만들고, 게시판마다 비밀글·답변형·본인인증·자동 알림
같은 세부 정책을 독립적으로 설정할 수 있습니다.
이 모듈은 관리자 화면과 공개 API 만 제공합니다. 방문자가 보는 목록·상세·글쓰기 화면은
템플릿(`sirsoft-basic`)이 이 모듈의 API 를 호출해 그립니다 — 운영자 입장에서는 "게시판 콘텐츠는
여기서 관리하고, 화면 디자인은 템플릿이 담당한다"로 이해하면 됩니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 게시판 관리 | 게시판 생성/수정/삭제, 게시판 유형(기본/갤러리/카드) 선택, 게시판별 세부 설정 일괄 적용 |
| 게시글·댓글 | 작성/수정/삭제/블라인드/복원, 답변형 게시판(원글-답변 트리), 대댓글, 비밀글 |
| 신고 처리 | 사용자 신고 접수 → 관리자 검토 → 블라인드/삭제/복원 처리, 처리 결과 알림 |
| 첨부파일 | 업로드/다운로드/순서 변경, 게시판별 허용 확장자·용량 제한 |
| 대시보드 | 게시판별 게시글·댓글·신고 현황과 추세, 미처리 신고 요약 |
| 알림 | 새 댓글/대댓글/답변글/신고 접수/처리 결과를 메일·앱 내 알림으로 발송, 회원별 수신 여부 설정 |
| 본인인증 연동 | 게시글/댓글 삭제, 신고 작성, 첫 글 작성 등 민감 작업에 코어 IDV 정책 적용(기본은 비활성) |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
V[방문자] -->|공개 API 호출| T[템플릿 화면]
T -->|GET boards/posts| API[게시판 공개 API]
API --> SVC[PostService/CommentService]
SVC --> DB[(board_posts 등)]
A[운영자] -->|관리자 화면| ADM[게시판 관리 UI]
ADM -->|CRUD·블라인드·신고 처리| SVC
SVC -->|훅 발행| N[알림/집계/검색색인]
```
방문자는 템플릿이 그린 화면에서 공개 API 만 호출하고, 운영자는 이 모듈이 직접 제공하는
관리자 화면을 씁니다. 두 경로 모두 결국 같은 Service 계층을 거치므로 비밀글 게이팅·카운트
동기화·훅 발행은 어느 쪽에서 들어오든 동일하게 적용됩니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan module:install sirsoft-board
# 활성화
php artisan module:activate sirsoft-board
# 업데이트 (번들 소스 기준 강제 반영)
php artisan module:update sirsoft-board --force
```
저장소: https://github.com/gnuboard/g7-module-sirsoft-board
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_별도의 관리자 설정 항목이 없습니다._
<!-- @generated:settings-summary END -->
<!-- @intent START -->
위 표가 비어 있는 이유는 이 모듈에 전역 환경설정(`getSettingsSchema()`) 이 없기 때문입니다 —
설정은 전역이 아니라 **게시판 하나하나**에 딸려 있습니다(`/admin/boards/{slug}/settings`).
게시판을 만들 때 기본/갤러리/카드 유형을 고르면 그 유형의 기본값이 채워지고, 이후 기본
정보·목록 표시·게시글 정책·댓글 정책·첨부 정책·본인인증·알림·SEO 탭에서 게시판별로 따로
조정합니다. 여러 게시판에 같은 값을 한 번에 반영하려면 게시판 목록 화면의 "설정 일괄 적용"을
씁니다(`settings.before_bulk_apply`/`after_bulk_apply` 훅으로 계측 가능).
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**게시판 신설**: `/admin/boards` → "게시판 추가" → 이름·slug·유형 지정 → 저장. 저장 즉시
관리자 메뉴·동적 권한(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`)이 자동 생성되므로,
바로 이어서 "권한" 탭에서 그 게시판을 담당할 운영자에게 `{slug}.manager` 역할을 부여합니다.
**신고 처리**: 방문자가 게시글/댓글을 신고하면 `/admin/boards/reports` 에 접수되고 담당자에게
메일이 갑니다. 신고 상세에서 신고 사유·신고 이력을 확인한 뒤 블라인드/삭제/복원 중 하나로
처리하면, 그 결과가 원 작성자에게 자동으로 통지됩니다 — 별도로 작성자에게 안내 메일을 보낼
필요가 없습니다.
**여러 게시판 설정 일괄 변경**: 예를 들어 전체 게시판의 첨부 용량 상한을 한 번에 올리고 싶으면,
게시판 목록에서 대상 게시판을 체크한 뒤 "설정 일괄 적용" 모달에서 첨부 탭 값만 바꿔 적용합니다.
다른 탭 값은 그대로 유지되고, 체크한 게시판에만 반영됩니다.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `sirsoft-basic` | 템플릿 | `>=1.0.0` |
<!-- @generated:integrations END -->
## 문서
<!-- @generated:docs-index START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 | 상태 |
|---|---|---|
| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ |
| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ |
| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ |
| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ |
| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ |
| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ |
| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ |
<!-- @generated:docs-index END -->
## 트러블슈팅
<!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 게시판 삭제 후에도 관리자 화면에 그 게시판 권한/역할이 남아 있음 | 정리 배치가 아직 실행되지 않았거나 삭제 트랜잭션이 중간에 실패 | `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()` 는 현재 `boards` 테이블 기준으로 계산되므로, 확장 정리 커맨드를 다시 실행하면 stale 항목이 잡힙니다 |
| 검색어에 `+`, `-`, `"` 를 넣으면 결과가 0건으로 나옴 | 코어 검색 정제기가 FULLTEXT 연산자를 제거한 뒤 검색 — 연산자만 입력하면 빈 결과가 정상 동작 | 오류가 아닙니다. 실제 키워드를 함께 입력하면 정상 매칭됩니다 |
| 비밀글의 댓글 개수가 0으로 보이는데 실제로는 댓글이 있음 | 열람 권한이 없는 요청에는 댓글 목록이 빈 배열(200)로 마스킹됨(KVE-2026-1914) | 정상 동작입니다. 작성자 본인 또는 `posts.read-secret`/관리 권한으로 조회하면 보입니다 |
| 게시판 설정 일괄 적용 후 일부 게시판만 반영됨 | 대상 게시판 중 일부가 적용 도중 실패(예: 유효성 위반) | `settings.after_bulk_apply_aborted` 훅 시점의 로그로 실패한 게시판을 특정한 뒤 개별 재적용 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -0,0 +1,22 @@
# 게시판 개발자 문서
> modules/_bundled/sirsoft-board · 모듈
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 90 · **구독 훅 수**: 77 · **라우트 수**: 80 · **모델 수**: 9 · **테이블 수**: 10 · **마이그레이션 수**: 30 · **레이아웃 수**: 46 · **핸들러 수**: 0
<!-- @generated:stats END -->
## 문서 목차
<!-- @generated:doc-toc START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 문서 | 내용 |
|---|---|
| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 |
| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 |
| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum |
| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 |
| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [api/](api/README.md) | API 레퍼런스 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,75 @@
# 게시판 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
게시판마다 완결된 권한·역할 체계를 갖게 하면서도, 새 게시판을 만드는 데 코드 변경이 필요 없게
하는 것이 이 모듈의 핵심 설계 목표입니다. `boards` 테이블 한 행이 하나의 확장처럼 동작하도록,
권한·역할·메뉴는 모두 **런타임에 게시판 데이터로부터 파생**됩니다(`getDynamicPermissionIdentifiers()`
등 3개 메서드가 코드가 아니라 DB 를 읽어 계산). 그 대가로 이 세 메서드는 게시판이 하나
추가/삭제될 때마다 정확해야 하고, 어긋나면 stale 권한이 남거나 존재하는 게시판의 권한이
정리 대상으로 오판됩니다.
또한 "관리자 백엔드 모듈 + 방문자 화면은 템플릿" 분리를 의도적으로 유지합니다. 방문자 화면을
이 모듈 안에 두면 템플릿마다 디자인이 다른 게시판 UI 를 이 모듈이 전부 알아야 하는데,
API 로만 노출하면 템플릿 쪽에서 자유롭게 화면을 구성할 수 있습니다. 이 경계 때문에
"레이아웃 확장"·"레이아웃"에는 오직 관리자 화면만 나타나며, 그것이 정상입니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
Http/Controllers (Admin/ 관리자, User/ 공개 API)
│
▼
FormRequest (검증 + *_validation_rules 필터 훅으로 게시판별 규칙 확장 지점)
│
▼
Services (BoardService/PostService/CommentService/ReportService/AttachmentService 등)
│ before_* → filter_*_data → 실행 → after_* (전 도메인 공통 3단 훅 패턴)
▼
Repositories (RepositoryInterface 경유, 정렬 화이트리스트·컬럼 프루닝)
│
▼
Models (SoftDeletes 적용 — Post/Comment/Attachment/Report)
```
Listeners(`src/Listeners/`)는 이 흐름과 별도 레인입니다 — Service 가 발행한 훅을 받아 카운트
동기화(`*CountSyncListener`)·활동 로그·SEO 캐시·검색 색인·알림 데이터 추출을 수행하며, Service
자신은 이 부가효과를 알지 못합니다. 이 분리 덕분에 새 부가효과(예: 신규 카운트 컬럼 동기화)는
Service 를 건드리지 않고 리스너 추가만으로 끝납니다.
<!-- @intent END -->
## 디렉토리
<!-- @generated:directory-map START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 |
| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 |
| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-board --force` (빌드 불필요) |
| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-board --force` |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-board --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-board --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-board --force` |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,161 @@
# 게시판 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
| `Attachment` | `board_attachments` | 15 | board→Board, post→Post, creator→User | SoftDeletes |
| `Board` | `boards` | 37 | creator→User, updater→User, posts→Post, comments→Comment, attachments→Attachment, reports→Report | - |
| `BoardStat` | `board_stats` | 3 | - | - |
| `BoardType` | `board_types` | 3 | - | HasUserOverrides |
| `Comment` | `board_comments` | 14 | board→Board, post→Post, user→User, parent→self, replies→self | SoftDeletes |
| `Post` | `board_posts` | 21 | board→Board, user→User, parent→self, replies→self, comments→Comment, attachments→Attachment, 외 1개 | SoftDeletes, 검색 색인 |
| `Report` | `boards_reports` | 11 | board→Board, author→User, logs→ReportLog, processor→User | SoftDeletes |
| `ReportLog` | `boards_report_logs` | 6 | report→Report, reporter→User | - |
| `UserNotificationSetting` | `board_user_notification_settings` | 5 | user→User | - |
<!-- @generated:models END -->
<!-- @intent START -->
`Post`·`Comment` 모두 `parent`/`replies` 자기참조 관계를 갖습니다 — `Post` 의 자기참조는
"답변형 게시판"(원글에 대한 관리자 답변)을, `Comment` 의 자기참조는 대댓글을 표현합니다. 둘은
서로 다른 기능이라 관계 이름은 같아도 코드에서 섞어 쓰지 않습니다. `Board` 는 `HasUserOverrides`
를 쓰지 **않습니다** — 게시판 자체는 운영자가 직접 소유·수정하는 리소스라 "모듈 재설치 시
운영자 수정 보존"이 필요 없는 반면, `BoardType`(게시판 유형 프리셋)은 모듈이 시딩한 기본값을
운영자가 손댈 수 있어야 하므로 그 트레이트를 씁니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 테이블 | 모델 |
|---|---|
| `board_attachments` | `Attachment` |
| `board_comments` | `Comment` |
| `board_mail_templates` | - |
| `board_posts` | `Post` |
| `board_stats` | `BoardStat` |
| `board_types` | `BoardType` |
| `board_user_notification_settings` | `UserNotificationSetting` |
| `boards` | `Board` |
| `boards_report_logs` | `ReportLog` |
| `boards_reports` | `Report` |
<!-- @generated:tables END -->
<!-- @intent START -->
`board_mail_templates` 는 모델이 없는 채로 남아 있습니다 — 아래 마이그레이션 표의
`drop_board_mail_templates_table`(2026-04-13)이 보여주듯, 메일 템플릿을 자체 테이블로
관리하던 초기 설계를 코어 `GenericNotification` 알림 정의(§알림 정의)로 이관하며 테이블만
드롭하고 이름은 이력상 남아 있는 상태입니다. 신규 코드에서 이 이름을 참조하지 않습니다.
테이블 접두어가 `board_*`와 `boards_*` 두 가지로 섞여 있는 것은 설계 의도가 아니라 이력입니다
— 새 테이블을 추가할 때는 `board_*`(단수)를 따릅니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
마이그레이션 30개.
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|---|---|---|---|
| `2026_04_01_000001_create_board_types_table.php` | `board_types` | - | ✅ |
| `2026_04_01_000002_create_boards_table.php` | `boards` | `boards` | ✅ |
| `2026_04_01_000003_create_board_user_notification_settings_table.php` | `board_user_notification_settings` | `board_user_notification_settings` | ✅ |
| `2026_04_01_000004_create_board_posts_table.php` | `board_posts` | - | ✅ |
| `2026_04_01_000005_create_board_comments_table.php` | `board_comments` | - | ✅ |
| `2026_04_01_000006_create_board_attachments_table.php` | `board_attachments` | - | ✅ |
| `2026_04_01_000007_create_boards_reports_table.php` | `boards_reports` | `boards_reports` | ✅ |
| `2026_04_01_000008_create_boards_report_logs_table.php` | `boards_report_logs` | `boards_report_logs` | ✅ |
| `2026_04_01_000009_create_board_mail_templates_table.php` | `board_mail_templates` | - | ✅ |
| `2026_04_01_000010_add_fulltext_indexes_to_boards_table.php` | - | `boards` | ✅ |
| `2026_04_01_000011_add_fulltext_indexes_to_boards_report_logs_table.php` | - | `boards_report_logs` | ✅ |
| `2026_04_01_000012_add_indexes_to_board_posts_table.php` | - | `board_posts` | ✅ |
| `2026_04_13_000001_drop_board_mail_templates_table.php` | `board_mail_templates` | - | ✅ |
| `2026_04_13_000002_add_user_overrides_to_board_types_table.php` | - | `board_types` | ✅ |
| `2026_04_14_000001_drop_channel_columns_from_boards_table.php` | - | `boards` | ✅ |
| `2026_04_17_000001_remove_partitions_from_board_tables.php` | - | - | ✅ |
| `2026_04_17_000002_add_count_columns_to_board_tables.php` | - | `board_posts`, `board_comments` | ✅ |
| `2026_04_17_000003_add_posts_count_and_comments_count_to_boards_table.php` | - | `boards` | ✅ |
| `2026_04_17_000004_update_indexes_in_board_tables.php` | - | `board_posts`, `board_comments`, `board_attachments` | ✅ |
| `2026_05_29_000001_create_board_stats_table.php` | `board_stats` | - | ✅ |
| `2026_06_08_000001_add_recent_across_boards_index_to_board_posts.php` | - | `board_posts` | ✅ |
| `2026_06_26_000001_modify_trigger_type_in_board_comments_table.php` | - | - | ✅ |
| `2026_06_26_000002_add_trigger_type_to_board_attachments_table.php` | - | `board_attachments` | ✅ |
| `2026_08_01_000001_add_tiebreak_to_board_posts_list_index.php` | - | - | ✅ |
| `2026_08_01_000002_add_list_sort_index_to_boards_reports_table.php` | - | - | ✅ |
| `2026_08_06_000001_update_max_reply_depth_comment_in_boards_table.php` | - | - | ✅ |
| `2026_08_06_000002_add_view_count_sort_index_to_board_posts_table.php` | - | - | ✅ |
| `2026_08_17_000001_add_reply_delete_policy_to_boards_table.php` | - | `boards` | ✅ |
| `2026_08_17_000002_modify_trigger_type_in_board_posts_table.php` | - | - | ✅ |
| `2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php` | - | `board_posts` | ✅ |
<!-- @generated:migrations END -->
<!-- @intent START -->
목록 성능 마이그레이션이 지속적으로 추가되는 것(인덱스 4건 + 정렬 타이브레이크 2건)은 우연이
아닙니다 — 게시글 목록은 공지 제외·답변 제외 필터가 항상 걸린 채로 `created_at`/`view_count`
2가지 정렬을 지원해야 하므로, 새 정렬 옵션을 추가할 때마다 그 정렬에 맞는 복합 인덱스를 함께
마이그레이션합니다(`getBenchmarkProfiles()` 의 `board_posts_by_view_count` 프로파일이 바로 이
목적으로 존재합니다). `remove_partitions_from_board_tables`(2026-04-17)는 `board_posts`/`board_comments`/
`board_attachments` 3개 테이블에 적용했던 파티셔닝을 되돌린 이력입니다 — 그 마이그레이션의
`down()` 은 "파티션 복원은 데이터 재배치가 필요해 자동 롤백 불가"라고 명시하므로, 파티셔닝을
다시 도입할 때는 이 파일을 그대로 재실행하는 방식이 아니라 새 마이그레이션으로 설계해야 합니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| Enum | backing | case 수 | case |
|---|---|---|---|
| `BoardOrderBy` | `string` | 4 | `created_at`, `view_count`, `title`, `author` |
| `OrderDirection` | `string` | 2 | `ASC`, `DESC` |
| `PostStatus` | `string` | 3 | `published`, `blinded`, `deleted` |
| `ReplyDeletePolicy` | `string` | 2 | `block`, `cascade` |
| `ReportReasonType` | `string` | 9 | `abuse`, `hate_speech`, `spam`, `copyright`, `privacy`, `misinformation`, `sexual`, `violence`, `외 1개` |
| `ReportStatus` | `string` | 5 | `pending`, `review`, `rejected`, `suspended`, `deleted` |
| `ReportType` | `string` | 2 | `post`, `comment` |
| `SecretMode` | `string` | 3 | `disabled`, `enabled`, `always` |
| `TriggerType` | `string` | 6 | `report`, `admin`, `system`, `auto_hide`, `user`, `cascade` |
<!-- @generated:enums END -->
<!-- @intent START -->
`TriggerType`(6 case: report/admin/system/auto_hide/user/cascade)은 "이 콘텐츠가 왜 지금
상태가 됐는가"를 기록하는 감사(audit) 축입니다 — 예를 들어 게시글 블라인드가 `report`(신고
처리 결과)인지 `admin`(관리자 직접 조치)인지에 따라 `post_action`/`report_action` 두 알림이
갈라집니다(§확장점 "알림 정의" 참고). `cascade` 는 부모(게시글)가 지워질 때 자식(댓글)이
함께 지워진 경우이며, `ReplyDeletePolicy`(`block`/`cascade`)가 게시판별로 부모 삭제 시
자식을 막을지 함께 지울지를 결정합니다 — 이 정책과 `TriggerType::Cascade` 는 같은 흐름의
서로 다른 절반(정책 설정 vs 결과 기록)입니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `AttachmentRepository` | 구현 | 게시판 첨부파일 Repository 구현체 |
| `AttachmentRepositoryInterface` | 인터페이스 | 게시판 첨부파일 Repository 인터페이스 |
| `BoardRepository` | 구현 | 게시판 Repository |
| `BoardRepositoryInterface` | 인터페이스 | 게시판 Repository 인터페이스 |
| `BoardStatRepository` | 구현 | 게시판 일별 집계 Repository |
| `BoardStatRepositoryInterface` | 인터페이스 | 게시판 일별 집계 Repository 인터페이스 |
| `BoardTypeRepository` | 구현 | - |
| `BoardTypeRepositoryInterface` | 인터페이스 | - |
| `CommentRepository` | 구현 | 댓글 Repository |
| `CommentRepositoryInterface` | 인터페이스 | 댓글 Repository 인터페이스 |
| `PostRepository` | 구현 | 게시글 Repository |
| `PostRepositoryInterface` | 인터페이스 | 게시글 Repository 인터페이스 |
| `ReportRepository` | 구현 | 신고 Repository |
| `ReportRepositoryInterface` | 인터페이스 | 신고 Repository 인터페이스 |
| `UserNotificationSettingRepository` | 구현 | 사용자 알림 설정 Repository |
| `UserNotificationSettingRepositoryInterface` | 인터페이스 | 사용자 알림 설정 Repository 인터페이스 |
<!-- @generated:repositories END -->
<!-- @intent START -->
`BoardStatRepository`(일별 집계)는 별도 Repository 로 분리돼 있습니다 — `sirsoft-board:aggregate-stats`
스케줄이 매시간 `board_stats` 를 갱신하는데, 이 집계 쿼리를 `PostRepository`/`CommentRepository`
에 섞으면 대시보드 조회 경로와 실시간 CRUD 경로가 같은 클래스 안에서 뒤엉킵니다. 새 Repository
를 추가할 때는 반드시 인터페이스를 함께 만들고 `CoreServiceProvider`(또는 이 모듈의
서비스 프로바이더)에서 바인딩합니다 — Service 가 구체 클래스를 직접 타입힌트하면 안 됩니다.
<!-- @intent END -->
@@ -0,0 +1,323 @@
# 게시판 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 90종 / 호출 지점 91곳. 이 중 90종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `core.module_settings.after_save` | action | — | `src/Http/Controllers/Admin/BoardSettingsController.php:123` |
| `sirsoft-board.attachment.after_delete` | action | — | `src/Services/AttachmentService.php:463` |
| `sirsoft-board.attachment.after_download` | action | — | `src/Services/AttachmentService.php:776` |
| `sirsoft-board.attachment.after_link` | action | — | `src/Services/AttachmentService.php:217` 외 1곳 |
| `sirsoft-board.attachment.after_reorder` | action | — | `src/Services/AttachmentService.php:631` |
| `sirsoft-board.attachment.after_upload` | action | — | `src/Services/AttachmentService.php:192` |
| `sirsoft-board.attachment.before_delete` | action | — | `src/Services/AttachmentService.php:442` |
| `sirsoft-board.attachment.before_reorder` | action | — | `src/Services/AttachmentService.php:626` |
| `sirsoft-board.attachment.before_upload` | action | — | `src/Services/AttachmentService.php:117` |
| `sirsoft-board.attachment.filter_upload_file` | filter | — | `src/Services/AttachmentService.php:120` |
| `sirsoft-board.attachment.reorder_validation_rules` | filter | — | `src/Http/Requests/ReorderAttachmentsRequest.php:35` |
| `sirsoft-board.attachment.upload_validation_rules` | filter | — | `src/Http/Requests/UploadAttachmentRequest.php:77` |
| `sirsoft-board.board.after_add_to_menu` | action | — | `src/Services/BoardService.php:1059` |
| `sirsoft-board.board.after_create` | action | — | `src/Services/BoardService.php:457` |
| `sirsoft-board.board.after_delete` | action | — | `src/Services/BoardService.php:623` |
| `sirsoft-board.board.after_remove_from_menu` | action | — | `src/Services/BoardService.php:1104` |
| `sirsoft-board.board.after_update` | action | — | `src/Services/BoardService.php:543` |
| `sirsoft-board.board.before_add_to_menu` | action | — | `src/Services/BoardService.php:1023` |
| `sirsoft-board.board.before_copy` | action | — | `src/Services/BoardService.php:646` |
| `sirsoft-board.board.before_create` | action | — | `src/Services/BoardService.php:382` |
| `sirsoft-board.board.before_delete` | action | — | `src/Services/BoardService.php:574` |
| `sirsoft-board.board.before_remove_from_menu` | action | — | `src/Services/BoardService.php:1090` |
| `sirsoft-board.board.before_update` | action | — | `src/Services/BoardService.php:495` |
| `sirsoft-board.board.filter_copy_data` | filter | — | `src/Services/BoardService.php:703` |
| `sirsoft-board.board.filter_create_data` | filter | — | `src/Services/BoardService.php:385` |
| `sirsoft-board.board.filter_menu_data` | filter | — | `src/Services/BoardService.php:1053` |
| `sirsoft-board.board.filter_update_data` | filter | — | `src/Services/BoardService.php:500` |
| `sirsoft-board.board.posts.before_force_delete` | action | — | `src/Services/BoardService.php:593` |
| `sirsoft-board.board.store_validation_rules` | filter | — | `src/Http/Requests/StoreBoardRequest.php:225` |
| `sirsoft-board.board.update_validation_rules` | filter | — | `src/Http/Requests/UpdateBoardRequest.php:228` |
| `sirsoft-board.board_type.after_create` | action | — | `src/Services/BoardTypeService.php:42` |
| `sirsoft-board.board_type.after_delete` | action | — | `src/Services/BoardTypeService.php:107` |
| `sirsoft-board.board_type.after_update` | action | — | `src/Services/BoardTypeService.php:70` |
| `sirsoft-board.board_type.before_create` | action | — | `src/Services/BoardTypeService.php:36` |
| `sirsoft-board.board_type.before_delete` | action | — | `src/Services/BoardTypeService.php:103` |
| `sirsoft-board.board_type.before_update` | action | — | `src/Services/BoardTypeService.php:62` |
| `sirsoft-board.board_type.filter_create_data` | filter | — | `src/Services/BoardTypeService.php:38` |
| `sirsoft-board.board_type.filter_update_data` | filter | — | `src/Services/BoardTypeService.php:66` |
| `sirsoft-board.comment.after_blind` | action | — | `src/Services/CommentService.php:480` |
| `sirsoft-board.comment.after_create` | action | — | `src/Services/CommentService.php:375` |
| `sirsoft-board.comment.after_delete` | action | — | `src/Services/CommentService.php:441` |
| `sirsoft-board.comment.after_restore` | action | — | `src/Services/CommentService.php:516` |
| `sirsoft-board.comment.after_update` | action | — | `src/Services/CommentService.php:410` |
| `sirsoft-board.comment.before_blind` | action | — | `src/Services/CommentService.php:471` |
| `sirsoft-board.comment.before_create` | action | — | `src/Services/CommentService.php:341` |
| `sirsoft-board.comment.before_delete` | action | — | `src/Services/CommentService.php:431` |
| `sirsoft-board.comment.before_restore` | action | — | `src/Services/CommentService.php:507` |
| `sirsoft-board.comment.before_update` | action | — | `src/Services/CommentService.php:399` |
| `sirsoft-board.comment.filter_create_data` | filter | — | `src/Services/CommentService.php:344` |
| `sirsoft-board.comment.filter_update_data` | filter | — | `src/Services/CommentService.php:404` |
| `sirsoft-board.comment.store_validation_rules` | filter | — | `src/Http/Requests/StoreCommentRequest.php:91` |
| `sirsoft-board.comment.update_validation_rules` | filter | — | `src/Http/Requests/UpdateCommentRequest.php:67` |
| `sirsoft-board.permissions.after_create` | action | — | `src/Services/BoardService.php:431` |
| `sirsoft-board.permissions.after_delete` | action | — | `src/Services/BoardService.php:600` |
| `sirsoft-board.permissions.after_update` | action | — | `src/Services/BoardService.php:523` |
| `sirsoft-board.post.after_blind` | action | — | `src/Services/PostService.php:500` |
| `sirsoft-board.post.after_create` | action | — | `src/Services/PostService.php:275` |
| `sirsoft-board.post.after_delete` | action | — | `src/Services/PostService.php:459` |
| `sirsoft-board.post.after_restore` | action | — | `src/Services/PostService.php:564` |
| `sirsoft-board.post.after_update` | action | — | `src/Services/PostService.php:351` |
| `sirsoft-board.post.before_blind` | action | — | `src/Services/PostService.php:491` |
| `sirsoft-board.post.before_create` | action | — | `src/Services/PostService.php:250` |
| `sirsoft-board.post.before_delete` | action | — | `src/Services/PostService.php:426` |
| `sirsoft-board.post.before_restore` | action | — | `src/Services/PostService.php:533` |
| `sirsoft-board.post.before_update` | action | — | `src/Services/PostService.php:324` |
| `sirsoft-board.post.filter_content_thumbnail` | filter | — | `src/Models/Post.php:138` |
| `sirsoft-board.post.filter_create_data` | filter | — | `src/Services/PostService.php:253` |
| `sirsoft-board.post.filter_update_data` | filter | — | `src/Services/PostService.php:329` |
| `sirsoft-board.post.store_validation_rules` | filter | — | `src/Http/Requests/StorePostRequest.php:120` |
| `sirsoft-board.post.update_validation_rules` | filter | — | `src/Http/Requests/UpdatePostRequest.php:77` |
| `sirsoft-board.report.after_blind_content` | action | — | `src/Services/ReportService.php:712` |
| `sirsoft-board.report.after_bulk_update_status` | action | — | `src/Services/ReportService.php:492` |
| `sirsoft-board.report.after_create` | action | — | `src/Services/ReportService.php:292` |
| `sirsoft-board.report.after_delete` | action | — | `src/Services/ReportService.php:559` |
| `sirsoft-board.report.after_delete_content` | action | — | `src/Services/ReportService.php:880` |
| `sirsoft-board.report.after_restore_content` | action | — | `src/Services/ReportService.php:678` |
| `sirsoft-board.report.after_update_status` | action | — | `src/Services/ReportService.php:369` |
| `sirsoft-board.report.before_bulk_update_status` | action | — | `src/Services/ReportService.php:385` |
| `sirsoft-board.report.before_create` | action | — | `src/Services/ReportService.php:203` |
| `sirsoft-board.report.before_delete` | action | — | `src/Services/ReportService.php:554` |
| `sirsoft-board.report.before_update_status` | action | — | `src/Services/ReportService.php:324` |
| `sirsoft-board.report.filter_create_data` | filter | — | `src/Services/ReportService.php:227` |
| `sirsoft-board.roles.after_create` | action | — | `src/Services/BoardService.php:411` |
| `sirsoft-board.roles.after_delete` | action | — | `src/Services/BoardService.php:604` |
| `sirsoft-board.search.post.index_should_update` | filter | — | `src/Models/Post.php:280` |
| `sirsoft-board.settings.after_bulk_apply` | action | — | `src/Services/BoardService.php:1368` |
| `sirsoft-board.settings.after_bulk_apply_aborted` | action | — | `src/Services/BoardService.php:1359` |
| `sirsoft-board.settings.before_bulk_apply` | action | — | `src/Services/BoardService.php:1263` |
| `sirsoft-board.user_post.store_validation_rules` | filter | — | `src/Http/Requests/User/StorePostRequest.php:162` |
| `sirsoft-board.user_post.update_validation_rules` | filter | — | `src/Http/Requests/User/UpdatePostRequest.php:112` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
`{도메인}.{동사}` 이름 규칙 안에서 4개 도메인(board/post/comment/report)이 전부 같은 3단
패턴(`before_*` → `filter_*_data` → `after_*`)을 반복합니다. 새 검증 규칙을 게시판별로 다르게
걸고 싶으면 `*.store_validation_rules`/`update_validation_rules` filter 를, 저장 직전 데이터를
가공하고 싶으면 `filter_*_data` 를, 저장 완료 후 부가 작업(알림·외부 연동)을 붙이고 싶으면
`after_*` action 을 잡습니다 — `before_*` 에서 예외를 던지면 저장 자체를 막을 수 있습니다.
`sirsoft-board.post.filter_content_thumbnail`/`sirsoft-board.search.post.index_should_update`
두 필터는 Model(`Post.php`) 안에서 직접 발행되는 예외적인 자리입니다 — Service 를 거치지 않는
지연 평가(썸네일 추출, 검색 색인 갱신 여부 판단)라 Model 이 직접 훅을 겁니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `core.activity_log.filter_description_params` | filter | `ActivityLogDescriptionResolver` | `resolveDescriptionParams` | 10 |
| `core.module_settings.after_save` | action (미선언) | `SeoBoardSettingsCacheListener` | `onModuleSettingsSave` | 20 |
| `core.notification.filter_default_definitions` | filter | `BoardNotificationDataListener` | `contributeDefaultDefinitions` | 20 |
| `core.search.build_response` | filter | `SearchPostsListener` | `buildPostsResponse` | 10 |
| `core.search.index_validation_rules` | filter | `SearchPostsListener` | `addValidationRules` | 10 |
| `core.search.results` | filter | `SearchPostsListener` | `searchPosts` | 10 |
| `core.user.after_create` | action (미선언) | `UserNotificationSettingsListener` | `afterCreate` | 10 |
| `core.user.create_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
| `core.user.filter_create_data` | filter | `UserNotificationSettingsListener` | `filterCreateData` | 10 |
| `core.user.filter_resource_data` | filter | `UserNotificationSettingsListener` | `filterResourceData` | 10 |
| `core.user.filter_update_data` | filter | `UserNotificationSettingsListener` | `filterUpdateData` | 10 |
| `core.user.update_profile_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
| `core.user.update_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 |
| `sirsoft-board.attachment.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterDelete` | 20 |
| `sirsoft-board.attachment.after_delete` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
| `sirsoft-board.attachment.after_download` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterDownload` | 20 |
| `sirsoft-board.attachment.after_link` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
| `sirsoft-board.attachment.after_upload` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterUpload` | 20 |
| `sirsoft-board.attachment.after_upload` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 |
| `sirsoft-board.board.after_add_to_menu` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterAddToMenu` | 20 |
| `sirsoft-board.board.after_create` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterCreate` | 20 |
| `sirsoft-board.board.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterDelete` | 20 |
| `sirsoft-board.board.after_remove_from_menu` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterRemoveFromMenu` | 20 |
| `sirsoft-board.board.after_update` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterUpdate` | 20 |
| `sirsoft-board.board.after_update` | action (미선언) | `SeoBoardCacheListener` | `onBoardUpdate` | 20 |
| `sirsoft-board.board_type.after_create` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterCreate` | 20 |
| `sirsoft-board.board_type.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterDelete` | 20 |
| `sirsoft-board.board_type.after_update` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterUpdate` | 20 |
| `sirsoft-board.comment.after_blind` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterBlind` | 20 |
| `sirsoft-board.comment.after_create` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterCreate` | 20 |
| `sirsoft-board.comment.after_create` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
| `sirsoft-board.comment.after_create` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
| `sirsoft-board.comment.after_create` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
| `sirsoft-board.comment.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterDelete` | 20 |
| `sirsoft-board.comment.after_delete` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
| `sirsoft-board.comment.after_delete` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
| `sirsoft-board.comment.after_delete` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
| `sirsoft-board.comment.after_restore` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterRestore` | 20 |
| `sirsoft-board.comment.after_restore` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 |
| `sirsoft-board.comment.after_restore` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 |
| `sirsoft-board.comment.after_restore` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 |
| `sirsoft-board.comment.after_update` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterUpdate` | 20 |
| `sirsoft-board.notification.channels` | filter | `BoardNotificationChannelListener` | `filterChannels` | 10 |
| `sirsoft-board.notification.extract_data` | filter | `BoardNotificationDataListener` | `extractData` | 20 |
| `sirsoft-board.post.after_blind` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterBlind` | 20 |
| `sirsoft-board.post.after_create` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterCreate` | 20 |
| `sirsoft-board.post.after_create` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
| `sirsoft-board.post.after_create` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
| `sirsoft-board.post.after_create` | action (미선언) | `SeoBoardCacheListener` | `onPostCreate` | 20 |
| `sirsoft-board.post.after_delete` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterDelete` | 20 |
| `sirsoft-board.post.after_delete` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
| `sirsoft-board.post.after_delete` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
| `sirsoft-board.post.after_delete` | action (미선언) | `SeoBoardCacheListener` | `onPostDelete` | 20 |
| `sirsoft-board.post.after_restore` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterRestore` | 20 |
| `sirsoft-board.post.after_restore` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 |
| `sirsoft-board.post.after_restore` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 |
| `sirsoft-board.post.after_update` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterUpdate` | 20 |
| `sirsoft-board.post.after_update` | action (미선언) | `SeoBoardCacheListener` | `onPostUpdate` | 20 |
| `sirsoft-board.report.after_blind_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterBlindContent` | 20 |
| `sirsoft-board.report.after_bulk_update_status` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterBulkUpdateStatus` | 20 |
| `sirsoft-board.report.after_create` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterCreate` | 20 |
| `sirsoft-board.report.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterDelete` | 20 |
| `sirsoft-board.report.after_delete_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterDeleteContent` | 20 |
| `sirsoft-board.report.after_restore_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterRestoreContent` | 20 |
| `sirsoft-board.report.after_update_status` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterUpdateStatus` | 20 |
| `sirsoft-board.settings.after_bulk_apply` | action (미선언) | `BoardActivityLogListener` | `handleSettingsAfterBulkApply` | 20 |
| `sirsoft-board.settings.after_bulk_apply` | action (미선언) | `SeoBoardSettingsCacheListener` | `onBulkApply` | 20 |
| `sirsoft-board.settings.after_bulk_apply_aborted` | action (미선언) | `BoardActivityLogListener` | `handleSettingsAfterBulkApplyAborted` | 20 |
| `sirsoft-ckeditor5.image.filter_reference_sources` | filter | `Ckeditor5ReferenceSourcesListener` | `addBoardSources` | 10 |
| `sirsoft-ecommerce.inquiry.count_replies` | filter | `EcommerceInquiryHookListener` | `countReplies` | 10 |
| `sirsoft-ecommerce.inquiry.create` | filter | `EcommerceInquiryHookListener` | `createAndReturn` | 10 |
| `sirsoft-ecommerce.inquiry.delete` | filter | `EcommerceInquiryHookListener` | `deletePost` | 10 |
| `sirsoft-ecommerce.inquiry.delete_reply` | filter | `EcommerceInquiryHookListener` | `deleteReplyPost` | 10 |
| `sirsoft-ecommerce.inquiry.get_by_ids` | filter | `EcommerceInquiryHookListener` | `getByIds` | 10 |
| `sirsoft-ecommerce.inquiry.get_settings` | filter | `EcommerceInquiryHookListener` | `getBoardSettings` | 10 |
| `sirsoft-ecommerce.inquiry.update` | filter | `EcommerceInquiryHookListener` | `updatePost` | 10 |
| `sirsoft-ecommerce.inquiry.update_reply` | filter | `EcommerceInquiryHookListener` | `updateReplyPost` | 10 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
`core.user.*` 4종을 구독하는 이유는 회원가입/수정 화면에 "댓글 알림 수신 여부" 필드를 끼워
넣기 위해서입니다 — 이 필드는 `UserNotificationSetting` 모델(board 소유)에 저장되지만, 입력
자체는 코어 회원 폼에서 받습니다. `sirsoft-ckeditor5.image.filter_reference_sources` 구독은
board 글 본문(HTML 에디터)에 삽입된 이미지가 삭제 시 함께 정리되도록 참조 소스 목록에 게시글을
등록하는 자리입니다. `sirsoft-ecommerce.inquiry.*` 8개는 이커머스 "상품 문의"가 board 의
Post/Comment CRUD 를 그대로 재사용하되 저장 로직만 이커머스가 대신 처리하는 위임 지점입니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `ActivityLogDescriptionResolver` | 1개 | 명시 등록 | ✅ | `src/Listeners/ActivityLogDescriptionResolver.php` |
| `BoardActivityLogListener` | 30개 | 명시 등록 | ✅ | `src/Listeners/BoardActivityLogListener.php` |
| `BoardCommentsCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/BoardCommentsCountSyncListener.php` |
| `BoardNotificationChannelListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/BoardNotificationChannelListener.php` |
| `BoardNotificationDataListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/BoardNotificationDataListener.php` |
| `BoardPostsCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/BoardPostsCountSyncListener.php` |
| `Ckeditor5ReferenceSourcesListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/Ckeditor5ReferenceSourcesListener.php` |
| `CommentReplySyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/CommentReplySyncListener.php` |
| `EcommerceInquiryHookListener` | 8개 | 명시 등록 | ✅ | `src/Listeners/EcommerceInquiryHookListener.php` |
| `PostAttachmentCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostAttachmentCountSyncListener.php` |
| `PostCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostCountSyncListener.php` |
| `PostReplySyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostReplySyncListener.php` |
| `SearchPostsListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SearchPostsListener.php` |
| `SeoBoardCacheListener` | 4개 | 명시 등록 | ✅ | `src/Listeners/SeoBoardCacheListener.php` |
| `SeoBoardSettingsCacheListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/SeoBoardSettingsCacheListener.php` |
| `UserNotificationSettingsListener` | 7개 | 명시 등록 | ✅ | `src/Listeners/UserNotificationSettingsListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
`BoardActivityLogListener` 하나가 30개 훅을 구독하는 것이 의도된 형태입니다 — 활동 로그는
"무엇이 언제 왜 바뀌었는가"를 도메인 전체에서 일관된 형식으로 남겨야 하므로, 도메인별로
리스너를 쪼개면 로그 스키마가 갈라질 위험이 커집니다. 반대로 카운트 동기화(`*CountSyncListener`)
는 목적이 하나씩이라 도메인별로 쪼개져 있습니다 — 첨부 개수와 댓글 개수는 서로 독립적으로
실패해도 되므로, 한쪽이 예외를 던져도 다른 쪽 동기화는 영향받지 않습니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/admin-ecommerce-inquiry-settings.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/admin_dashboard_community.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/admin_dashboard_quick_menu.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/user-notification-detail.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/user-notification-settings.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
5개 조각 중 `admin-ecommerce-inquiry-settings.json`·`admin_dashboard_community.json`·
`admin_dashboard_quick_menu.json` 은 board 자신의 화면이 아니라 **다른 확장(이커머스 문의
설정 화면, 관리자 대시보드)에** 게시판 관련 UI 를 끼워 넣는 조각입니다. 이 모듈이 다른 확장의
레이아웃을 코드로 알지 못한 채(레이아웃 확장 시스템을 통해서만) UI 를 주입한다는 뜻입니다.
나머지 2개(`user-notification-*`)는 코어 회원 알림 설정 화면에 board 알림 수신 옵션을
끼워 넣는 자리입니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
공개 API 라우트(`optional.sanctum`)와 관리자 API 라우트(코어 `auth`+권한 미들웨어)는 전부
코어가 이미 등록한 미들웨어로 충분합니다. board 만의 요청 전처리(예: 게시판별 rate limit)가
필요해지면 이 자리에 선언형으로 추가하되, 대상(targets)을 명시해 자기 라우트에만 부착합니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
실시간 갱신(새 댓글이 열려 있는 화면에 즉시 반영되는 등)은 이 모듈의 범위 밖입니다(§1 참고).
필요해지면 `sirsoft-board.{slug}.*` 채널을 신설하되, 게시판별로 채널을 분리해야 방문자가
관심 없는 다른 게시판의 이벤트까지 구독하지 않습니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 스케줄 | 주기 | 설명 |
|---|---|---|
| `sirsoft-board:aggregate-stats` | `-` | 대시보드 게시물 현황 집계 |
| `sirsoft-board:prune-attachments --scheduled` | `-` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 |
<!-- @generated:schedules END -->
<!-- @intent START -->
`prune-attachments` 는 두 가지 서로 다른 작업을 한 스케줄에 묶습니다 — "방치된 임시 첨부
정리"(업로드했지만 게시글 저장까지 이어지지 않은 파일)는 사용자 파일을 지우지 않으므로 항상
실행되고, "보존기간 경과 삭제 첨부 영구 정리"(이미 삭제 처리된 첨부의 실제 파일 파기)는
`attachment_settings.purge_enabled` 로 게이트됩니다 — module.php 의 `enabled_config: null` 은
스케줄 자체는 끌 수 없다는 뜻이고, 실제 파기 여부만 설정으로 조정됩니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 알림 키 | 채널 |
|---|---|
| `new_comment` | `mail`, `database` |
| `reply_comment` | `mail`, `database` |
| `post_reply` | `mail`, `database` |
| `post_action` | `mail`, `database` |
| `new_post_admin` | `mail`, `database` |
| `report_received_admin` | `mail`, `database` |
| `report_action` | `mail`, `database` |
<!-- @generated:notifications END -->
<!-- @intent START -->
7종 중 `new_comment`/`reply_comment`/`post_reply` 는 회원이 끌 수 있습니다
(`UserNotificationSetting`, `core.user.*` 훅으로 회원 폼에 노출) — 반면 관리자 대상 알림
(`new_post_admin`/`report_received_admin`)과 신고 처리 결과 알림(`post_action`/`report_action`)
은 끌 수 없습니다. `post_action`과 `report_action`이 정확히 같은 훅 6개를 구독하는 것은
중복이 아니라 **관점의 차이**입니다 — 관리자가 직접 블라인드했는지, 신고 처리 결과로
블라인드됐는지에 따라 원 작성자에게 보이는 문구(원인 설명)가 갈라져야 하기 때문입니다.
<!-- @intent END -->
@@ -0,0 +1,121 @@
# 게시판 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 46개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 46개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `admin_board_form` | `admin` | 화면 | `_admin_base` |
| `admin_board_index` | `admin` | 화면 | `_admin_base` |
| `admin_board_post_detail` | `admin` | 화면 | `_admin_base` |
| `admin_board_post_form` | `admin` | 화면 | `_admin_base` |
| `admin_board_posts_index` | `admin` | 화면 | `_admin_base` |
| `admin_board_reports_detail` | `admin` | 화면 | `_admin_base` |
| `admin_board_reports_index` | `admin` | 화면 | `_admin_base` |
| `admin_board_settings` | `admin` | 화면 | `_admin_base` |
| `_board_type_manage_modal` | `admin` | partial | - |
| `_tab_basic` | `admin` | partial | - |
| `_tab_list` | `admin` | partial | - |
| `_tab_notification` | `admin` | partial | - |
| `_tab_permissions` | `admin` | partial | - |
| `_tab_post` | `admin` | partial | - |
| `_comment` | `admin` | partial | - |
| `_comments` | `admin` | partial | - |
| `_post_card_content` | `admin` | partial | - |
| `_reply_card_content` | `admin` | partial | - |
| `_attachments` | `admin` | partial | - |
| `_form_fields` | `admin` | partial | - |
| `_parent_post` | `admin` | partial | - |
| `_alert_status` | `admin` | partial | - |
| `_card_history` | `admin` | partial | - |
| `_card_report_info` | `admin` | partial | - |
| `_bulk_apply_modal` | `admin` | partial | - |
| `_modal_identity_policy_delete` | `admin` | partial | - |
| `_modal_identity_policy_form` | `admin` | partial | - |
| `_modal_mail_template_edit` | `admin` | partial | - |
| `_modal_notification_definition_reset` | `admin` | partial | - |
| `_modal_notification_template_edit` | `admin` | partial | - |
| `_modal_notification_template_preview` | `admin` | partial | - |
| `_tab_board_settings_attachment` | `admin` | partial | - |
| `_tab_board_settings_basic` | `admin` | partial | - |
| `_tab_board_settings_bulk_apply` | `admin` | partial | - |
| `_tab_board_settings_comment` | `admin` | partial | - |
| `_tab_board_settings_list` | `admin` | partial | - |
| `_tab_board_settings_notification` | `admin` | partial | - |
| `_tab_board_settings_permissions` | `admin` | partial | - |
| `_tab_board_settings_post` | `admin` | partial | - |
| `_tab_board_settings_reply` | `admin` | partial | - |
| `_tab_general` | `admin` | partial | - |
| `_tab_identity_policies` | `admin` | partial | - |
| `_tab_notification_definitions` | `admin` | partial | - |
| `_tab_report_policy` | `admin` | partial | - |
| `_tab_seo` | `admin` | partial | - |
| `_tab_spam_security` | `admin` | partial | - |
<!-- @generated:layouts END -->
<!-- @intent START -->
46개 레이아웃이 **전부** `admin` 그룹입니다 — 이것은 이 모듈이 방문자 화면을 그리지 않는다는
증거입니다(§1, §architecture.md 참고). 새로 방문자용 게시판 화면(예: 다른 스타일의 목록)이
필요하면 이 디렉토리가 아니라 그 화면을 쓸 템플릿의 `layouts/` 에 추가합니다. `_tab_*` partial
이 24개로 가장 많은 것은 이 모듈에 탭 구조를 쓰는 화면이 최소 세 곳이기 때문입니다 —
게시판 생성/수정 폼(`_tab_basic`/`list`/`post`/`permissions`/`notification`), 게시판별
개별 설정 화면(`_tab_board_settings_*` 9개 — attachment/basic/bulk_apply/comment/list/
notification/permissions/post/reply), 모듈 전역 환경설정 화면(`_tab_general`/
`identity_policies`/`notification_definitions`/`report_policy`/`seo`/`spam_security`).
세 화면의 탭 이름이 겹치더라도(`_tab_basic`, `_tab_board_settings_basic` 등) 서로 다른
partial 파일이므로 한쪽만 고치면 다른 화면은 그대로입니다 — 같은 항목을 여러 화면에
반영해야 한다면 파일을 전부 찾아 고쳐야 합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 액션 핸들러가 없습니다._
<!-- @generated:handlers END -->
<!-- @intent START -->
이 모듈의 관리자 레이아웃은 코어 빌트인 핸들러(`apiCall`/`navigate`/`setState` 등)만으로
전부 구성됩니다 — 게시판 CRUD·신고 처리·설정 저장은 결국 REST 호출 + 표준 폼 상태 관리라
전용 핸들러를 등록할 필요가 없었습니다. 새 관리자 화면을 추가할 때도 먼저 빌트인 핸들러
조합으로 가능한지 확인하고, 그래도 부족할 때만(예: 파일 업로드 진행률 같은 복잡한 클라이언트
상태) `resources/js/` 에 전용 핸들러를 신설합니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_프론트 엔트리포인트가 없습니다._
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
액션 핸들러가 없는 것과 같은 이유로 `window.__[Name]` 재등록 진입점도 없습니다 — 로케일
전환 후 재등록해야 할 자체 핸들러가 이 모듈에는 없기 때문입니다. 프론트 전용 코드
(`resources/js/`)를 신설하면 그 순간부터 이 자리에 진입점을 만들어야 합니다(§CLAUDE.md
"확장 미들웨어는..." 항목 인근의 재등록 진입점 규정 참고) — 없으면 로케일 전환 후 그 확장의
액션이 전부 무반응이 됩니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
`editor-spec.json` 이 이 모듈의 유일한 프론트 자산인 것도 위와 같은 이유입니다 — 레이아웃
편집기가 게시판 관리 화면의 컴포넌트를 인식하려면 이 선언이 필요하지만, 실행 시점에 로드할
JS/CSS 번들은 없습니다. `priority: 100` 은 다른 확장의 에셋 우선순위와 충돌하지 않는 기본값이며,
`dependencies: []` 는 이 확장의 에디터 스펙이 다른 확장의 스펙 로드를 전제하지 않는다는 뜻입니다.
<!-- @intent END -->
@@ -0,0 +1,98 @@
# 게시판 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_`getSettingsSchema()` 선언이 없습니다._
기본값 파일: `config/settings/defaults.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
`getSettingsSchema()` 가 없는 것은 누락이 아니라 설계입니다 — 이 모듈에는 "전역 설정"이라
부를 만한 것이 없습니다. 운영자가 조정하는 값은 전부 **게시판 하나**에 속한 설정
(`BoardSettingsService`, `/admin/boards/{slug}/settings`)이라 코어의 전역 설정 스키마
메커니즘과 맞지 않습니다. `config/board.php` 는 운영자가 바꾸는 자리가 아니라 개발자가
정의하는 상수(첨부 저장 디스크, 게시판별 동적 권한 정의 템플릿)이며, 이 값은 `.env` 로만
바꿉니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 카테고리 | 이름 | 액션 | 라우트 키 |
|---|---|---|---|
| `boards` | 게시판 관리 | `read`, `create`, `update`, `delete` | `board` |
| `settings` | 환경설정 | `read`, `update` | - |
| `identity.policies` | 게시판 본인인증 정책 | `read`, `update` | - |
| `dashboard` | 게시판 대시보드 | `view` | - |
| `reports` | 게시판 신고 관리 | `view`, `manage` | `report` |
<!-- @generated:permissions END -->
<!-- @intent START -->
위 표는 **모듈 레벨** 권한(게시판 관리 자체를 다루는 관리자 권한)만 보여줍니다. 게시판 하나를
만들면 그 게시판 전용 권한이 `config/board.php` 의 `board_permission_definitions` 템플릿을
기반으로 추가 생성됩니다(admin.posts.read/write, posts.read-secret 등 — 게시판마다 독립적인
권한 묶음). 그 동적 권한은 이 표에 나타나지 않으며 `getDynamicPermissionIdentifiers()` 로만
전수를 확인할 수 있습니다. `boards`/`reports` 카테고리에 `resource_route_key`/`owner_key` 가
붙어 있는 것은 소유자 기반 스코프 판정(자기 글만 관리 가능한 `manager` 이하 역할 등)이 걸려
있다는 뜻입니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 구분 | slug | 이름 | URL | 하위 |
|---|---|---|---|---|
| 관리자 | `sirsoft-board` | 게시판 관리 | - | 3개 |
<!-- @generated:menus END -->
<!-- @intent START -->
정적 관리자 메뉴는 "게시판 관리" 3개 하위 메뉴(환경설정/목록/신고현황)뿐입니다. 게시판을
만들 때마다 생기는 `board-{slug}` 메뉴는 동적 메뉴라 이 표에 없으며
`getDynamicMenuSlugs()` 로 전수를 확인합니다. 방문자용 메뉴(사이트 상단 게시판 링크 등)는
이 모듈이 등록하지 않습니다 — 템플릿이 공개 API(`boards.board-menu`)를 호출해 직접 구성합니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/modules/sirsoft-board/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
같은 `src/routes/api.php` 파일 안에 관리자 전용 그룹(`/admin/board/{slug}/...`, 권한 미들웨어)과
공개 그룹(`/boards/...`, `optional.sanctum`)이 함께 있습니다 — 파일을 분리하지 않은 것은
board 의 라우트가 20개 안팎으로 한 파일에서 관리 가능한 규모이기 때문입니다. 새 공개
엔드포인트를 추가할 때는 반드시 `optional.sanctum`(비회원도 접근 가능, 회원이면 컨텍스트
주입)을 쓰고 `auth:sanctum` 을 쓰지 않습니다 — 게시판 열람은 비회원에게도 열려 있어야 합니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
| 확장 | 유형 | 요구 버전 |
|---|---|---|
| `sirsoft-basic` | 템플릿 | `>=1.0.0` |
<!-- @generated:dependencies END -->
<!-- @intent START -->
이 모듈이 의존하는 확장이 "없음"인 것은 board 가 코어 훅·API 만으로 완결되도록 설계됐다는
뜻입니다. 반대로 이 모듈에 의존하는 쪽은 하나(템플릿)뿐이지만, 그보다 결합이 느슨한
**필터 훅 위임** 소비자(이커머스 문의)는 `dependencies` 로 선언되지 않습니다 — 이커머스는
board 를 자기 도메인으로 대체할 뿐 board API 계약에 실제로 묶여 있지 않기 때문입니다.
이 모듈의 공개 표면(라우트·API 응답 구조)을 바꿀 때는 `sirsoft-basic` 의 최소 버전 상향을
검토해야 합니다(§CLAUDE.md "확장 → 확장 동기화").
<!-- @intent END -->
+184
View File
@@ -0,0 +1,184 @@
# GDPR (일반 데이터 보호 규정) — 에이전트 가이드
> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
## TL;DR (5초 요약)
```text
1. 유형: 플러그인 (sirsoft-gdpr) — 쿠키 동의 배너·동의 이력·GDPR/개인정보보호법 대응을 소유
2. 확장 방식: `sirsoft-gdpr.consent.granted`/`revoked` 훅 구독, `data-gdpr-category` HTML 속성으로 자체 호스팅 자원 등록
3. 건드리면 안 되는 것: `CookieConsentMiddleware`(functional 미동의 시 Set-Cookie 게이팅)의 strictly-necessary allowlist, 동의 이력(immutable append-only) 직접 수정
4. 작업 위치: `plugins/_bundled/sirsoft-gdpr` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan plugin:update sirsoft-gdpr --force`
```
## 1. 이 확장은 무엇인가
<!-- @intent START -->
GDPR(EU) 및 한국 개인정보보호법이 요구하는 "동의 전 처리 금지" 원칙을 서버·클라이언트 양쪽에서
강제하는 플러그인입니다. 두 계층이 서로 다른 것을 막습니다 — 서버 계층(`CookieConsentMiddleware`)
은 백엔드가 심으려는 Set-Cookie 헤더를, 클라이언트 계층(자동 차단 스크립트)은 외부 추적
스크립트·iframe·1st-party 저장소(localStorage/sessionStorage) 접근을 각각 동의 전까지 막습니다.
"동의했다"는 사실 자체도 상태(`gdpr_user_consents`, mutable)와 이력(`gdpr_user_consent_histories`,
immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와 "언제 무엇에 동의했었는가" 입증
(Art.7(1))은 서로 다른 질문이라 하나로 합칠 수 없습니다.
**설계 원칙**: 정책 버전 발행은 수동입니다 — 정책 본문이 바뀌었다고 자동으로 전 회원 재동의를
트리거하지 않습니다. 운영자가 "이 변경이 재동의가 필요한 변경인가"를 판단해 명시적으로 발행
버튼을 눌러야 합니다(README "사용 방법" 표 참고). 자동화하면 사소한 오탈자 수정에도 전 회원이
재동의 화면을 보게 되어 UX 를 해칩니다.
**의도적으로 하지 않는 것**: 게스트 → 회원 동의 자동 승계(§README 소개 참고), 그리고 운영자가
등록한 "허용" functional 쿠키 화이트리스트도 두지 않습니다 — functional 미동의 시 strictly
necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제외한 **모든** 쿠키를
차단합니다. EDPB Guidelines 2/2023 §16 원칙이 "비필수는 동의 전 전면 차단"이지 "등록된 것만
차단"이 아니기 때문입니다.
<!-- @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/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-gdpr --force` (빌드 불필요) |
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-gdpr --force` |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-gdpr --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-gdpr --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-gdpr --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-gdpr --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**동의 부여**: `Public\GdprCookieConsentController` → `GdprConsentService::grantConsent()` —
회원이면 `user_id`, 게스트면 서명된 `gdpr_session` 쿠키로 식별한 뒤 `GdprUserConsent`(현재
상태, upsert)와 `GdprUserConsentHistory`(이력, insert-only) 를 같은 트랜잭션에서 함께 기록하고
`sirsoft-gdpr.consent.granted` 훅을 발행합니다. 이 흐름은 배너·마이페이지·회원가입·전체
재동의 4개 진입점이 전부 공유합니다 — 진입점마다 다른 저장 로직을 만들지 않습니다.
**요청마다 반복되는 게이팅**: `CookieConsentMiddleware`(web/api 그룹에 prepend)가 응답 직전
`GdprConsentService::getCurrentCookieConsents()` 로 현재 functional 동의 여부를 조회하고,
미동의면 strictly-necessary 4종을 제외한 모든 Set-Cookie 헤더를 응답에서 제거합니다. 이
서버측 게이팅과 별개로, 클라이언트에서는 `data-gdpr-category` 속성이 붙은 스크립트/iframe 과
분석/마케팅 카테고리 도메인 매칭 리소스가 동의 전까지 로드되지 않습니다.
**회원탈퇴와 완전삭제는 다른 훅, 다른 처리**입니다 — `GdprUserWithdrawListener`
(`core.user.after_withdraw`)는 코어가 user 행 자체를 보존하는 "탈퇴"에 반응해 활성 동의를
전부 철회 처리(UPDATE + `source=withdraw` revoked 이력 INSERT)할 뿐 신원 정보는 그대로
남깁니다 — 탈퇴는 "의사 표시 종료"이지 신원 삭제가 아니기 때문입니다. 반대로
`GdprUserDeleteListener`(`core.user.before_delete`, 완전 삭제/hard delete)는 이력 행의
`user_id`/IP/User-Agent 만 NULL 로 **익명화**하고 행 자체는 남깁니다(Art.17 삭제권과 Art.7(1)
입증 의무 양립). 두 훅을 헷갈리면 탈퇴 시점에 신원이 조기 삭제되거나, 완전삭제 시점에 입증
자료 행까지 통째로 사라집니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 2개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 4개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 4개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
다른 확장이 이 플러그인 없이는 몰랐을 사실(방문자가 방금 분석 카테고리에 동의/철회했다)을
알아야 할 때 `sirsoft-gdpr.consent.granted`/`revoked` 훅을 잡습니다 — 예: 분석 SDK 초기화를
"페이지 로드 시 무조건"이 아니라 "동의 부여 시에만" 하고 싶은 확장. 반대로 자체 호스팅
추적 자원을 이 플러그인의 자동 차단·복원 대상에 포함시키고 싶다면 훅이 아니라 HTML 속성
(`data-gdpr-category="analytics"`)을 붙이는 쪽이 맞습니다 — 이 플러그인이 그 속성을 스캔해
차단/복원을 대신 수행하므로 소비 측 코드가 필요 없습니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-gdpr --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-gdpr` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] `gdpr_user_consent_histories` 는 append-only — UPDATE/DELETE 로 기존 행을 고치지 않는다 (완전삭제 시 익명화 UPDATE 예외는 `GdprUserDeleteListener` 단일 지점에서만 수행)
- [ ] 새 자동 차단 카테고리(기능/분석/마케팅 외)를 추가하면 배너 UI·`blocked_domains` 스키마·차단 스크립트 3곳 동기화
- [ ] `CookieConsentMiddleware` 의 strictly-necessary allowlist(4종)를 확장할 때는 ePrivacy Art.5(3) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| `gdpr_user_consent_histories` 행을 UPDATE/DELETE 로 직접 정정 | 정정이 필요하면 새 이력 행을 INSERT | 이력은 시점별 스냅샷이 생명 — 과거 행을 고치면 Art.7(1) 입증 자료로서 효력을 잃는다 |
| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 |
| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | strictly necessary 4종 고정 목록만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 |
| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 24개 | `plugins/_bundled/sirsoft-gdpr/tests` |
| Vitest | 12개 | `vitest.config.ts` |
| Playwright | 3개 | `tests/Playwright` |
| 시나리오 매니페스트 | 5개 | `tests/scenarios` |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd plugins/_bundled/sirsoft-gdpr && powershell -Command "npm run test:run -- <대상>"
# Playwright E2E (Bash)
npx playwright test plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/<대상>.spec.ts
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @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/)를 따르며, 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.4] - 2026-08-31
### Fixed
- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다.
## [1.0.3] - 2026-08-19 ## [1.0.3] - 2026-08-19
### Fixed ### Fixed
+160 -102
View File
@@ -1,151 +1,209 @@
# GDPR Plugin for G7 # GDPR
GDPR(유럽 일반 데이터 보호 규정) 및 한국 개인정보보호법 대응 핵심 기능을 제공하는 G7 플러그인입니다. 쿠키 동의 배너·동의 전 자동 차단·동의 이력 영구 저장·마이페이지 동의 철회를 한 패키지로 제공합니다. **G7 플러그인 · sirsoft-gdpr**
GDPR·개인정보보호법 대응 쿠키 동의 배너와 동의 이력 관리를 제공하는 플러그인
## 핵심 기능 <!-- @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.6-1F883D?style=flat-square" alt="G7 &gt;=7.0.6">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
| 기능 | 설명 | ---
|------|------|
| 쿠키 동의 배너 | 필수/기능/분석/마케팅 4분류 (ICO·CNIL 권장 표준), 다크 모드, 4 위치(하단 바·좌하단·우하단·중앙 모달) | [소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
| 동의 전 자동 차단 | 외부 추적 스크립트·iframe + 기능 카테고리 1st-party 저장소(localStorage·sessionStorage·1st-party 쿠키) 게이팅 |
| 동의 이력 저장 | 정책 버전·출처·카테고리 스냅샷을 함께 immutable 보존 (GDPR Art.7(1) 입증 자료) | ---
## 소개
<!-- @intent START -->
GDPR(유럽 일반 데이터 보호 규정) 및 한국 개인정보보호법 대응 핵심 기능을 제공하는 플러그인입니다.
쿠키 동의 배너·동의 전 자동 차단·동의 이력 영구 보존·마이페이지 동의 철회를 한 패키지로
제공합니다.
이 플러그인이 의도적으로 하지 않는 것 하나는 **게스트 → 회원 동의 자동 승계**입니다. GDPR
Art.6/ePrivacy Art.5(3) 관점에서 게스트(디바이스 단위)와 회원(주체 단위)은 별도 동의 모델이며,
회원가입 폼 동의로 Art.7(1) 입증 책임이 별도로 충족됩니다. 게스트 시절 동의 이력은 세션 기준
으로 보존될 뿐 회원 계정과 자동으로 이어붙지 않습니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 쿠키 동의 배너 | 필수/기능/분석/마케팅 4분류(ICO·CNIL 권장 표준), 다크 모드, 4가지 위치(하단 바·좌하단·우하단·중앙 모달) |
| 동의 전 자동 차단 | 외부 추적 스크립트·iframe + 기능 카테고리 1st-party 저장소(localStorage·sessionStorage·쿠키) 게이팅 |
| 동의 이력 저장 | 정책 버전·출처·카테고리 스냅샷을 함께 immutable 보존(GDPR Art.7(1) 입증 자료) |
| 마이페이지 동의 관리 | 회원이 자신의 동의 현황을 조회·개별 철회·재동의·전체 일괄 재동의 | | 마이페이지 동의 관리 | 회원이 자신의 동의 현황을 조회·개별 철회·재동의·전체 일괄 재동의 |
| 관리자 동의 이력 조회 | 회원/게스트 동의 변경 이력 (이메일·세션 검색, 카테고리·출처 다중 필터, 카테고리 스냅샷 표) | | 관리자 동의 이력 조회 | 회원/게스트 동의 변경 이력(이메일·세션 검색, 카테고리·출처 다중 필터, 카테고리 스냅샷 표) |
| 정책 버전 수동 발행 | 정책 본문 변경 시 「+ 새 버전 발행」 클릭으로 모든 회원 재동의 트리거 | | 정책 버전 발행 | 정책 본문 변경 시 수동 발행으로 모든 회원에게 재동의를 트리거 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
V[방문자] -->|첫 방문| Banner[쿠키 배너 노출]
Banner -->|동의 전| Block[외부 스크립트·iframe·1st-party 저장소 자동 차단]
Banner -->|동의| Grant[GdprUserConsent 저장 + 훅 발행]
Grant --> Restore[차단 해제·스크립트 로드]
Grant --> History[(동의 이력 append-only 보존)]
Admin[운영자] -->|정책 본문 변경| Publish[새 정책 버전 발행]
Publish -->|다음 방문| Renew[모든 회원 재동의 안내]
```
동의는 부여든 철회든 상태 저장(`gdpr_user_consents`, mutable)과 이력 기록
(`gdpr_user_consent_histories`, immutable append-only)이 항상 함께 일어납니다 — "지금 동의
상태가 무엇인가"와 "언제 무엇에 동의했었는가"를 구분해서 답할 수 있어야 하기 때문입니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.6` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
## 설치 ## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash ```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan plugin:install sirsoft-gdpr php artisan plugin:install sirsoft-gdpr
# 활성화
php artisan plugin:activate sirsoft-gdpr php artisan plugin:activate sirsoft-gdpr
# 업데이트 (번들 소스 기준 강제 반영)
php artisan plugin:update sirsoft-gdpr --force
``` ```
설치 직후 쿠키 배너는 **비활성** 상태로 제공됩니다. 운영자가 운영 주체명·데이터 저장 위치·정책 페이지 슬러그를 입력한 뒤 「쿠키 배너 노출」 토글을 켜야 사이트에 노출됩니다. 저장소: https://github.com/gnuboard/g7-plugin-sirsoft-gdpr
<!-- @generated:install END -->
## 설정 설치 직후 쿠키 배너는 **비활성** 상태입니다. 운영자가 운영 주체명·데이터 저장 위치·정책 페이지
슬러그를 입력한 뒤 "쿠키 배너 노출" 토글을 켜야 사이트에 노출됩니다.
`관리자 → 플러그인 → GDPR 설정` 에서 구성합니다. ## 관리자 설정
### 운영 정보 <!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `privacy_policy_slug` | 개인정보처리방침 페이지 슬러그 | `privacy` |
| `legal_entity_name` | 운영 주체명 | - |
| `data_storage_location` | 데이터 저장 위치 | - |
| `banner_enabled` | 쿠키 배너 노출 | `true` |
| `banner_position` | 배너 위치 | `bottom_bar` |
| `blocked_domains` | 추적 도메인 차단 목록 | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` |
| `cookie_categories` | 쿠키 카테고리 정의 | `[]` |
| 항목 | 설명 | 개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
|------|------| <!-- @generated:settings-summary END -->
| 운영 주체명 (`legal_entity_name`) | 쿠키 배너 푸터·마이페이지 동의 카드에 노출되는 사이트 운영 주체 (예: "(주)홍길동컴퍼니") |
| 데이터 저장 위치 (`data_storage_location`) | 사용자에게 안내할 데이터 저장 국가 (예: "대한민국", "미국 (AWS)"). IP 주소·CIDR·클라우드 리전 코드(예: `ap-northeast-2`)는 보안상 자동 거부됩니다 |
| 개인정보처리방침 슬러그 (`privacy_policy_slug`) | sirsoft-page 플러그인에 등록된 처리방침 페이지의 슬러그. 비어있으면 쿠키 배너의 정책 링크가 자동 숨겨집니다 |
### 쿠키 배너 <!-- @intent START -->
`쿠키 배너 노출` 은 마스터 토글입니다 — ON 하면 배너와 자동 차단이 **함께** 켜집니다. GDPR
Art.6 "동의 전 처리 금지"를 강제하는 메커니즘인 자동 차단만 단독으로 끌 수 없도록 의도적으로
하나의 토글에 묶었습니다. 반대로 마이페이지 동의 관리 카드는 이 토글과 무관하게, 동의/철회
이력이 있는 회원에게는 항상 노출됩니다(Art.7(3) 철회 대칭성 보장 — 동의를 배너로 받았다면
철회도 언제든 마이페이지에서 가능해야 합니다).
| 항목 | 설명 | 데이터 저장 위치(`data_storage_location`)에는 IP 주소·CIDR·클라우드 리전 코드(예:
|------|------| `ap-northeast-2`)를 입력할 수 없습니다 — 보안상 자동 거부됩니다. "대한민국", "미국 (AWS)"처럼
| 쿠키 배너 노출 (`banner_enabled`) | 마스터 토글 — ON 시 배너 + 자동 차단이 함께 활성화됩니다. GDPR Art.6 "동의 전 처리 금지" 의 강제 메커니즘인 자동 차단을 단독 OFF 할 수 없도록 단일 토글로 통합되어 있습니다. 마이페이지 동의 관리 카드는 이 토글과 무관하게 동의/철회 이력이 있는 회원에게 항상 노출됩니다 (Art.7(3) 철회 대칭성 보장) | 사용자에게 안내할 국가/지역명만 입력합니다.
| 배너 위치 (`banner_position`) | 하단 바 / 좌하단 팝업 / 우하단 팝업 / 중앙 모달 |
### 자동 차단 정책 자동 차단 대상 도메인은 카테고리(기능/분석/마케팅)별 카탈로그로 관리하며, 기본 카탈로그(예:
Google Analytics, Facebook Pixel, Kakao Pixel 등)가 시드되어 있고 운영자가 추가·삭제할 수
있습니다. 도메인 형식은 `example.com` 또는 와일드카드 `*.example.com` 만 지원하며, `localhost`
같은 단일 라벨과 한글 도메인(xn-- 변환)은 지원하지 않습니다.
<!-- @intent END -->
쿠키 배너가 ON 일 때 다음 카테고리의 외부 도메인 리소스가 동의 전까지 자동 차단됩니다. 카탈로그 기본값이 시드되어 있으며, 운영자가 카테고리별로 추가·삭제할 수 있습니다. ## 사용 방법
| 카테고리 | 기본 카탈로그 예시 | <!-- @intent START -->
|---------|------------------| **자체 호스팅 추적 자원 등록**: 자체 도메인에서 서빙하는 추적 스크립트·iframe·임베드는 도메인
| 기능 (functional) | Crisp, Intercom, Tawk.to, Weglot 등 | 매칭 대상이 아니므로 HTML 속성으로 분류합니다.
| 분석 (analytics) | Google Analytics, Hotjar, Mixpanel, 네이버 프리미엄 로그분석 등 |
| 마케팅 (marketing) | Facebook Pixel, Google Ads, Kakao Pixel, YouTube embed 등 |
도메인 형식: `example.com` 또는 와일드카드 `*.example.com`. `localhost` 같은 단일 라벨, 한글 도메인(xn-- 변환)은 미지원.
## 자체 호스팅 추적 자원 분류
자체 도메인에서 호스팅되는 추적 스크립트·iframe·임베드는 도메인 매칭 대상이 아니므로 HTML 속성으로 분류합니다.
```html ```html
<script src="/js/my-analytics.js" data-gdpr-category="analytics"></script> <script src="/js/my-analytics.js" data-gdpr-category="analytics"></script>
<iframe src="/embed/custom-tracker" data-gdpr-category="marketing"></iframe> <iframe src="/embed/custom-tracker" data-gdpr-category="marketing"></iframe>
``` ```
동의 전까지 자동 차단되며, 동의 후 자동 복원됩니다. 동의 철회 시 다시 차단됩니다. 동의 전까지 자동 차단되고, 동의 후 자동 복원되며, 동의 철회 시 다시 차단됩니다.
## 정책 버전 발행 **정책 버전 발행**: `관리자 → 플러그인 → GDPR 설정` 의 "정책 버전" 카드에서 "+ 새 버전 발행"을
누르면 모든 회원이 다음 방문 시 재동의 화면을 보게 됩니다. 아래 기준으로 발행 여부를 판단합니다.
`관리자 → 플러그인 → GDPR 설정` 의 「정책 버전」 카드에서 「+ 새 버전 발행」 을 클릭합니다. 발행 즉시 모든 회원이 다음 방문 시 재동의 화면(amber 안내 박스 + 「최신 정책으로 갱신」 버튼)을 보게 됩니다.
| 발행이 필요한 변경 | 발행이 필요 없는 변경 | | 발행이 필요한 변경 | 발행이 필요 없는 변경 |
|------------------|---------------------| |---|---|
| 정책 본문 (개인정보처리방침 페이지) 변경 | 차단 도메인 추가/삭제 | | 정책 본문(개인정보처리방침 페이지) 변경 | 차단 도메인 추가/삭제 |
| 카테고리 의미 변경 | UI 라벨/설명 정정 | | 카테고리 의미 변경 | UI 라벨/설명 정정 |
| 위탁자·데이터 보관 정보 변경 | 운영 주체명·저장 위치 정정 | | 위탁자·데이터 보관 정보 변경 | 운영 주체명·저장 위치 정정 |
발행 시 변경 사유 메모를 함께 저장합니다 (GDPR Art.30 처리 기록 의무). 발행 시 변경 사유 메모를 함께 저장합니다(GDPR Art.30 처리 기록 의무).
## 동의 이력 조회 **동의 이력 조회**: `관리자 → GDPR 동의 이력` 메뉴에서 이메일 부분 일치·세션 ID 로 검색하고,
카테고리·출처(banner/mypage/register/withdraw 등)·동의 액션(granted/revoked)으로 필터링합니다.
각 행을 펼치면 동의 시점의 전체 카테고리 의사 스냅샷을 확인할 수 있습니다(Art.7(1) 입증 자료).
<!-- @intent END -->
`관리자 → GDPR 동의 이력` 메뉴에서 회원/게스트의 모든 동의 변경 이력을 조회할 수 있습니다. ## 다른 확장과의 연동
- **검색**: 이메일 부분 일치, 세션 ID <!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
- **필터**: 카테고리(필수/기능/분석/마케팅), 출처(banner/mypage/register/withdraw 등), 동의 액션(granted/revoked) **이 확장이 의존하는 확장**
- **카테고리 스냅샷**: 각 행 펼침에서 동의 시점의 전체 카테고리 의사 표를 immutable 보존 (GDPR Art.7(1) 입증 자료)
## 가용 훅 (Hook) 없음 — 코어만으로 동작합니다.
다른 확장에서 본 플러그인의 동의 이벤트를 구독할 수 있습니다. **이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
### 액션 훅 없음.
<!-- @generated:integrations END -->
| 훅 이름 | 시점 | 인수 | <!-- @intent START -->
|---------|------|------| `sirsoft-page` 는 소프트 의존(런타임 체크)입니다 — 미설치 시 쿠키 배너의 "자세히" 정책 링크만
| `sirsoft-gdpr.consent.granted` | 동의 부여 시 | `GdprUserConsent $consent, string $source` | 자동으로 숨겨지고 나머지 기능은 정상 동작합니다. `sirsoft-basic` 템플릿은 배너가 주입되는
| `sirsoft-gdpr.consent.revoked` | 동의 철회 시 | `GdprUserConsent $consent, string $source` | 지점(`_user_base.json` 의 공용 확장 지점)을 제공해야 하므로, 다른 사용자 템플릿을 쓰려면
그 템플릿에도 같은 주입 지점이 있어야 배너가 정상 노출됩니다.
<!-- @intent END -->
`$source` 값: `banner` / `mypage` / `mypage_renew_all` / `register` / `withdraw`. ## 문서
### 훅 등록 예시 <!-- @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 -->
```php ## 트러블슈팅
use App\Extension\HookManager;
HookManager::addAction( <!-- @intent START -->
'sirsoft-gdpr.consent.granted', | 증상 | 원인 | 조치 |
function ($consent, string $source) { |---|---|---|
// 예: 분석 동의 부여 시 외부 분석 도구에 사용자 식별 전송 | 쿠키 배너 설정을 저장했는데도 배너가 안 뜸 | "쿠키 배너 노출" 마스터 토글이 꺼져 있음 | 관리자 설정에서 토글을 켠다 — 개별 항목 저장만으로는 배너가 켜지지 않는다 |
if ($consent->consent_key === 'cookie_analytics' && $consent->is_consented) { | 정책 페이지 링크가 배너에 안 보임 | `privacy_policy_slug` 미설정 또는 `sirsoft-page` 미설치 | 슬러그를 입력하거나, 링크 없이 운영할지 결정한다(자동 숨김은 정상 동작) |
AnalyticsService::identify($consent->user_id); | 배너에서 동의했는데 마이페이지에 이력이 안 보임 | 게스트 상태에서 동의 후 회원가입한 경우 — 게스트→회원 자동 승계를 제공하지 않음 | 의도된 동작이다(§소개 참고). 회원가입 시 별도 동의를 다시 받는다 |
} | 자체 호스팅 스크립트가 동의 전에도 로드됨 | `data-gdpr-category` 속성 누락 | 스크립트/iframe 태그에 카테고리 속성을 추가한다 |
}, <!-- @intent END -->
priority: 10
);
```
## 데이터베이스 ## 변경 이력
| 테이블 | 용도 | 보존 정책 | [CHANGELOG.md](CHANGELOG.md)
|--------|------|----------|
| `gdpr_user_consents` | 회원 현재 동의 상태 (mutable) | 사용자 삭제 시 명시 삭제 |
| `gdpr_user_consent_histories` | 동의 변경 이력 (immutable append-only) | 사용자 삭제 시 `user_id`/IP/UA 만 NULL 익명화하여 행 보존 (GDPR Art.17 + Art.7(1) 양립) |
| `gdpr_policy_versions` | 정책 버전 발행 이력 (불변) | 영구 보존 (Art.30 처리 기록) |
플러그인 제거 시 위 3 테이블이 자동 DROP 됩니다.
## 게스트 → 회원 동의 승계
본 플러그인은 게스트 → 회원 동의 자동 승계를 제공하지 않습니다. GDPR Art.6/ePrivacy Art.5(3) 관점에서 게스트(디바이스 단위)와 회원(주체 단위)은 별도 동의 모델이며, 글로벌 CMP 대부분도 자동 승계를 기본으로 제공하지 않습니다. 회원가입 폼 동의로 Art.7(1) 입증 책임이 충족되며, 게스트 시절 동의 이력은 세션 기준으로 보존됩니다.
## 의존성
| 대상 | 의존 수준 | 미설치 시 동작 |
|------|----------|---------------|
| `sirsoft-page` 모듈 | 소프트 (런타임 체크) | 배너 "자세히" 링크만 자동 숨김. 나머지 정상 |
| `sirsoft-basic` 템플릿 | 주입 지점 의존 | 다른 사용자 템플릿 사용 시 해당 템플릿에도 `_user_base.json` 의 공용 확장 지점이 있어야 정상 동작 |
## 테스트 실행
```bash
# 백엔드
php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests
# 프론트엔드
cd plugins/_bundled/sirsoft-gdpr
npm run test:run
```
## 라이선스 ## 라이선스
MIT MIT
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "plugins/sirsoft-gdpr", "name": "plugins/sirsoft-gdpr",
"description": "GDPR (General Data Protection Regulation) plugin for G7 platform by sirsoft", "description": "GDPR (General Data Protection Regulation) plugin for G7 platform by sirsoft",
"type": "library", "type": "library",
"version": "1.0.3", "version": "1.0.4",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
{ {
@@ -0,0 +1,22 @@
# GDPR (일반 데이터 보호 규정) 개발자 문서
> plugins/_bundled/sirsoft-gdpr · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 2 · **구독 훅 수**: 4 · **라우트 수**: 15 · **모델 수**: 3 · **테이블 수**: 3 · **마이그레이션 수**: 4 · **레이아웃 수**: 4 · **핸들러 수**: 1
<!-- @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,78 @@
# GDPR (일반 데이터 보호 규정) — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
"동의 전 처리 금지"를 소스 하나가 아니라 **서버(쿠키)·클라이언트(스크립트/저장소) 두 표면
모두**에서 강제하는 것이 이 플러그인의 핵심 설계입니다. 서버 표면만 막으면 클라이언트에서
직접 실행되는 추적 스크립트를 막지 못하고, 클라이언트 표면만 막으면 백엔드가 심는 분석용
쿠키를 막지 못합니다. 두 표면은 서로 다른 코드 경로(미들웨어 vs 프론트 차단 로직)이지만
같은 동의 판정 소스(`GdprConsentService`)를 공유해야 판정이 갈리지 않습니다.
동의 "상태"와 "이력"을 분리 보존하는 것도 설계 결정입니다 — 상태(mutable)는 지금 게이팅
판정에 쓰이고, 이력(immutable append-only)은 감사 대응(Art.7(1))에 쓰입니다. 회원탈퇴·완전삭제
두 이벤트에서 서로 다른 처리(철회 vs 익명화)를 하는 것도 이 분리 때문에 가능합니다 — 하나의
테이블이었다면 "지금 상태를 지울까 이력을 지울까"를 매번 다시 판단해야 했을 것입니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
Http/Controllers (Admin/ 관리자 설정·동의이력·정책버전, Public/ 배너 API, User/ 마이페이지)
│
▼
Services (GdprConsentService/GdprSettingsService/GdprPolicyVersionService/
GdprConsentLogService/CookieCategoryService)
│
├──▶ Models (GdprUserConsent 상태 · GdprUserConsentHistory 이력 · GdprPolicyVersion)
│
└──▶ 훅 발행 (consent.granted/revoked) ──▶ 다른 확장 리스너
CookieConsentMiddleware (web/api 그룹 prepend)
│
└──▶ GdprConsentService::getCurrentCookieConsents() 조회 후 응답 Set-Cookie 게이팅
core.user.after_withdraw / before_delete 훅
│
└──▶ GdprUserWithdrawListener(철회 처리) / GdprUserDeleteListener(이력 익명화)
```
미들웨어는 위 Service 계층과 별도 레인에서 **매 요청마다** 동작하고, 회원탈퇴/삭제 리스너는
코어 회원 도메인 이벤트에 반응하는 또 다른 별도 레인입니다. 세 레인 모두 같은
`GdprConsentService`/모델을 공유하므로 그 계층 하나만 잘 유지하면 나머지는 일관됩니다.
<!-- @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/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-gdpr --force` (빌드 불필요) |
| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-gdpr --force` |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-gdpr --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-gdpr --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-gdpr --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-gdpr --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,107 @@
# GDPR (일반 데이터 보호 규정) — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
| `GdprPolicyVersion` | `gdpr_policy_versions` | 5 | createdBy→User | - |
| `GdprUserConsent` | `gdpr_user_consents` | 11 | user→User | - |
| `GdprUserConsentHistory` | `gdpr_user_consent_histories` | 9 | user→User | - |
<!-- @generated:models END -->
<!-- @intent START -->
`GdprUserConsent`(mutable, "지금 동의 상태")와 `GdprUserConsentHistory`(immutable append-only,
"동의 변경 이력")를 별도 모델·테이블로 분리한 것이 이 도메인의 핵심 결정입니다. 게이팅
판정(`CookieConsentMiddleware`)은 상태만 읽고, 감사 대응(Art.7(1))은 이력만 봅니다. 하나로
합쳤다면 상태를 UPDATE 할 때마다 과거 값을 별도 보존하는 로직을 매번 다시 구현해야 했을
것입니다. `GdprPolicyVersion` 은 정책 발행 이력이며 이 역시 immutable — 발행된 버전은 그
시점의 정책 내용을 그대로 유지해야 "그 버전에 동의했다"는 이력이 의미를 가집니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 테이블 | 모델 |
|---|---|
| `gdpr_policy_versions` | `GdprPolicyVersion` |
| `gdpr_user_consent_histories` | `GdprUserConsentHistory` |
| `gdpr_user_consents` | `GdprUserConsent` |
<!-- @generated:tables END -->
<!-- @intent START -->
3개 테이블이 전부입니다 — 쿠키 카테고리 정의(`blocked_domains`/`cookie_categories`)는 별도
테이블이 아니라 `getSettingsSchema()` 의 JSON 설정 값으로 저장됩니다(§settings.md). 별도
테이블로 만들지 않은 이유는 그 값이 "운영자가 조정하는 설정"이지 "사용자별로 쌓이는 데이터"가
아니기 때문입니다 — 전자는 설정 스키마, 후자만 전용 테이블을 둡니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
마이그레이션 4개.
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|---|---|---|---|
| `2026_04_27_000001_create_gdpr_user_consents_table.php` | `gdpr_user_consents` | `gdpr_user_consents` | ✅ |
| `2026_04_27_000002_create_gdpr_user_consent_histories_table.php` | `gdpr_user_consent_histories` | `gdpr_user_consent_histories` | ✅ |
| `2026_05_12_000003_create_gdpr_policy_versions_table.php` | `gdpr_policy_versions` | `gdpr_policy_versions` | ✅ |
| `2026_07_14_000001_add_rejection_to_gdpr_user_consents.php` | - | `gdpr_user_consents` | ✅ |
<!-- @generated:migrations END -->
<!-- @intent START -->
`add_rejection_to_gdpr_user_consents`(2026-07-14)는 "동의 안 함"을 명시적으로 기록하기 위한
추가입니다 — 그전에는 동의 행이 없으면 "아직 응답 안 함"과 "거부함"을 구분할 수 없었습니다.
`ConsentAction::Rejected` 케이스와 짝을 이루는 마이그레이션입니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| Enum | backing | case 수 | case |
|---|---|---|---|
| `ConsentAction` | `string` | 3 | `granted`, `revoked`, `rejected` |
| `ConsentSource` | `string` | 6 | `banner`, `preference_center`, `register`, `mypage`, `mypage_renew_all`, `withdraw` |
| `CookieCategory` | `string` | 4 | `cookie_necessary`, `cookie_functional`, `cookie_analytics`, `cookie_marketing` |
| `GdprPolicyChangeType` | `string` | 3 | `material`, `non_material`, `initial` |
<!-- @generated:enums END -->
<!-- @intent START -->
`ConsentSource` 는 자기 docblock에 "어휘를 이 enum 밖(서비스/리스너 리터럴)에 흩어 두면 화면
필터가 실제 기록 어휘의 부분집합이 되어 일부 행이 어떤 필터로도 도달하지 못한다"고 명시합니다
(#492 과거 결함). `withdraw`(회원탈퇴 시 일괄 철회, `GdprConsentService::revokeAllOnWithdraw()`
/ `GdprUserConsentRepository::revokeAllForUser()`)가 정확히 이 결함군으로 한 번 더 발생했던
case입니다 — 두 지점 모두 enum이 아닌 `'withdraw'` 리터럴을 직접 기록해, 그렇게 기록된 행이
관리자 동의 이력 화면의 어떤 출처 필터로도 걸러지지 않고 라벨도 원시 문자열로 노출됐습니다.
`ConsentSourceVocabularyParityTest`(기록 경로가 enum 을 참조하는지 검사)가 이미 있었는데도
놓친 이유는 두 가지입니다 — 검사 대상 파일 목록에 `GdprUserConsentRepository.php` 가
빠져 있었고, 정규식이 `'source' =>`/`'last_source' =>` 형태만 잡아 `updateConsent(...,
'withdraw')` 같은 **위치 인자** 형태는 못 봤습니다. `Withdraw` case 추가 + 두 지점을
`ConsentSource::Withdraw->value` 참조로 교체 + 테스트의 스캔 대상 파일 목록에 Repository
추가로 정정했습니다. 새 기록 지점을 추가할 때 위치 인자로 리터럴을 넘기면 이 가드가 여전히
못 볼 수 있다는 점을 유의하세요 — 가능하면 `'source' =>`/`'last_source' =>` 형태(배열 키)를
쓰거나 이 테스트의 정규식을 함께 넓힙니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `GdprPolicyVersionRepository` | 구현 | GDPR 정책 버전 Repository 구현체 (immutable append-only) |
| `GdprPolicyVersionRepositoryInterface` | 인터페이스 | GDPR 정책 버전 Repository 인터페이스 (immutable append-only) |
| `GdprUserConsentHistoryRepository` | 구현 | GDPR 동의 변경 이력 Repository 구현체 (immutable append-only) |
| `GdprUserConsentHistoryRepositoryInterface` | 인터페이스 | GDPR 동의 변경 이력 Repository 인터페이스 (immutable append-only) |
| `GdprUserConsentRepository` | 구현 | GDPR 사용자 현재 동의 상태 Repository 구현체 |
| `GdprUserConsentRepositoryInterface` | 인터페이스 | GDPR 사용자 현재 동의 상태 Repository 인터페이스 |
<!-- @generated:repositories END -->
<!-- @intent START -->
`GdprPolicyVersionRepository`·`GdprUserConsentHistoryRepository` 설명에 "immutable
append-only"가 반복 명시된 것은 우연이 아닙니다 — 이 두 Repository 에는 `update()`/`delete()`
류 메서드를 추가하지 않습니다(§AGENTS.md 금지 패턴). 상태를 고치는 메서드가 필요하다면 그것은
`GdprUserConsentRepository`(mutable) 의 몫이며, 두 종류를 같은 Repository 에 섞으면 "이
메서드가 이력을 고치는지 상태를 고치는지"를 매 호출부에서 다시 확인해야 합니다.
<!-- @intent END -->
@@ -0,0 +1,128 @@
# GDPR (일반 데이터 보호 규정) — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 2종 / 호출 지점 12곳. 훅 이름이 상수·변수로 조립된 호출이 12곳 있어 호출 위치가 표에 다 실리지 않을 수 있습니다.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `sirsoft-gdpr.consent.granted` | action | 동의 부여 시 발화 | 선언 (호출 위치 미확인) |
| `sirsoft-gdpr.consent.revoked` | action | 동의 철회 시 발화 | 선언 (호출 위치 미확인) |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
두 훅 모두 `GdprUserConsent $consent, string $source` 를 인자로 넘깁니다. `$source` 값은
`banner`/`mypage`/`mypage_renew_all`/`register`/`withdraw` 중 하나이며, 어떤 화면에서 동의가
바뀌었는지 구분해야 하는 리스너(예: 배너 동의만 특정 방식으로 처리하고 싶은 경우)는 이 값으로
분기합니다. `revoked` 는 명시적 철회(마이페이지)뿐 아니라 회원탈퇴로 인한 일괄 철회
(`source=withdraw`)에서도 발화됩니다 — "동의 취소" 이벤트를 하나로 통일해 구독자가 두 경로를
따로 처리하지 않아도 되게 합니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `core.auth.logout` | action (미선언) | `GdprAuthLogoutListener` | `forgetGdprCookies` | 10 |
| `core.auth.record_consents` | action (미선언) | `GdprAuthConsentListener` | `recordRegisterConsents` | 10 |
| `core.user.after_withdraw` | action (미선언) | `GdprUserWithdrawListener` | `handleWithdraw` | 10 |
| `core.user.before_delete` | action (미선언) | `GdprUserDeleteListener` | `cascadePluginData` | 10 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
`core.auth.record_consents` 는 회원가입 폼에서 받은 동의 값을 코어가 이 플러그인에 **위임**하는
자리입니다 — 회원가입 컨트롤러는 동의 저장 로직을 몰라도 되고, 이 플러그인이 폼 데이터에서
동의 관련 키만 추출해 회원가입과 같은 트랜잭션에서 기록합니다. 나머지 3개
(`logout`/`after_withdraw`/`before_delete`)는 전부 회원 생명주기 이벤트에 반응하는 정리 로직이며,
§핵심 흐름(AGENTS.md)에서 다룬 대로 탈퇴와 완전삭제는 반드시 구분해서 처리합니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `GdprAuthConsentListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprAuthConsentListener.php` |
| `GdprAuthLogoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprAuthLogoutListener.php` |
| `GdprUserDeleteListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprUserDeleteListener.php` |
| `GdprUserWithdrawListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/GdprUserWithdrawListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
4개 리스너가 전부 훅 1개씩만 구독하는 것은 각자 트리거가 회원 생명주기의 서로 다른 순간
(로그인 로그아웃/동의 기록/탈퇴/완전삭제)이라 합쳐도 이득이 없기 때문입니다.
`GdprAuthLogoutListener` 는 로그아웃 시 `gdpr_session` 게스트 쿠키를 폐기합니다 — 로그인
후에는 신원이 회원으로 바뀌므로, 로그아웃 시 남아 있는 게스트 세션 쿠키가 다음 방문자와
뒤섞이지 않도록 정리하는 것입니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/cookie_banner.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/mypage_privacy_tab.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
`cookie_banner.json` 은 사이트 전역에 배너를 띄우는 조각(코어/템플릿 공용 확장 지점에 주입)이고,
`mypage_privacy_tab.json` 은 마이페이지에 "개인정보/동의 관리" 탭을 추가하는 조각입니다. 둘 다
`sirsoft-basic` 템플릿의 확장 지점에 의존하므로, 다른 사용자 템플릿을 쓰려면 그 템플릿에도
같은 지점이 있어야 두 UI 가 정상 노출됩니다(README "다른 확장과의 연동" 참고).
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 미들웨어 | 부착 대상(targets) | 우선순위 |
|---|---|---|
| `CookieConsentMiddleware` | `everything` | - |
<!-- @generated:middleware END -->
<!-- @intent START -->
대상이 `everything`(모든 요청)인 이유는 functional 미동의 상태에서 어느 응답이 쿠키를
심으려 하는지 이 플러그인이 미리 알 수 없기 때문입니다 — 특정 라우트만 골라 부착하면 그
목록에서 빠진 응답의 쿠키는 게이팅되지 않습니다. 등록은 `GdprServiceProvider::boot()` 에서
Laravel 커널의 `prependMiddlewareToGroup('web'|'api')` 로 이뤄지며, 코어의 미들웨어
self-gate 규정(대상 명시)에 대한 근거가 바로 이 전면 적용 필요성입니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
동의 상태 변경은 실시간 브로드캐스트 대상이 아닙니다 — 같은 방문자가 여러 탭을 열어둔 상태를
동기화해야 할 만큼 시급한 이벤트가 아니고, 다음 페이지 요청 시 미들웨어가 최신 상태를 다시
평가하므로 자연스럽게 수렴합니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
동의 이력은 영구 보존이 원칙(Art.30)이라 배치로 정리할 대상이 없습니다. 게스트 세션 데이터의
만료·정리는 코어 세션 메커니즘에 위임하며, 이 플러그인이 별도 정리 스케줄을 두지 않습니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
동의 부여/철회는 방문자 본인의 조작 결과이므로 본인에게 알림을 보낼 이유가 없고, 정책 버전
발행처럼 운영자가 이미 인지하고 수행한 조작도 마찬가지입니다. 관리자에게 알려야 할 이벤트가
생기면(예: 대량 철회 급증 같은 이상 신호) 그때 코어 알림 정의를 신설합니다.
<!-- @intent END -->
@@ -0,0 +1,84 @@
# GDPR (일반 데이터 보호 규정) — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md)
## 레이아웃
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
레이아웃 4개 (루트: `resources/layouts`).
| 그룹 | 개수 |
|---|---|
| `admin` | 4개 |
| 레이아웃 | 그룹 | 종류 | extends |
|---|---|---|---|
| `gdpr_consent_log` | `admin` | 화면 | `_admin_base` |
| `_policy_version_snapshot_modal` | `admin` | partial | - |
| `_policy_version_publish_modal` | `admin` | partial | - |
| `plugin_settings` | `admin` | 화면 | `_admin_base` |
<!-- @generated:layouts END -->
<!-- @intent START -->
관리자 화면 4개뿐이고 **쿠키 배너·마이페이지 동의 탭은 이 표에 없습니다** — 그 둘은
"레이아웃"이 아니라 "레이아웃 확장 조각"(`resources/extensions/cookie_banner.json`,
`mypage_privacy_tab.json`, §extension-points.md)으로 다른 확장/템플릿 레이아웃에 주입되는
형태라 별도 수집 축에 잡힙니다. 방문자가 실제로 보는 UI를 찾으려면 이 표가 아니라
확장점 문서를 봐야 합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 1개 (정의: `resources/js/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `syncConsent` | `sirsoft-gdpr.syncConsent` |
<!-- @generated:handlers END -->
<!-- @intent START -->
`syncConsent` 하나뿐인 이유는 배너·마이페이지 UI 가 사실상 "동의 상태를 서버에 반영하고
화면을 갱신한다"는 단일 동작만 필요로 하기 때문입니다. 자동 차단/복원 로직(외부 스크립트·
iframe·1st-party 저장소 게이팅)은 이 액션 핸들러가 아니라 `dist/js/plugin.iife.js` 가 페이지
로드 시 스스로 수행합니다 — 사용자 조작에 반응하는 것과 페이지 로드마다 항상 실행되는 것을
액션 핸들러/전역 스크립트로 구분한 것입니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftGdpr` |
| 재등록 진입점 | `initPlugin()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`initPlugin()` 이 "핸들러 재등록만" 하도록 좁혀 둔 것은 코어 규정(§CLAUDE.md "확장 미들웨어는
...")을 그대로 따른 결과입니다 — 자동 차단 스크립트의 부팅(도메인 카탈로그 로드, DOM 스캔
시작)을 여기 넣으면 로케일 전환마다 그 부팅이 중복 실행됩니다. 자동 차단 부팅은 `blocker.ts`/
`preblocker.ts` 가 페이지 최초 로드 시 1회만 수행합니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/css/plugin.css` | 빌드 산출물 (커밋 대상) |
| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":0,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
`priority: 0`(다른 확장보다 먼저 로드)인 이유는 자동 차단이 **다른 확장의 추적 스크립트가
실행되기 전에** 걸려 있어야 하기 때문입니다 — 이 플러그인이 늦게 로드되면 동의 없이 이미
로드된 스크립트를 사후에 막을 방법이 없습니다. `strategy: "global"` 도 같은 이유로, 특정
페이지에서만 지연 로드하면 그 페이지에서는 동의 전 차단이 통째로 빠집니다.
<!-- @intent END -->
@@ -0,0 +1,96 @@
# GDPR (일반 데이터 보호 규정) — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `privacy_policy_slug` | `string` | `privacy` | 개인정보처리방침 페이지 슬러그 |
| `legal_entity_name` | `string` | - | 운영 주체명 |
| `data_storage_location` | `string` | - | 데이터 저장 위치 |
| `banner_enabled` | `boolean` | `true` | 쿠키 배너 노출 |
| `banner_position` | `string` | `bottom_bar` | 배너 위치 |
| `blocked_domains` | `json` | `{"functional":["*.crisp.chat","client.crisp.chat","*.intercom.io","widget.intercom.io","*.tawk.to","embed.tawk.to","cdn.weglot.com","*.weglot.com","*.usercentrics.eu"],"analytics":["google-analytics.com","*.google-analytics.com","googletagmanager.com","*.googletagmanager.com","ssl.google-analytics.com","*.hotjar.com","static.hotjar.com","*.mixpanel.com","cdn.mxpnl.com","*.amplitude.com","cdn.amplitude.com","*.segment.io","*.segment.com","wcs.naver.net","wcs.naver.com","*.beusable.net"],"marketing":["facebook.net","connect.facebook.net","facebook.com","*.facebook.com","doubleclick.net","*.doubleclick.net","googleadservices.com","googlesyndication.com","ads.google.com","*.criteo.com","static.criteo.net","*.adnxs.com","*.taboola.com","cdn.taboola.com","*.outbrain.com","*.kakao.com","analytics.ad.daum.net","platform.twitter.com","*.twitter.com","platform.linkedin.com","*.linkedin.com"]}` | 추적 도메인 차단 목록 |
| `cookie_categories` | `json` | `[]` | 쿠키 카테고리 정의 |
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
`blocked_domains` 가 카테고리별(functional/analytics/marketing) 배열을 담은 단일 JSON 컬럼인
것은, 카테고리 추가·삭제가 스키마 변경이 아니라 값 변경으로 끝나게 하기 위해서입니다 — 새
카테고리를 추가할 때 마이그레이션이 필요하지 않습니다(다만 배너 UI 의 카테고리 목록은 별도
동기화가 필요합니다, §AGENTS.md 수정 시 동반 의무). `cookie_categories` 가 기본값 `[]` 로
비어 있는 것은 4대 표준 카테고리(필수/기능/분석/마케팅)가 이미 코드/Enum(`CookieCategory`)에
고정돼 있어, 이 설정은 그 표준을 벗어나는 **추가** 카테고리를 위한 자리이기 때문입니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 카테고리 | 이름 | 액션 | 라우트 키 |
|---|---|---|---|
| `privacy` | 개인정보 보호 | `view`, `update` | - |
<!-- @generated:permissions END -->
<!-- @intent START -->
권한이 `view`/`update` 하나씩만 있고 관리자 동의 이력·정책 버전 발행이 별도 권한으로 세분화
되지 않은 것은, 이 플러그인의 관리자 기능 전체가 "개인정보 보호 담당자"라는 하나의 역할
단위로 다뤄지기 때문입니다. 조회와 변경(설정 수정·정책 발행)을 분리해 둔 것은 감사 목적으로
"누가 정책을 발행했는지"와 "누가 그냥 보기만 했는지"를 구분할 필요가 있어서입니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 구분 | slug | 이름 | URL | 하위 |
|---|---|---|---|---|
| 관리자 | `sirsoft-gdpr-consent-log` | GDPR 동의 이력 | `/admin/plugins/sirsoft-gdpr/consent-log` | - |
<!-- @generated:menus END -->
<!-- @intent START -->
"GDPR 설정"(정책 버전 발행 포함) 화면은 별도 메뉴가 아니라 플러그인 공통 설정 화면
(`관리자 → 플러그인 → GDPR 설정`)에 얹혀 있고, "동의 이력" 조회만 독립 메뉴입니다 — 설정은
가끔 바꾸는 화면이라 플러그인 목록에서 진입해도 충분하지만, 동의 이력은 자주 확인하는
운영 화면이라 별도 메뉴로 빠르게 도달할 수 있어야 하기 때문입니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-gdpr/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
`Public\GdprCookieConsentController`/`GdprSettingsController` 는 인증 없이 호출됩니다 —
게스트 방문자도 배너를 봐야 하고 동의를 기록해야 하므로, 공개 라우트로 두되 게스트 식별은
`gdpr_session` 서명 쿠키로 처리합니다. 반대로 관리자·마이페이지 라우트는 코어 인증/권한
미들웨어가 걸립니다 — 이 셋을 하나의 미들웨어 그룹으로 묶지 않고 컨트롤러 계층(Public/User/Admin)
으로 분리한 것이 인증 요구사항의 차이를 드러냅니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
formal `dependencies` 선언이 둘 다 "없음"인데도 README 는 `sirsoft-page`(소프트, 런타임 체크)와
`sirsoft-basic`(레이아웃 확장 주입 지점)을 명시합니다 — 둘 다 manifest 의존성 제약으로
선언할 만큼 강한 결합이 아니기 때문입니다. `sirsoft-page` 미설치는 기능 저하(링크 숨김)로
그치고, `sirsoft-basic` 미사용은 다른 템플릿이 같은 주입 지점을 제공하면 해소됩니다 — 둘 다
"없으면 설치가 막히는" 수준의 의존이 아니라 manifest 의존성 목록에 넣지 않습니다.
<!-- @intent END -->
@@ -201,6 +201,7 @@ return [
'register' => 'Sign-up', 'register' => 'Sign-up',
'mypage' => 'MyPage', 'mypage' => 'MyPage',
'mypage_renew_all' => 'MyPage bulk re-consent', 'mypage_renew_all' => 'MyPage bulk re-consent',
'withdraw' => 'Account withdrawal',
], ],
'col' => [ 'col' => [
'created_at' => 'Time', 'created_at' => 'Time',
@@ -201,6 +201,7 @@ return [
'register' => '회원가입', 'register' => '회원가입',
'mypage' => '마이페이지', 'mypage' => '마이페이지',
'mypage_renew_all' => '마이페이지 일괄 재동의', 'mypage_renew_all' => '마이페이지 일괄 재동의',
'withdraw' => '회원탈퇴',
], ],
'col' => [ 'col' => [
'created_at' => '시점', 'created_at' => '시점',
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@plugins/sirsoft-gdpr", "name": "@plugins/sirsoft-gdpr",
"version": "1.0.3", "version": "1.0.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "GDPR (일반 데이터 보호 규정)", "ko": "GDPR (일반 데이터 보호 규정)",
"en": "GDPR (General Data Protection Regulation)" "en": "GDPR (General Data Protection Regulation)"
}, },
"version": "1.0.3", "version": "1.0.4",
"description": { "description": {
"ko": "쿠키 동의 배너, 자동 차단, 동의 이력 저장, 마이페이지 동의 철회를 제공하는 GDPR 대응 플러그인입니다.", "ko": "쿠키 동의 배너, 자동 차단, 동의 이력 저장, 마이페이지 동의 철회를 제공하는 GDPR 대응 플러그인입니다.",
"en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal." "en": "GDPR-ready plugin: cookie consent banner, auto-blocking, consent history, and mypage consent withdrawal."
@@ -238,7 +238,8 @@
"preference_center": "Preferences", "preference_center": "Preferences",
"mypage": "MyPage", "mypage": "MyPage",
"register": "Sign-up", "register": "Sign-up",
"mypage_renew_all": "MyPage bulk re-consent" "mypage_renew_all": "MyPage bulk re-consent",
"withdraw": "Account withdrawal"
}, },
"col": { "col": {
"created_at": "Time", "created_at": "Time",
@@ -238,7 +238,8 @@
"preference_center": "환경설정", "preference_center": "환경설정",
"mypage": "마이페이지", "mypage": "마이페이지",
"register": "회원가입", "register": "회원가입",
"mypage_renew_all": "마이페이지 일괄 재동의" "mypage_renew_all": "마이페이지 일괄 재동의",
"withdraw": "회원탈퇴"
}, },
"col": { "col": {
"created_at": "시점", "created_at": "시점",
@@ -1119,6 +1119,52 @@
"text": "$t:sirsoft-gdpr.admin.consent_log.source.mypage_renew_all" "text": "$t:sirsoft-gdpr.admin.consent_log.source.mypage_renew_all"
} }
] ]
},
{
"type": "basic",
"name": "Label",
"props": {
"className": "inline-clickable"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "checkbox",
"className": "checkbox",
"checked": "{{(_local.filter.sources || []).includes('withdraw')}}"
},
"actions": [
{
"type": "change",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"filter.sources": "{{$event.target.checked ? [...(_local.filter.sources || []).filter(s => s !== 'withdraw'), 'withdraw'] : (_local.filter.sources || []).filter(s => s !== 'withdraw')}}"
}
},
{
"actionRef": "searchConsentLogs"
}
]
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label"
},
"text": "$t:sirsoft-gdpr.admin.consent_log.source.withdraw"
}
]
} }
] ]
} }
@@ -17,6 +17,7 @@ namespace Plugins\Sirsoft\Gdpr\Enums;
* - register: 회원가입 시 동의 (GdprAuthConsentListener) * - register: 회원가입 시 동의 (GdprAuthConsentListener)
* - mypage: 마이페이지 동의 관리에서 변경 * - mypage: 마이페이지 동의 관리에서 변경
* - mypage_renew_all: 정책 개정 후 마이페이지에서 일괄 재동의 (GdprConsentService::renewAll) * - mypage_renew_all: 정책 개정 후 마이페이지에서 일괄 재동의 (GdprConsentService::renewAll)
* - withdraw: 회원탈퇴 시 활성 동의 일괄 철회 (GdprConsentService::revokeAllOnWithdraw)
*/ */
enum ConsentSource: string enum ConsentSource: string
{ {
@@ -25,6 +26,7 @@ enum ConsentSource: string
case Register = 'register'; case Register = 'register';
case Mypage = 'mypage'; case Mypage = 'mypage';
case MypageRenewAll = 'mypage_renew_all'; case MypageRenewAll = 'mypage_renew_all';
case Withdraw = 'withdraw';
/** /**
* 사용자 친화 라벨을 반환합니다. * 사용자 친화 라벨을 반환합니다.
@@ -3,6 +3,7 @@
namespace Plugins\Sirsoft\Gdpr\Repositories; namespace Plugins\Sirsoft\Gdpr\Repositories;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Plugins\Sirsoft\Gdpr\Enums\ConsentSource;
use Plugins\Sirsoft\Gdpr\Models\GdprUserConsent; use Plugins\Sirsoft\Gdpr\Models\GdprUserConsent;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface;
@@ -14,8 +15,8 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/** /**
* 사용자 ID와 동의 키로 동의 상태를 조회합니다. * 사용자 ID와 동의 키로 동의 상태를 조회합니다.
* *
* @param int $userId 사용자 ID * @param int $userId 사용자 ID
* @param string $consentKey 동의 항목 키 * @param string $consentKey 동의 항목 키
* @return GdprUserConsent|null * @return GdprUserConsent|null
*/ */
public function findByUserAndKey(int $userId, string $consentKey): ?GdprUserConsent public function findByUserAndKey(int $userId, string $consentKey): ?GdprUserConsent
@@ -28,7 +29,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/** /**
* 사용자 ID로 모든 동의 상태를 조회합니다. * 사용자 ID로 모든 동의 상태를 조회합니다.
* *
* @param int $userId 사용자 ID * @param int $userId 사용자 ID
* @return Collection<int, GdprUserConsent> * @return Collection<int, GdprUserConsent>
*/ */
public function getAllByUserId(int $userId): Collection public function getAllByUserId(int $userId): Collection
@@ -39,7 +40,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/** /**
* 사용자 ID로 활성 동의(is_consented=true)만 조회합니다. * 사용자 ID로 활성 동의(is_consented=true)만 조회합니다.
* *
* @param int $userId 사용자 ID * @param int $userId 사용자 ID
* @return Collection<int, GdprUserConsent> * @return Collection<int, GdprUserConsent>
*/ */
public function getActiveByUserId(int $userId): Collection public function getActiveByUserId(int $userId): Collection
@@ -52,14 +53,14 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/** /**
* 가상의 비활성 동의 상태 모델을 합성합니다 (DB 미저장). * 가상의 비활성 동의 상태 모델을 합성합니다 (DB 미저장).
* *
* @param int $userId 사용자 ID * @param int $userId 사용자 ID
* @param string $consentKey 동의 항목 키 (cookie_ 접두사 포함) * @param string $consentKey 동의 항목 키 (cookie_ 접두사 포함)
* @param string|null $consentCategory 카테고리 * @param string|null $consentCategory 카테고리
* @return GdprUserConsent 합성된 비활성 모델 (DB 미저장) * @return GdprUserConsent 합성된 비활성 모델 (DB 미저장)
*/ */
public function buildVirtualStatus(int $userId, string $consentKey, ?string $consentCategory = null): GdprUserConsent public function buildVirtualStatus(int $userId, string $consentKey, ?string $consentCategory = null): GdprUserConsent
{ {
$row = new GdprUserConsent(); $row = new GdprUserConsent;
$row->user_id = $userId; $row->user_id = $userId;
$row->consent_key = $consentKey; $row->consent_key = $consentKey;
$row->consent_category = $consentCategory; $row->consent_category = $consentCategory;
@@ -79,9 +80,9 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/** /**
* 동의 상태 레코드를 생성하거나 업데이트합니다. * 동의 상태 레코드를 생성하거나 업데이트합니다.
* *
* @param int $userId 사용자 ID * @param int $userId 사용자 ID
* @param string $consentKey 동의 항목 키 * @param string $consentKey 동의 항목 키
* @param array $data 업데이트 데이터 * @param array $data 업데이트 데이터
* @return GdprUserConsent * @return GdprUserConsent
*/ */
public function upsert(int $userId, string $consentKey, array $data): GdprUserConsent public function upsert(int $userId, string $consentKey, array $data): GdprUserConsent
@@ -99,7 +100,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/** /**
* 사용자의 모든 활성 동의를 일괄 철회 처리합니다 (탈퇴 시 사용). * 사용자의 모든 활성 동의를 일괄 철회 처리합니다 (탈퇴 시 사용).
* *
* @param int $userId 사용자 ID * @param int $userId 사용자 ID
* @return int 영향받은 행 수 * @return int 영향받은 행 수
*/ */
public function revokeAllForUser(int $userId): int public function revokeAllForUser(int $userId): int
@@ -109,14 +110,14 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
->update([ ->update([
'is_consented' => false, 'is_consented' => false,
'revoked_at' => now(), 'revoked_at' => now(),
'last_source' => 'withdraw', 'last_source' => ConsentSource::Withdraw->value,
]); ]);
} }
/** /**
* 특정 동의 키에 동의한 사용자 수를 반환합니다. * 특정 동의 키에 동의한 사용자 수를 반환합니다.
* *
* @param string $consentKey 동의 항목 키 * @param string $consentKey 동의 항목 키
* @return int * @return int
*/ */
public function countConsentedByKey(string $consentKey): int public function countConsentedByKey(string $consentKey): int
@@ -129,7 +130,7 @@ class GdprUserConsentRepository implements GdprUserConsentRepositoryInterface
/** /**
* 사용자 ID로 모든 동의 상태 레코드를 삭제합니다. * 사용자 ID로 모든 동의 상태 레코드를 삭제합니다.
* *
* @param int $userId 사용자 ID * @param int $userId 사용자 ID
* @return void * @return void
*/ */
public function deleteByUserId(int $userId): void public function deleteByUserId(int $userId): void
@@ -73,7 +73,7 @@ class GdprConsentService
* @param string|null $sessionId 게스트 세션 ID (회원이면 NULL) * @param string|null $sessionId 게스트 세션 ID (회원이면 NULL)
* @param string $consentKey 동의 항목 키 * @param string $consentKey 동의 항목 키
* @param bool $value 동의 여부 * @param bool $value 동의 여부
* @param string $source 변경 경로 (허용 어휘는 ConsentSource enum — banner/preference_center/register/mypage/mypage_renew_all) * @param string $source 변경 경로 (허용 어휘는 {@see ConsentSource} 가 SSoT)
* @param array|null $categories 카테고리 스냅샷 (배너 일괄 변경 시) * @param array|null $categories 카테고리 스냅샷 (배너 일괄 변경 시)
* @param bool $isRejection 명시적 거부 신호 (이슈 #430). 선택형 미동의 항목을 is_rejected=true 로 저장. * @param bool $isRejection 명시적 거부 신호 (이슈 #430). 선택형 미동의 항목을 is_rejected=true 로 저장.
* @return void * @return void
@@ -302,7 +302,7 @@ class GdprConsentService
// 멱등하게 작성해야 한다. // 멱등하게 작성해야 한다.
DB::transaction(function () use ($userId, $activeConsents) { DB::transaction(function () use ($userId, $activeConsents) {
foreach ($activeConsents as $consent) { foreach ($activeConsents as $consent) {
$this->updateConsent($userId, null, $consent->consent_key, false, 'withdraw'); $this->updateConsent($userId, null, $consent->consent_key, false, ConsentSource::Withdraw->value);
} }
}); });
} }
@@ -0,0 +1,55 @@
/**
* E2E: 관리자 「GDPR 동의 이력」 화면 — 출처 필터 「회원탈퇴」 체크박스 (#601 문서화 세션 중 발견)
*
* @scenario admin_gdpr_consent_log_source_filter_withdraw
* @effects withdraw_checkbox_visible, withdraw_filter_updates_query_and_refetches
*
* 배경: `ConsentSource` enum 에 `withdraw`(회원탈퇴 시 일괄 철회) case 가 없어, 그 출처로
* 기록된 동의 이력 행이 출처 필터 어디로도 걸러지지 않던 결함을 발견해 enum·기록 지점(Service·
* Repository)·라벨(ko/en/ja)·이 필터 체크박스를 함께 추가했다. PHPUnit 쪽은
* `ConsentSourceVocabularyParityTest` 가 JSON 안의 `includes('withdraw')`/라벨 바인딩
* 존재를 정적으로 검증하지만, 그 체크박스가 실제 브라우저에서 보이고 클릭 시 목록이 다시
* 조회되는지는 별도로 확인해야 한다.
*
* 검증:
* 1. 동의 이력 화면에 "회원탈퇴" 출처 필터 체크박스가 보인다
* 2. 체크하면 URL 쿼리에 `sources` 값으로 `withdraw` 가 반영되고 목록이 재조회된다
* 3. 다시 해제하면 `withdraw` 가 쿼리에서 빠진다
*/
import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth';
const WITHDRAW_FILTER_LABEL = '회원탈퇴';
async function gotoConsentLog(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin/plugins/sirsoft-gdpr/consent-log');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator('#gdpr_consent_log_datagrid__body')).toBeAttached({ timeout: 20_000 });
}
// @scenario source_filter=withdraw
// @effects withdraw_checkbox_visible
test('#601 - 동의 이력 출처 필터에 "회원탈퇴" 체크박스가 보인다', async ({ page, privacyManageToken }) => {
await authenticatePage(page, privacyManageToken);
await gotoConsentLog(page);
const checkbox = page.getByLabel(WITHDRAW_FILTER_LABEL);
await expect(checkbox).toBeAttached({ timeout: 10_000 });
await expect(checkbox).not.toBeChecked();
});
// @scenario source_filter=withdraw, toggle=on_then_off
// @effects withdraw_filter_updates_query_and_refetches
test('#601 - "회원탈퇴" 체크 시 URL 쿼리에 반영되고, 해제하면 빠진다', async ({ page, privacyManageToken }) => {
await authenticatePage(page, privacyManageToken);
await gotoConsentLog(page);
const checkbox = page.getByLabel(WITHDRAW_FILTER_LABEL);
await checkbox.check();
// searchConsentLogs 가 navigate(mergeQuery) 로 sources 배열을 쿼리에 싣는다.
await expect(page).toHaveURL(/withdraw/, { timeout: 10_000 });
await expect(checkbox).toBeChecked();
await checkbox.uncheck();
await expect(page).not.toHaveURL(/withdraw/, { timeout: 10_000 });
});
@@ -3,10 +3,12 @@
namespace Plugins\Sirsoft\Gdpr\Tests; namespace Plugins\Sirsoft\Gdpr\Tests;
use App\Enums\PermissionType; use App\Enums\PermissionType;
use App\Extension\HookManager;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprPolicyVersionRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprPolicyVersionRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface; use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface;
@@ -48,6 +50,39 @@ abstract class PluginTestCase extends TestCase
$this->app->bind(GdprUserConsentRepositoryInterface::class, GdprUserConsentRepository::class); $this->app->bind(GdprUserConsentRepositoryInterface::class, GdprUserConsentRepository::class);
$this->app->bind(GdprUserConsentHistoryRepositoryInterface::class, GdprUserConsentHistoryRepository::class); $this->app->bind(GdprUserConsentHistoryRepositoryInterface::class, GdprUserConsentHistoryRepository::class);
$this->app->bind(GdprPolicyVersionRepositoryInterface::class, GdprPolicyVersionRepository::class); $this->app->bind(GdprPolicyVersionRepositoryInterface::class, GdprPolicyVersionRepository::class);
$this->registerPluginApiRoutes();
}
/**
* 플러그인 API 라우트를 테스트 앱에 등록합니다.
*
* `PluginRouteServiceProvider` 는 `plugins` 테이블의 활성 행을 대조해서만 라우트를
* 등록합니다(#603). 테스트는 `RefreshDatabase` 로 매번 빈 테이블에서 부팅하므로 그
* 게이트가 항상 닫히고, 이 플러그인의 모든 엔드포인트가 404 가 됩니다 — 실패는
* 권한·검증이 아니라 "주소 없음" 으로 나타나 원인이 드러나지 않습니다.
*
* 활성 행을 심는 것으로는 낫지 않습니다. 라우트 등록은 `parent::setUp()` 의 앱 부팅
* 시점에 끝나고 DB 초기화는 그 뒤에 오기 때문입니다. 다른 번들 확장의 테스트 베이스도
* 같은 이유로 라우트 파일을 직접 그룹에 물립니다.
*
* prefix·name·middleware 는 프로바이더와 동일하게 맞춥니다 — 어긋나면 테스트가
* 통과해도 운영 주소와 다른 곳을 밟게 됩니다.
*
* @return void
*/
protected function registerPluginApiRoutes(): void
{
$apiRoutesFile = dirname(__DIR__).'/src/routes/api.php';
if (! file_exists($apiRoutesFile)) {
return;
}
Route::prefix('api/plugins/sirsoft-gdpr')
->name('api.plugins.sirsoft-gdpr.')
->middleware('api')
->group($apiRoutesFile);
} }
/** /**
@@ -69,10 +104,10 @@ abstract class PluginTestCase extends TestCase
*/ */
private function snapshotHookManager(): void private function snapshotHookManager(): void
{ {
$ref = new \ReflectionClass(\App\Extension\HookManager::class); $ref = new \ReflectionClass(HookManager::class);
$this->hookSnapshot = [ $this->hookSnapshot = [
'hooks' => $ref->getProperty('hooks')->getValue(), 'hooks' => $ref->getProperty('hooks')->getValue(),
'filters' => $ref->getProperty('filters')->getValue(), 'filters' => $ref->getProperty('filters')->getValue(),
'dispatching' => $ref->getProperty('dispatching')->getValue(), 'dispatching' => $ref->getProperty('dispatching')->getValue(),
]; ];
} }
@@ -88,7 +123,7 @@ abstract class PluginTestCase extends TestCase
return; return;
} }
$ref = new \ReflectionClass(\App\Extension\HookManager::class); $ref = new \ReflectionClass(HookManager::class);
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
@@ -165,17 +200,17 @@ abstract class PluginTestCase extends TestCase
{ {
$paths = ['database/migrations']; $paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $p); $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
} }
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $p); $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
} }
return [ return [
'--drop-views' => $this->shouldDropViews(), '--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(), '--drop-types' => $this->shouldDropTypes(),
'--seed' => false, '--seed' => false,
'--path' => $paths, '--path' => $paths,
]; ];
} }
} }
@@ -17,6 +17,7 @@ use Plugins\Sirsoft\Gdpr\Enums\ConsentSource;
* *
* DB·라우트에 의존하지 않는 정적 검사이므로 `Tests\TestCase` 가 아니라 순수 TestCase 를 상속합니다. * DB·라우트에 의존하지 않는 정적 검사이므로 `Tests\TestCase` 가 아니라 순수 TestCase 를 상속합니다.
*/ */
// audit:allow test-extension-base-class reason: 파일 내용·enum 값만 비교하는 순수 정적 검사 — DB/라우트/오토로드 부팅이 필요 없어 PluginTestCase 상속 시 불필요한 부팅 비용만 늘어난다
class ConsentSourceVocabularyParityTest extends TestCase class ConsentSourceVocabularyParityTest extends TestCase
{ {
/** 플러그인 루트 경로 */ /** 플러그인 루트 경로 */
@@ -36,9 +37,14 @@ class ConsentSourceVocabularyParityTest extends TestCase
$declared = ConsentSource::allValues(); $declared = ConsentSource::allValues();
// 실제 기록 지점 — source:/'source' =>/'last_source' => 인자로 넘어가는 리터럴 // 실제 기록 지점 — source:/'source' =>/'last_source' => 인자로 넘어가는 리터럴
//
// Repository 도 포함한다 — GdprUserConsentRepository::revokeAllForUser() 가
// Service 를 거치지 않고 직접 'last_source' => 리터럴을 UPDATE 쿼리에 싣는다.
// 이 파일이 빠져 있으면 그 리터럴은 어떤 축으로도 검사되지 않는다.
$sources = [ $sources = [
'src/Listeners/GdprAuthConsentListener.php', 'src/Listeners/GdprAuthConsentListener.php',
'src/Services/GdprConsentService.php', 'src/Services/GdprConsentService.php',
'src/Repositories/GdprUserConsentRepository.php',
'src/Http/Controllers/User/GdprConsentController.php', 'src/Http/Controllers/User/GdprConsentController.php',
]; ];
@@ -0,0 +1,173 @@
# KG 이니시스 — 에이전트 가이드
> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
## TL;DR (5초 요약)
```text
1. 유형: 플러그인 (sirsoft-pay_kginicis) — KG 이니시스 PG 연동(PC/모바일/가상계좌/에스크로/일본 CBT). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유
2. 확장 방식: `RegisterPgProviderListener`/`RegisterCashReceiptProviderListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다
3. 건드리면 안 되는 것: `authUrl`/`P_REQ_URL`/`netCancelUrl` 화이트리스트 검증 생략, 콜백 재처리 방지 로직 우회, IP 화이트리스트 미들웨어(`InicisNotifyIpWhitelist`) 미부착
4. 작업 위치: `plugins/_bundled/sirsoft-pay_kginicis` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan plugin:update sirsoft-pay_kginicis --force`
```
## 1. 이 확장은 무엇인가
<!-- @intent START -->
KG 이니시스 PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 결제수단마다
프로토콜이 다릅니다 — PC 는 브라우저 결제창 + 서버 승인 API, 모바일은 폼 POST 이동 + 별도
승인 API, 일본 CBT 는 완전히 다른 인증/승인 체계(JPPG)를 씁니다. 이 플러그인의 역할은 그
세 가지 서로 다른 프로토콜을 전부 흡수해 이커머스 쪽에는 "결제 성공/실패/취소"라는 하나의
결과만 넘기는 것입니다.
**설계 원칙**: 이 플러그인은 상태를 소유하지 않습니다(§data-model.md — 모델·테이블 0개).
주문·결제 상태는 전부 `sirsoft-ecommerce`의 테이블에 있고, 이 플러그인은 PG API 와 그 상태를
동기화하는 역할만 합니다. 등록도 코드 결합이 아니라 훅 기반입니다
(`sirsoft-ecommerce.payment.registered_pg_providers` 필터) — 이커머스 모듈은 이 플러그인의
존재를 컴파일 타임에 몰라도 됩니다.
**의도적으로 하지 않는 것**: 결제 실패 시 자동으로 다른 PG 로 재시도하지 않습니다 — PG 마다
가맹점 계약·결제수단이 다르므로 자동 전환은 이중 결제·과금 위험을 만듭니다. 또한 일본 결제
설정이 불완전할 때 한국 표준결제로 조용히 대체하지 않고 결제 자체를 중단합니다 — 설정 실수를
"어쨌든 결제는 된다"로 감추면 잘못된 통화·수수료로 승인될 수 있습니다.
<!-- @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/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_kginicis --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_kginicis --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_kginicis --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_kginicis --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_kginicis --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**PC 결제 승인**: `PaymentCallbackController`(KG 이니시스가 POST 하는 authToken/authUrl 수신)
→ `authUrl` 화이트리스트 검증 → `sirsoft-pay_kginicis.payment.before_authorize` 훅 →
`KgInicisApiService` 가 승인 API 호출 → `sirsoft-pay_kginicis.payment.after_authorize` 훅 →
이커머스 주문 결제 완료 처리. 승인 후 로컬 처리 실패 시 `netCancelUrl` 로 망취소를 시도합니다
— 이 지점이 실패하면 "PG 는 승인, 우리는 실패"인 가장 위험한 상태이므로 반드시 오류 로그를
남깁니다.
**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가
`sirsoft-ecommerce.payment.refund` 필터 발화 → 이 플러그인의 `PaymentRefundListener`
(우선순위 10)가 먼저 KG 이니시스 취소 API 호출 → `CancelActivityLogListener`(우선순위 20)가
그 결과(PG 응답 시각·취소 TID)를 활동 로그에 별도 기록. 우선순위 순서가 중요합니다 — 취소가
실제로 성공한 뒤에야 로그를 남겨야 "로그는 있는데 실제 취소는 실패"가 생기지 않습니다.
**일본 CBT 승인**: `/payment/cbt/hash-data` 로 해시 생성(타임스탬프 신선도 검증) →
CBT 인증 URL 로 폼 POST → KG 이니시스가 `sid` 를 콜백으로 전달 → `cbtapprove` API 호출 →
카드/PayPay 는 즉시 완료, 편의점은 입금대기로 저장 후 별도 NOTI 수신 시 완료. 로컬 후속
처리가 실패하면 CBT 전용 취소 API 로 자동 취소를 시도하고, 그마저 실패하면 수동 취소가
필요하다는 오류 로그를 남깁니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 6개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 14개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 11개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 4개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
`before_authorize`/`before_cancel`/`before_cbt_refund` 는 PG 호출 **전** 개입 지점입니다 —
예를 들어 고액 결제에 본인인증을 추가로 요구하고 싶은 확장이 `before_cancel` 을 잡아 조건
미충족 시 예외를 던지면 KG 이니시스 API 호출 자체가 일어나지 않습니다(`before_cancel` 의
용도로 이미 "본인인증 등 확장 지점"이라 발행 위치에 명시돼 있습니다). `after_*` 훅은 PG 응답을
받은 뒤 부가효과(추가 로그, 알림 등)를 붙이는 자리입니다. 구독 훅 14개 중 다수가
`core.layout_extension.after_apply` 인 이유는 관리자 주문 목록/상세 화면에 "테스트 모드
배지"·"거래 조회 UI"를 레이아웃 확장으로 주입하기 때문입니다(§레이아웃 확장).
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_kginicis --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_kginicis` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다
- [ ] IP 화이트리스트(`InicisNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백/통보 URL 등록")에도 반영
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| PG 콜백의 `authUrl`/`P_REQ_URL`/`netCancelUrl`을 화이트리스트 없이 그대로 호출 | KG 이니시스 허용 URL 목록과 대조 후에만 호출 | 콜백 파라미터를 신뢰하면 공격자가 임의 URL로 서버발 요청을 유도할 수 있다(SSRF) |
| 동일 거래번호 콜백을 매번 재처리 | 콜백 재처리 방지 검사를 거친 뒤 처리 | 재처리를 막지 않으면 같은 결제가 중복 완료 처리되거나 중복 환불될 수 있다 |
| 결제창 서명/모바일 해시/CBT 해시 요청에 타임스탬프 검증 생략 | 타임스탬프 신선도 검증 유지 | 오래된 서명 재사용(replay)으로 위조 결제 요청이 통과할 수 있다 |
| 일본 결제 설정 미완료 시 한국 표준결제로 조용히 대체 | 설정 미완료면 결제 자체를 중단 | 통화·수수료·정산 구조가 다른 결제가 잘못된 흐름으로 승인될 수 있다 |
| 라이브 키(사인키·INIAPI 키/IV·해시키)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 서명을 위조할 수 있다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 35개 | `plugins/_bundled/sirsoft-pay_kginicis/tests` |
| Vitest | 12개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_kginicis/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd plugins/_bundled/sirsoft-pay_kginicis && powershell -Command "npm run test:run -- <대상>"
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @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 -->
+223 -216
View File
@@ -1,267 +1,274 @@
# KG Inicis Plugin for G7 # KG 이니시스
KG 이니시스 표준결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. **G7 플러그인 · sirsoft-pay_kginicis**
KG 이니시스 표준결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인
PC 결제는 KG 이니시스 `INIStdPay.js` 표준결제창을 사용하고, 모바일 결제는 KG 이니시스 모바일 표준결제창으로 이동한 뒤 서버 승인 API로 최종 승인합니다. 일본 엔(JPY) 결제는 KG 이니시스 CBT(JPPG) 흐름을 사용합니다. <!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.1.2-0066FF?style=flat-square" alt="version 1.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.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">
<img src="https://img.shields.io/badge/requires-sirsoft--ecommerce-BF8700?style=flat-square" alt="requires sirsoft-ecommerce">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
KG 이니시스 표준결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. PC 결제는
`INIStdPay.js` 표준결제창을, 모바일 결제는 모바일 표준결제창으로 이동한 뒤 서버 승인 API로
최종 승인하는 흐름을 씁니다. 일본 엔(JPY) 결제는 별도의 KG 이니시스 CBT(JPPG) 흐름을 씁니다.
이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를 소유하지 않습니다 — 그 상태는
`sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를 KG 이니시스 API 와
어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유 테이블/모델이 하나도 없습니다
(§data-model.md).
<!-- @intent END -->
## 주요 기능 ## 주요 기능
- 신용카드, 계좌이체, 가상계좌, 휴대폰결제 지원 <!-- @intent START -->
- PC 표준결제창 연동 | 영역 | 설명 |
- 모바일 표준결제창 연동 및 `P_CHKFAKE` 위변조 방지 해시 생성 |---|---|
- 삼성페이, L.pay, 카카오페이 간편결제 버튼 주입 | 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 |
- 가상계좌 발급, PC/모바일 입금통보 처리 | 간편결제 | 삼성페이, L.pay, 카카오페이 버튼 주입 (다른 PG가 기본이어도 노출 가능) |
- 에스크로 결제, 배송 등록, 구매결정, 구매거절확인 연동 | 가상계좌 | 발급 + PC/모바일 입금통보 처리 |
- 결제 취소 및 부분취소 연동 | 에스크로 | 결제, 배송 등록, 구매결정, 구매거절확인 연동 |
- PG 측 결제 취소 확인 시점의 활동 로그 별도 기록 (PG 응답 시각·취소 TID 사후 추적) | 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 TID) |
- 주문 완료/마이페이지 영수증 버튼 주입 | 영수증 | 주문 완료/마이페이지 영수증 버튼, 현금영수증 발급/취소(이커머스 공용 프로바이더) |
- 관리자 주문 상세의 거래 조회, 에스크로 처리 UI 확장 | 관리자 확장 | 주문 상세 거래 조회, 에스크로 처리 UI |
- 현금영수증 발급/취소 (이커머스 모듈의 공용 현금영수증 프로바이더로 등록 — PG 결제사와 독립 선택 가능) | 일본 결제(CBT) | 인증/승인, 테스트 상품 생성, 연결 진단 |
- 일본 결제 CBT(JPPG) 인증/승인, 테스트 상품 생성, 연결 진단 | 보안 | 승인 URL 화이트리스트, 콜백 재처리 방지, 타임스탬프 신선도 검증 |
- 승인 URL 화이트리스트, 콜백 재처리 방지, 타임스탬프 신선도 검증 <!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
A[체크아웃 주문 생성] -->|PC| B["/payment/signature 호출 → INIStdPay.js 결제창"]
A -->|모바일| C["/payment/mobile/signature → 모바일 표준결제창"]
A -->|JPY| D["/payment/cbt/hash-data → CBT 인증 URL"]
B --> E["/payment/callback (authToken·authUrl)"]
C --> F["/payment/mobile/callback (P_TID·P_REQ_URL)"]
D --> G["/payment/cbt/callback (sid)"]
E --> H[서버가 authUrl 화이트리스트 검증 후 승인 API 호출]
F --> H
G --> I[cbtapprove API 호출]
H --> J[주문 결제 완료 처리]
I --> J
J --> K[성공 URL 리다이렉트]
```
승인 후 로컬 처리가 실패하면 PC/모바일은 각각 netCancel/취소 API로, CBT는 CBT 전용 취소
API로 자동 취소를 시도합니다(자동 취소까지 실패하면 수동 취소가 필요하다는 오류 로그를
남깁니다) — "PG 는 승인됐는데 우리 시스템은 실패"라는 상태가 남지 않도록 하기 위함입니다.
가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, KG 이니시스가 입금통보
URL로 결과를 POST 하면 거래번호 재처리·금액 검증 후 결제 완료 처리됩니다. PC/모바일 입금통보는
URL이 다르지만 같은 IP 화이트리스트 미들웨어를 거칩니다.
일본 CBT 는 카드/PayPay는 즉시 승인 후 완료 처리되고, 편의점(CVS) 결제는 입금대기로 저장된 뒤
`/payment/cbt/cvs-notify` 수신 시 완료 처리됩니다. JPY 주문은 일본 결제 설정이 완료된 경우에만
CBT 결제창으로 진입하며, 설정이 부족하면 한국 표준결제로 대체하지 않고 결제 자체를 중단합니다.
<!-- @intent END -->
## 요구 사항 ## 요구 사항
| 항목 | 내용 | <!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|------|------| | 항목 | 값 |
| G7 | `>= 7.0.0-beta.2` | |---|---|
| 의존 모듈 | `sirsoft-ecommerce >= 1.0.0-beta.4` | | G7 코어 | `>=7.0.10` |
| PHP | `^8.2` | | PHP | `^8.2` |
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
<!-- @generated:requirements END -->
<!-- @intent START -->
| 항목 | 필요한 것 |
|---|---|
| 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KG 이니시스 가맹점 계약 정보 | | 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KG 이니시스 가맹점 계약 정보 |
| PC 결제 | MID, signKey | | PC 결제 | MID, signKey |
| 모바일 결제 | MID, 모바일 hash key | | 모바일 결제 | MID, 모바일 hash key |
| 취소/거래조회/현금영수증 | INIAPI key, INIAPI IV | | 취소/거래조회/현금영수증 | INIAPI key, INIAPI IV |
| 일본 CBT | 별도 일본 결제 MID, CBT hash key | | 일본 CBT | 별도 일본 결제 MID, CBT hash key |
서버에서 KG 이니시스 결제/INIAPI/CBT 호스트로 HTTPS outbound 요청이 가능해야 합니다. CBT 테스트 환경 `devcbt.inicis.com`은 KG 이니시스 측에 서버 egress IP 등록이 필요할 수 있습니다. 서버에서 KG 이니시스 결제/INIAPI/CBT 호스트로 HTTPS outbound 요청이 가능해야 합니다. CBT
테스트 환경 `devcbt.inicis.com`은 KG 이니시스 측에 서버 egress IP 등록이 필요할 수 있습니다.
<!-- @intent END -->
## 설치 ## 설치
플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다. <!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```text
plugins/sirsoft-pay_kginicis
```
프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
```bash ```bash
npm install # 번들 설치 (코어에 동봉된 소스에서 설치)
npm run build php artisan plugin:install sirsoft-pay_kginicis
# 활성화
php artisan plugin:activate sirsoft-pay_kginicis
# 업데이트 (번들 소스 기준 강제 반영)
php artisan plugin:update sirsoft-pay_kginicis --force
``` ```
그다음 G7 관리자에서 플러그인을 활성화하고, 이커머스 결제 설정에서 PG 제공자를 `KG 이니시스`로 선택합니다. 저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis
<!-- @generated:install END -->
설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "KG 이니시스"로 선택해야 실제로 결제
흐름에 연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다.
## 관리자 설정 ## 관리자 설정
관리자 플러그인 설정 화면에서 KG 이니시스 계약 정보를 입력합니다. <!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `is_test_mode` | 테스트 모드 | `true` |
| `test_mid` | 테스트 가맹점 ID (MID) | `INIpayTest` |
| `test_sign_key` | 테스트 사인키 | `SU5JTElURV9UUklQTEVERVNfS0VZU1RS` |
| `test_iniapi_key` | 테스트 INIAPI 키 | `ItEQKi3rY7uvDS8l` |
| `test_iniapi_iv` | 테스트 INIAPI IV | `HYb3yQ4f65QL89==` |
| `live_mid` | 라이브 가맹점 ID (MID) | - |
| `live_sign_key` | 라이브 사인키 | - |
| `live_iniapi_key` | 라이브 INIAPI 키 | - |
| `live_iniapi_iv` | 라이브 INIAPI IV | - |
| `test_mobile_hash_key` | 테스트 모바일 해시키 | `3CB8183A4BE283555ACC8363C0360223` |
| `live_mobile_hash_key` | 라이브 모바일 해시키 | - |
| `use_escrow` | 에스크로 결제 활성화 | `false` |
| `japan_enabled` | 일본 결제 활성화 | `false` |
| `japan_restrict_jpy_payment_methods` | JPY 주문 결제수단 제한 | `false` |
| `test_japan_sign_key` | 테스트 일본 CBT 해시키 | `5AL5Djb1Ipualn0F` |
| `live_japan_mid` | 라이브 일본 MID | - |
| `live_japan_sign_key` | 라이브 일본 CBT 해시키 | - |
| `japan_merchant_name` | 일본 결제 가맹점명 | `サンプルストア` |
| `japan_merchant_name_kana` | 일본 결제 가맹점명 Kana | `サンプルストア` |
| `japan_merchant_name_alphabet` | 일본 결제 가맹점명 영문 | `Sample Store` |
| `japan_merchant_name_short` | 일본 결제 가맹점 약칭 | `サンプル` |
| `japan_contact_name` | 일본 결제 문의처명 | `サポート窓口` |
| `japan_contact_email` | 일본 결제 문의 이메일 | `support@example.com` |
| `japan_contact_phone` | 일본 결제 문의 전화번호 | `0120-123-456` |
| `japan_contact_opening_hours` | 일본 결제 문의 영업시간 | `10:00-18:00` |
| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` |
| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` |
| `easy_pay_allow_with_other_pg` | 타 PG와 사용가능함 | `false` |
| `easy_pay_samsung_pay` | KG이니시스 삼성페이 사용 | `false` |
| `easy_pay_naverpay` | KG이니시스 네이버페이 사용 | `false` |
| `easy_pay_show_brand_button` | 간편결제 브랜드 버튼 표시 | `false` |
| `easy_pay_lpay` | KG이니시스 L.pay 사용 | `false` |
| `easy_pay_kakaopay` | KG이니시스 카카오페이 사용 | `false` |
| `use_credit_point` | 신용카드 포인트 사용 | `false` |
| 설정 | 설명 | 개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
|------|------| <!-- @generated:settings-summary END -->
| 테스트 모드 | 활성화 시 KG 이니시스 테스트 환경을 사용합니다. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일 23:00~23:50 사이 자동 취소될 수 있습니다. |
| 테스트 MID | 기본값은 `INIpayTest`입니다. 에스크로 테스트 사용 시 내부적으로 `iniescrow0`을 사용합니다. |
| 테스트 사인키 | PC 결제창 서명 생성에 사용합니다. |
| 테스트 INIAPI 키/IV | 취소, 거래조회, 현금영수증, 에스크로 API 인증에 사용합니다. |
| 테스트 모바일 해시키 | 모바일 `P_CHKFAKE` 생성에 사용합니다. |
| 라이브 MID | 운영 MID입니다. `SIR` prefix 없이 입력해도 플러그인이 자동 보정합니다. |
| 라이브 사인키 | 운영 결제창 서명 생성에 사용합니다. 외부에 노출하지 마세요. |
| 라이브 INIAPI 키/IV | 운영 취소, 거래조회, 현금영수증, 에스크로 API 인증에 사용합니다. |
| 라이브 모바일 해시키 | 운영 모바일 `P_CHKFAKE` 생성에 사용합니다. |
| 에스크로 결제 활성화 | PC는 `acceptmethod`에 `useescrow`, 모바일은 `P_RESERVED`에 `useescrow=Y`를 추가합니다. |
| 일본 결제 활성화 | JPY 주문에서 KG 이니시스 CBT(JPPG) 결제 흐름을 사용합니다. |
| 테스트 일본 CBT 해시키 | CBT 테스트 해시 생성에 사용합니다. 테스트 MID는 `CBTTEST001` 고정값을 사용합니다. |
| 라이브 일본 MID/해시키 | 운영 CBT 결제에 사용합니다. |
| JPPG 결제창 표시 정보 | 일본 결제창 `extraData`에 포함되는 가맹점명, 가나명, 영문명, 문의처 정보를 설정합니다. 운영 전 실제 계약 정보로 교체하세요. |
| 결제 성공 URL | 기본값은 `/shop/orders/{orderId}/complete`입니다. |
| 결제 실패 URL | 기본값은 `/shop/checkout`입니다. |
| 간편결제 | KG 이니시스 계약이 완료된 간편결제만 활성화하세요. |
| 타 PG와 사용가능함 | 다른 PG가 기본값이어도 KG 이니시스 간편결제 버튼을 체크아웃 화면에 표시합니다. |
| 신용카드 포인트 사용 | PC 카드 결제 `acceptmethod`에 신용카드 포인트 사용 옵션을 추가합니다. |
테스트 모드 주문은 실제 배송하지 마세요. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일 23:00~23:50 사이 자동 취소될 수 있습니다. <!-- @intent START -->
테스트 모드에서는 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 거래는 매일
23:00~23:50 사이 자동 취소될 수 있습니다 — **테스트 모드 주문을 실제로 배송하지 마세요.**
운영 키(라이브 사인키·INIAPI 키/IV·모바일/CBT 해시키)는 외부에 노출하지 말고, 배포 전
테스트 모드가 의도한 값인지 반드시 확인하세요.
운영 키와 해시키는 외부에 노출하지 말고, 배포 전 테스트 모드가 의도한 값인지 확인하세요. 라이브 MID는 `SIR` 접두사 없이 입력해도 플러그인이 자동 보정합니다. 에스크로는 PC의
`acceptmethod`에 `useescrow`를, 모바일은 `P_RESERVED`에 `useescrow=Y`를 추가하는 방식으로
켜집니다. 일본 결제를 운영 모드로 켜려면 라이브 일본 MID/CBT 해시키와 실제 JPPG 가맹점
표시 정보가 필요합니다 — 기본 샘플값이 남아 있으면 설정 저장 단계에서 차단됩니다.
## 콜백 및 통보 URL **콜백/통보 URL 등록** — KG 이니시스 가맹점 관리자에 아래 URL을 실제 운영 도메인으로 등록합니다.
KG 이니시스 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제 운영 도메인으로 바꿔 입력하세요.
| 용도 | URL | | 용도 | URL |
|------|-----| |---|---|
| PC 결제 결과 Return URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/callback` | | PC 결제 결과 Return URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/callback` |
| PC 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/vbank-notify` | | PC 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/vbank-notify` |
| 모바일 결제 결과 `P_NEXT_URL` | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/mobile/callback` | | 모바일 결제 결과 `P_NEXT_URL` | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/mobile/callback` |
| 모바일 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify` | | 모바일 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify` |
| CBT 콜백 URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/cbt/callback` | | CBT 콜백 URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/cbt/callback` |
| CBT 편의점 입금 NOTI URL | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` | | CBT 편의점 입금 NOTI URL | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` |
| 에스크로 구매결정 화면 | `https://your-domain.com/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/{orderNumber}` | | 에스크로 구매결정 화면 | `https://{도메인}/plugins/sirsoft-pay_kginicis/payment/escrow-confirm/{orderNumber}` |
PC 결제 결과 Return URL과 모바일 `P_NEXT_URL`은 사용자 브라우저를 통해 호출됩니다. 가상계좌 입금통보 URL은 KG 이니시스 서버가 직접 호출하므로 운영 환경에서 IP 화이트리스트가 적용됩니다. 가상계좌 입금통보 URL은 KG 이니시스 서버가 직접 호출하므로 운영 환경에서 IP 화이트리스트가
적용됩니다(`203.238.37.15`, `39.115.212.9`, `118.129.210.25`, `183.109.71.153` — 운영 전
KG 이니시스 최신 연동 가이드로 다시 확인하세요). `local`/`testing` 환경에서는 개발·테스트를
위해 이 제한을 우회합니다.
<!-- @intent END -->
KG 이니시스 PC 에스크로 매뉴얼 기준 별도 webhook 통보 채널은 사용하지 않습니다. 에스크로 배송등록, 구매결정, 구매거절확인은 플러그인이 제공하는 화면과 API를 통해 처리합니다. ## 사용 방법
## IP 화이트리스트 <!-- @intent START -->
**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가
`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, 이 플러그인의 `PaymentRefundListener`
가 KG 이니시스 취소/부분취소 API를 호출합니다(전액취소는 `cancelPrice=null` +
`totalAmount=null`, 부분취소는 취소 금액 + 원래 결제금액). 배송비가 포함된 주문은 전체취소 시
배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후)이 PG
취소 금액으로 전달됩니다. 부분취소로 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면
코어가 취소 자체를 거부(422)해 PG 호출이 아예 발생하지 않습니다. KG 이니시스 API 호출이
실패하면 주문 상태 변경이 롤백됩니다.
운영 환경에서는 아래 IP에서 들어온 KG 이니시스 가상계좌 입금통보만 허용합니다. `local`, `testing` 환경에서는 개발과 테스트를 위해 제한을 우회합니다. **에스크로 처리**: 에스크로 결제 완료 후 관리자 주문 상세에서 배송 등록을 호출할 수 있고,
사용자는 에스크로 구매결정 화면에서 구매확인을 진행합니다. 구매거절이 발생한 주문은 관리자
주문 상세에서 구매거절확인을 호출할 수 있습니다.
| IP | **CBT 연결 진단**: 일본 결제 테스트가 실패하면 관리자 CBT 연결 진단(§API)에서 서버 egress
|----| IP와 `devcbt.inicis.com` 443 연결 상태를 먼저 확인합니다.
| `203.238.37.15` |
| `39.115.212.9` |
| `118.129.210.25` |
| `183.109.71.153` |
운영 전 KG 이니시스 가맹점 관리자와 최신 연동 가이드의 통보 서버 IP를 다시 확인하세요. 전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
<!-- @intent END -->
## 결제 흐름 ## 다른 확장과의 연동
### PC 결제 <!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
```text | 확장 | 유형 | 버전 제약 | 번들 |
체크아웃 주문 생성 |---|---|---|---|
→ 프론트엔드 핸들러가 /payment/signature 호출 | `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
→ INIStdPay.js 결제창 실행
→ KG 이니시스가 /payment/callback 으로 authToken, authUrl POST
→ 서버가 authUrl 화이트리스트 검증 후 승인 API 호출
→ 주문 결제 완료 처리
→ 성공 URL로 리다이렉트
```
승인 후 주문 처리에 실패하면 KG 이니시스 netCancel URL로 망취소를 시도합니다. **이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
### 모바일 결제 없음.
<!-- @generated:integrations END -->
```text <!-- @intent START -->
체크아웃 주문 생성 `RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에, `RegisterCashReceiptProviderListener`
→ /payment/mobile/signature 호출 가 현금영수증 프로바이더 레지스트리에 각각 등록합니다 — PG 결제사 선택과 현금영수증 발급사
→ 모바일 표준결제창으로 form POST 선택은 서로 독립적이라, 다른 PG를 쓰면서도 KG 이니시스로 현금영수증만 발급하는 조합이
→ KG 이니시스가 /payment/mobile/callback 으로 P_TID, P_REQ_URL 전달 가능합니다.
→ 서버가 P_REQ_URL 화이트리스트 검증 후 모바일 승인 API 호출 <!-- @intent END -->
→ 주문 결제 완료 처리
→ 성공 URL로 리다이렉트
```
모바일 승인 후 주문 처리에 실패하면 취소 API로 자동 취소를 시도합니다. ## 문서
### 가상계좌 <!-- @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 -->
```text ## 트러블슈팅
결제창에서 가상계좌 발급
→ 주문 결제 정보에 은행, 계좌번호, 예금주, 만료일 저장
→ 주문은 입금대기 상태 유지
→ KG 이니시스가 입금통보 URL로 입금 결과 POST
→ 거래번호 재처리와 금액 검증 후 주문 결제 완료 처리
```
PC와 모바일 입금통보는 서로 다른 URL을 사용하지만, 모두 같은 IP 화이트리스트 미들웨어를 통과해야 합니다. <!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 KG 이니시스 통보 서버 IP가 없음 | 최신 연동 가이드의 통보 서버 IP로 화이트리스트를 갱신 |
| 결제 승인 후 주문이 실패 상태로 남음 | 로컬 후속 처리 실패 후 자동 취소(망취소/취소 API)까지 실패 | 오류 로그의 안내대로 수동 취소 진행 — PG 승인은 이미 됐을 수 있음 |
| 일본 결제창이 안 열리고 결제가 중단됨 | 일본 결제 설정(라이브 MID/해시키/가맹점 정보) 미완료 | 설정을 완료하거나, 완료 전까지는 JPY 주문을 받지 않음 — 한국 표준결제로 자동 대체되지 않음 |
| CBT 테스트가 계속 실패함 | 서버 egress IP 미등록 또는 방화벽으로 443 포트 차단 | 관리자 CBT 연결 진단 실행 후 KG 이니시스에 서버 IP 등록 요청 |
| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | KG 이니시스 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 |
<!-- @intent END -->
### 일본 CBT ## 변경 이력
```text [CHANGELOG.md](CHANGELOG.md)
JPY 주문 생성
→ /payment/cbt/hash-data 호출
→ CBT 인증 URL로 form POST
→ KG 이니시스가 /payment/cbt/callback 으로 sid 전달
→ 서버가 cbtapprove API 호출
→ 카드/PayPay는 주문 결제 완료 처리
→ 편의점(CVS)은 입금대기 저장 후 /payment/cbt/cvs-notify 입금 NOTI 수신 시 결제 완료 처리
```
CBT 승인 이후 로컬 후속 처리에 실패하면 CBT 전용 취소 API로 자동 취소를 시도합니다. 자동 취소까지 실패한 경우에는 운영자 수동 취소가 필요하다는 오류 로그를 남깁니다.
일본 엔(JPY) 주문은 일본 결제 설정이 완료된 경우에만 CBT 결제창으로 진입합니다. 설정이 부족하면 한국 표준결제 흐름으로 대체하지 않고 결제를 중단합니다.
현재 CBT 결제창은 선택한 결제수단에 맞춰 지불수단을 제한합니다. 신용카드는 `CARD`만, PayPay는 `PAYpay`만, 일본 편의점결제는 `CVS`만 열립니다.
운영 모드에서 일본 결제를 활성화하려면 라이브 일본 MID/CBT 해시키와 실제 JPPG 가맹점 표시 정보가 필요합니다. 기본 샘플값이 남아 있으면 설정 저장 단계에서 차단됩니다.
### 에스크로
에스크로를 활성화하면 결제 요청에 에스크로 옵션을 전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 배송 등록을 호출할 수 있고, 사용자는 에스크로 구매결정 화면에서 구매확인을 진행할 수 있습니다.
구매거절이 발생한 주문은 관리자 주문 상세에서 구매거절확인을 호출할 수 있습니다.
### 결제 취소 / 부분취소
```text
관리자 주문 취소 요청 (cancel_pg=true)
→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화
→ PaymentRefundListener 가 KG 이니시스 취소/부분취소 API 호출
· 전액취소: cancelPrice=null, totalAmount=null
· 부분취소: cancelPrice=취소 금액, totalAmount=원래 결제금액
→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원
→ CancelActivityLogListener 가 PG 응답 시각·취소 TID를 활동 로그에 기록
```
배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG 취소 금액으로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. KG 이니시스 API 호출이 실패하면 주문 상태 변경이 롤백됩니다.
## API
### 사용자 API
| Method | Path | 설명 |
|--------|------|------|
| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/signature` | PC 결제창 서명 생성 |
| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/mobile/signature` | 모바일 `P_CHKFAKE` 생성 |
| `POST` | `/api/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data` | CBT hashData 생성 |
| `GET` | `/api/plugins/sirsoft-pay_kginicis/user/orders/{orderNumber}/receipt` | KG 이니시스 영수증 URL 조회 |
### 관리자 API
| Method | Path | 설명 |
|--------|------|------|
| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/vbank-notify-url` | PC/모바일 가상계좌 입금통보 URL 조회 |
| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/test-mode-map` | 주문목록 테스트 모드 배지용 맵 조회 |
| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/transaction/query` | TID로 KG 이니시스 거래 조회 |
| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/transaction-status` | 주문번호로 거래 상태 조회 |
| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery` | 에스크로 배송 등록 폼 데이터 조회 |
| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-delivery` | KG 이니시스 에스크로 배송 등록 |
| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/orders/{orderNumber}/escrow-deny-confirm` | 에스크로 구매거절확인 |
| `POST` | `/api/plugins/sirsoft-pay_kginicis/admin/cbt-test-product` | CBT 테스트용 JPY 상품 생성 |
| `GET` | `/api/plugins/sirsoft-pay_kginicis/admin/cbt-connectivity-check` | CBT 테스트 호스트 연결 진단 |
## 훅
다른 모듈이나 플러그인에서 아래 훅에 연결해 결제 흐름을 확장할 수 있습니다.
| 훅 | 타입 | 시점 |
|----|------|------|
| `sirsoft-pay_kginicis.payment.before_authorize` | action | KG 이니시스 서버 승인 API 호출 전 |
| `sirsoft-pay_kginicis.payment.after_authorize` | action | KG 이니시스 서버 승인 완료 후 |
| `sirsoft-pay_kginicis.payment.before_cancel` | action | KG 이니시스 결제 취소 API 호출 전 |
| `sirsoft-pay_kginicis.payment.after_cancel` | action | KG 이니시스 결제 취소 완료 후 |
`sirsoft-ecommerce.payment.refund` 필터를 통해 이커머스 환불 요청을 KG 이니시스 취소/부분취소 API로 연결합니다.
## 보안 및 운영 참고
- 운영 도메인의 `APP_URL`을 HTTPS 절대 URL로 정확히 설정하세요.
- 운영 signKey, INIAPI key, INIAPI IV, 모바일 hash key, CBT hash key는 외부에 노출하지 마세요.
- 결제창 서명, 모바일 해시, CBT 해시 생성 요청은 타임스탬프 신선도를 검증합니다.
- CBT 해시 생성 요청은 주문자 이메일/연락처와 서버 주문 정보를 대조하고, IP/주문번호 단위 요청 횟수를 제한합니다.
- CBT 환불은 결제 당시 저장된 테스트/운영 모드와 일본 MID 기준으로 처리합니다.
- PC `authUrl`, 모바일 `P_REQ_URL`, PC `netCancelUrl`은 KG 이니시스 허용 URL만 사용합니다.
- 동일 거래번호 콜백은 중복 처리하지 않도록 방어합니다.
- 운영 환경에서는 가상계좌 입금통보 IP 화이트리스트가 적용됩니다.
- 가상계좌 입금통보 URL은 KG 이니시스 가맹점 관리자에 반드시 등록해야 합니다.
- KG 이니시스 계약이 없는 결제수단이나 간편결제를 활성화하면 결제창 오류가 발생할 수 있습니다.
- CBT 테스트가 실패하면 관리자 CBT 연결 진단에서 서버 egress IP와 `devcbt.inicis.com` 443 연결 상태를 먼저 확인하세요.
## 테스트
플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다.
```bash
php artisan test plugins/sirsoft-pay_kginicis/tests
```
프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다.
```bash
npm install
npm run test:run
npm run build
```
## 라이선스 ## 라이선스
@@ -0,0 +1,22 @@
# KG 이니시스 개발자 문서
> plugins/_bundled/sirsoft-pay_kginicis · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 6 · **구독 훅 수**: 14 · **라우트 수**: 35 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 1
<!-- @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,65 @@
# KG 이니시스 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
결제 프로토콜(PC/모바일/CBT)마다 별도 컨트롤러·서비스 경로를 두면서도, 이커머스 쪽에는
`sirsoft-ecommerce.payment.registered_pg_providers`/`registered_cash_receipt_providers`
필터로 등록하는 하나의 진입점만 노출합니다. 이 경계 덕분에 KG 이니시스가 프로토콜을 바꾸거나
새 결제수단을 추가해도 이커머스 모듈 코드는 건드리지 않습니다 — 변경은 이 플러그인 안에서만
일어납니다. 반대로 이 플러그인이 소유 테이블을 두지 않는 것도 같은 경계 원칙입니다: 결제
"사실"(성공/실패/금액/취소)은 이커머스가 소유하고, 이 플러그인은 그 사실을 만드는 절차만
소유합니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```
Controllers (PaymentCallbackController 등 — PG 콜백 수신, 화이트리스트·재처리 방지 검증)
│
▼
Services (KgInicisApiService — 승인/취소/CBT API 호출, 서명·해시 생성)
│
├──▶ Repositories (CbtCvsOperationsRepository/CbtReconciliationRepository
│ — sirsoft-ecommerce 의 Order 모델을 조회, 자체 테이블 없음)
│
└──▶ 훅 발행 (before/after_authorize·cancel·cbt_refund) ──▶ 다른 확장 리스너
Listeners (RegisterPgProviderListener 등 — 이커머스 레지스트리 등록,
레이아웃 확장 주입, 설정 검증)
```
미들웨어(`InicisNotifyIpWhitelist`)는 이 흐름과 별도 레인에서 가상계좌/CBT 편의점 입금통보
라우트 앞단을 지킵니다 — Service 계층이 아니라 라우팅 계층에서 걸러야 신뢰할 수 없는 발신자의
요청이 애초에 비즈니스 로직에 닿지 않습니다.
<!-- @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/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_kginicis --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_kginicis --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_kginicis --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_kginicis --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_kginicis --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,75 @@
# KG 이니시스 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 모델이 없습니다._
<!-- @generated:models END -->
<!-- @intent START -->
결제 상태(주문·결제 성공/실패/취소/금액)는 전부 `sirsoft-ecommerce`의 `Order`/결제 모델에
있습니다. 이 플러그인이 자체 모델을 두지 않는 것은 실수나 미완성이 아니라 설계입니다(§AGENTS.md
"이 확장은 무엇인가") — PG 마다 결제 기록 테이블을 따로 두면 "이 주문이 지금 실제로 어떤
상태인가"를 물을 때 여러 테이블을 조인해야 하고, PG 를 교체하면 과거 주문의 결제 이력을
조회할 방법이 갈라집니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 테이블이 없습니다._
<!-- @generated:tables END -->
<!-- @intent START -->
KG 이니시스 고유 정보(MID·서명키 등)는 코어 `PluginSettingsService`(설정 스키마)에, 가상계좌
계좌정보·CBT 승인 정보 같은 거래별 데이터는 이커머스 주문/결제 레코드의 JSON 컬럼 또는
연관 필드에 함께 저장됩니다 — 이 플러그인이 그 값을 "소유"하지 않고 이커머스 테이블에
"기록"만 남기는 형태입니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_마이그레이션이 없습니다._
<!-- @generated:migrations END -->
<!-- @intent START -->
소유 테이블이 없으므로 마이그레이션도 없습니다. 이 플러그인이 설정 스키마를 바꿀 때는
마이그레이션이 아니라 `config/settings/defaults.json`(§settings.md)과 필요 시 업그레이드
스텝(과거 설정값 정정용)을 씁니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
결제수단·상태 분류(카드/계좌이체/가상계좌/휴대폰 등)는 이 플러그인이 아니라 이커머스가 소유한
결제수단 Enum 을 그대로 따릅니다 — PG 마다 결제수단 이름을 다시 정의하면 이커머스가 PG 를
교체 가능한 형태로 다룰 수 없습니다. KG 이니시스 API 고유의 코드값(예: `acceptmethod` 문자열
조합)은 Enum 이 아니라 KG 이니시스 API 스펙에 맞춘 문자열 상수로만 존재합니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `CbtCvsOperationsRepository` | 구현 | - |
| `CbtCvsOperationsRepositoryInterface` | 인터페이스 | - |
| `CbtReconciliationRepository` | 구현 | - |
| `CbtReconciliationRepositoryInterface` | 인터페이스 | - |
<!-- @generated:repositories END -->
<!-- @intent START -->
두 Repository 모두 자체 테이블이 아니라 `sirsoft-ecommerce`의 `Order` 모델을 조회합니다
(`CbtCvsOperationsRepository::findOrderWithPayment()`가 대표적 예). 소유 데이터가 없는데도
Repository 인터페이스를 쓰는 이유는 "이 플러그인이 이커머스 데이터에 접근하는 지점"을
Service 안에 흩어진 쿼리가 아니라 한 곳으로 모아, 나중에 이커머스의 주문 조회 방식이 바뀌어도
이 두 클래스만 고치면 되게 하기 위해서입니다 — 일반적인 "내 테이블 CRUD" Repository 와는
쓰임이 다릅니다.
<!-- @intent END -->
@@ -0,0 +1,155 @@
# KG 이니시스 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 6종 / 호출 지점 6곳.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `sirsoft-pay_kginicis.payment.after_authorize` | action | KG 이니시스 서버 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:247` |
| `sirsoft-pay_kginicis.payment.after_cancel` | action | KG 이니시스 결제 취소 완료 후 | `src/Services/KgInicisApiService.php:780` |
| `sirsoft-pay_kginicis.payment.after_cbt_refund` | action | KG 이니시스 일본 CBT 결제 취소 완료 후 | `src/Services/KgInicisApiService.php:592` |
| `sirsoft-pay_kginicis.payment.before_authorize` | action | KG 이니시스 서버 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:243` |
| `sirsoft-pay_kginicis.payment.before_cancel` | action | KG 이니시스 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/KgInicisApiService.php:758` |
| `sirsoft-pay_kginicis.payment.before_cbt_refund` | action | KG 이니시스 일본 CBT 결제 취소 API 호출 전 | `src/Services/KgInicisApiService.php:564` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
일반 결제(승인/취소)와 CBT(일본)가 각각 별도 `before/after_cbt_refund` 훅 쌍을 갖는 이유는
두 흐름이 서로 다른 API·통화·해시 체계를 쓰기 때문입니다 — 하나로 합치면 구독자가 매번
"이게 CBT 인지 일반인지"를 페이로드로 분기해야 합니다. `before_cancel`은 발행 위치 설명에
"본인인증 등 확장 지점"이라고 명시돼 있습니다 — 고액 취소에 관리자 재인증을 강제하고 싶은
확장은 이 훅에서 예외를 던져 PG 호출 자체를 막을 수 있습니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 20 |
| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 |
| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailTestModeLayoutListener` | `ensureTestModeLayout` | 65 |
| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 |
| `core.plugin_settings.before_save` | action (미선언) | `ValidateCbtSettingsListener` | `validateBeforeSave` | 10 |
| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 |
| `sirsoft-ecommerce.cash_receipt.cancel` | filter | `RegisterCashReceiptProviderListener` | `cancel` | 10 |
| `sirsoft-ecommerce.cash_receipt.issue` | filter | `RegisterCashReceiptProviderListener` | `issue` | 10 |
| `sirsoft-ecommerce.cash_receipt.registered_providers` | filter | `RegisterCashReceiptProviderListener` | `registerProvider` | 10 |
| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 |
| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 |
| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 |
| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 |
| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 20 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
`core.layout_extension.after_apply`를 구독하는 3개 리스너(`Ensure*LayoutListener`)가 서로
다른 우선순위(60/65/66)를 갖는 것은 우연이 아닙니다 — 관리자 주문 목록/상세 레이아웃에 여러
확장이 조각을 주입할 수 있어, 이 플러그인의 조각들이 서로 겹치지 않는 순서로 배치되도록
번호를 나눠 씁니다. `sirsoft-ecommerce.payment.refund`를 구독하는 두 리스너의 우선순위
(`PaymentRefundListener`=10, `CancelActivityLogListener`=20)는 §AGENTS.md 핵심 흐름에서
설명한 대로 "취소 성공 후에만 로그 기록"을 강제하기 위한 순서입니다 — 뒤바뀌면 실패한 취소도
로그에 성공처럼 남을 수 있습니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` |
| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` |
| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` |
| `EnsureAdminOrderDetailTestModeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailTestModeLayoutListener.php` |
| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` |
| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` |
| `RegisterCashReceiptProviderListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/RegisterCashReceiptProviderListener.php` |
| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` |
| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` |
| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` |
| `ValidateCbtSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateCbtSettingsListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
`RegisterPgProviderListener`·`RegisterCashReceiptProviderListener`·`RegisterEasyPayMethodsListener`
가 이 플러그인의 "등록" 축입니다 — 이 셋이 없으면 플러그인을 활성화해도 이커머스 화면에서
KG 이니시스가 보이지 않습니다. 나머지는 전부 부가 UI(`Ensure*LayoutListener`)나 정합성
검증(`ValidateCbtSettingsListener`)입니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/checkout_payment_error.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
`checkout_payment_error.json`은 체크아웃 화면에 KG 이니시스 특유의 오류 메시지(예: 계약되지
않은 결제수단 선택 시 안내)를 끼워 넣고, `user_order_show.json`은 주문 상세에 영수증 버튼을
추가합니다. 관리자 쪽 두 조각(`admin_order_list_test_badge`/`admin_order_payment_query`)은
"이 주문이 테스트 모드로 결제됐는가"와 "PG 거래 상태를 다시 조회"를 관리자 화면에서 바로
확인하게 하는 운영 편의 기능입니다 — 실제 결제 로직과는 분리돼 있어 이 조각만 비활성화해도
결제 자체는 영향받지 않습니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 미들웨어 | 부착 대상(targets) | 우선순위 |
|---|---|---|
| `InicisNotifyIpWhitelist` | `web.plugins.sirsoft-pay_kginicis.payment.cbt.cvs-notify`, `web.plugins.sirsoft-pay_kginicis.payment.vbank-notify`, `web.plugins.sirsoft-pay_kginicis.payment.mobile.vbank-notify` | - |
<!-- @generated:middleware END -->
<!-- @intent START -->
가상계좌/CBT 편의점 입금통보 3개 라우트에만 부착되고 결제 승인/취소 콜백 라우트에는
부착되지 않습니다 — 입금통보는 KG 이니시스 서버가 발신자 인증 수단 없이 단순 POST 로
호출하므로 IP 로 걸러야 하지만, 승인/취소 콜백은 `authUrl`/`authToken` 자체가 위조 방지
수단(§AGENTS.md 금지 패턴)이라 별도 IP 제한이 없어도 안전합니다. `local`/`testing` 환경에서는
이 제한이 우회됩니다 — 개발 중에는 실제 KG 이니시스 IP 대역에서 요청이 오지 않기 때문입니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
결제 진행 상황(승인 대기 등)을 실시간으로 밀어줄 필요가 없습니다 — PC/모바일 결제는 결제창이
닫히고 콜백이 오는 시점에 화면이 이미 그 페이지에 있고, 가상계좌 입금통보는 방문자가 화면을
보고 있지 않은 시점에 도착하므로 알림(§알림 정의)이나 다음 방문 시 조회가 더 적절합니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
가상계좌 만료 처리나 CBT 정산 대사(reconciliation) 같은 주기적 점검이 있을 법하지만
(`CbtReconciliationRepository` 참고), 현재는 관리자가 필요할 때 수동으로 조회·확인하는
구조입니다 — 자동 스케줄로 상태를 바꾸면 결제 상태 변경 시점을 운영자가 놓칠 수 있습니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
결제 완료/실패 알림은 이 플러그인이 아니라 `sirsoft-ecommerce`의 주문 상태 알림 정의가
담당합니다 — PG 가 여러 개일 수 있는데 PG 마다 "결제 완료 알림"을 각자 만들면 같은 이벤트에
대해 서로 다른 알림 정의가 난립합니다. 이 플러그인은 "그 결제가 KG 이니시스를 통했다"는
사실만 이커머스에 전달하고, 알림 발송은 이커머스가 단일하게 책임집니다.
<!-- @intent END -->
@@ -0,0 +1,81 @@
# KG 이니시스 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [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 -->
이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면 하나뿐입니다 — 체크아웃·주문상세의
결제 UI는 이 플러그인 소유가 아니라 §레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는
조각)으로 존재합니다. "화면"과 "레이아웃 확장 조각"을 헷갈리면 체크아웃 결제 버튼을 찾으러
`resources/layouts/`를 뒤지게 되는데, 실제로는 `resources/extensions/`에 있습니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 1개 (정의: `resources/js/handlers/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `requestPayment` | `sirsoft-pay_kginicis.requestPayment` |
<!-- @generated:handlers END -->
<!-- @intent START -->
`requestPayment` 하나로 PC/모바일/CBT 3가지 프로토콜을 전부 처리합니다 — 체크아웃 버튼은
결제수단·통화가 무엇이든 이 핸들러 하나만 호출하고, PC 결제창을 열지 모바일 폼을 제출할지
CBT 인증 URL로 이동할지는 핸들러 내부에서 서버 응답(§API `/payment/*/signature`,
`/payment/cbt/hash-data`)에 따라 분기합니다. 레이아웃 JSON 작성자가 결제수단별로 다른
핸들러를 호출할 필요가 없다는 뜻입니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftKginicis` |
| 재등록 진입점 | `initPlugin()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`window.__SirsoftKginicis` 로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록
진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). `initPlugin()`이 KG 이니시스 결제창
스크립트(`INIStdPay.js`) 자체를 미리 로드하지 않는 것도 의도입니다 — 그 스크립트는
`requestPayment` 핸들러가 실제 결제 시도 시점에만 동적으로 로드합니다(모든 방문자가 결제
페이지에 오는 것은 아니므로 전역 부팅에서 미리 불러올 필요가 없습니다).
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
KG 이니시스가 제공하는 `INIStdPay.js`/모바일 결제창 스크립트는 이 목록에 없습니다 — 그
스크립트들은 KG 이니시스 CDN 에서 결제 시도 시점에 동적으로 로드되는 제3자 자산이라, 이
플러그인이 빌드 시 번들링하는 `dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은
결제창 자체는 KG 이니시스가 그리고, 이 플러그인은 결제 버튼 같은 최소한의 UI만 코어
컴포넌트로 구성하기 때문입니다.
<!-- @intent END -->
@@ -0,0 +1,119 @@
# KG 이니시스 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
| `test_mid` | `string` | `INIpayTest` | 테스트 가맹점 ID (MID) |
| `test_sign_key` | `string` | `SU5JTElURV9UUklQTEVERVNfS0VZU1RS` | 테스트 사인키 |
| `test_iniapi_key` | `string` | `ItEQKi3rY7uvDS8l` | 테스트 INIAPI 키 |
| `test_iniapi_iv` | `string` | `HYb3yQ4f65QL89==` | 테스트 INIAPI IV |
| `live_mid` | `string` | - | 라이브 가맹점 ID (MID) |
| `live_sign_key` | `string` | - | 라이브 사인키 |
| `live_iniapi_key` | `string` | - | 라이브 INIAPI 키 |
| `live_iniapi_iv` | `string` | - | 라이브 INIAPI IV |
| `test_mobile_hash_key` | `string` | `3CB8183A4BE283555ACC8363C0360223` | 테스트 모바일 해시키 |
| `live_mobile_hash_key` | `string` | - | 라이브 모바일 해시키 |
| `use_escrow` | `boolean` | `false` | 에스크로 결제 활성화 |
| `japan_enabled` | `boolean` | `false` | 일본 결제 활성화 |
| `japan_restrict_jpy_payment_methods` | `boolean` | `false` | JPY 주문 결제수단 제한 |
| `test_japan_sign_key` | `string` | `5AL5Djb1Ipualn0F` | 테스트 일본 CBT 해시키 |
| `live_japan_mid` | `string` | - | 라이브 일본 MID |
| `live_japan_sign_key` | `string` | - | 라이브 일본 CBT 해시키 |
| `japan_merchant_name` | `string` | `サンプルストア` | 일본 결제 가맹점명 |
| `japan_merchant_name_kana` | `string` | `サンプルストア` | 일본 결제 가맹점명 Kana |
| `japan_merchant_name_alphabet` | `string` | `Sample Store` | 일본 결제 가맹점명 영문 |
| `japan_merchant_name_short` | `string` | `サンプル` | 일본 결제 가맹점 약칭 |
| `japan_contact_name` | `string` | `サポート窓口` | 일본 결제 문의처명 |
| `japan_contact_email` | `string` | `support@example.com` | 일본 결제 문의 이메일 |
| `japan_contact_phone` | `string` | `0120-123-456` | 일본 결제 문의 전화번호 |
| `japan_contact_opening_hours` | `string` | `10:00-18:00` | 일본 결제 문의 영업시간 |
| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL |
| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL |
| `easy_pay_allow_with_other_pg` | `boolean` | `false` | 타 PG와 사용가능함 |
| `easy_pay_samsung_pay` | `boolean` | `false` | KG이니시스 삼성페이 사용 |
| `easy_pay_naverpay` | `boolean` | `false` | KG이니시스 네이버페이 사용 |
| `easy_pay_show_brand_button` | `boolean` | `false` | 간편결제 브랜드 버튼 표시 |
| `easy_pay_lpay` | `boolean` | `false` | KG이니시스 L.pay 사용 |
| `easy_pay_kakaopay` | `boolean` | `false` | KG이니시스 카카오페이 사용 |
| `use_credit_point` | `boolean` | `false` | 신용카드 포인트 사용 |
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
`test_*`/`live_*` 접두어 쌍이 반복되는 것이 이 스키마의 핵심 구조입니다 — 테스트 모드와
운영 모드가 완전히 다른 자격증명 집합을 쓰기 때문에, 하나의 키를 두고 모드에 따라 값을
바꾸는 대신 애초에 별도 키로 분리했습니다. 이 덕분에 `is_test_mode` 를 껐다 켰다 해도 각
모드의 자격증명은 서로 덮어쓰이지 않습니다. 일본(`japan_*`) 설정군이 특히 많은 이유는
KG 이니시스 CBT 결제창의 `extraData`(가맹점 표시 정보)가 한국 표준결제와 별개의 계약·심사
단위이기 때문입니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_선언된 권한이 없습니다._
<!-- @generated:permissions END -->
<!-- @intent START -->
결제 설정은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — "결제 설정을 볼 수 있는 사람"은
PG 마다 다시 정의할 이유가 없는 하나의 개념이라, 이 플러그인이 별도 권한을 선언하지 않고
이커머스의 결제/설정 권한에 얹혀 갑니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 메뉴가 없습니다._
<!-- @generated:menus END -->
<!-- @intent START -->
설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가
난립합니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_kginicis/...` |
| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_kginicis/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
`api`(Bearer 토큰 인증, 결제창 서명·해시 생성처럼 로그인 사용자가 브라우저에서 직접 호출하는
엔드포인트)와 `web`(콜백·입금통보처럼 KG 이니시스 서버나 리다이렉트로 도달하는 엔드포인트)이
분리된 이유는 인증 방식이 다르기 때문입니다 — KG 이니시스는 우리 서비스의 Bearer 토큰을 모르므로
콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다. 새 KG 이니시스 콜백을 추가할 때는 `web`
쪽에, 프론트엔드가 로그인 상태로 직접 호출하는 기능은 `api` 쪽에 둡니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는 이커머스가
소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이 플러그인이 다룰
주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅(`registered_pg_providers` 등)이나
`Order` 모델 구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화").
<!-- @intent END -->
@@ -0,0 +1,177 @@
# NHN KCP — 에이전트 가이드
> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
## TL;DR (5초 요약)
```text
1. 유형: 플러그인 (sirsoft-pay_nhnkcp) — NHN KCP PG 연동(PC CLI 승인/모바일 SOAP/가상계좌/에스크로). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유
2. 확장 방식: `RegisterPgProviderListener`/`RegisterEasyPayMethodsListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다
3. 건드리면 안 되는 것: KCP CLI(`bin/pp_cli*`) 호출 인자 사전검증(`assertSafeCliValue`) 생략, 동일 거래번호 콜백 재처리 방지 우회, IP 화이트리스트 미들웨어(`RestrictKcpIp`) 미부착
4. 작업 위치: `plugins/_bundled/sirsoft-pay_nhnkcp` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan plugin:update sirsoft-pay_nhnkcp --force`
```
## 1. 이 확장은 무엇인가
<!-- @intent START -->
NHN KCP PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. PC 결제는 KCP CLI
바이너리(`bin/pp_cli*`)를 서버에서 직접 실행해 승인 응답을 받고, 모바일 결제는 SOAP
승인키 발급 후 모바일 결제창으로 폼 이동합니다. 두 프로토콜 모두 `sirsoft-pay_kginicis`(HTTP
API 호출)와 달리 **로컬 프로세스 실행**을 최종 승인 수단으로 쓴다는 점이 이 플러그인 고유의
설계 축입니다.
**설계 원칙**: 이 플러그인도 `sirsoft-pay_kginicis`와 마찬가지로 상태를 소유하지 않습니다
(§data-model.md — 모델·테이블·Repository 0개, kginicis 의 CBT 정산 Repository 2개조차 없음:
이 플러그인은 일본/CBT 결제를 아예 구현하지 않기 때문입니다). 등록은 훅 기반입니다
(`sirsoft-ecommerce.payment.registered_pg_providers` 필터) — 이커머스 모듈은 이 플러그인의
존재를 컴파일 타임에 몰라도 됩니다.
**의도적으로 하지 않는 것**: CLI 실행 권한이 사라진 경우(예: `plugin:update` 가 `_bundled` 의
0664 권한을 활성 디렉토리로 그대로 복사) 조용히 실패하지 않고 결제 hot path 에서
`ensureCliExecutable()` 로 0755 자가 복구를 시도합니다 — "결제 버튼을 눌렀는데 원인 불명으로
9502 오류"라는 상태를 막기 위함입니다. 또한 CLI 인자에 위험 문자·제어문자가 섞이면 그 값을
정제해서 통과시키지 않고 `NhnKcpApiException` 으로 즉시 거부합니다 — 부분 정제는 안전하다는
착각을 주면서 실제로는 우회 경로를 남길 수 있습니다.
<!-- @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/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nhnkcp --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
| `bin/` | 확장이 실행하는 외부 바이너리·인증서 | 교체 시 OS별 파일과 권한을 함께 확인 (비면 해당 기능 정지) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**PC 결제 승인**: `PaymentCallbackController`(KCP 결제창이 POST 하는 `enc_data`/`enc_info`
수신) → `sirsoft-pay_nhnkcp.payment.before_confirm` 훅 → `NhnKcpApiService::executeCli()` 가
OS 판별 후 `executeCliWindows()`/`executeCliLinux()` 로 분기 → CLI 인자 전량
`assertSafeCliValue()` 사전검증 → `escapeshellarg()` 이중 quoting 후 `exec()` 실행 →
`sirsoft-pay_nhnkcp.payment.after_confirm` 훅 → 이커머스 주문 결제 완료 처리.
`PreventsReplayCallback` 트레이트가 콜백 진입 시점에 동일 `transaction_id` 가 이미 `paid`
상태인지 먼저 확인해 재처리를 조기 차단합니다.
**모바일 결제 승인**: `/mobile/approval-key` API 호출 → KCP SOAP `approve` 로 승인키·`pay_url`
획득 → 브라우저가 `pay_url` 로 폼 POST → KCP가 `/payment/callback` 으로 결과 POST(PC와 동일
콜백 엔드포인트 공유) → 이후는 PC 흐름과 합류.
**가상계좌 입금통보 / 에스크로 공통통보**: KCP 서버가 `/payment/vbank-notify` 또는
`/payment/escrow-common-notify` 를 직접 호출 → `RestrictKcpIp` 미들웨어가 운영 모드에서
발신 IP 를 화이트리스트와 대조 → `EscrowCommonNotifyController` 가 `tx_cd`/`cl_status` 조합으로
4가지 훅(`escrow.purchase_confirmed`/`purchase_cancelled`/`denial_confirmed`/
`delivery_started`) 중 하나를 분기 발화. 결제 취소는 `PaymentRefundListener`(우선순위 10)가
먼저 KCP 취소 API 를 호출한 뒤 `CancelActivityLogListener`(우선순위 20)가 그 결과를 활동
로그에 별도 기록합니다 — `sirsoft-pay_kginicis` 와 동일한 순서 원칙입니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 8개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 8개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
`before_confirm`/`before_cancel` 은 PG(CLI/SOAP) 호출 **전** 개입 지점입니다 — 예를 들어
`before_cancel` 을 잡아 조건 미충족 시 예외를 던지면 KCP 취소 API 자체가 호출되지 않습니다.
`after_*` 훅은 응답을 받은 뒤 부가효과를 붙이는 자리입니다. 에스크로 훅 4종은 KCP 공통통보의
`tx_cd`/`cl_status` 조합을 이미 해석해 발화하므로, 구독하는 확장은 원시 통보 파라미터를
다시 파싱할 필요가 없습니다. 구독 훅의 `core.layout_extension.after_apply` 3건은 관리자
주문 목록/상세에 "테스트 모드 배지"·"거래 조회 UI"를 레이아웃 확장으로 주입하기 때문입니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_nhnkcp --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_nhnkcp` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] CLI 인자 조립부를 고칠 때 `assertSafeCliValue()` 검증을 모든 인자에 유지 — 인자 하나만 빠져도 그 필드가 injection 통로가 된다
- [ ] `bin/` 바이너리(OS별 CLI·`pub.key`·WSDL) 교체 시 실행 권한(0755)과 파일 존재를 관리자 설정 화면의 시스템 점검(§API)으로 확인
- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다
- [ ] IP 화이트리스트(`RestrictKcpIp`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
- [ ] 새 결제수단·통화를 추가하면 그 결제수단의 콜백 URL을 관리자 설정 안내(README "콜백 및 통보 URL")에도 반영
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| KCP CLI 인자(`site_cd`/`tx_cd`/`enc_data` 등)를 검증 없이 문자열 결합해 `exec()` 에 전달 | `assertSafeCliValue()` 로 위험 문자·제어문자 사전 거부 후 `escapeshellarg()` 로 quoting | 검증을 생략하면 서버가 받은 KCP 응답 값이 그대로 셸 명령 인자가 되어 명령 삽입(command injection)으로 이어질 수 있다 |
| CLI 실행 권한 오류(9502)를 그대로 사용자에게 노출하고 자가 복구를 생략 | `ensureCliExecutable()` 로 결제 hot path 진입 시 0755 자가 복구 시도 | `plugin:update` 가 파일 권한을 0664 로 되돌리는 것은 배포 절차의 부작용이지 운영자 실수가 아니다 — 매 결제 실패로 드러나게 두면 안 된다 |
| 동일 `transaction_id` 콜백을 매번 재처리 | `PreventsReplayCallback::wasAlreadyPaid()` 로 이미 `paid` 상태면 멱등 응답 | KCP 서버의 재전송·사용자의 새로고침으로 같은 콜백이 두 번 오면 결제완료 알림·마일리지가 중복 적립될 수 있다 |
| 에스크로 공통통보의 `tx_cd`/`cl_status` 매핑을 컨트롤러 밖(리스너 등)에서 다시 판정 | `EscrowCommonNotifyController` 의 매핑표(§핵심 흐름)를 SSoT 로 유지 | 판정 로직이 두 곳에 있으면 KCP 가 새 `cl_status` 값을 보낼 때 한쪽만 갱신되어 조용히 어긋난다 |
| 라이브 사이트 키(`live_site_key`)를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제창 요청을 위조할 수 있다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 28개 | `plugins/_bundled/sirsoft-pay_nhnkcp/tests` |
| Vitest | 8개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_nhnkcp/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd plugins/_bundled/sirsoft-pay_nhnkcp && powershell -Command "npm run test:run -- <대상>"
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @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 -->
+191 -161
View File
@@ -1,30 +1,96 @@
# NHN KCP Plugin for G7 # NHN KCP
NHN KCP Standard Pay 결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. **G7 플러그인 · sirsoft-pay_nhnkcp**
NHN KCP Standard Pay 결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인
PC 결제는 KCP `payplus_web.jsp` 결제창과 KCP CLI 승인 모듈을 사용하고, 모바일 결제는 SmartPhone Pay SOAP 승인키를 받은 뒤 KCP 모바일 결제창으로 이동합니다. <!-- @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">
<img src="https://img.shields.io/badge/requires-sirsoft--ecommerce-BF8700?style=flat-square" alt="requires sirsoft-ecommerce">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
NHN KCP Standard Pay 결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다. PC
결제는 `payplus_web.jsp` 결제창 + 서버의 KCP CLI 승인 모듈을, 모바일 결제는 SmartPhone Pay
SOAP 승인키 발급 + 모바일 결제창을 씁니다.
`sirsoft-pay_kginicis`와 마찬가지로 이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를
소유하지 않습니다 — 그 상태는 `sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은
"그 상태를 KCP CLI/SOAP API 와 어떻게 주고받는가"만 책임집니다(§data-model.md). 다른 PG
플러그인과 구별되는 이 플러그인만의 특징은 PC 결제 최종 승인이 HTTP API 호출이 아니라
**서버에서 실행하는 CLI 바이너리**라는 점입니다 — KCP 가 표준결제 승인 로직을 컴파일된
실행파일로만 배포하기 때문입니다.
<!-- @intent END -->
## 주요 기능 ## 주요 기능
- 신용카드, 계좌이체, 가상계좌, 휴대폰결제 지원 <!-- @intent START -->
- PC Standard Pay 결제창 연동 | 영역 | 설명 |
- 모바일 SmartPhone Pay 승인키 발급 및 모바일 결제창 연동 |---|---|
- PAYCO, 네이버페이, 네이버페이 포인트, 카카오페이, Apple Pay 간편결제 버튼 주입 | 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 |
- 가상계좌 발급, 입금통보, 테스트 모드 모의입금 처리 | 간편결제 | PAYCO, 네이버페이, 네이버페이 포인트, 카카오페이, Apple Pay 버튼 주입 |
- 에스크로 결제, 에스크로 배송 등록, 공통통보 처리 | PC 결제 | `payplus_web.jsp` 표준결제창 + 서버 KCP CLI 승인 |
- 결제 취소 및 부분취소 연동 | 모바일 결제 | SmartPhone Pay SOAP 승인키 발급 + 모바일 결제창 |
- PG 측 결제 취소 확인 시점의 활동 로그 별도 기록 (PG 응답 시각·취소 거래번호 사후 추적) | 가상계좌 | 발급, 입금통보, 테스트 모드 모의입금 |
- 주문 완료/마이페이지 영수증, 현금영수증 조회 버튼 주입 | 에스크로 | 결제, 배송 등록, 공통통보(구매확인/구매취소/구매취소확인/배송시작) |
- 관리자 주문 상세의 KCP 거래 정보 표시 | 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 거래번호) |
- 관리자 설정 화면의 KCP 실행 환경 점검 | 영수증 | 주문 완료/마이페이지 영수증, 현금영수증 조회 버튼 |
| 관리자 확장 | 주문 상세 KCP 거래 정보 표시, KCP 실행 환경(CLI/SOAP) 점검 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
A[체크아웃 주문 생성] -->|PC| B["payplus_web.jsp 결제창 iframe 로드"]
A -->|모바일| C["/mobile/approval-key 호출 → SOAP 승인키 발급"]
B --> D["/payment/callback (enc_data·enc_info)"]
C --> E["모바일 결제창 → /payment/callback"]
D --> F[서버가 KCP CLI 실행해 승인 확인]
E --> F
F --> G[주문 결제 완료 처리]
G --> H[성공 URL 리다이렉트]
```
PC 결제 승인은 `NhnKcpApiService`가 OS 를 판별해 `pp_cli`/`pp_cli_x64`/`pp_cli_exe.exe` 중
하나를 `exec()`로 실행하는 방식입니다. 모든 CLI 인자는 `assertSafeCliValue()`로 위험
문자·제어문자를 사전 거부한 뒤 `escapeshellarg()`로 quoting 합니다 — KCP 응답값을 검증 없이
셸 명령에 넣으면 명령 삽입(command injection) 통로가 됩니다. `PreventsReplayCallback`
트레이트가 콜백 진입 시점에 동일 거래번호가 이미 결제완료 상태인지 확인해 중복 처리를
막습니다.
가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, KCP 가 입금통보 URL로
결과를 POST 하면 금액 검증 후 결제 완료 처리됩니다. 에스크로는 KCP 공통통보의 `tx_cd`/
`cl_status` 조합을 해석해 구매확인/구매취소/구매취소확인/배송시작 4가지 훅으로 분기
발화합니다.
<!-- @intent END -->
## 요구 사항 ## 요구 사항
| 항목 | 내용 | <!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|------|------| | 항목 | 값 |
| G7 | `>= 7.0.0-beta.2` | |---|---|
| 의존 모듈 | `sirsoft-ecommerce >= 1.0.0-beta.5` | | G7 코어 | `>=7.0.10` |
| PHP | `^8.2` | | PHP | `^8.2` |
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
<!-- @generated:requirements END -->
<!-- @intent START -->
| 항목 | 필요한 것 |
|---|---|
| PC 결제 | PHP `exec()` 사용 가능, KCP CLI 바이너리, `pub.key` | | PC 결제 | PHP `exec()` 사용 가능, KCP CLI 바이너리, `pub.key` |
| 모바일 결제 | PHP SOAP 확장, KCP WSDL 파일 | | 모바일 결제 | PHP SOAP 확장, KCP WSDL 파일 |
| 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KCP 가맹점 계약 정보 | | 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KCP 가맹점 계약 정보 |
@@ -47,61 +113,70 @@ chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli
chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli_x64 chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli_x64
``` ```
관리자 설정 화면의 시스템 점검 API가 실행 권한을 자동 복구할 수 있지만, 서버 권한 정책에 따라 직접 조치가 필요할 수 있습니다. 관리자 설정 화면의 시스템 점검 API 와 결제 hot path 의 자가 복구(`ensureCliExecutable()`)가
실행 권한을 자동 복구할 수 있지만, 서버 권한 정책에 따라 직접 조치가 필요할 수 있습니다.
<!-- @intent END -->
## 설치 ## 설치
플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다. <!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```text
plugins/sirsoft-pay_nhnkcp
```
프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
```bash ```bash
npm install # 번들 설치 (코어에 동봉된 소스에서 설치)
npm run build php artisan plugin:install sirsoft-pay_nhnkcp
# 활성화
php artisan plugin:activate sirsoft-pay_nhnkcp
# 업데이트 (번들 소스 기준 강제 반영)
php artisan plugin:update sirsoft-pay_nhnkcp --force
``` ```
그다음 G7 관리자에서 플러그인을 활성화하고, 이커머스 결제 설정에서 PG 제공자를 `NHN KCP`로 선택합니다. 저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp
<!-- @generated:install END -->
설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "NHN KCP"로 선택해야 실제로 결제 흐름에
연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다.
## 관리자 설정 ## 관리자 설정
관리자 플러그인 설정 화면에서 KCP 계약 정보를 입력합니다. <!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `is_test_mode` | 테스트 모드 | `true` |
| `test_site_cd` | 테스트 사이트 코드 (site_cd) | `T0000` |
| `test_site_key` | 테스트 사이트 키 (site_key) | - |
| `live_site_cd` | 라이브 사이트 코드 (site_cd) | - |
| `live_site_key` | 라이브 사이트 키 (site_key) | - |
| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` |
| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` |
| `use_escrow` | 에스크로 결제 활성화 | `false` |
| `escrow_test_site_cd` | 테스트 에스크로 사이트 코드 | - |
| `vbank_expire_days` | 가상계좌 입금 만료(일) | `3` |
| `easy_pay_allow_with_other_pg` | - | `false` |
| `easy_pay_payco` | - | `false` |
| `easy_pay_naverpay` | - | `false` |
| `easy_pay_naverpay_point` | - | `false` |
| `easy_pay_kakaopay` | - | `false` |
| `easy_pay_applepay` | - | `false` |
| 설정 | 설명 | 개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
|------|------| <!-- @generated:settings-summary END -->
| 테스트 모드 | 활성화 시 KCP 테스트 환경을 사용합니다. |
| 테스트 사이트 코드 | 기본값은 `T0000`입니다. |
| 테스트 사이트 키 | KCP 테스트 site key입니다. |
| 라이브 사이트 코드 | 운영 site code입니다. `SR` prefix 없이 입력해도 플러그인이 자동 보정합니다. |
| 라이브 사이트 키 | 운영 site key입니다. 외부에 노출하지 마세요. |
| 결제 성공 URL | 기본값은 `/shop/orders/{orderId}/complete`입니다. |
| 결제 실패 URL | 기본값은 `/shop/checkout`입니다. |
| 가상계좌 입금 만료일 | 가상계좌 발급 후 입금 가능 기간입니다. |
| 에스크로 결제 사용 | 활성화 시 KCP 에스크로 결제 파라미터를 함께 전달합니다. |
| 에스크로 테스트 사이트 코드 | 테스트 에스크로 site code입니다. 기본 fallback은 `T0007`입니다. |
| 간편결제 | KCP 계약이 완료된 간편결제만 활성화하세요. |
| 타 PG와 사용가능함 | 다른 PG가 기본값이어도 KCP 간편결제 버튼을 체크아웃 화면에 표시합니다. |
PAYCO 테스트 결제는 내부 기본값으로 간편결제 테스트 site code `S6729`를 사용합니다. <!-- @intent START -->
라이브 사이트 키는 외부에 노출하지 마세요. 배포 전 테스트 모드가 의도한 값인지 반드시
확인하세요.
## 콜백 및 통보 URL **콜백 및 통보 URL 등록** — KCP 가맹점 관리자에 아래 URL을 실제 운영 도메인으로 등록합니다.
KCP 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제 운영 도메인으로 바꿔 입력하세요.
| 용도 | URL | | 용도 | URL |
|------|-----| |---|---|
| 결제 결과 Return URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/callback` | | 결제 결과 Return URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/callback` |
| 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify` | | 가상계좌 입금통보 URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify` |
| 에스크로 공통통보 URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify` | | 에스크로 공통통보 URL | `https://{도메인}/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify` |
결제 결과 Return URL은 브라우저가 POST하는 경로이므로 IP 제한을 적용하지 않습니다. 가상계좌 입금통보와 에스크로 공통통보는 KCP 서버가 직접 호출하므로 운영 모드에서 IP 화이트리스트를 적용합니다. 결제 결과 Return URL은 브라우저가 POST하는 경로이므로 IP 제한을 적용하지 않습니다. 가상계좌
입금통보와 에스크로 공통통보는 KCP 서버가 직접 호출하므로 운영 모드에서 아래 IP
## IP 화이트리스트 화이트리스트를 적용합니다(테스트 모드에서는 개발·KCP testadmin 모의입금을 위해 우회).
운영 모드에서는 아래 IP에서 들어온 KCP 서버 통보만 허용합니다. 테스트 모드에서는 개발과 KCP testadmin 모의입금을 위해 IP 제한을 우회합니다.
| IP | | IP |
|----| |----|
@@ -116,49 +191,22 @@ KCP 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제
| `210.122.72.173` | | `210.122.72.173` |
운영 전 KCP 가맹점 관리자와 최신 연동 가이드의 통보 서버 IP를 다시 확인하세요. 운영 전 KCP 가맹점 관리자와 최신 연동 가이드의 통보 서버 IP를 다시 확인하세요.
<!-- @intent END -->
## 결제 흐름 ## 사용 방법
### PC 결제 <!-- @intent START -->
**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가
`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, 이 플러그인의 `PaymentRefundListener`
가 KCP 취소 API를 호출합니다(전액취소는 `isPartial=false`, 부분취소는 `isPartial=true` +
원래 결제금액). 배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고,
쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후)이 PG `cancelAmt`로 전달됩니다. 부분취소로
쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부(422)해 PG 호출이
아예 발생하지 않습니다. KCP API 호출이 실패하면 주문 상태 변경이 롤백됩니다.
```text **에스크로 처리**: 에스크로를 활성화하면 결제 요청에 `escw_used=Y`, `pay_mod=O`를
체크아웃 주문 생성 전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 운송장번호와 택배사를 입력해 KCP
→ 프론트엔드 핸들러가 payplus_web.jsp 결제창 실행 배송 등록을 호출할 수 있습니다. KCP 공통통보는 아래 이벤트를 처리합니다.
→ KCP가 /payment/callback 으로 enc_data, enc_info POST
→ 서버가 KCP CLI로 승인 확인
→ 주문 결제 완료 처리
→ 성공 URL로 리다이렉트
```
### 모바일 결제
```text
체크아웃 주문 생성
→ /api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key 호출
→ 서버가 KCP SOAP approve 로 approval_key, pay_url 획득
→ 브라우저가 pay_url 로 form POST
→ KCP가 /payment/callback 으로 결과 POST
→ 주문 결제 완료 처리
→ 성공 URL로 리다이렉트
```
### 가상계좌
```text
결제창에서 가상계좌 발급
→ 주문 결제 정보에 은행, 계좌번호, 예금주, 만료일 저장
→ 주문은 입금대기 상태 유지
→ KCP가 /payment/vbank-notify 로 입금통보 POST
→ 입금 금액 검증 후 주문 결제 완료 처리
```
테스트 모드에서는 마이페이지 주문 상세에 KCP testadmin 모의입금 폼이 표시될 수 있습니다.
### 에스크로
에스크로를 활성화하면 결제 요청에 `escw_used=Y`, `pay_mod=O`를 전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 운송장번호와 택배사를 입력해 KCP 배송 등록을 호출할 수 있습니다.
KCP 공통통보는 아래 이벤트를 처리합니다.
| tx_cd | 조건 | 처리 | | tx_cd | 조건 | 처리 |
|-------|------|------| |-------|------|------|
@@ -167,82 +215,64 @@ KCP 공통통보는 아래 이벤트를 처리합니다.
| `TX02` | `cl_status=3` | 구매취소 확인 훅 실행 | | `TX02` | `cl_status=3` | 구매취소 확인 훅 실행 |
| `TX03` | - | 배송시작 훅 실행 | | `TX03` | - | 배송시작 훅 실행 |
### 결제 취소 / 부분취소 **가상계좌 모의입금**: 테스트 모드에서는 마이페이지 주문 상세에 KCP testadmin 모의입금
폼이 표시될 수 있습니다.
```text 전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
관리자 주문 취소 요청 (cancel_pg=true) [docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화 <!-- @intent END -->
→ PaymentRefundListener 가 KCP cancelPayment API 호출
· 전액취소: isPartial=false
· 부분취소: isPartial=true, totalAmt=원래 결제금액
→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원
→ CancelActivityLogListener 가 PG 응답 시각·취소 거래번호를 활동 로그에 기록
```
배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG cancelAmt 로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. KCP API 호출이 실패하면 주문 상태 변경이 롤백됩니다. ## 다른 확장과의 연동
## API <!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
### 사용자 API | 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
| Method | Path | 설명 | **이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
|--------|------|------|
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/receipt` | KCP 영수증, 현금영수증 URL 조회 |
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/vbank-mock-deposit-info` | 테스트 모드 가상계좌 모의입금 정보 조회 |
| `POST` | `/api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key` | 모바일 결제 승인키 발급 |
### 관리자 API 없음.
<!-- @generated:integrations END -->
| Method | Path | 설명 | <!-- @intent START -->
|--------|------|------| `RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에,
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/vbank-notify-url` | 가상계좌/에스크로 통보 URL 조회 | `RegisterEasyPayMethodsListener`가 간편결제 결제수단 레지스트리에 각각 등록합니다 — PG
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/test-mode-map` | 주문목록 테스트 모드 배지용 맵 조회 | 결제사 선택과 간편결제 노출은 서로 독립적이라, 다른 PG가 기본값이어도 KCP 간편결제 버튼을
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status` | 저장된 KCP 거래 정보 조회 | 체크아웃 화면에 노출하는 조합이 가능합니다(`easy_pay_allow_with_other_pg`).
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | 에스크로 배송 등록 폼 데이터 조회 | <!-- @intent END -->
| `POST` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | KCP 에스크로 배송 등록 |
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/health` | KCP 실행 환경 점검 |
## 훅 ## 문서
다른 모듈이나 플러그인에서 아래 훅에 연결해 결제 흐름을 확장할 수 있습니다. <!-- @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 -->
| 훅 | 타입 | 시점 | ## 트러블슈팅
|----|------|------|
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP CLI 승인 확인 전 |
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP CLI 승인 확인 후 |
| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 취소 API 호출 전 |
| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 취소 API 호출 후 |
| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | 에스크로 구매확인 통보 수신 |
| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | 에스크로 구매취소 통보 수신 |
| `sirsoft-pay_nhnkcp.escrow.denial_confirmed` | action | 에스크로 구매취소 확인 통보 수신 |
| `sirsoft-pay_nhnkcp.escrow.delivery_started` | action | 에스크로 배송시작 통보 수신 |
## 보안 및 운영 참고 <!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 결제 승인 시 res_cd=9502 오류 | `plugin:update` 가 CLI 바이너리 실행 권한을 0664 로 되돌림 | 관리자 설정 화면의 시스템 점검을 실행하거나 `chmod 755` 로 직접 복구 |
| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 KCP 통보 서버 IP가 없음 | 최신 연동 가이드의 통보 서버 IP로 화이트리스트를 갱신 |
| 결제 요청이 CLI 인자 오류로 거부됨 | 주문번호·인코딩 데이터 등에 위험 문자/제어문자 포함 | `assertSafeCliValue()`가 의도적으로 거부한 것 — 원인 값을 정제하지 말고 왜 그런 값이 만들어졌는지 상위 데이터를 확인 |
| 모바일 결제 승인키 발급 실패 | PHP SOAP 확장 미설치 또는 WSDL 파일 누락 | `php -m`으로 soap 확장 확인, `bin/*.wsdl` 존재 확인 |
| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | KCP 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 |
<!-- @intent END -->
- 운영 도메인의 `APP_URL`을 HTTPS 절대 URL로 정확히 설정하세요. ## 변경 이력
- 운영 site key는 외부에 노출하지 마세요.
- 운영 모드에서는 KCP 서버 통보 IP 화이트리스트가 적용됩니다.
- 결제 승인 후 서버 후속 처리에 실패하면 PG 잔존 승인을 자동 취소합니다.
- 동일 거래번호 콜백은 중복 처리하지 않도록 방어합니다.
- KCP CLI 호출 인자는 위험 문자와 제어문자를 사전에 거부합니다.
- 가상계좌와 에스크로 통보 URL은 KCP 가맹점 관리자에 반드시 등록해야 합니다.
- KCP 계약이 없는 결제수단이나 간편결제를 활성화하면 KCP 오류가 발생할 수 있습니다.
## 테스트 [CHANGELOG.md](CHANGELOG.md)
플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다.
```bash
php artisan test plugins/sirsoft-pay_nhnkcp/tests
```
프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다.
```bash
npm install
npm run test:run
npm run build
```
## 라이선스 ## 라이선스
@@ -0,0 +1,22 @@
# NHN KCP 개발자 문서
> plugins/_bundled/sirsoft-pay_nhnkcp · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 8 · **구독 훅 수**: 9 · **라우트 수**: 16 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 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,63 @@
# NHN KCP — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
NHN KCP 표준결제(Standard Pay)를 `sirsoft-ecommerce` 에 연결하는 어댑터입니다. 다른 PG
플러그인(`sirsoft-pay_kginicis`, `sirsoft-tosspayments` 등)이 전부 HTTP API 로 승인을
받는 것과 달리, 이 플러그인의 PC 결제 승인은 **서버에서 KCP CLI 바이너리를 실행**하는
방식입니다 — KCP 가 표준결제 승인 로직을 컴파일된 실행파일로만 배포하기 때문입니다. 이
차이가 데이터 모델(§data-model.md — Repository 조차 없는 이유), 확장점(CLI 실행 권한
점검 API), 금지 패턴(CLI 인자 injection 방어) 전체에 스며 있습니다.
이 플러그인도 결제 상태 자체는 소유하지 않습니다 — 주문·결제 테이블은 `sirsoft-ecommerce`
소유이고, 이 플러그인은 "그 상태를 KCP CLI/SOAP API 와 어떻게 주고받는가"만 책임집니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```text
Controller (PaymentCallbackController / EscrowCommonNotifyController / MobileApprovalController)
→ NhnKcpApiService (CLI 실행 · SOAP 호출 · 인자 사전검증)
→ sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음)
Listener (RegisterPgProviderListener 등)
→ sirsoft-ecommerce 의 필터 훅에 등록 (컴파일 타임 결합 없음)
```
이 플러그인에는 FormRequest 계층이 얕습니다 — 결제 승인·통보 콜백은 사용자가 채운 폼이
아니라 KCP 가 보내는 고정 스키마이므로, 검증의 대부분은 `NhnKcpApiService`의
`assertSafeCliValue()`(CLI 인자 안전성)와 `PreventsReplayCallback`(중복 콜백 방지)이
담당합니다. 일반 CRUD 플러그인의 "Controller → FormRequest → Service → Repository → Model"
5단 계층과 다른 이유가 여기 있습니다.
<!-- @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/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nhnkcp --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nhnkcp --force` |
| `bin/` | 확장이 실행하는 외부 바이너리·인증서 | 교체 시 OS별 파일과 권한을 함께 확인 (비면 해당 기능 정지) |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,69 @@
# NHN KCP — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 모델이 없습니다._
<!-- @generated:models END -->
<!-- @intent START -->
결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이
소유합니다(§AGENTS.md "설계 원칙"). 이 플러그인은 그 모델을 직접 참조해 읽고 쓸 뿐, 자기
Repository 조차 두지 않았습니다(§Repository) — `sirsoft-pay_kginicis`가 CBT(일본) 정산용
Repository 2개를 갖는 것과 달리, 이 플러그인은 일본/CBT 결제를 아예 구현하지 않으므로
그 계층이 존재할 이유가 없습니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 테이블이 없습니다._
<!-- @generated:tables END -->
<!-- @intent START -->
가상계좌 발급 정보(은행·계좌번호·예금주·만료일)와 KCP 거래 정보(`tno` 등)는 이커머스
`OrderPayment` 테이블의 기존 컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면
관리자 주문 상세가 PG 종류에 따라 다른 테이블을 조인해야 해 화면 로직이 PG 개수만큼
분기합니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_마이그레이션이 없습니다._
<!-- @generated:migrations END -->
<!-- @intent START -->
소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 이 플러그인의
설정 스키마 변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB
마이그레이션 대상이 아닙니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
KCP 공통통보의 `tx_cd`/`cl_status` 값은 Enum 대신 `EscrowCommonNotifyController`(§extension-points.md
"핵심 흐름"의 매핑표)의 조건 분기로 직접 처리합니다 — 이 값들은 이 플러그인 코드 어디에도
재사용되지 않는 KCP 고유 프로토콜 상수라, Enum 으로 승격해도 얻는 타입 안전성 대비 간접
계층만 늘어납니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Repository 가 없습니다._
<!-- @generated:repositories END -->
<!-- @intent START -->
이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너·`Concerns`
트레이트)은 모두 이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유
테이블이 없는 상태에서 남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는
빈 계층입니다. `sirsoft-pay_kginicis`의 CBT Repository 2개는 이 플러그인에는 없는
일본 결제 전용 정산 데이터(자체 소유 테이블)를 다루기 위한 것이라 대칭이 아닙니다.
<!-- @intent END -->
@@ -0,0 +1,148 @@
# NHN KCP — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 8종 / 호출 지점 8곳. 이 중 4종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `sirsoft-pay_nhnkcp.escrow.delivery_started` | action | — | `src/Controllers/EscrowCommonNotifyController.php:101` |
| `sirsoft-pay_nhnkcp.escrow.denial_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:98` |
| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | — | `src/Controllers/EscrowCommonNotifyController.php:97` |
| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | — | `src/Controllers/EscrowCommonNotifyController.php:96` |
| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 결제 취소 완료 후 | `src/Services/NhnKcpApiService.php:250` |
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP 결제 승인 확인 완료 후 | `src/Controllers/PaymentCallbackController.php:279` |
| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NhnKcpApiService.php:229` |
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP 결제 승인 확인 전 | `src/Controllers/PaymentCallbackController.php:274` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
`escrow.*` 4종에 `유형`/`설명`이 비어 있는 것은 실수가 아니라 선언 누락입니다 — 소스에서
자동 감지된 훅이라 `getHooks()`에 등록하면 이름 그대로도 의미가 분명해 설명을 생략했습니다.
`before_confirm`/`before_cancel`은 KCP API 호출 **전** 개입 지점이라 여기서 예외를 던지면
실제 KCP 호출 자체가 일어나지 않습니다(예: 고액 결제에 추가 인증을 요구하고 싶은 확장이
`before_cancel`에서 조건 미충족 시 예외). `after_*`는 응답을 받은 뒤 부가효과(로그, 알림)를
붙이는 자리입니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 30 |
| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 |
| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 |
| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 |
| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 |
| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 |
| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 |
| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 |
| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 30 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
`RegisterPgProviderListener` 가 우선순위 10 으로 두 훅(`get_client_config`/
`registered_pg_providers`)을 모두 구독하는 이유는 "이 PG 가 존재한다는 사실"과 "체크아웃
화면이 필요로 하는 클라이언트 설정값"이 같은 리스너의 책임이기 때문입니다 — 등록과 설정
노출이 다른 리스너로 갈라지면 한쪽만 갱신되는 사각이 생깁니다. `PaymentRefundListener`(10)
가 `CancelActivityLogListener`(20)보다 먼저 실행되도록 우선순위를 명시한 것은 실제 취소가
성공한 뒤에야 활동 로그를 남기기 위함입니다 — 순서가 뒤바뀌면 "로그는 있는데 취소는 실패"가
생깁니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` |
| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` |
| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` |
| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` |
| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` |
| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` |
| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` |
| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
`RestoreLayoutExtensionsAfterUpdateListener`가 존재하는 이유는 `plugin:update`가 레이아웃
확장 조각(§레이아웃 확장)의 활성/비활성 상태를 초기화할 수 있어서입니다 — 운영자가 특정
화면(예: 테스트배지)을 꺼둔 상태로 플러그인을 업데이트해도 그 선택이 사라지지 않도록
업데이트 직후 복원합니다. 8개 리스너 전부가 `HookListenerInterface`를 구현하는 것은
auto-discovery 대상이라는 뜻이 아니라 이 저장소의 전 리스너 공통 계약입니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/checkout_easy_pay.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/user_order_complete_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
5개 조각은 각각 독립적인 화면 관심사입니다 — 관리자 주문 목록의 테스트배지, 관리자 주문
상세의 거래조회 UI, 체크아웃의 간편결제 버튼, 주문완료/마이페이지의 영수증 버튼이 서로
다른 화면·다른 컴포넌트 트리에 주입되므로 하나의 조각으로 합치지 않았습니다. 새 KCP 기능이
필요로 하는 화면이 이 5개 중 하나에 해당하면 새 조각을 만들지 말고 기존 조각을 확장합니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 미들웨어 | 부착 대상(targets) | 우선순위 |
|---|---|---|
| `RestrictKcpIp` | `web.plugins.sirsoft-pay_nhnkcp.payment.vbank-notify`, `web.plugins.sirsoft-pay_nhnkcp.payment.escrow-common-notify` | - |
<!-- @generated:middleware END -->
<!-- @intent START -->
결제 결과 Return URL(`/payment/callback`)에는 이 미들웨어가 붙지 않습니다 — 그 경로는
브라우저가 POST 하는 경로라 발신 IP 가 사용자마다 다르기 때문입니다. IP 화이트리스트가
의미 있는 것은 KCP 서버가 직접 호출하는 두 통보 경로(가상계좌 입금통보·에스크로 공통통보)
뿐입니다. 테스트 모드에서는 개발 편의와 KCP testadmin 모의입금을 위해 이 제한을 우회합니다
— 운영 모드로 전환할 때 이 우회가 함께 꺼지는지 확인해야 합니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
결제 승인·통보는 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가
필요한 지점이 없습니다 — 가상계좌 입금통보조차 KCP 서버의 POST 요청 하나로 완결됩니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
가상계좌 만료 처리(§settings.md `vbank_expire_days`)는 이 플러그인이 크론으로 직접 만료
스캔을 하지 않고, 만료 이후 도착하는 KCP 입금통보를 거부하는 방식으로 처리됩니다 — 별도
스케줄 작업이 필요 없습니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미
포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트(결제완료)에 대해 PG 수만큼 중복
알림 정의가 생깁니다.
<!-- @intent END -->
@@ -0,0 +1,85 @@
# NHN KCP — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [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 -->
`sirsoft-pay_kginicis`와 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면
하나뿐입니다 — 체크아웃·주문상세·마이페이지의 결제 UI는 이 플러그인 소유가 아니라
§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 3개 (정의: `resources/js/handlers/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `requestPayment` | `sirsoft-pay_nhnkcp.requestPayment` |
| `setPaymentMethod` | `sirsoft-pay_nhnkcp.setPaymentMethod` |
| `copyToClipboard` | `sirsoft-pay_nhnkcp.copyToClipboard` |
<!-- @generated:handlers END -->
<!-- @intent START -->
`sirsoft-pay_kginicis`가 핸들러 1개(`requestPayment`)로 끝나는 것과 달리 이 플러그인은
3개입니다. `setPaymentMethod`가 별도로 필요한 이유는 KCP 간편결제 버튼(PAYCO/네이버페이/
카카오페이/Apple Pay)이 레이아웃 컴포넌트가 아니라 KCP 가 제공하는 DOM 을 그대로 쓰기
때문입니다 — React 상태로 선택 하이라이트를 그리는 대신 DOM 을 직접 조작해 선택된 버튼에
테두리를 입힙니다(`updateEasyPayButtonStyles`). Apple Pay 는 iOS 모바일이 아니면 여기서
바로 오류 모달을 띄우고 요청 자체를 막습니다 — KCP 서버까지 보냈다가 거부당하면 사용자가
결제 실패 이유를 알 수 없기 때문입니다. `copyToClipboard`는 가상계좌 계좌번호 복사
버튼처럼 결제와 무관한 범용 유틸리티라 KCP 고유 로직이 없습니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftNhnkcp` |
| 재등록 진입점 | `initPlugin()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`window.__SirsoftNhnkcp`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록
진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). KCP 결제창 스크립트 자체는 이
진입점이 미리 로드하지 않습니다 — 모든 방문자가 결제 페이지에 오는 것은 아니므로 전역
부팅에서 미리 불러올 필요가 없습니다(`requestPayment` 핸들러가 실제 결제 시도 시점에만
동적으로 로드).
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
KCP 가 제공하는 `payplus_web.jsp` SDK 는 이 목록에 없습니다 — `requestPayment` 핸들러가
결제 시도 시점에 iframe 안으로 동기 로드하는 제3자 자산이라, 이 플러그인이 빌드 시
번들링하는 `dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은 결제창 자체는 KCP 가
그리고, 이 플러그인은 간편결제 버튼·복사 버튼 같은 최소한의 UI만 코어 컴포넌트로
구성하기 때문입니다.
<!-- @intent END -->
@@ -0,0 +1,101 @@
# NHN KCP — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
| `test_site_cd` | `string` | `T0000` | 테스트 사이트 코드 (site_cd) |
| `test_site_key` | `string` | - | 테스트 사이트 키 (site_key) |
| `live_site_cd` | `string` | - | 라이브 사이트 코드 (site_cd) |
| `live_site_key` | `string` | - | 라이브 사이트 키 (site_key) |
| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL |
| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL |
| `use_escrow` | `boolean` | `false` | 에스크로 결제 활성화 |
| `escrow_test_site_cd` | `string` | - | 테스트 에스크로 사이트 코드 |
| `vbank_expire_days` | `integer` | `3` | 가상계좌 입금 만료(일) |
| `easy_pay_allow_with_other_pg` | `boolean` | `false` | - |
| `easy_pay_payco` | `boolean` | `false` | - |
| `easy_pay_naverpay` | `boolean` | `false` | - |
| `easy_pay_naverpay_point` | `boolean` | `false` | - |
| `easy_pay_kakaopay` | `boolean` | `false` | - |
| `easy_pay_applepay` | `boolean` | `false` | - |
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
`test_*`/`live_*` 쌍 구조는 `sirsoft-pay_kginicis`와 동일한 이유입니다 — 테스트 모드와 운영
모드가 완전히 다른 자격증명 집합을 쓰므로 `is_test_mode`를 켜고 꺼도 서로의 값을 덮어쓰지
않습니다. kginicis 와 달리 `japan_*` 설정군이 전혀 없는 것은 이 플러그인이 KCP 의 일본/CBT
결제 상품을 구현하지 않기 때문입니다(§data-model.md, §architecture.md) — 이 플러그인에
일본 결제를 요구하는 요청이 오면 새 설정 키를 추가하는 대신 별도 플러그인 여부를 먼저
검토해야 합니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_선언된 권한이 없습니다._
<!-- @generated:permissions END -->
<!-- @intent START -->
결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을
선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이
플러그인 수만큼 중복 정의됩니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 메뉴가 없습니다._
<!-- @generated:menus END -->
<!-- @intent START -->
설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가
난립합니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_nhnkcp/...` |
| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_nhnkcp/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
`api`(Bearer 토큰 인증, 모바일 승인키 발급처럼 로그인 사용자가 브라우저에서 직접 호출하는
엔드포인트)와 `web`(콜백·입금통보·공통통보처럼 KCP 서버나 리다이렉트로 도달하는
엔드포인트)이 분리된 이유는 인증 방식이 다르기 때문입니다 — KCP 는 우리 서비스의 Bearer
토큰을 모르므로 콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다. 새 KCP 콜백을 추가할
때는 `web` 쪽에 둡니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는
이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이
플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델
구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화").
<!-- @intent END -->
@@ -0,0 +1,169 @@
# 나이스페이먼츠 — 에이전트 가이드
> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
## TL;DR (5초 요약)
```text
1. 유형: 플러그인 (sirsoft-pay_nicepayments) — 나이스페이먼츠 PG 연동(인증+승인 2단계/가상계좌/에스크로/간편결제 8종). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유
2. 확장 방식: `RegisterPgProviderListener`/`RegisterEasyPayMethodsListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다
3. 건드리면 안 되는 것: 결제창 인증 실패(`AuthResultCode != '0000'`)를 승인 API 호출 전인데도 hard failure 로 취급, 가상계좌 입금통보 IP 화이트리스트(`VbankNotifyIpWhitelist`) 미부착
4. 작업 위치: `plugins/_bundled/sirsoft-pay_nicepayments` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan plugin:update sirsoft-pay_nicepayments --force`
```
## 1. 이 확장은 무엇인가
<!-- @intent START -->
나이스페이먼츠 PG를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 결제 승인은 **인증→승인
2단계**입니다 — 결제창(`goPay` iframe 팝업/모바일 폼)이 먼저 인증 결과(`AuthResultCode`)를
`/payment/callback`으로 POST 하고, 서버가 그 결과를 받아 `NextAppURL`로 다시 승인 API를
호출해야 최종 완료됩니다. `sirsoft-pay_kginicis`(결제창 후 단일 승인 API)와 달리 인증
단계에서 실패하는 것과 승인 단계에서 실패하는 것을 서로 다르게 취급해야 합니다(§금지 패턴).
**설계 원칙**: 이 플러그인도 상태를 소유하지 않습니다(§data-model.md — 모델·테이블·Repository
0개). 주문·결제 상태는 `sirsoft-ecommerce`에 있고, 이 플러그인은 그 상태를 나이스페이먼츠
API 와 동기화하는 역할만 합니다. 등록은 훅 기반입니다
(`sirsoft-ecommerce.payment.registered_pg_providers` 필터).
**의도적으로 하지 않는 것**: 사용자가 결제창에서 취소하거나 PG 가 인증을 거부한 경우
(`AuthResultCode != '0000'`)는 아직 승인 API 호출 전이므로 일반 오류 메시지를 띄우지 않고
체크아웃으로 조용히 리다이렉트합니다 — 이 시점은 "결제 시도 자체를 안 한 것"과 사실상
같아서, 사용자에게 오류로 보이면 혼란만 커집니다. 운영 가시성은 로그(`auth_result_code`/
`auth_result_msg`)로만 보존합니다. 반면 2단계(승인) 이후의 실패(서명·MID·금액 불일치)는
"돈이 오갔을 수 있는" 실패라 `?error=` 쿼리로 명시적으로 안내합니다.
<!-- @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/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nicepayments --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nicepayments --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**PC/모바일 결제 승인**: 결제창(iframe 팝업)이 `/payment/callback`으로 인증 결과 POST →
`AuthResultCode == '0000'` 확인(아니면 §1 "의도적으로 하지 않는 것"의 silent redirect) →
`sirsoft-pay_nicepayments.payment.before_authorize` 훅 → 서버가 `NextAppURL`로 승인 API
호출 → `sirsoft-pay_nicepayments.payment.after_authorize` 훅 → 이커머스 주문 결제 완료
처리. 결제 요청 시점에 주문의 `total_tax_amount`/`total_vat_amount`/`total_tax_free_amount`
가 모두 0이 아니면 과세 필드를 폼에 포함합니다(§4 "과세 처리").
**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가
`sirsoft-ecommerce.payment.refund` 필터 발화 → `PaymentRefundListener`(우선순위 10)가 먼저
나이스페이먼츠 취소 API 호출(전액취소 `isPartial=0`/부분취소 `isPartial=1`) →
`CancelActivityLogListener`(우선순위 20)가 결과를 활동 로그에 별도 기록. 가상계좌 입금
완료 건은 환불 계좌 정보가 필요해 일반 취소 API가 아니라 별도 어드민 환불 계좌 API 경로로
처리됩니다. 취소 API 호출이 실패하면 `refund_failed` 훅이 발화합니다.
**에스크로 배송 등록**: 관리자 주문 상세에서 운송장번호·택배사를 입력 →
`AdminEscrowController::registerDelivery()` → 나이스페이먼츠 배송 등록 API 호출.
`EscrowDeliveryRegisterRequest`가 입력을 검증합니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 5개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 8개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
`before_authorize`/`before_cancel`은 API 호출 **전** 개입 지점이라 여기서 예외를 던지면
실제 나이스페이먼츠 호출이 일어나지 않습니다. `refund_failed`는 취소 API 호출이 실패했을
때만 발화하는 별도 훅입니다 — `after_cancel`(성공 응답 후)과 구분해서 구독해야 합니다.
운영자 알림(예: Slack) 을 붙이고 싶은 확장은 `after_*`가 아니라 `refund_failed`를 잡습니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-pay_nicepayments --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-pay_nicepayments` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] 승인/취소 흐름을 고칠 때 `before_*`/`after_*` 훅 순서와 우선순위(`PaymentRefundListener` < `CancelActivityLogListener`)를 유지 — 로그가 실제 처리보다 먼저 실행되면 안 된다
- [ ] 인증 실패(1단계)와 승인 실패(2단계)의 사용자 안내 방식(silent redirect vs `?error=`)을 구분 유지 — §1 "의도적으로 하지 않는 것" 참고
- [ ] IP 화이트리스트(`VbankNotifyIpWhitelist`) 대상 라우트를 추가/변경하면 미들웨어 부착 대상(targets)도 함께 갱신
- [ ] 새 간편결제 수단을 추가하면 그 결제수단의 계약 상태를 관리자 안내에도 반영
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 결제창 인증 실패(`AuthResultCode != '0000'`)를 승인 실패와 동일하게 `?error=` 로 안내 | 승인 API 호출 전 실패는 체크아웃으로 silent redirect, 로그로만 기록 | 아직 결제 시도 자체가 성립하지 않은 단계인데 오류 메시지를 띄우면 사용자가 "돈이 빠져나갔나" 불필요하게 불안해한다 |
| 가상계좌 입금통보(`vbank-notify`)에 IP 화이트리스트 미부착 | `VbankNotifyIpWhitelist` 미들웨어 유지 | 통보 엔드포인트는 나이스페이먼츠 서버만 호출해야 하며, 화이트리스트가 없으면 제3자가 위조 입금통보를 보내 결제 상태를 조작할 수 있다 |
| 부분취소인데 가상계좌 입금 완료 건을 일반 취소 API로 처리 | 환불 계좌 정보가 필요한 가상계좌 건은 별도 어드민 환불 계좌 API 경로로 처리 | 가상계좌는 카드와 달리 PG가 자동으로 환불할 계좌를 모르므로 일반 취소 API를 호출하면 실패하거나 환불이 누락된다 |
| 라이브 가맹점 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 결제 요청을 위조할 수 있다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 23개 | `plugins/_bundled/sirsoft-pay_nicepayments/tests` |
| Vitest | 7개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit plugins/_bundled/sirsoft-pay_nicepayments/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd plugins/_bundled/sirsoft-pay_nicepayments && powershell -Command "npm run test:run -- <대상>"
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @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 -->
@@ -1,146 +1,236 @@
# NicePayments Plugin for G7 # NicePayments
나이스페이먼츠(NicePayments) PG 연동 플러그인입니다. G7 플랫폼의 sirsoft-ecommerce 모듈과 함께 동작합니다. **G7 플러그인 · sirsoft-pay_nicepayments**
나이스페이먼츠 결제를 sirsoft-ecommerce 에 연결하는 결제 플러그인
## 지원 결제 수단 <!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.2-0066FF?style=flat-square" alt="version 1.0.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.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">
<img src="https://img.shields.io/badge/requires-sirsoft--ecommerce-BF8700?style=flat-square" alt="requires sirsoft-ecommerce">
</p>
<!-- @generated:badges END -->
| 결제 수단 | PayMethod | ---
|-----------|-----------|
| 신용카드 | CARD | [소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
| 가상계좌 | VBANK |
| 계좌이체 | BANK | ---
| 휴대폰결제 | CELLPHONE |
## 소개
<!-- @intent START -->
나이스페이먼츠(NicePayments) 표준결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제
플러그인입니다. 결제 승인은 **인증→승인 2단계**로 나뉩니다 — 결제창(`goPay` iframe
팝업/모바일 폼)이 먼저 인증 결과를 서버로 보내고, 서버가 그 결과를 받아 별도 승인 API를
호출해야 최종 완료됩니다.
이 플러그인은 결제 자체의 상태(주문·결제 성공/실패/취소)를 소유하지 않습니다 — 그 상태는
`sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를 나이스페이먼츠
API 와 어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유 테이블/모델이 하나도
없습니다(§data-model.md).
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 결제수단 | 신용카드, 계좌이체, 가상계좌, 휴대폰결제 |
| 간편결제 | 네이버페이, 카카오페이, 삼성페이, 애플페이, PAYCO, 11pay, SSG페이, L.pay 버튼 주입 |
| 승인 방식 | 결제창 인증 + 서버 승인 API 2단계 |
| 가상계좌 | 발급 + 입금통보 처리 |
| 에스크로 | 배송 등록, 거래 조회 |
| 결제 취소 | 전액/부분취소, PG 취소 확인 시점 별도 활동 로그(PG 응답 시각·취소 TID), 실패 시 `refund_failed` 훅 |
| 영수증 | 주문 완료/마이페이지 영수증 버튼 |
| 관리자 확장 | 주문 상세 거래 조회, 에스크로 배송 등록 UI |
| 과세 처리 | 주문의 세금/부가세/면세 금액 자동 반영 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
A[체크아웃 주문 생성] --> B["결제창(goPay iframe) 로드"]
B --> C["/payment/callback (AuthResultCode)"]
C -->|실패| D[체크아웃으로 silent redirect]
C -->|성공| E["NextAppURL 로 승인 API 호출"]
E --> F[주문 결제 완료 처리]
F --> G[성공 URL 리다이렉트]
```
결제창에서 사용자가 취소하거나 PG 가 인증을 거부하면(`AuthResultCode != '0000'`) 아직
승인 API 호출 전이므로 일반 오류 메시지를 띄우지 않고 체크아웃으로 조용히 리다이렉트합니다
— 운영 가시성은 로그(`auth_result_code`/`auth_result_msg`)로 보존합니다. 2단계(승인) 이후의
실패(서명/MID/금액 불일치)는 `?error=` 쿼리로 명시적으로 안내합니다.
가상계좌는 결제창에서 발급되면 주문이 입금대기 상태로 유지되다가, 나이스페이먼츠가 입금통보
URL로 결과를 POST 하면 결제 완료 처리됩니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
<!-- @generated:requirements END -->
<!-- @intent START -->
| 항목 | 필요한 것 |
|---|---|
| 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, 나이스페이먼츠 가맹점 계약 정보 |
| PC/모바일 결제 | MID, 가맹점 키 |
서버에서 나이스페이먼츠 API 호스트로 HTTPS outbound 요청이 가능해야 합니다.
<!-- @intent END -->
## 설치 ## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash ```bash
# 플러그인 디렉토리에 배치 후 # 번들 설치 (코어에 동봉된 소스에서 설치)
composer install php artisan plugin:install sirsoft-pay_nicepayments
npm install && npm run build
# 활성화
php artisan plugin:activate sirsoft-pay_nicepayments
# 업데이트 (번들 소스 기준 강제 반영)
php artisan plugin:update sirsoft-pay_nicepayments --force
``` ```
## 설정 저장소: https://github.com/gnuboard/g7-plugin-sirsoft-pay_nicepayments
<!-- @generated:install END -->
관리자 → 플러그인 → NicePayments 설정에서 구성합니다. 설치·활성화 후 이커머스 결제 설정에서 PG 제공자를 "나이스페이먼츠"로 선택해야 실제로 결제
흐름에 연결됩니다 — 활성화만으로는 체크아웃 화면에 나타나지 않습니다.
| 항목 | 설명 | ## 관리자 설정
|------|------|
| 테스트 모드 | 활성화 시 나이스페이먼츠 공용 테스트 MID를 사용합니다. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일 23:30경 일괄 자동 취소됩니다. |
| 테스트 MID | 테스트 가맹점 ID (`nicepay00m` 기본값) |
| 테스트 가맹점 키 | 나이스페이 공용 테스트 키 |
| 라이브 MID | 실서비스 가맹점 ID |
| 라이브 가맹점 키 | 실서비스 가맹점 키 (외부 노출 금지) |
| 결제 성공 URL | 결제 완료 후 리다이렉트 경로 (`{orderId}` 치환 지원) |
| 결제 실패 URL | 결제 실패 후 리다이렉트 경로 |
테스트 모드 주문은 실제 배송하지 마세요. 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일 23:30경 일괄 자동 취소됩니다. <!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `is_test_mode` | 테스트 모드 | `true` |
| `test_mid` | 테스트 가맹점 ID (MID) | `nicepay00m` |
| `test_merchant_key` | 테스트 가맹점 키 | `EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==` |
| `live_mid` | 라이브 가맹점 ID (MID) | - |
| `live_merchant_key` | 라이브 가맹점 키 | - |
| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` |
| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` |
| `use_escrow` | 에스크로 결제 사용 | `false` |
| `easy_pay_allow_with_other_pg` | 타 PG와 사용가능함 | `false` |
| `easy_pay_naverpay` | 네이버페이 간편결제 | `false` |
| `easy_pay_kakaopay` | 카카오페이 간편결제 | `false` |
| `easy_pay_samsungpay` | 삼성페이 간편결제 | `false` |
| `easy_pay_applepay` | 애플페이 간편결제 | `false` |
| `easy_pay_payco` | PAYCO 간편결제 | `false` |
| `easy_pay_skpay` | 11pay (SK페이) 간편결제 | `false` |
| `easy_pay_ssgpay` | SSG페이 간편결제 | `false` |
| `easy_pay_lpay` | L.pay 간편결제 | `false` |
## 웹훅 (가상계좌 입금 통보) 개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
<!-- @generated:settings-summary END -->
나이스페이먼츠 관리자에서 가상계좌 입금 통보 URL을 아래로 설정하세요: <!-- @intent START -->
테스트 모드에서는 실제 카드 승인/출금 알림이 발생할 수 있으며, 테스트 계정 결제는 당일
23:30경 일괄 자동 취소될 수 있습니다 — **테스트 모드 주문을 실제로 배송하지 마세요.** 라이브
가맹점 키는 외부에 노출하지 마세요.
``` **웹훅(가상계좌 입금 통보)** — 나이스페이먼츠 관리자에 아래 URL을 실제 운영 도메인으로
등록합니다.
```text
https://your-domain.com/plugins/sirsoft-pay_nicepayments/payment/vbank-notify https://your-domain.com/plugins/sirsoft-pay_nicepayments/payment/vbank-notify
``` ```
### IP 화이트리스트 가상계좌 입금 통보는 나이스페이먼츠 서버가 직접 호출하므로 아래 IP 화이트리스트가
적용됩니다(로컬/테스트 환경에서는 자동 우회).
나이스페이먼츠 서버 IP만 허용됩니다. 로컬/테스트 환경에서는 자동으로 우회됩니다.
| IP | | IP |
|----| |----|
| 121.133.126.10 | | `121.133.126.10` |
| 121.133.126.11 | | `121.133.126.11` |
| 211.33.136.39 | | `211.33.136.39` |
<!-- @intent END -->
## 결제 흐름 ## 사용 방법
### PC / 모바일 결제 (인증 + 승인 2단계) <!-- @intent START -->
**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가
`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, `PaymentRefundListener`가
나이스페이먼츠 취소 API를 호출합니다(전액취소 `isPartial=0`/부분취소 `isPartial=1`).
배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된
주문은 실결제금액(쿠폰 차감 후)이 PG `cancelAmt`로 전달됩니다. 부분취소로 쿠폰 최소
주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부(422)해 PG 호출이 아예
발생하지 않습니다. 가상계좌 입금 완료 건은 환불 계좌 정보가 필요해 일반 취소 API가 아닌
별도 어드민 환불 계좌 API 경로로 처리됩니다.
``` **에스크로 배송 등록**: 관리자 주문 상세에서 운송장번호·택배사를 입력해 나이스페이먼츠
브라우저 → goPay(form) / 모바일 결제창 form POST 배송 등록을 호출할 수 있습니다.
결제창 → POST /payment/callback → authCallback() (1단계 인증)
서버 → POST NextAppURL → 승인 API 호출 (2단계)
승인 완료 → completePayment() → 성공 페이지 리다이렉트
```
### 결제창 취소 / 인증 실패 **거래 단건 조회**: `NicePaymentsApiService::queryTransaction(string $tid): array`로 거래
상태를 조회할 수 있습니다.
모바일 결제창에서 사용자가 취소버튼을 누르거나 PG 가 인증을 거부하면 (AuthResultCode != '0000') 결제 승인 (NextAppURL 호출) 이전이므로 사용자에게 generic 오류 메시지를 띄우지 않고 체크아웃으로 silent redirect 합니다. 운영 가시성은 로그(`auth_result_code` / `auth_result_msg`) 로 보존됩니다. 2단계 이후 hard failure (signature / mid / amount / authorize) 는 종전대로 `?error=` 쿼리 부착하여 안내합니다. 전체 API 목록(사용자/관리자)은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
<!-- @intent END -->
### 결제 취소 / 부분취소 ## 다른 확장과의 연동
```text <!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
관리자 주문 취소 요청 (cancel_pg=true) **이 확장이 의존하는 확장**
→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화
→ PaymentRefundListener 가 NicePayments cancelPayment API 호출
· 전액취소: isPartial=0
· 부분취소: isPartial=1
→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원
→ CancelActivityLogListener 가 PG 응답 시각·취소 TID를 활동 로그에 기록
```
배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG cancelAmt 로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. 가상계좌 입금 완료 건은 환불 계좌 정보가 필요해 일반 취소 API 가 아닌 별도 어드민 환불 계좌 API 경로로 처리됩니다. | 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
## 가용 훅 (Hook) **이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
다른 플러그인이나 리스너에서 아래 훅에 연결할 수 있습니다. 없음.
<!-- @generated:integrations END -->
### 액션 훅 <!-- @intent START -->
`RegisterPgProviderListener`가 이 플러그인을 이커머스의 PG 제공자 레지스트리에,
`RegisterEasyPayMethodsListener`가 간편결제 결제수단 레지스트리에 각각 등록합니다 — PG
결제사 선택과 간편결제 노출은 서로 독립적이라, 다른 PG가 기본값이어도 나이스페이먼츠
간편결제 버튼을 체크아웃 화면에 노출하는 조합이 가능합니다(`easy_pay_allow_with_other_pg`).
<!-- @intent END -->
| 훅 이름 | 시점 | 인수 | ## 문서
|---------|------|------|
| `sirsoft-pay_nicepayments.payment.before_authorize` | 서버 승인 API 호출 직전 | `Order $order, array $pgParams` |
| `sirsoft-pay_nicepayments.payment.after_authorize` | 서버 승인 API 응답 직후 | `Order $order, array $pgResponse` |
| `sirsoft-pay_nicepayments.payment.before_cancel` | NicePayments 취소 API 호출 직전 | `Order $order, OrderPayment $payment, float $refundAmount` |
| `sirsoft-pay_nicepayments.payment.after_cancel` | NicePayments 취소 API 호출 직후 | `Order $order, OrderPayment $payment, array $pgResponse` |
| `sirsoft-pay_nicepayments.payment.refund_failed` | 환불 API 호출 실패 시 | `Order $order, OrderPayment $payment, array $context` |
#### `refund_failed` context 구조 <!-- @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 -->
```php ## 트러블슈팅
[
'tid' => string, // 나이스페이 거래번호
'cancel_amt' => int, // 환불 시도 금액 (원)
'error' => string, // 오류 메시지
]
```
### 훅 등록 예시 <!-- @intent START -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 결제창에서 취소했는데 오류 화면이 뜸 | `AuthResultCode != '0000'` 분기가 silent redirect 대신 오류를 노출하도록 잘못 수정됨 | §동작 방식의 의도된 동작(조용히 체크아웃 복귀)으로 되돌리고 로그로만 확인 |
| 가상계좌 입금통보가 반영되지 않음 | 운영 환경 IP 화이트리스트에 나이스페이먼츠 통보 서버 IP가 없음 | 위 IP 목록으로 화이트리스트를 갱신 |
| 결제 승인 후 주문이 실패 상태로 남음 | 인증은 성공했지만 승인 API 호출 또는 로컬 후속 처리 실패 | 오류 로그 확인 — PG 승인은 이미 됐을 수 있으므로 수동 확인 필요 |
| 부분취소가 실패하고 422 응답 | 부분취소로 쿠폰 최소 주문금액 조건 미충족 | 코어가 의도적으로 거부한 것 — 쿠폰 조건을 다시 충족하거나 전액취소 |
| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | 나이스페이먼츠 계약이 없는 결제수단/간편결제를 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 |
<!-- @intent END -->
```php ## 변경 이력
use App\Extension\HookManager;
HookManager::addAction( [CHANGELOG.md](CHANGELOG.md)
'sirsoft-pay_nicepayments.payment.refund_failed',
function (Order $order, OrderPayment $payment, array $context) {
// 예: Slack 알림 발송
SlackNotifier::send("환불 실패: 주문 #{$order->order_number}, 오류: {$context['error']}");
},
priority: 10
);
```
## API 단건 조회
`NicePaymentsApiService::queryTransaction(string $tid): array` 메서드로 거래 상태를 조회할 수 있습니다.
```php
$apiService = app(\Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService::class);
$result = $apiService->queryTransaction('NICE_TID_12345');
// $result['ResultCode'], $result['Amt'], ...
```
## 과세 처리
결제 요청 시 주문의 `total_tax_amount`, `total_vat_amount`, `total_tax_free_amount` 값을 자동으로 나이스페이 폼에 포함합니다. 세 값이 모두 0이면 과세 필드를 생략합니다.
## 테스트 실행
```bash
cd c:/g7
php artisan test --filter=Nicepayments
```
## 라이선스 ## 라이선스
@@ -0,0 +1,22 @@
# 나이스페이먼츠 개발자 문서
> plugins/_bundled/sirsoft-pay_nicepayments · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 5 · **구독 훅 수**: 9 · **라우트 수**: 15 · **모델 수**: 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) | 레이아웃·액션 핸들러·전역 진입점·에셋 |
| [api/](api/README.md) | API 레퍼런스 |
| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 |
| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 |
<!-- @generated:doc-toc END -->
@@ -0,0 +1,60 @@
# 나이스페이먼츠 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
나이스페이먼츠 표준결제를 `sirsoft-ecommerce` 에 연결하는 어댑터입니다. 이 플러그인의
고유한 설계 축은 승인이 **인증→승인 2단계**로 나뉜다는 점입니다 — 결제창이 먼저 인증
결과만 보내고, 서버가 그 결과를 별도 승인 API 호출로 확정합니다. `sirsoft-pay_kginicis`처럼
결제창 콜백 하나로 승인까지 끝나는 구조와 달리, 이 플러그인은 "인증은 됐지만 아직 승인
전"이라는 중간 상태를 다뤄야 합니다(§AGENTS.md "의도적으로 하지 않는 것").
이 플러그인도 결제 상태 자체는 소유하지 않습니다 — 주문·결제 테이블은 `sirsoft-ecommerce`
소유이고, 이 플러그인은 그 상태를 나이스페이먼츠 API 와 어떻게 주고받는가만 책임집니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```text
Controller (PaymentCallbackController / AdminEscrowController / AdminTransactionController)
→ NicePaymentsApiService (인증 검증 · 승인/취소/단건조회 API 호출)
→ sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음)
Listener (RegisterPgProviderListener 등)
→ sirsoft-ecommerce 의 필터 훅에 등록 (컴파일 타임 결합 없음)
```
`PaymentCallbackController`가 인증 결과(`AuthResultCode`)를 먼저 판정하고 실패 시 승인
API 호출 없이 조기 반환하는 것이 이 플러그인 계층 구조의 특징입니다 — 일반적인 "Controller
→ FormRequest → Service" 흐름과 달리, 이 판정 자체가 Controller 안에 있는 이유는 그
판정 결과에 따라 아예 다른 응답(silent redirect vs `?error=`)을 골라야 하기 때문입니다.
<!-- @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/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-pay_nicepayments --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-pay_nicepayments --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-pay_nicepayments --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,65 @@
# 나이스페이먼츠 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 모델이 없습니다._
<!-- @generated:models END -->
<!-- @intent START -->
결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이
소유합니다(§AGENTS.md "설계 원칙"). 이 플러그인은 그 모델을 직접 참조해 읽고 쓸 뿐, 자기
Repository 조차 두지 않았습니다(§Repository) — 인증→승인 2단계 흐름의 중간 상태도 별도
테이블에 저장하지 않고, 인증 결과를 받은 요청 컨텍스트 안에서만 다루다가 승인이 확정되는
순간 이커머스 테이블에 반영합니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 테이블이 없습니다._
<!-- @generated:tables END -->
<!-- @intent START -->
가상계좌 발급 정보와 나이스페이먼츠 거래번호(TID)는 이커머스 `OrderPayment` 테이블의 기존
컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면 관리자 주문 상세가 PG
종류에 따라 다른 테이블을 조인해야 합니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_마이그레이션이 없습니다._
<!-- @generated:migrations END -->
<!-- @intent START -->
소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 설정 스키마
변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB 마이그레이션
대상이 아닙니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
`AuthResultCode`(`'0000'` = 성공)와 PG 응답 코드는 Enum 대신 컨트롤러의 조건 분기로 직접
판정합니다 — 나이스페이먼츠 고유 프로토콜 상수라 이 플러그인 코드 어디에도 재사용되지
않으며, Enum 으로 승격해도 얻는 타입 안전성 대비 간접 계층만 늘어납니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Repository 가 없습니다._
<!-- @generated:repositories END -->
<!-- @intent START -->
이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너)은 모두
이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유 테이블이 없는 상태에서
남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는 빈 계층입니다.
<!-- @intent END -->
@@ -0,0 +1,141 @@
# 나이스페이먼츠 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 5종 / 호출 지점 5곳. 이 중 1종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `sirsoft-pay_nicepayments.payment.after_authorize` | action | 나이스페이먼츠 서버 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:257` |
| `sirsoft-pay_nicepayments.payment.after_cancel` | action | 나이스페이먼츠 결제 취소 완료 후 | `src/Services/NicePaymentsApiService.php:308` |
| `sirsoft-pay_nicepayments.payment.before_authorize` | action | 나이스페이먼츠 서버 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:252` |
| `sirsoft-pay_nicepayments.payment.before_cancel` | action | 나이스페이먼츠 결제 취소 API 호출 전 (본인인증 등 확장 지점) | `src/Services/NicePaymentsApiService.php:285` |
| `sirsoft-pay_nicepayments.payment.refund_failed` | action | — | `src/Listeners/PaymentRefundListener.php:131` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
`before_authorize`/`before_cancel`은 API 호출 **전** 개입 지점입니다 — 예외를 던지면 실제
나이스페이먼츠 호출 자체가 일어나지 않습니다. `refund_failed`는 `getHooks()` 선언 없이
소스에서 자동 감지된 훅으로, `after_cancel`(성공 응답)과 달리 취소 API 호출이 **실패**했을
때만 발화합니다 — 운영 알림을 붙이려는 확장은 이 훅을 구독해야 합니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `core.layout_extension.after_apply` | filter | `AdjustEcommercePaymentMethodsLayoutListener` | `adjustPaymentMethodsLayout` | 40 |
| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderDetailPaymentQueryLayoutListener` | `ensurePaymentQueryLayout` | 66 |
| `core.layout_extension.after_apply` | filter | `EnsureAdminOrderListTestBadgeLayoutListener` | `ensureTestBadgeLayout` | 60 |
| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 |
| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 |
| `sirsoft-ecommerce.payment.refund` | filter | `CancelActivityLogListener` | `logCancelConfirmed` | 20 |
| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 |
| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 |
| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterEasyPayMethodsListener` | `injectEasyPayMethods` | 40 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
`PaymentRefundListener`(10)가 `CancelActivityLogListener`(20)보다 먼저 실행되도록 우선순위를
명시한 것은 실제 취소가 성공한 뒤에야 활동 로그를 남기기 위함입니다 — 순서가 뒤바뀌면
"로그는 있는데 취소는 실패"가 생깁니다. `RegisterEasyPayMethodsListener`가 8종의 간편결제
방식을 하나의 필터 훅에서 한 번에 주입하는 이유는 §settings.md 의 설정 스키마와 대칭을
맞추기 위함입니다 — 설정 키가 8개인데 훅 등록이 여러 곳으로 흩어지면 한쪽만 갱신되는
사각이 생깁니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `AdjustEcommercePaymentMethodsLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php` |
| `CancelActivityLogListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/CancelActivityLogListener.php` |
| `EnsureAdminOrderDetailPaymentQueryLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderDetailPaymentQueryLayoutListener.php` |
| `EnsureAdminOrderListTestBadgeLayoutListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/EnsureAdminOrderListTestBadgeLayoutListener.php` |
| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` |
| `RegisterEasyPayMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterEasyPayMethodsListener.php` |
| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` |
| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
`RestoreLayoutExtensionsAfterUpdateListener`는 `plugin:update`가 레이아웃 확장 조각(§레이아웃
확장)의 활성/비활성 상태를 초기화할 수 있어서 존재합니다 — 운영자가 특정 화면을 꺼둔 상태로
업데이트해도 그 선택이 사라지지 않도록 복원합니다. 8개 리스너 전부가
`HookListenerInterface`를 구현하는 것은 이 저장소의 전 리스너 공통 계약입니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/admin_order_list_test_badge.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/admin_order_payment_query.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/checkout_easy_pay.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/user_mypage_order_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/user_order_complete_receipt.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
5개 조각은 각각 독립적인 화면 관심사입니다 — 관리자 주문 목록의 테스트배지, 관리자 주문
상세의 거래조회 UI, 체크아웃의 간편결제 버튼, 마이페이지·주문완료의 영수증 버튼이 서로
다른 화면에 주입되므로 하나로 합치지 않았습니다. `user_mypage_order_receipt.json`과
`user_order_complete_receipt.json`이 별도 파일인 것은 두 화면의 컴포넌트 트리와 데이터
소스가 다르기 때문입니다(§AGENTS.md "설계 원칙" — 상태는 이커머스 소유이므로 화면마다
필요한 조회 방식이 다를 수 있습니다).
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 미들웨어 | 부착 대상(targets) | 우선순위 |
|---|---|---|
| `VbankNotifyIpWhitelist` | `web.plugins.sirsoft-pay_nicepayments.payment.vbank-notify` | - |
<!-- @generated:middleware END -->
<!-- @intent START -->
결제 결과 콜백(`/payment/callback`)에는 이 미들웨어가 붙지 않습니다 — 그 경로는 브라우저가
POST 하는 경로라 발신 IP 가 사용자마다 다르기 때문입니다. IP 화이트리스트가 의미 있는 것은
나이스페이먼츠 서버가 직접 호출하는 가상계좌 입금통보뿐입니다. 로컬/테스트 환경에서는
이 제한을 자동 우회합니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
결제 승인·통보는 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가
필요한 지점이 없습니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
가상계좌 만료는 이 플러그인이 크론으로 스캔하지 않고, 만료 이후 도착하는 나이스페이먼츠
입금통보를 거부하는 방식으로 처리됩니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미
포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트에 대해 PG 수만큼 중복 정의가
생깁니다. 운영자에게 실패를 알리고 싶은 확장은 §발행 훅의 `refund_failed` 를 구독합니다.
<!-- @intent END -->
@@ -0,0 +1,79 @@
# 나이스페이먼츠 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [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 -->
다른 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면
하나뿐입니다 — 체크아웃·주문상세·마이페이지의 결제 UI는 이 플러그인 소유가 아니라
§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 2개 (정의: `resources/js/handlers/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `requestPayment` | `sirsoft-pay_nicepayments.requestPayment` |
| `setPaymentMethod` | `sirsoft-pay_nicepayments.setPaymentMethod` |
<!-- @generated:handlers END -->
<!-- @intent START -->
`setPaymentMethod`가 별도로 필요한 이유는 나이스페이먼츠 간편결제 버튼(네이버페이/카카오페이/
삼성페이/애플페이/PAYCO/11pay/SSG페이/L.pay 8종)이 레이아웃 컴포넌트가 아니라 PG가
제공하는 DOM을 그대로 쓰기 때문입니다 — React 상태로 선택 하이라이트를 그리는 대신 DOM을
직접 조작해 선택된 버튼에 테두리를 입힙니다(`updateEasyPayButtonStyles`). `requestPayment`
하나로 PC/모바일 2가지 프로토콜(§docs/architecture.md "인증→승인 2단계")을 모두 처리합니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftNicepayments` |
| 재등록 진입점 | `initPlugin()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`window.__SirsoftNicepayments`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록
진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 나이스페이먼츠 결제창 스크립트
자체는 이 진입점이 미리 로드하지 않습니다 — `requestPayment` 핸들러가 결제 시도 시점에
동적으로 스크립트를 삽입한 뒤 `goPay()`를 호출합니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
나이스페이먼츠가 제공하는 결제창 SDK는 이 목록에 없습니다 — `requestPayment` 핸들러가
결제 시도 시점에 동적으로 로드하는 제3자 자산이라, 이 플러그인이 빌드 시 번들링하는
`dist/` 산출물과는 다른 층입니다. CSS 산출물이 없는 것은 결제창 자체는 PG 가 그리고, 이
플러그인은 간편결제 버튼 같은 최소한의 UI만 코어 컴포넌트로 구성하기 때문입니다.
<!-- @intent END -->
@@ -0,0 +1,102 @@
# 나이스페이먼츠 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
| `test_mid` | `string` | `nicepay00m` | 테스트 가맹점 ID (MID) |
| `test_merchant_key` | `string` | `EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==` | 테스트 가맹점 키 |
| `live_mid` | `string` | - | 라이브 가맹점 ID (MID) |
| `live_merchant_key` | `string` | - | 라이브 가맹점 키 |
| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL |
| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL |
| `use_escrow` | `boolean` | `false` | 에스크로 결제 사용 |
| `easy_pay_allow_with_other_pg` | `boolean` | `false` | 타 PG와 사용가능함 |
| `easy_pay_naverpay` | `boolean` | `false` | 네이버페이 간편결제 |
| `easy_pay_kakaopay` | `boolean` | `false` | 카카오페이 간편결제 |
| `easy_pay_samsungpay` | `boolean` | `false` | 삼성페이 간편결제 |
| `easy_pay_applepay` | `boolean` | `false` | 애플페이 간편결제 |
| `easy_pay_payco` | `boolean` | `false` | PAYCO 간편결제 |
| `easy_pay_skpay` | `boolean` | `false` | 11pay (SK페이) 간편결제 |
| `easy_pay_ssgpay` | `boolean` | `false` | SSG페이 간편결제 |
| `easy_pay_lpay` | `boolean` | `false` | L.pay 간편결제 |
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
`test_*`/`live_*` 쌍 구조는 다른 PG 플러그인과 동일한 이유입니다 — 테스트 모드와 운영
모드가 완전히 다른 자격증명을 쓰므로 `is_test_mode`를 켜고 꺼도 서로의 값을 덮어쓰지
않습니다. 간편결제 플래그가 8개(네이버페이/카카오페이/삼성페이/애플페이/PAYCO/11pay/
SSG페이/L.pay)로 다른 PG 플러그인보다 많은 것은 나이스페이먼츠가 실제로 이 8종 전부를
중계하기 때문입니다 — 계약이 없는 수단을 켜면 결제 시도 시점에 PG 오류로 드러납니다
(§AGENTS.md "금지 패턴"과 유사하게, 계약 여부는 이 플러그인이 검증하지 않고 PG 응답에
위임합니다).
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_선언된 권한이 없습니다._
<!-- @generated:permissions END -->
<!-- @intent START -->
결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을
선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이
플러그인 수만큼 중복 정의됩니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 메뉴가 없습니다._
<!-- @generated:menus END -->
<!-- @intent START -->
설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가
난립합니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-pay_nicepayments/...` |
| `web` | `src/routes/web.php` | `/plugins/sirsoft-pay_nicepayments/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
`api`(Bearer 토큰 인증, 관리자 거래조회·에스크로 배송등록처럼 로그인 사용자가 직접
호출하는 엔드포인트)와 `web`(결제 콜백·가상계좌 입금통보처럼 결제창이나 PG 서버가
도달하는 엔드포인트)이 분리된 이유는 인증 방식이 다르기 때문입니다 — 나이스페이먼츠는
우리 서비스의 Bearer 토큰을 모르므로 콜백 라우트에 `api` 인증 미들웨어를 걸 수 없습니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는
이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이
플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델
구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화").
<!-- @intent END -->
@@ -0,0 +1,178 @@
# 토스페이먼츠 — 에이전트 가이드
> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
## TL;DR (5초 요약)
```text
1. 유형: 플러그인 (sirsoft-tosspayments) — 토스페이먼츠 PG 연동(통합결제창 SDK/가상계좌 웹훅 secret 대조/에스크로 3-상태). 소유 테이블 없음 — 상태는 sirsoft-ecommerce 소유
2. 확장 방식: `RegisterPgProviderListener`/`RegisterTossPaymentMethodsListener`/`RegisterCashReceiptProviderListener` 로 이커머스 레지스트리에 등록 — 이커머스 코드는 이 플러그인을 모른다
3. 건드리면 안 되는 것: 결제 승인 확인 시 서버가 재계산한 금액과 PG 콜백 금액 대조(amount mismatch 검사) 생략, 가상계좌 웹훅의 secret 대조(`webhook_secret_verify`) 우회 — 토스는 notify IP 목록·서명을 제공하지 않아 secret 대조가 유일한 위조 방지 수단
4. 작업 위치: `plugins/_bundled/sirsoft-tosspayments` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan plugin:update sirsoft-tosspayments --force`
```
## 1. 이 확장은 무엇인가
<!-- @intent START -->
토스페이먼츠 PG(결제 게이트웨이)를 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 승인은
브라우저 리다이렉트 기반입니다 — 통합결제창 SDK(`js.tosspayments.com/v2/standard`)가
결제를 처리한 뒤 브라우저를 `?paymentKey&orderId&amount`가 붙은 콜백 URL로 리다이렉트하고,
서버가 그 파라미터로 승인 확인 API를 호출합니다. 다른 PG 플러그인(iframe 팝업/CLI/SOAP)과
달리 프론트엔드 계층이 SDK 호출 하나로 끝나고 프로토콜 복잡도가 대부분 서버 쪽(확인·웹훅)에
있습니다.
이 플러그인은 **결제창형**과 **주문서형**(`order_sheet_mode`) 두 UI 모드를 지원합니다.
결제창형은 통합결제창이 결제수단을 전부 처리하는 카드 하나로 노출되고, 주문서형은
`method_*` 설정으로 활성화한 개별 토스 결제수단(카드/가상계좌/계좌이체/휴대폰/토스페이/
카카오페이/네이버페이/페이코/삼성페이)이 체크아웃 화면에 개별 버튼으로 뜹니다 — 어느
쪽이든 최종 처리는 토스 결제창 하나로 귀결되므로 각 수단은 `pg_provider` 를 이 플러그인
자신으로 고정(`pg_locked`)합니다(§AGENTS.md "4. 확장점").
**설계 원칙**: 이 플러그인도 상태를 소유하지 않습니다(§data-model.md — 모델·테이블 0개).
가상계좌 웹훅 검증은 토스가 notify IP 목록이나 서명을 제공하지 않는다는 제약에서
비롯됩니다 — 승인 확인 응답에만 실리는 `secret` 값을 `payment_meta`에 저장해 두었다가
웹훅이 도착하면 대조하는 것이 토스 공식 문서가 제시하는 유일한 위조 방지 수단입니다.
**의도적으로 하지 않는 것**: 승인 확인 시 PG가 돌려준 금액과 서버가 재계산한 주문 금액이
다르면(`amount_mismatch`) 결제를 완료 처리하지 않고 실패로 되돌립니다 — 콜백 URL의
`amount` 쿼리 파라미터는 브라우저를 거치므로 신뢰할 수 없는 입력이기 때문입니다.
<!-- @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/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-tosspayments --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-tosspayments --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-tosspayments --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-tosspayments --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**결제 승인**: 통합결제창이 브라우저를 `/payment/callback?paymentKey&orderId&amount`로
리다이렉트 → `PaymentCallbackController`가 주문 조회 →
`sirsoft-tosspayments.payment.before_confirm` 훅 → `TossPaymentsApiService::confirmPayment()`
가 토스 승인 확인 API 호출 → 응답 금액과 서버 재계산 금액 대조(불일치 시 실패 처리) →
`sirsoft-tosspayments.payment.after_confirm` 훅 → 이커머스 주문 결제 완료 처리. 가상계좌가
발급된 경우 응답에 실린 `secret`을 `payment_meta.toss_secret`에 저장해 이후 웹훅 대조에
씁니다.
**가상계좌 입금 웹훅**: 토스가 `/webhook/deposit`으로 POST → 저장된 `toss_secret`과 웹훅
본문의 `secret`을 대조(`webhook_secret_verify` 설정이 꺼져 있지 않은 한 강제) → 일치하면
결제 완료 처리, 불일치하면 경고 로그만 남기고 처리하지 않습니다.
**결제 취소(환불)**: 관리자가 주문 취소(`cancel_pg=true`) → 코어가
`sirsoft-ecommerce.payment.refund` 필터 발화 → `PaymentRefundListener`가 토스 취소 API 호출
→ `before_cancel`/`after_cancel` 훅 발화.
**설정 저장 검증**: 관리자가 플러그인 설정을 저장 → `core.plugin_settings.before_save`
(동기 훅, `sync: true`) → `ValidateTossSettingsListener`가 `vbank_valid_hours`(1~2160시간)와
`use_escrow`(`off`/`on`/`buyer_choice` 3-상태) 범위를 검증 → 위반 시 `ValidationException`으로
저장 자체를 막습니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 4개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 9개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 6개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 3개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
`before_confirm`/`before_cancel`은 API 호출 **전** 개입 지점이라 예외를 던지면 실제
토스 호출이 일어나지 않습니다. `core.plugin_settings.before_save`를 `ValidateTossSettingsListener`
가 구독하는 것은 다른 PG 플러그인에는 없는 패턴입니다 — 이 훅은 코어 `PluginSettingsService`
가 발행하며, `sync: true`가 없으면 큐로 비동기 디스패치되어 `ValidationException`이 워커
안에서 죽고 저장은 그대로 진행됩니다(§CLAUDE.md "Listener 데이터 접근 규정" 의 sync 훅
규칙과 동일한 이유).
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-tosspayments --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-tosspayments` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] 승인 확인에서 금액 대조(`amount_mismatch`) 로직을 우회하거나 완화하지 않는다
- [ ] 가상계좌 웹훅의 secret 대조(`webhook_secret_verify`)를 기본값 `true` 이외로 바꾸지 않는다 — 끄면 토스 노티 위조를 막을 수단이 사라진다
- [ ] `order_sheet_mode` 관련 로직을 고칠 때 `RegisterPgProviderListener`(enabled_methods)와 `RegisterTossPaymentMethodsListener`(builtin 결제수단 주입) 양쪽을 함께 갱신 — 한쪽만 고치면 설정과 노출 목록이 어긋난다
- [ ] `ValidateTossSettingsListener`에 새 범위 검증을 추가하면 `core.plugin_settings.before_save` 의 `sync: true`를 유지
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 콜백 URL의 `amount` 쿼리 파라미터를 그대로 신뢰해 결제 완료 처리 | 서버가 주문 금액을 재계산해 PG 응답 금액과 대조, 불일치 시 실패 처리 | 콜백은 브라우저를 거치므로 사용자가 쿼리 파라미터를 조작해 실제 결제 금액보다 낮은 금액으로 완료 처리를 유도할 수 있다 |
| 가상계좌 웹훅의 secret 대조를 생략하거나 항상 통과 | `payment_meta.toss_secret`과 웹훅 본문의 secret을 항상 대조 | 토스는 notify IP 목록·서명을 제공하지 않아 secret 대조가 유일한 위조 방지 수단이다 — 생략하면 제3자가 임의 주문에 대해 위조 입금통보를 보낼 수 있다 |
| `core.plugin_settings.before_save` 리스너에 `sync: true` 없이 등록 | 저장을 막아야 하는 검증 훅은 반드시 `sync: true` | 기본값(비동기 큐)이면 `ValidationException`이 워커 안에서 죽고 저장이 그대로 진행되어 검증이 무력화된다 |
| 라이브 시크릿 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 서버측 API를 위조 호출할 수 있다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 12개 | `plugins/_bundled/sirsoft-tosspayments/tests` |
| Vitest | 5개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit plugins/_bundled/sirsoft-tosspayments/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd plugins/_bundled/sirsoft-tosspayments && powershell -Command "npm run test:run -- <대상>"
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @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 -->
@@ -0,0 +1,213 @@
# 토스페이먼츠
**G7 플러그인 · sirsoft-tosspayments**
토스페이먼츠 결제 게이트웨이 (통합결제창 연동)
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.2-0066FF?style=flat-square" alt="version 1.0.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.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">
<img src="https://img.shields.io/badge/requires-sirsoft--ecommerce-BF8700?style=flat-square" alt="requires sirsoft-ecommerce">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
토스페이먼츠 통합결제창 결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다.
승인은 브라우저 리다이렉트 기반입니다 — 결제창(SDK)이 결제를 처리한 뒤 브라우저를 콜백
URL로 돌려보내고, 서버가 그 파라미터로 승인 확인 API를 호출합니다.
이 플러그인은 결제창형(통합결제창 카드 하나)과 주문서형(개별 토스 결제수단 버튼) 두 UI
모드를 지원합니다. 결제 자체의 상태(주문·결제 성공/실패/취소)는 소유하지 않습니다 — 그
상태는 `sirsoft-ecommerce`의 주문·결제 테이블에 있고, 이 플러그인은 "그 상태를
토스페이먼츠 API 와 어떻게 주고받는가"만 책임집니다. 그래서 이 플러그인은 소유
테이블/모델이 하나도 없습니다(§data-model.md).
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 승인 방식 | 통합결제창 SDK + 브라우저 리다이렉트 콜백 + 서버 승인 확인 |
| UI 모드 | 결제창형(단일 카드) / 주문서형(개별 결제수단 버튼) 전환 |
| 결제수단 | 카드, 가상계좌, 계좌이체, 휴대폰, 토스페이, 카카오페이, 네이버페이, 페이코, 삼성페이 |
| 가상계좌 | 발급 + 웹훅 입금통보(secret 대조로 위조 방지) |
| 에스크로 | 3-상태(끔/켬/구매자선택) |
| 현금영수증 | 카드/계좌이체 발급·취소 프로바이더 등록 |
| 결제 취소 | 전액/부분취소, 실패 시 별도 훅 |
| 설정 저장 검증 | 가상계좌 유효시간·에스크로 값 서버측 범위 강제 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
A[체크아웃 주문 생성] --> B["통합결제창 SDK 호출"]
B --> C["/payment/callback (paymentKey·orderId·amount)"]
C --> D[서버가 금액 재계산 후 대조]
D -->|일치| E["승인 확인 API 호출"]
D -->|불일치| F[결제 실패 처리]
E --> G[주문 결제 완료 처리]
G --> H[성공 URL 리다이렉트]
```
가상계좌가 발급되면 승인 확인 응답에만 실리는 secret 값을 저장해 두었다가, 토스가 입금
웹훅을 보내면 그 secret 을 대조해 위조를 막습니다 — 토스는 notify IP 목록이나 서명을
제공하지 않아 이 방식이 공식적으로 제시되는 유일한 검증 수단입니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.10` |
| PHP | `^8.2` |
| 의존 모듈 | `sirsoft-ecommerce` `>=1.1.0` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan plugin:install sirsoft-tosspayments
# 활성화
php artisan plugin:activate sirsoft-tosspayments
# 업데이트 (번들 소스 기준 강제 반영)
php artisan plugin:update sirsoft-tosspayments --force
```
저장소: https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `is_test_mode` | 테스트 모드 | `true` |
| `test_client_key` | 테스트 클라이언트 키 | - |
| `test_secret_key` | 테스트 시크릿 키 | - |
| `live_client_key` | 라이브 클라이언트 키 | - |
| `live_secret_key` | 라이브 시크릿 키 | - |
| `redirect_success_url` | 결제 성공 리다이렉트 URL | `{shopBase}/orders/{orderId}/complete` |
| `redirect_fail_url` | 결제 실패 리다이렉트 URL | `{shopBase}/checkout` |
| `order_sheet_mode` | 주문서형 결제 | `false` |
| `method_card` | 카드 | `true` |
| `method_virtual_account` | 가상계좌 | `false` |
| `method_transfer` | 계좌이체 | `false` |
| `method_mobile_phone` | 휴대폰 | `false` |
| `method_tosspay` | 토스페이 | `false` |
| `method_kakaopay` | 카카오페이 | `false` |
| `method_naverpay` | 네이버페이 | `false` |
| `method_payco` | 페이코 | `false` |
| `method_samsungpay` | 삼성페이 | `false` |
| `vbank_valid_hours` | 가상계좌 입금기한(시간) | `24` |
| `vbank_cash_receipt_type` | 가상계좌 현금영수증 유형 | - |
| `use_escrow` | 에스크로 사용 | `off` |
| `webhook_secret_verify` | 웹훅 secret 검증 | `true` |
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
<!-- @generated:settings-summary END -->
<!-- @intent START -->
`order_sheet_mode`를 켜야 `method_*` 개별 결제수단 플래그가 체크아웃 화면에 실제로
반영됩니다 — 꺼둔 상태(기본값)에서는 `method_*` 값을 바꿔도 통합결제창이 결제수단을
전부 처리하므로 화면에 변화가 없습니다. 라이브 키(클라이언트 키·시크릿 키)는 외부에
노출하지 마세요.
**웹훅 URL 등록** — 토스페이먼츠 개발자센터에 아래 URL을 실제 운영 도메인으로 등록합니다.
```text
https://your-domain.com/plugins/sirsoft-tosspayments/webhook/deposit
```
`webhook_secret_verify`(기본값 켜짐)를 끄면 이 웹훅의 위조 방지 수단이 사라지므로 특별한
이유가 없는 한 켜둡니다.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**결제창형으로 시작하기**: 별도 설정 없이 활성화만 하면 통합결제창 카드 하나로 카드·계좌이체·
가상계좌·휴대폰결제가 전부 처리됩니다. 간편결제(토스페이/카카오페이/네이버페이 등)를
개별 버튼으로 노출하고 싶다면 `order_sheet_mode`를 켜고 해당 `method_*` 플래그를
활성화하세요.
**결제 취소/부분취소**: 관리자가 주문 취소를 요청(`cancel_pg=true`)하면 코어가
`sirsoft-ecommerce.payment.refund` 필터 훅을 발화하고, `PaymentRefundListener`가
토스페이먼츠 취소 API를 호출합니다. `after_cancel`은 취소 API 가 **성공했을 때만**
발화합니다 — 실패하면 `TossPaymentsApiException`이 던져지고 코어가 이를 받아 취소 요청
자체를 실패로 응답합니다(이 플러그인에는 나이스페이먼츠의 `refund_failed` 같은 별도
실패 훅이 없습니다).
**가상계좌 확인**: 테스트 모드에서 가상계좌 웹훅이 도착하지 않으면 §웹훅 URL 등록이
완료됐는지, `webhook_secret_verify`가 켜져 있다면 저장된 secret 이 정상 발급됐는지
확인합니다.
전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
<!-- @intent END -->
## 다른 확장과의 연동
<!-- @generated:integrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-ecommerce` | 모듈 | `>=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 -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 결제는 성공했는데 체크아웃으로 실패 리다이렉트됨 | 콜백 `amount`가 서버 재계산 금액과 불일치 | 의도된 안전장치 — 쿠폰/재고 변경 등으로 주문 금액이 결제 시점과 달라졌는지 확인 |
| 가상계좌 입금통보가 반영되지 않음 | 웹훅 URL 미등록, 또는 secret 불일치로 조용히 무시됨 | §웹훅 URL 등록 확인 + 로그의 `deposit webhook secret mismatch` 경고 확인 |
| 주문서형으로 켰는데 개별 결제수단 버튼이 안 보임 | `order_sheet_mode`는 켰지만 해당 `method_*` 플래그 비활성 | 노출하려는 결제수단의 `method_*`를 개별로 활성화 |
| 설정 저장 시 422 오류 | `vbank_valid_hours` 범위(1~2160) 또는 `use_escrow` 값(`off`/`on`/`buyer_choice`) 위반 | `ValidateTossSettingsListener`가 의도적으로 차단한 것 — 값을 허용 범위로 수정 |
| 결제 성공했는데 간편결제 버튼 클릭 시 오류 | 토스페이먼츠 계약이 없는 결제수단을 활성화 | 계약이 완료된 결제수단만 관리자 설정에서 활성화 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -0,0 +1,22 @@
# 토스페이먼츠 개발자 문서
> plugins/_bundled/sirsoft-tosspayments · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 4 · **구독 훅 수**: 9 · **라우트 수**: 4 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 1 · **핸들러 수**: 1
<!-- @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,58 @@
# 토스페이먼츠 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
토스페이먼츠 통합결제창을 `sirsoft-ecommerce`에 연결하는 어댑터입니다. 다른 PG
플러그인(iframe 팝업, CLI 실행, SOAP)과 달리 이 플러그인은 순수 리다이렉트 기반입니다 —
결제창이 브라우저를 콜백 URL로 돌려보내고, 서버는 그 쿼리 파라미터로 승인을 확인합니다.
이 단순함의 대가로 콜백 파라미터(특히 `amount`)를 신뢰하지 않고 서버가 재검증해야
합니다(§AGENTS.md "의도적으로 하지 않는 것").
가상계좌 웹훅 검증도 다른 PG 와 다릅니다 — IP 화이트리스트가 아니라 결제 승인 응답에만
실리는 secret 값 대조입니다. 토스가 notify IP 목록이나 서명을 제공하지 않기 때문입니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```text
Controller (PaymentCallbackController / WebhookController)
→ TossPaymentsApiService (승인 확인 · 취소 API 호출)
→ sirsoft-ecommerce 의 Order/OrderPayment 모델 (직접 참조 — 이 플러그인 소유 모델 없음)
Listener (RegisterPgProviderListener / RegisterTossPaymentMethodsListener / ValidateTossSettingsListener 등)
→ sirsoft-ecommerce 의 필터 훅 + 코어 설정 저장 훅에 등록 (컴파일 타임 결합 없음)
```
`ValidateTossSettingsListener`가 `core.plugin_settings.before_save`(코어 설정 저장 훅)를
구독하는 것은 이 플러그인만의 계층 특징입니다 — 결제 도메인 훅(`sirsoft-ecommerce.payment.*`)
뿐 아니라 코어 설정 저장 경로에도 개입해 저장 시점에 값을 검증합니다.
<!-- @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/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 |
| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 |
| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) |
| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-tosspayments --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-tosspayments --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-tosspayments --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-tosspayments --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,64 @@
# 토스페이먼츠 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 모델이 없습니다._
<!-- @generated:models END -->
<!-- @intent START -->
결제 상태는 이 플러그인이 아니라 `sirsoft-ecommerce`의 `Order`/`OrderPayment` 모델이
소유합니다(§AGENTS.md "설계 원칙"). 가상계좌 웹훅 검증에 쓰는 secret 조차 별도 테이블이
아니라 `OrderPayment.payment_meta`(JSON 컬럼)의 `toss_secret` 키에 저장됩니다 — 이 값은
그 주문 하나에만 의미가 있어 독립 테이블을 둘 이유가 없습니다.
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_소유 테이블이 없습니다._
<!-- @generated:tables END -->
<!-- @intent START -->
가상계좌 발급 정보와 토스 거래키(`paymentKey`)는 이커머스 `OrderPayment` 테이블의 기존
컬럼/메타에 저장됩니다 — PG 마다 별도 결제상세 테이블을 두면 관리자 주문 상세가 PG
종류에 따라 다른 테이블을 조인해야 합니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_마이그레이션이 없습니다._
<!-- @generated:migrations END -->
<!-- @intent START -->
소유 테이블이 없으므로(§소유 테이블) 스키마 변경 자체가 발생하지 않습니다. 설정 스키마
변경(§settings.md)은 `config/settings/defaults.json` 갱신만으로 끝나며 DB 마이그레이션
대상이 아닙니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Enum 이 없습니다._
<!-- @generated:enums END -->
<!-- @intent START -->
`use_escrow`의 3-상태(`off`/`on`/`buyer_choice`)는 Enum이 아니라
`ValidateTossSettingsListener::USE_ESCROW_VALUES` 상수 배열로 검증합니다 — 설정값 하나에만
쓰이는 닫힌 어휘라 Enum 승격의 이득(여러 곳에서 타입으로 재사용)이 없습니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_Repository 가 없습니다._
<!-- @generated:repositories END -->
<!-- @intent START -->
이 플러그인이 이커머스 `Order`/`OrderPayment`를 읽고 쓰는 지점(컨트롤러·리스너)은 모두
이커머스가 이미 노출한 Eloquent 모델을 직접 참조합니다 — 자기 소유 테이블이 없는 상태에서
남의 모델을 감싸는 Repository 를 새로 만드는 것은 위임만 하는 빈 계층입니다.
<!-- @intent END -->
@@ -0,0 +1,135 @@
# 토스페이먼츠 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 4종 / 호출 지점 4곳.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `sirsoft-tosspayments.payment.after_cancel` | action | 토스페이먼츠 결제 취소 완료 후 | `src/Services/TossPaymentsApiService.php:102` |
| `sirsoft-tosspayments.payment.after_confirm` | action | 토스페이먼츠 결제 승인 완료 후 | `src/Controllers/PaymentCallbackController.php:98` |
| `sirsoft-tosspayments.payment.before_cancel` | action | 토스페이먼츠 결제 취소 API 호출 전 | `src/Services/TossPaymentsApiService.php:98` |
| `sirsoft-tosspayments.payment.before_confirm` | action | 토스페이먼츠 결제 승인 API 호출 전 | `src/Controllers/PaymentCallbackController.php:94` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
`before_confirm`/`before_cancel`은 API 호출 **전** 개입 지점이라 예외를 던지면 실제 토스
호출이 일어나지 않습니다. `after_confirm`은 승인 확인 응답을 받은 뒤(§AGENTS.md "핵심 흐름")
발화하므로, 이 시점에는 아직 금액 대조가 끝나지 않았을 수 있습니다 — 구독하는 확장은
이커머스가 최종 완료 처리를 마쳤는지 별도로 확인해야 합니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `core.plugin_settings.before_save` | action (미선언) | `ValidateTossSettingsListener` | `validateBeforeSave` | 10 |
| `core.plugins.updated` | action | `RestoreLayoutExtensionsAfterUpdateListener` | `restoreCurrentExtensionsAfterUpdate` | 20 |
| `sirsoft-ecommerce.cash_receipt.cancel` | filter | `RegisterCashReceiptProviderListener` | `cancel` | 10 |
| `sirsoft-ecommerce.cash_receipt.issue` | filter | `RegisterCashReceiptProviderListener` | `issue` | 10 |
| `sirsoft-ecommerce.cash_receipt.registered_providers` | filter | `RegisterCashReceiptProviderListener` | `registerProvider` | 10 |
| `sirsoft-ecommerce.payment.get_client_config` | filter | `RegisterPgProviderListener` | `getClientConfig` | 10 |
| `sirsoft-ecommerce.payment.refund` | filter | `PaymentRefundListener` | `processRefund` | 10 |
| `sirsoft-ecommerce.payment.registered_pg_providers` | filter | `RegisterPgProviderListener` | `registerProvider` | 10 |
| `sirsoft-ecommerce.settings.filter_available_payment_methods` | filter | `RegisterTossPaymentMethodsListener` | `injectTossMethods` | 20 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
`core.plugin_settings.before_save`는 다른 PG 플러그인에는 없는 구독입니다 — 이 플러그인만
설정 저장 시점에 서버측 범위 검증(`vbank_valid_hours`, `use_escrow`)을 강제합니다
(§AGENTS.md "핵심 흐름"). `RegisterTossPaymentMethodsListener`가 `order_sheet_mode`가
꺼져 있으면 아무것도 주입하지 않는 것은 결제창형에서는 토스 결제수단 전부가 통합결제창
카드 하나로 처리되기 때문입니다 — 개별 버튼을 만들 필요가 없습니다. 3종의 현금영수증 훅
(`cash_receipt.*`)은 카드/계좌이체 발급, 취소, 프로바이더 등록을 각각 담당하며 모두
`RegisterCashReceiptProviderListener` 하나가 처리합니다 — PG 선택과 현금영수증 발급사
선택이 이커머스에서 독립적인 개념이라 별도 등록입니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `PaymentRefundListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/PaymentRefundListener.php` |
| `RegisterCashReceiptProviderListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/RegisterCashReceiptProviderListener.php` |
| `RegisterPgProviderListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/RegisterPgProviderListener.php` |
| `RegisterTossPaymentMethodsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterTossPaymentMethodsListener.php` |
| `RestoreLayoutExtensionsAfterUpdateListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RestoreLayoutExtensionsAfterUpdateListener.php` |
| `ValidateTossSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateTossSettingsListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
`ValidateTossSettingsListener`는 구독 훅이 `sirsoft-ecommerce.*`가 아니라
`core.plugin_settings.before_save`라는 점에서 이 표의 다른 5개 리스너와 성격이 다릅니다 —
결제 도메인이 아니라 코어 설정 저장 파이프라인에 개입합니다. `getSubscribedHooks()`에서
`sync: true`를 선언하는 것도 이 리스너뿐입니다(§AGENTS.md "금지 패턴").
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/admin_order_payment.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/checkout-payment-error.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/user_order_show.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
`checkout-payment-error.json`은 다른 PG 플러그인에는 없는 조각입니다 — 승인 확인 실패
(`amount_mismatch`, 서명 오류 등)가 `?error=` 쿼리로 체크아웃에 되돌아왔을 때 그 오류를
사용자에게 보여주는 전용 UI입니다. 다른 PG는 오류 안내를 체크아웃 레이아웃이 이미 가진
공용 오류 처리에 맡기지만, 이 플러그인은 리다이렉트 기반 승인이라 오류 사유가 다양해
전용 조각을 둡니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
다른 PG 플러그인은 가상계좌 입금통보에 IP 화이트리스트 미들웨어를 부착하지만, 이
플러그인은 미들웨어가 없습니다 — 토스가 notify IP 목록을 제공하지 않아 IP 기반 검증
자체가 불가능하기 때문입니다. 대신 §핵심 흐름의 secret 대조(`WebhookController` 안의
애플리케이션 레벨 검증)가 그 역할을 대신합니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
결제 승인·웹훅은 전부 동기 HTTP 요청/응답 안에서 끝나는 흐름이라 실시간 브로드캐스트가
필요한 지점이 없습니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
가상계좌 만료는 이 플러그인이 크론으로 스캔하지 않고, 만료 이후 도착하는 토스 입금 웹훅을
거부하는 방식으로 처리됩니다.
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
결제 완료/실패 알림은 이커머스 모듈이 주문 상태 변화를 기준으로 발송하는 공용 알림에 이미
포함됩니다 — PG 마다 별도 알림 정의를 만들면 같은 이벤트에 대해 PG 수만큼 중복 정의가
생깁니다.
<!-- @intent END -->
@@ -0,0 +1,79 @@
# 토스페이먼츠 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [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 -->
다른 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면
하나뿐입니다 — 체크아웃·주문상세의 결제 UI는 이 플러그인 소유가 아니라 §레이아웃 확장
(다른 확장/템플릿 레이아웃에 주입되는 조각)으로 존재합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 1개 (정의: `resources/js/handlers/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `requestPayment` | `sirsoft-tosspayments.requestPayment` |
<!-- @generated:handlers END -->
<!-- @intent START -->
핸들러가 이것 하나뿐인 이유는 이 플러그인이 통합결제창 SDK 호출 이후를 전부 브라우저
리다이렉트에 위임하기 때문입니다(§AGENTS.md "1. 이 확장은 무엇인가") — 다른 PG처럼 결제
수단 선택 UI를 DOM으로 직접 조작할 필요가 없습니다. `order_sheet_mode`에 따라 통합결제창
하나(카드 한 장)를 열지, 사용자가 고른 개별 토스 결제수단(`params.paymentMethodId`)을
지정해 열지가 갈리지만(`params.paymentMethod`, 미지정 시 `_local.paymentMethod` 참조) 그
분기도 이 핸들러 하나 안에서 처리합니다.
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftTosspayments` |
| 재등록 진입점 | `initPlugin()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`window.__SirsoftTosspayments`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 재등록
진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 토스 SDK(`js.tosspayments.com/v2/standard`)
자체는 이 진입점이 미리 로드하지 않습니다 — `requestPayment` 핸들러가 결제 시도 시점에
동적으로 로드합니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
토스 SDK 자체는 이 목록에 없습니다 — `requestPayment` 핸들러가 결제 시도 시점에 동적으로
로드하는 제3자 자산이라, 이 플러그인이 빌드 시 번들링하는 `dist/` 산출물과는 다른 층입니다.
다른 PG 플러그인과 달리 `editor-spec.json`이 없는 것은 이 플러그인이 레이아웃 편집기에서
커스터마이즈 가능한 전용 컴포넌트를 노출하지 않기 때문입니다 — 결제 UI는 코어 기본
컴포넌트와 §레이아웃 확장 조각만으로 구성됩니다.
<!-- @intent END -->
@@ -0,0 +1,105 @@
# 토스페이먼츠 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
| `test_client_key` | `string` | - | 테스트 클라이언트 키 |
| `test_secret_key` | `string` | - | 테스트 시크릿 키 |
| `live_client_key` | `string` | - | 라이브 클라이언트 키 |
| `live_secret_key` | `string` | - | 라이브 시크릿 키 |
| `redirect_success_url` | `string` | `{shopBase}/orders/{orderId}/complete` | 결제 성공 리다이렉트 URL |
| `redirect_fail_url` | `string` | `{shopBase}/checkout` | 결제 실패 리다이렉트 URL |
| `order_sheet_mode` | `boolean` | `false` | 주문서형 결제 |
| `method_card` | `boolean` | `true` | 카드 |
| `method_virtual_account` | `boolean` | `false` | 가상계좌 |
| `method_transfer` | `boolean` | `false` | 계좌이체 |
| `method_mobile_phone` | `boolean` | `false` | 휴대폰 |
| `method_tosspay` | `boolean` | `false` | 토스페이 |
| `method_kakaopay` | `boolean` | `false` | 카카오페이 |
| `method_naverpay` | `boolean` | `false` | 네이버페이 |
| `method_payco` | `boolean` | `false` | 페이코 |
| `method_samsungpay` | `boolean` | `false` | 삼성페이 |
| `vbank_valid_hours` | `integer` | `24` | 가상계좌 입금기한(시간) |
| `vbank_cash_receipt_type` | `string` | - | 가상계좌 현금영수증 유형 |
| `use_escrow` | `string` | `off` | 에스크로 사용 |
| `webhook_secret_verify` | `boolean` | `true` | 웹훅 secret 검증 |
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
`order_sheet_mode`가 이 스키마의 분기점입니다 — `false`(결제창형)면 `method_*` 8개 플래그는
읽히지 않고 통합결제창이 결제수단 선택을 전담합니다. `true`(주문서형)로 켜야 `method_*`
플래그가 실제로 체크아웃 화면의 개별 버튼 노출 여부를 결정합니다(§AGENTS.md "이 확장은
무엇인가"). `vbank_valid_hours`(1~2160시간)와 `use_escrow`(`off`/`on`/`buyer_choice`)는
`ValidateTossSettingsListener`가 저장 시점에 범위를 강제합니다 — UI 의 input 힌트만으로는
관리자 설정 저장 API 직접 호출을 막을 수 없기 때문입니다. `webhook_secret_verify`를 끄면
가상계좌 웹훅의 유일한 위조 방지 수단이 사라지므로 기본값 `true`를 유지해야 합니다.
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_선언된 권한이 없습니다._
<!-- @generated:permissions END -->
<!-- @intent START -->
결제 설정 접근 권한은 이커머스의 관리자 권한 체계 안에서 다뤄집니다 — PG 마다 별도 권한을
선언하면 PG 를 여러 개 설치했을 때 "결제 설정을 볼 수 있는 사람"이라는 하나의 개념이
플러그인 수만큼 중복 정의됩니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 메뉴가 없습니다._
<!-- @generated:menus END -->
<!-- @intent START -->
설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
접근합니다 — PG 플러그인마다 전용 사이드바 메뉴를 만들면 PG 를 여러 개 설치했을 때 메뉴가
난립합니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `web` | `src/routes/web.php` | `/plugins/sirsoft-tosspayments/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
다른 PG 플러그인과 달리 `api` 라우트 파일이 없습니다 — 이 플러그인은 로그인 사용자가
Bearer 토큰으로 직접 호출하는 엔드포인트(예: 관리자 거래조회)를 두지 않습니다. 승인
콜백·가상계좌 웹훅 모두 결제창 리다이렉트나 토스 서버가 도달하는 경로라 `web`에만
있습니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
| 확장 | 유형 | 버전 제약 | 번들 |
|---|---|---|---|
| `sirsoft-ecommerce` | 모듈 | `>=1.1.0` | ✅ |
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
`sirsoft-ecommerce >=1.1.0` 하드 의존은 §data-model.md 에서 설명한 구조(결제 상태는
이커머스가 소유, 이 플러그인은 절차만 소유)의 직접적 결과입니다 — 이커머스 없이는 이
플러그인이 다룰 주문 자체가 존재하지 않습니다. 이커머스의 PG 등록 훅이나 `Order` 모델
구조가 바뀌면 이 최소 버전을 올려야 합니다(§CLAUDE.md "확장 → 확장 동기화").
<!-- @intent END -->
@@ -0,0 +1,187 @@
# KG이니시스 본인인증 — 에이전트 가이드
> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
## TL;DR (5초 요약)
```text
1. 유형: 플러그인 (sirsoft-verification_kginicis) — KG이니시스 본인확인(reqSvcCd=03) IDV Provider. 코어 `IdentityVerificationInterface` 12메서드 구현, PII 레코드 소유 (payment 플러그인과 달리 소유 테이블 있음)
2. 확장 방식: `RegisterInicisProviderListener` 로 코어 `core.identity.registered_providers` 필터에 등록 — 코어는 이 플러그인의 존재를 모른다
3. 건드리면 안 되는 것: 비로그인 사용자 PII 캐시 stash(`inicis:pending_record:` 접두) 로직 우회, 라이브 MID `SRB` 프리픽스 정책값 상수(`LIVE_MID_PREFIX`) 미참조, 중복가입 차단(`AssertNoDuplicateInicisIdentity`) 우회
4. 작업 위치: `plugins/_bundled/sirsoft-verification_kginicis` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan plugin:update sirsoft-verification_kginicis --force`
```
## 1. 이 확장은 무엇인가
<!-- @intent START -->
KG이니시스 본인확인(휴대폰 인증, `reqSvcCd=03`)을 코어 본인인증(IDV) 체계에 연결하는
Provider 입니다. 코어 `IdentityVerificationInterface`(표준 12메서드)를 구현해, 회원가입·
비밀번호 찾기·민감작업 등 코어가 정의한 모든 IDV 강제 지점에서 이메일 인증 대신 이니시스
팝업이 대신 동작하게 합니다. `sirsoft-verification_nhnkcp`도 같은 인터페이스를 구현하며,
운영자는 둘 중 어느 것이든(또는 둘 다) 설치해 사용할 수 있습니다 — 코어는 등록된
provider ID로만 구분하고 어느 PG사인지 모릅니다.
**결제 PG 플러그인과의 결정적 차이**: 이 플러그인은 실제 PII(개인식별정보) 레코드를
소유합니다(§data-model.md — `inicis_identity_records` 테이블, `InicisIdentityRecord`
모델). 결제 플러그인들이 "상태는 남의 것, 절차만 내 것"이었던 것과 달리, 본인확인은 그
확인 결과(이름·생년월일·성별·CI/DI 등)를 이 플러그인이 직접 보관해야 이후 재확인 없이
"본인확인 완료 여부"를 판단할 수 있습니다.
**의도적으로 하지 않는 것**: 비로그인 사용자(예: 회원가입 도중)의 PII는 확인 즉시
DB 에 쓰지 않고 Cache 에 임시 저장(`inicis:pending_record:` 접두)했다가, 가입이 실제로
완료된 뒤(`core.auth.after_register` 훅)에야 레코드로 흡수합니다 — 가입을 완료하지 않은
방문자의 PII 를 DB 에 영구 저장하지 않기 위함입니다. 사용자가 탈퇴하거나 계정이 삭제되면
`core.user.after_withdraw`/`core.user.before_delete` 훅에서 관련 레코드를 정리합니다.
<!-- @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/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_kginicis --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_kginicis --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_kginicis --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_kginicis --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_kginicis --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**본인확인 시작~완료**: 코어가 IDV 를 요구하는 지점(회원가입 등)에서 428 응답 →
프론트 `startAuth` 핸들러가 사용자 클릭 컨텍스트 안에서 `window.open`으로 빈 팝업 생성
(Chrome popup blocker 회피 — 자동 호출은 차단되지만 클릭 직후 호출은 통과) → 코어
challenge 시작 응답의 `mid`/`mtxid`/`authHash`로 팝업에 이니시스 인증 폼 제출 → 이니시스
인증 완료 후 `InicisChallengeMappingRepository`가 mTxId ↔ challenge_id 매핑을 저장 →
인증 결과는 postMessage 또는 팝업 종료 감지로 회수 → `InicisIdentityProvider::verify()`가
SEED 복호화 후 결과를 반환. 성인인증(`inicis.adult_verification` purpose)으로 발행된
challenge 는 만 19세 이상만 통과시킵니다.
**비로그인 사용자(회원가입 도중) 처리**: `verify()` 시점에 로그인 사용자가 없으면 PII 를
Cache 에 stash(`inicis:pending_record:{key}`) → 회원가입 완료 → `core.auth.after_register`
훅 → `CompleteInicisRecordAfterRegister`가 같은 캐시 키로 PII 를 회수해
`InicisIdentityRecord`로 흡수.
**중복가입 차단**: `core.auth.before_register` 훅 → `AssertNoDuplicateInicisIdentity`가
`duplicate_block_enabled` 설정이 켜져 있으면 `duplicate_field`(DI 또는 CI) 기준으로
`InicisIdentityLogQueryRepository`를 조회해 이미 가입된 동일인이 있는지 확인 → 있으면
가입을 차단.
**사용자 삭제/탈퇴 시 PII 정리**: `core.user.before_delete`/`core.user.after_withdraw` 훅 →
`CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`가 해당 사용자의
`inicis_identity_records`를 정리.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 3개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 6개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 6개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
`core.identity.registered_providers` 는 코어가 등록된 IDV provider 목록을 모으는 필터
훅입니다 — 새 IDV PG 를 추가하려는 확장은 이 훅에 자기 provider 를 등록하면 됩니다
(`sirsoft-verification_nhnkcp`가 동일 패턴). `core.plugin_settings.update_validation_rules`는
`ValidateInicisSettingsListener`가 `is_test_mode=false`(라이브 모드) 진입 시
`live_mid`/`live_api_key`에 `required` 규칙을 동적으로 부여하는 자리입니다 — 코어
`UpdatePluginSettingsRequest`의 정적 스키마는 "테스트 모드일 땐 선택, 라이브 모드일 땐
필수" 같은 조건부 검증을 표현할 수 없기 때문입니다. `live_mid`의 `SRB` 프리픽스는 이
필터가 아니라 `InicisIdentityProvider::buildLiveMid()`가 그 값을 실제로 쓸 때(요청 조립
시점) 동적으로 부착하므로 별도 형식 검증을 두지 않습니다 — DB 에는 운영자가 입력한 원본
값이 그대로 저장됩니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-verification_kginicis --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-verification_kginicis` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] PII 컬럼(이름·생년월일·성별·CI/DI 등)을 다루는 코드 변경 시 GDPR 삭제/탈퇴 정리 리스너(`CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`)가 여전히 그 컬럼을 정리하는지 확인
- [ ] 팝업 기반 인증 흐름(`startAuth`)을 고칠 때 `window.open`을 사용자 클릭 컨텍스트 밖으로 옮기지 않는다 — Chrome popup blocker 회피가 깨진다
- [ ] `duplicate_field`/`duplicate_block_enabled` 로직을 고치면 `InicisDuplicateField` Enum 과 `AssertNoDuplicateInicisIdentity`를 함께 갱신
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 비로그인 사용자의 PII 를 verify 즉시 DB 에 저장 | Cache 에 stash(`inicis:pending_record:` 접두) 후 가입 완료 시 흡수 | 가입을 완료하지 않은 방문자의 PII 를 DB 에 영구 저장하면 불필요한 개인정보 보유가 된다 |
| 사용자 삭제/탈퇴 리스너 없이 PII 컬럼 추가 | `CleanInicisRecordOnUserDelete`/`CleanInicisRecordOnUserWithdraw`에 정리 로직 동반 | 정리 누락 시 탈퇴한 사용자의 PII 가 무기한 남는다 |
| `LIVE_MID_PREFIX` 상수를 참조하지 않고 `'SRB'`를 문자열로 재작성 | `InicisIdentityProvider::LIVE_MID_PREFIX` 참조 | 이니시스 프리픽스 정책이 바뀌면 상수 1곳만 갱신해야 런타임 로직 전체에 반영된다 — 문자열 재작성은 사각을 만든다 |
| 팝업을 사용자 클릭 이벤트 핸들러 밖(비동기 콜백 등)에서 `window.open` | 사용자 제스처 컨텍스트 안에서 직접 호출 | Chrome 등 브라우저는 사용자 제스처 없이 열리는 팝업을 자동 차단한다 |
| 라이브 API 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 본인확인 API 를 위조 호출할 수 있다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 22개 | `plugins/_bundled/sirsoft-verification_kginicis/tests` |
| Vitest | 8개 | `vitest.config.ts` |
| Playwright | 0개 | — |
| 시나리오 매니페스트 | 8개 | `tests/scenarios` |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit plugins/_bundled/sirsoft-verification_kginicis/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd plugins/_bundled/sirsoft-verification_kginicis && powershell -Command "npm run test:run -- <대상>"
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @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 -->
@@ -0,0 +1,182 @@
# KG이니시스 본인인증
**G7 플러그인 · sirsoft-verification_kginicis**
KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인
<!-- @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.8-1F883D?style=flat-square" alt="G7 &gt;=7.0.8">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
KG이니시스 본인확인(휴대폰 인증, reqSvcCd=03)을 G7 코어의 본인인증(IDV) 체계에 연결하는
플러그인입니다. 코어가 정의한 표준 인터페이스를 구현해, 회원가입·비밀번호 찾기·민감작업
등 코어가 IDV 를 요구하는 모든 지점에서 이메일 인증 대신 이니시스 팝업이 동작하게
합니다.
결제 PG 플러그인들과 달리 이 플러그인은 실제 개인식별정보(PII — 이름·생년월일·성별·CI/DI
등)를 직접 보관합니다. 본인확인 결과를 저장해 두어야 이후 재확인 없이 "이 사용자가
본인확인을 완료했는가"를 즉시 판단할 수 있기 때문입니다. `sirsoft-ecommerce`를 비롯한
어떤 다른 확장에도 의존하지 않고 코어만으로 동작합니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 본인확인 | KG이니시스 통합인증(reqSvcCd=03) 팝업 기반 본인확인 |
| 성인인증 | 만 19세 이상 여부만 확인하는 별도 purpose |
| 중복가입 차단 | DI 또는 CI 기준으로 동일인 재가입 차단 (선택) |
| 게스트 처리 | 비로그인 사용자의 본인확인 결과를 임시 보관 후 가입 완료 시 흡수 |
| 개인정보 정리 | 사용자 탈퇴/삭제 시 보관 중인 PII 레코드 자동 파기 |
| 마이페이지 | 본인확인 완료 상태 카드 노출 |
| 관리자 설정 | 테스트/라이브 모드 전환, 중복가입 판정 기준 설정 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
A[코어가 IDV 요구 · 428] --> B[사용자 클릭 → 팝업 오픈]
B --> C[이니시스 인증 폼 제출]
C --> D[인증 완료 → 결과 회수]
D --> E[SEED 복호화 → PII 확보]
E -->|로그인 사용자| F[레코드 즉시 저장]
E -->|비로그인 사용자| G[Cache 임시 보관]
G --> H[가입 완료 시 레코드로 흡수]
```
팝업은 반드시 사용자 클릭 이벤트 안에서 열립니다 — 비동기 콜백 이후에 열면 브라우저
팝업 차단기에 걸립니다. 비로그인 사용자(회원가입 도중)의 본인확인 결과는 가입이 실제로
완료되기 전까지 DB 가 아니라 Cache 에만 임시 보관됩니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.8` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan plugin:install sirsoft-verification_kginicis
# 활성화
php artisan plugin:activate sirsoft-verification_kginicis
# 업데이트 (번들 소스 기준 강제 반영)
php artisan plugin:update sirsoft-verification_kginicis --force
```
저장소: https://github.com/gnuboard/g7-plugin-sirsoft-verification_kginicis
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `is_test_mode` | 테스트 모드 | `true` |
| `test_mid` | 테스트 MID | `INIiasTest` |
| `test_api_key` | 테스트 API 키 | `TGdxb2l3enJDWFRTbTgvREU3MGYwUT09` |
| `live_mid` | 라이브 MID | - |
| `live_api_key` | 라이브 API 키 | - |
| `duplicate_field` | 중복 판정 필드 | `di` |
| `duplicate_block_enabled` | 중복 가입 차단 | `true` |
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
<!-- @generated:settings-summary END -->
<!-- @intent START -->
`live_mid`는 실제 사용 시점(요청 조립 시)에 `SRB` 프리픽스가 자동으로 붙으므로 프리픽스
없이 입력해도 됩니다.
`live_mid`/`live_api_key`는 `is_test_mode`를 끄는(라이브 모드) 순간부터 필수가 됩니다 —
테스트 모드에서는 비워둘 수 있습니다. `duplicate_field`(`di` 또는 `ci`)와
`duplicate_block_enabled`는 다른 IDV provider(예: `sirsoft-verification_nhnkcp`)와 별개로
이 provider 를 통해 확인한 사용자에게만 적용됩니다. 라이브 API 키는 외부에 노출하지
마세요.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**활성화하기**: 플러그인을 활성화하면 자동으로 코어 IDV provider 목록에 등록됩니다.
별도 화면 배치 작업 없이 코어가 이미 정의한 IDV 강제 지점(회원가입 등)에서 즉시
동작합니다. 여러 IDV provider 를 동시에 설치했다면 코어 본인인증 정책 화면에서 어느
provider 를 쓸지 선택합니다.
**중복가입 차단 켜기**: 동일인이 여러 계정을 만드는 것을 막고 싶다면
`duplicate_block_enabled`를 켜고 `duplicate_field`로 DI/CI 중 판정 기준을 고릅니다.
**개인정보 보관 정책 확인**: 사용자가 탈퇴하거나 관리자가 계정을 삭제하면 보관 중인
본인확인 PII 가 자동으로 파기됩니다 — 별도 운영 작업이 필요 없습니다.
전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
<!-- @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 -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 인증 버튼을 눌러도 팝업이 안 뜸 | 브라우저 팝업 차단기 | `startAuth`가 클릭 이벤트 핸들러 안에서 직접 호출되는지 확인 — 비동기 콜백 뒤로 옮기면 차단됨 |
| 설정 저장 시 422 오류 | 라이브 모드인데 `live_mid`/`live_api_key` 미입력 | `is_test_mode`를 켜거나 라이브 자격증명을 입력 |
| 이미 가입된 사용자인데 중복 오류 없이 재가입됨 | `duplicate_block_enabled`가 꺼져 있거나 `duplicate_field` 기준이 실제 판정과 다름 | 관리자 설정에서 두 값을 확인 |
| 탈퇴한 사용자의 본인확인 정보가 남아있는 것으로 보임 | 정리 리스너 실행 여부를 별도로 확인하지 않음 | `CleanInicisRecordOnUserWithdraw`/`CleanInicisRecordOnUserDelete` 정상 등록 여부를 훅 캐시에서 확인 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -0,0 +1,22 @@
# KG이니시스 본인인증 개발자 문서
> plugins/_bundled/sirsoft-verification_kginicis · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 3 · **구독 훅 수**: 6 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 3 · **레이아웃 수**: 1 · **핸들러 수**: 1
<!-- @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,68 @@
# KG이니시스 본인인증 — 아키텍처
> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md)
## 설계 의도
<!-- @intent START -->
KG이니시스 본인확인을 코어 IDV(본인인증) 체계에 연결하는 Provider 입니다. 코어
`IdentityVerificationInterface`를 구현하는 것이 이 플러그인의 유일한 계약이며, 코어는
`core.identity.registered_providers` 필터로 등록된 provider 목록만 알고 어느 PG사의
구현인지는 모릅니다.
결제 PG 플러그인들과 달리 이 플러그인은 실제 PII 를 소유합니다(§data-model.md). 본인확인
결과(CI/DI 등)를 재확인 없이 판단하려면 그 결과를 어딘가 보관해야 하고, 그 보관 책임은
Provider 자신에게 있습니다 — 코어는 "본인확인 완료 여부"만 알면 되고 원본 PII 를 알 필요가
없습니다.
<!-- @intent END -->
## 계층 지도
<!-- @intent START -->
```text
Controller (Http/Controllers) → FormRequest (Http/Requests)
→ InicisIdentityProvider (IdentityVerificationInterface 구현 — verify/challenge 표준 진입점)
→ InicisGatewayInterface (외부 통신 + SEED 복호화)
→ InicisChallengeMappingRepositoryInterface (mTxId ↔ challenge_id)
→ InicisIdentityRecordRepositoryInterface (PII record)
→ CacheInterface (비로그인 verify PII 임시 stash)
Listener (RegisterInicisProviderListener 등)
→ 코어 identity/auth/user 훅에 등록 (컴파일 타임 결합 없음)
```
`InicisIdentityProvider`가 4개의 협력자(게이트웨이·Repository 2종·캐시)를 생성자 주입받는
것은 그 각각이 서로 다른 관심사(외부 API 통신, DB 영속, 임시 캐시)이기 때문입니다 — 하나로
합치면 단위 테스트에서 외부 API 를 모킹할 때 DB/캐시까지 함께 모킹해야 합니다.
<!-- @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/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_kginicis --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_kginicis --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_kginicis --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_kginicis --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_kginicis --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
@@ -0,0 +1,95 @@
# KG이니시스 본인인증 — 데이터 모델
> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md)
## 모델
<!-- @generated:models START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 모델 | 테이블 | fillable | 관계 | 특성 |
|---|---|---|---|---|
| `InicisChallengeMapping` | `inicis_challenge_mappings` | 2 | challenge→IdentityVerificationLog | - |
| `InicisIdentityRecord` | `inicis_identity_records` | 17 | user→User | - |
<!-- @generated:models END -->
<!-- @intent START -->
`InicisIdentityRecord`가 PII(이름·생년월일·성별·CI/DI 등)를 직접 보관하는 것이 결제
플러그인들과의 근본적 차이입니다(§AGENTS.md "1. 이 확장은 무엇인가") — 이 레코드가 없으면
"이 사용자가 본인확인을 완료했는가"를 매번 이니시스에 재조회해야 합니다.
`InicisChallengeMapping`은 별도 모델입니다 — 진행 중인 인증 시도(mTxId ↔ challenge_id)와
완료된 확인 결과(PII record)는 생명주기가 다르기 때문입니다(전자는 인증 세션 하나,
후자는 사용자당 최신 1건).
<!-- @intent END -->
## 소유 테이블
<!-- @generated:tables START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 테이블 | 모델 |
|---|---|
| `inicis_challenge_mappings` | `InicisChallengeMapping` |
| `inicis_identity_records` | `InicisIdentityRecord` |
<!-- @generated:tables END -->
<!-- @intent START -->
`inicis_identity_records.user_id`는 `unique` 제약을 갖습니다 — 한 사용자는 본인확인 결과를
1건만 보유하며, 재인증 시 기존 레코드를 갱신합니다(새 레코드를 추가하지 않습니다). 이
설계 덕분에 "이 사용자가 본인확인을 완료했는가"는 단순 존재 조회 하나로 판정됩니다.
<!-- @intent END -->
## 마이그레이션
<!-- @generated:migrations START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
마이그레이션 3개.
| 파일 | 생성 테이블 | 변경 테이블 | down() |
|---|---|---|---|
| `2026_05_08_000001_create_inicis_identity_records_table.php` | `inicis_identity_records` | `inicis_identity_records` | ✅ |
| `2026_05_08_000002_create_inicis_challenge_mappings_table.php` | `inicis_challenge_mappings` | `inicis_challenge_mappings` | ✅ |
| `2026_06_22_000001_make_inicis_record_aux_fields_nullable.php` | - | `inicis_identity_records` | ✅ |
<!-- @generated:migrations END -->
<!-- @intent START -->
세 번째 마이그레이션이 `name`/`phone`/`birthday` 암호화 컬럼을 nullable 로 완화한 이유는
누락 값을 `null`로 저장하기 위함입니다 — `Crypt::encryptString('')`로 "암호화된 빈
문자열"을 저장하면 복호화 시 빈 칸이 나오는 오염 레코드가 됩니다. 정상 경로에서는
`verify()` 가드(`INCOMPLETE_IDENTITY`)가 이 신원 핵심값의 누락을 이미 차단하므로 항상
채워지며, 이 nullable 화는 가드를 우회한 비정상 입력이 암호화된 빈 문자열로 오염
저장되는 것을 막는 방어적 통일입니다.
<!-- @intent END -->
## Enum
<!-- @generated:enums START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| Enum | backing | case 수 | case |
|---|---|---|---|
| `InicisDuplicateField` | `string` | 2 | `di`, `ci` |
<!-- @generated:enums END -->
<!-- @intent START -->
`di`(연계정보)와 `ci`(연계정보의 상위 개념 — 사이트 간 동일인 식별용) 중 어느 필드로 중복
가입을 판정할지는 운영자가 설정으로 고릅니다(§settings.md `duplicate_field`). Enum 으로
닫힌 것은 이 값이 `AssertNoDuplicateInicisIdentity`의 조회 조건과
`InicisIdentityLogQueryRepository`의 컬럼 화이트리스트 양쪽에서 타입 안전하게 재사용되기
때문입니다 — 문자열 리터럴이었다면 오타가 조용히 "중복 없음"으로 판정될 위험이 있습니다.
<!-- @intent END -->
## Repository
<!-- @generated:repositories START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 클래스 | 종류 | 설명 |
|---|---|---|
| `InicisChallengeMappingRepository` | 구현 | `inicis_challenge_mappings` 테이블 Repository 구현체. |
| `InicisChallengeMappingRepositoryInterface` | 인터페이스 | 이니시스 mTxId ↔ challenge_id 매핑 Repository 인터페이스. |
| `InicisIdentityLogQueryRepository` | 구현 | InicisIdentityLogQueryRepositoryInterface 구현체. |
| `InicisIdentityLogQueryRepositoryInterface` | 인터페이스 | 본 plugin 의 동일인 검증 listener 전용 IdentityVerificationLog 조회 Repository. |
| `InicisIdentityRecordRepository` | 구현 | `inicis_identity_records` 테이블 Repository 구현체. |
| `InicisIdentityRecordRepositoryInterface` | 인터페이스 | KG이니시스 본인확인 PII 레코드 Repository 인터페이스. |
<!-- @generated:repositories END -->
<!-- @intent START -->
3쌍(인터페이스+구현체)으로 나뉜 것은 각자 다른 데이터를 다루기 때문입니다 —
`InicisChallengeMappingRepository`(진행 중인 인증 세션), `InicisIdentityRecordRepository`
(완료된 PII), `InicisIdentityLogQueryRepository`(동일인 검증 전용 `IdentityVerificationLog`
조회, 코어 로그 테이블을 이 플러그인 관점으로 좁혀 읽는 어댑터). 결제 플러그인들이
Repository 를 하나도 두지 않는 것과 대조적으로, 이 플러그인은 실제 PII 를 소유하므로
Repository 인터페이스 주입 원칙(§CLAUDE.md "Service-Repository 패턴")이 그대로 적용됩니다.
<!-- @intent END -->
@@ -0,0 +1,130 @@
# KG이니시스 본인인증 — 확장점
> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md)
## 발행 훅
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
발행 훅 3종 / 호출 지점 3곳. 이 중 3종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.
| 훅 이름 | 유형 | 설명 | 발행 위치 |
|---|---|---|---|
| `core.auth.after_register` | action | — | `src/Listeners/CompleteInicisRecordAfterRegister.php:64` |
| `core.user.after_withdraw` | action | — | `src/Listeners/CleanInicisRecordOnUserWithdraw.php:18` |
| `core.user.before_delete` | action | — | `src/Listeners/CleanInicisRecordOnUserDelete.php:23` |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
이 3종은 이 플러그인이 실제로 발행하는 훅이 아닙니다 — 세 리스너 파일 모두 코어가 그
훅을 어떻게 호출하는지 보여주는 **docblock 예시**(`HookManager::doAction('core.user.before_delete', $user);`
형태의 주석)를 갖고 있는데, 소스 자동 감지가 그 주석 텍스트를 실제 발행 호출로 오인해
잡아낸 결과입니다. 실제 발행 주체는 코어이며, 이 플러그인은 §구독 훅에서 같은 이름으로
**구독**만 합니다. 이 확장의 리스너에 이런 docblock 예시를 새로 추가할 때는 이 표에
가짜 항목이 늘어난다는 점을 감안합니다.
<!-- @intent END -->
## 구독 훅
<!-- @generated:hooks-subscribed START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 |
|---|---|---|---|---|
| `core.auth.after_register` | action (미선언) | `CompleteInicisRecordAfterRegister` | `handle` | 50 |
| `core.auth.before_register` | action (미선언) | `AssertNoDuplicateInicisIdentity` | `handle` | 20 |
| `core.identity.registered_providers` | filter | `RegisterInicisProviderListener` | `register` | 20 |
| `core.plugin_settings.update_validation_rules` | filter | `ValidateInicisSettingsListener` | `addLiveModeRules` | 10 |
| `core.user.after_withdraw` | action (미선언) | `CleanInicisRecordOnUserWithdraw` | `handle` | 50 |
| `core.user.before_delete` | action (미선언) | `CleanInicisRecordOnUserDelete` | `handle` | 50 |
<!-- @generated:hooks-subscribed END -->
<!-- @intent START -->
`AssertNoDuplicateInicisIdentity`가 `core.auth.before_register`(가입 **전**)을 구독하는
것은 중복 가입을 막으려면 가입 트랜잭션이 커밋되기 전에 차단해야 하기 때문입니다 —
`after_register`에서 잡으면 이미 중복 계정이 생성된 뒤라 롤백이 더 복잡해집니다.
`core.user.before_delete`는 `sync: true`로 동기 실행됩니다 — 이 시점 정리가 실패하면
FK 제약(1451)으로 사용자 삭제 자체가 실패해야 하므로, 비동기 큐로 미뤄지면 삭제가 먼저
끝나버릴 수 있습니다.
<!-- @intent END -->
## 훅 리스너
<!-- @generated:listeners START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 |
|---|---|---|---|---|
| `AssertNoDuplicateInicisIdentity` | 1개 | 명시 등록 | ✅ | `src/Listeners/AssertNoDuplicateInicisIdentity.php` |
| `CleanInicisRecordOnUserDelete` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanInicisRecordOnUserDelete.php` |
| `CleanInicisRecordOnUserWithdraw` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanInicisRecordOnUserWithdraw.php` |
| `CompleteInicisRecordAfterRegister` | 1개 | 명시 등록 | ✅ | `src/Listeners/CompleteInicisRecordAfterRegister.php` |
| `RegisterInicisProviderListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterInicisProviderListener.php` |
| `ValidateInicisSettingsListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/ValidateInicisSettingsListener.php` |
<!-- @generated:listeners END -->
<!-- @intent START -->
6개 리스너 중 3개(`CompleteInicisRecordAfterRegister`, `CleanInicisRecordOnUserWithdraw`,
`CleanInicisRecordOnUserDelete`)는 사용자 생명주기 각 단계(가입 완료·탈퇴·삭제)에 맞춰
PII 레코드를 흡수하거나 정리하는 대칭 구조입니다 — 하나를 고칠 때 나머지 둘도 같은 PII
필드를 다루고 있는지 확인해야 합니다.
<!-- @intent END -->
## 레이아웃 확장
<!-- @generated:layout-extensions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 대상 | 설명 |
|---|---|
| `resources/extensions/identity_provider_inicis.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
| `resources/extensions/mypage_identity_card.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 |
<!-- @generated:layout-extensions END -->
<!-- @intent START -->
`identity_provider_inicis.json`은 코어 IDV 팝업(§CLAUDE.md "본인인증(IDV) 공통 UI 가이드")이
provider 별로 다른 안내 문구·로고를 보여줘야 할 때 이 플러그인이 자기 몫을 주입하는
조각입니다. `mypage_identity_card.json`은 마이페이지에 "본인확인 완료" 상태 카드를
보여주는 조각으로, `sirsoft-verification_nhnkcp`도 동일한 명명 규칙의 자기 조각을 갖습니다
— 여러 IDV provider 가 동시에 설치돼도 각자 자기 카드만 주입하므로 충돌하지 않습니다.
<!-- @intent END -->
## 미들웨어
<!-- @generated:middleware START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 미들웨어가 없습니다._
<!-- @generated:middleware END -->
<!-- @intent START -->
결제 플러그인들과 달리 이 플러그인은 PG 서버가 직접 호출하는 웹훅/통보 엔드포인트가
없습니다 — 본인확인 결과는 팝업 콜백(사용자 브라우저 경유)으로만 도달하므로 IP
화이트리스트 같은 서버간 통신 검증이 필요 없습니다.
<!-- @intent END -->
## 브로드캐스트 채널
<!-- @generated:channels START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 브로드캐스트 채널이 없습니다._
<!-- @generated:channels END -->
<!-- @intent START -->
인증 결과는 팝업의 postMessage 또는 팝업 종료 감지로 프론트가 직접 회수합니다(§AGENTS.md
"핵심 흐름") — 서버가 다른 클라이언트에 실시간으로 알려야 할 상태 변화가 없어 브로드캐스트
채널이 필요 없습니다.
<!-- @intent END -->
## 스케줄
<!-- @generated:schedules START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 스케줄이 없습니다._
<!-- @generated:schedules END -->
<!-- @intent START -->
mTxId ↔ challenge_id 매핑(`inicis_challenge_mappings`)이나 pending PII 캐시는 만료된
항목을 별도 배치로 청소하지 않습니다 — 캐시는 TTL 로 자연 소멸하고, 매핑 테이블의 정리는
사용자 삭제/탈퇴 시점 리스너가 담당합니다(§훅 리스너).
<!-- @intent END -->
## 알림 정의
<!-- @generated:notifications START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 알림 정의가 없습니다._
<!-- @generated:notifications END -->
<!-- @intent START -->
본인확인 성공/실패는 사용자가 팝업 화면에서 즉시 확인하는 동기적 상호작용이라, 별도
알림(이메일/SMS 등)을 발송할 지점이 없습니다.
<!-- @intent END -->
@@ -0,0 +1,81 @@
# KG이니시스 본인인증 — 프론트엔드
> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [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 -->
결제 PG 플러그인들과 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 설정 화면
하나뿐입니다 — 회원가입·마이페이지의 본인확인 UI는 이 플러그인 소유가 아니라
§레이아웃 확장(다른 확장/템플릿 레이아웃에 주입되는 조각) 및 코어 IDV 공통 팝업 UI로
존재합니다.
<!-- @intent END -->
## 액션 핸들러
<!-- @generated:handlers START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
핸들러 1개 (정의: `resources/js/index.ts`).
| 핸들러 | 레이아웃에서 부르는 이름 |
|---|---|
| `startAuth` | `sirsoft-verification_kginicis.startAuth` |
<!-- @generated:handlers END -->
<!-- @intent START -->
`startAuth`는 반드시 사용자 클릭 이벤트 핸들러 안에서(동기적으로) 호출돼야 합니다 —
`window.open`으로 빈 팝업을 먼저 연 뒤 그 팝업에 이니시스 인증 폼을 제출하는 방식인데,
`window.open`이 사용자 제스처 컨텍스트 밖(예: API 응답을 기다린 뒤의 비동기 콜백)에서
호출되면 Chrome 등 브라우저의 팝업 차단기에 걸립니다. 과거 `setLauncher`로 코어 IDV
런처를 덮어써 챌린지 발급 이후 시점에 팝업을 여는 방식을 썼다가 이 문제로 폐기됐습니다
(소스 주석 "Phase E′-revert").
<!-- @intent END -->
## 전역 진입점
<!-- @generated:frontend-entry START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| 엔트리 파일 | `resources/js/index.ts` |
| 전역 객체 | `window.__SirsoftVerificationKginicis` |
| 재등록 진입점 | `initPlugin()` |
로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.
<!-- @generated:frontend-entry END -->
<!-- @intent START -->
`window.__SirsoftVerificationKginicis`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로
재등록 진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). 이 플러그인은 결제 PG
플러그인들과 달리 결제창 SDK 를 동적 로드하지 않습니다 — 이니시스 인증 폼은 팝업
안에서 서버가 렌더링한 페이지로 제출되므로 프론트가 별도 스크립트를 불러올 필요가
없습니다.
<!-- @intent END -->
## 에셋
<!-- @generated:assets START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 경로 | 구분 |
|---|---|
| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) |
| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) |
로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}`
<!-- @generated:assets END -->
<!-- @intent START -->
제3자 SDK 스크립트가 목록에 없는 것은 §전역 진입점에서 설명한 대로 이 플러그인이 그런
자산을 동적 로드하지 않기 때문입니다 — 인증 폼 자체가 서버 렌더링 페이지라 프론트는
팝업을 열고 결과를 회수하는 역할만 합니다. CSS 산출물이 없는 것은 이 플러그인의 UI가
버튼·상태 카드 같은 최소한의 코어 컴포넌트로만 구성되기 때문입니다.
<!-- @intent END -->
@@ -0,0 +1,93 @@
# KG이니시스 본인인증 — 설정·권한·라우트
> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md)
## 설정 스키마
<!-- @generated:settings-schema START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| `is_test_mode` | `boolean` | `true` | 테스트 모드 |
| `test_mid` | `string` | `INIiasTest` | 테스트 MID |
| `test_api_key` | `string` | `TGdxb2l3enJDWFRTbTgvREU3MGYwUT09` | 테스트 API 키 |
| `live_mid` | `string` | - | 라이브 MID |
| `live_api_key` | `string` | - | 라이브 API 키 |
| `duplicate_field` | `enum` | `di` | 중복 판정 필드 |
| `duplicate_block_enabled` | `boolean` | `true` | 중복 가입 차단 |
기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json`
<!-- @generated:settings-schema END -->
<!-- @intent START -->
결제 PG 플러그인들의 `test_*`/`live_*` 쌍과 같은 구조이지만 필드는 단 2개(`test_mid`/
`test_api_key`, `live_mid`/`live_api_key`)뿐입니다 — 본인확인 API 는 결제와 달리 사인키·
INIAPI 키·모바일 해시키처럼 기능별로 분리된 자격증명이 필요 없습니다. `live_mid`는
`InicisIdentityProvider::buildLiveMid()`가 그 값을 요청 조립 시점에 쓸 때 `SRB` 프리픽스를
동적으로 부착하므로(DB 저장값은 원본 그대로) 운영자가 프리픽스를 직접 입력할 필요가
없습니다. `live_mid`/`live_api_key`는 `is_test_mode=false`일 때만
`ValidateInicisSettingsListener`가 `required`로 강제합니다(§AGENTS.md "핵심 흐름").
`duplicate_field`/`duplicate_block_enabled`는 `sirsoft-ecommerce`와 무관한 코어 레벨
회원가입 규칙입니다 — 이 플러그인은 이커머스에 의존하지 않습니다(§의존 관계).
<!-- @intent END -->
## 권한
<!-- @generated:permissions START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_선언된 권한이 없습니다._
<!-- @generated:permissions END -->
<!-- @intent START -->
본인인증 설정 접근 권한은 코어의 관리자 권한 체계 안에서 다뤄집니다 — IDV provider 마다
별도 권한을 선언하면 provider 를 여러 개 설치했을 때 "본인인증 설정을 볼 수 있는 사람"이
provider 수만큼 중복 정의됩니다.
<!-- @intent END -->
## 메뉴
<!-- @generated:menus START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
_등록하는 메뉴가 없습니다._
<!-- @generated:menus END -->
<!-- @intent START -->
설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해
접근합니다 — IDV provider 마다 전용 사이드바 메뉴를 만들면 provider 를 여러 개 설치했을
때 메뉴가 난립합니다.
<!-- @intent END -->
## 라우트
<!-- @generated:routes START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 파일 | URL prefix |
|---|---|---|
| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-verification_kginicis/...` |
| `web` | `src/routes/web.php` | `/plugins/sirsoft-verification_kginicis/...` |
확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.
<!-- @generated:routes END -->
<!-- @intent START -->
`web` 라우트는 CSRF 검증을 명시적으로 제외합니다(`ValidateCsrfToken` 제외 그룹) — 이니시스
결제창이 팝업 안에서 우리 서버로 폼을 직접 POST 하는데, 그 요청은 우리 CSRF 토큰을 모르기
때문입니다. `popup-bridge`는 팝업과 opener 창 사이의 postMessage 중계용 정적 페이지라
별도 인증이 필요 없습니다. `api`는 로그인 사용자가 Bearer 토큰으로 자기 본인확인 상태를
조회하는 마이페이지 엔드포인트(`GET /me/identity/inicis`)입니다 — 챌린지 시작 자체는
코어 `/api/identity/challenges`가 담당하므로 이 플러그인의 `api` 라우트에는 없습니다.
<!-- @intent END -->
## 의존 관계
<!-- @generated:dependencies START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**이 확장이 의존하는 확장**
없음 — 코어만으로 동작합니다.
**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)
없음.
<!-- @generated:dependencies END -->
<!-- @intent START -->
결제 PG 플러그인들과 달리 이 플러그인은 `sirsoft-ecommerce`에 의존하지 않습니다 — 본인확인은
결제와 무관하게 회원가입·비밀번호 찾기 등 코어 인증 흐름 전반에 쓰이는 기능이라, 이커머스가
설치되지 않은 사이트(게시판만 운영하는 등)에서도 단독으로 동작해야 합니다.
<!-- @intent END -->
@@ -0,0 +1,189 @@
# NHN KCP 휴대폰 본인확인 — 에이전트 가이드
> 이 문서는 이 플러그인을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요.
## TL;DR (5초 요약)
```text
1. 유형: 플러그인 (sirsoft-verification_nhnkcp) — NHN KCP 휴대폰 본인확인 IDV Provider. 코어 `IdentityVerificationInterface` 구현, PII 레코드 소유 (`sirsoft-verification_kginicis`와 같은 부류·다른 벤더)
2. 확장 방식: `RegisterKcpProviderListener` 로 코어 `core.identity.registered_providers` 필터에 등록 — 코어는 이 플러그인의 존재를 모른다
3. 건드리면 안 되는 것: 데스크톱 팝업/모바일 리다이렉트 분기(`isMobileEnvironment()`) 우회, 라이브 사이트코드 `SM` 프리픽스 정책값 상수(`LIVE_SITE_CD_PREFIX`) 미참조, 중복가입 차단(`AssertNoDuplicateKcpIdentity`) 우회
4. 작업 위치: `plugins/_bundled/sirsoft-verification_nhnkcp` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan plugin:update sirsoft-verification_nhnkcp --force`
```
## 1. 이 확장은 무엇인가
<!-- @intent START -->
NHN KCP 휴대폰 본인확인을 코어 본인인증(IDV) 체계에 연결하는 Provider 입니다.
`sirsoft-verification_kginicis`와 같은 부류(코어 `IdentityVerificationInterface` 12메서드
구현)지만 다른 벤더이며, 운영자는 둘 중 하나 또는 둘 다 설치해 사용할 수 있습니다 —
코어는 등록된 provider ID로만 구분합니다.
**이 플러그인만의 차이**: 인증 화면 진입 방식이 기기별로 갈립니다 — 데스크톱은
`sirsoft-verification_kginicis`와 동일하게 사용자 클릭 컨텍스트 안에서 빈 팝업을 열고
그 안에서 인증을 진행하지만, 모바일은 팝업 대신 **전체 페이지 리다이렉트**로 KCP 인증
화면으로 이동한 뒤 콜백으로 복귀합니다(`isMobileEnvironment()` 분기) — 모바일 브라우저는
팝업 UX 가 나쁘고 앱 전환(문자 인증 등)이 얽히면 팝업 컨텍스트 자체가 끊기기 쉽기
때문입니다. 리다이렉트 복귀 시 원래 상태를 되살리기 위해 `sessionStorage`에
`g7.identity.redirectStash` 키로 복귀 정보를 임시 저장합니다.
**설계 원칙**: 이 플러그인도 실제 PII 를 소유합니다(§data-model.md — `kginicis`와 같은
구조: 완료된 확인 결과 레코드 + 진행 중인 인증 거래 매핑을 별도 테이블로 분리).
**의도적으로 하지 않는 것**: `kginicis`와 동일하게, 비로그인 사용자의 PII 는 확인 즉시
DB 에 쓰지 않고 임시 보관했다가 가입 완료 시에만 레코드로 흡수합니다. 사용자 탈퇴/삭제
시 관련 PII 를 자동 정리합니다.
<!-- @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/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) |
| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 |
| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 |
| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 |
| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_nhnkcp --force` (빌드 불필요) |
| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) |
| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 |
| `tests/` | 테스트 | 변경 범위만 필터 실행 |
| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_nhnkcp --force` |
| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 |
| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
<!-- @generated:directory-map END -->
## 3. 핵심 흐름
<!-- @intent START -->
**본인확인 시작~완료(데스크톱)**: 코어 428 응답 → `startAuth`가 사용자 클릭 컨텍스트
안에서 `window.open`으로 빈 팝업 생성 → KCP 인증 폼 제출 → 인증 완료 후
`KcpCertTransactionRepository`가 거래 매핑을 저장 → 팝업 종료 감지로 결과 회수 →
`KcpIdentityProvider::verify()`가 결과를 반환. 성인인증(`nhnkcp.adult_verification`
purpose)으로 발행된 challenge 는 만 19세 이상만 통과시킵니다.
**본인확인 시작~완료(모바일)**: `isMobileEnvironment()`가 참이면 팝업 대신 전체 페이지
리다이렉트로 KCP 인증 화면으로 이동 → 복귀 정보를 `sessionStorage`(`g7.identity.redirectStash`)에
저장 → 인증 완료 후 콜백 URL로 복귀 → 저장된 stash 로 원래 challenge 컨텍스트를 복원.
**비로그인 사용자 처리 / 중복가입 차단 / 사용자 삭제·탈퇴 시 정리**:
`sirsoft-verification_kginicis`와 동일한 3단계 패턴입니다 — `verify()` 시 Cache
stash(`nhnkcp:pending_record:`) → `core.auth.after_register`에서 흡수,
`core.auth.before_register`에서 `AssertNoDuplicateKcpIdentity`가 중복 차단,
`core.user.before_delete`/`after_withdraw`에서 PII 정리.
**설정 저장 시 검증 + 라이브 사이트코드 정규화**: 관리자가 설정 저장 요청 → FormRequest
검증 단계에서 `core.plugin_settings.update_validation_rules` 훅 →
`ValidateKcpSettingsListener::addLiveModeRules()`가 라이브 모드일 때 `live_site_cd`/
`live_enc_key`를 필수로 강제(값이 비어있지 않은지만 확인 — 아직 프리픽스는 보지 않음) →
검증 통과 후 `PluginSettingsService`가 실제 저장하는 단계에서
`core.plugin_settings.filter_save_data` 훅 → 같은 리스너의 `normalizeLiveSiteCd()`가
`live_site_cd`에 `SM` 프리픽스가 없으면 자동으로 붙여 저장. 두 훅이 분리된 이유는 코어의
FormRequest 검증과 Service 저장이 서로 다른 파이프라인 단계이기 때문입니다 — "필수값이
비어있지 않은가"는 검증 단계에서, "저장될 값의 형식을 교정"은 저장 단계에서 처리합니다.
<!-- @intent END -->
## 4. 확장점
<!-- @generated:extension-points-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 확장점 | 수 | 상세 |
|---|---|---|
| 발행 훅 | 0개 | [발행 훅](docs/extension-points.md#발행-훅) |
| 구독 훅 | 7개 | [구독 훅](docs/extension-points.md#구독-훅) |
| 훅 리스너 | 6개 | [훅 리스너](docs/extension-points.md#훅-리스너) |
| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) |
| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) |
| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) |
| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) |
| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) |
<!-- @generated:extension-points-summary END -->
<!-- @intent START -->
발행 훅이 0개인 것은 `sirsoft-verification_kginicis`와의 실제 차이입니다 — kginicis 쪽
3건은 리스너 docblock 의 코어 호출 예시 주석을 소스 자동 감지가 오인한 결과였는데(§data-model.md
계열의 동일 패턴), 이 플러그인의 리스너 docblock 은 그런 예시 서술 방식을 쓰지 않아
오탐이 없습니다. `core.identity.registered_providers`는 코어가 등록된 IDV provider 목록을
모으는 필터 훅입니다.
<!-- @intent END -->
## 5. 수정 시 동반 의무
- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-verification_nhnkcp --force` 로 반영
- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재
- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝
- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-verification_nhnkcp` 재실행 + `docs/api/**` 갱신
- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)
- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- [ ] PII 컬럼을 다루는 코드 변경 시 GDPR 삭제/탈퇴 정리 리스너(`CleanKcpRecordOnUserDelete`/`CleanKcpRecordOnUserWithdraw`)가 여전히 그 컬럼을 정리하는지 확인
- [ ] 데스크톱/모바일 분기(`isMobileEnvironment()`)를 고칠 때 양쪽 복귀 경로(팝업 종료 감지 / `redirectStash` 복원)를 함께 테스트
- [ ] `duplicate_field`/`duplicate_block_enabled` 로직을 고치면 `KcpDuplicateField` Enum 과 `AssertNoDuplicateKcpIdentity`를 함께 갱신
- [ ] `normalizeLiveSiteCd()`/`addLiveModeRules()`를 고칠 때 두 훅(`filter_save_data`/`update_validation_rules`)의 실행 순서(검증 먼저, 정규화는 저장 시점)를 유지
## 6. 금지 패턴
<!-- @intent START -->
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
| 비로그인 사용자의 PII 를 verify 즉시 DB 에 저장 | Cache 에 stash(`nhnkcp:pending_record:` 접두) 후 가입 완료 시 흡수 | 가입을 완료하지 않은 방문자의 PII 를 DB 에 영구 저장하면 불필요한 개인정보 보유가 된다 |
| 사용자 삭제/탈퇴 리스너 없이 PII 컬럼 추가 | `CleanKcpRecordOnUserDelete`/`CleanKcpRecordOnUserWithdraw`에 정리 로직 동반 | 정리 누락 시 탈퇴한 사용자의 PII 가 무기한 남는다 |
| `LIVE_SITE_CD_PREFIX` 상수를 참조하지 않고 `'SM'`을 문자열로 재작성 | `KcpIdentityProvider::LIVE_SITE_CD_PREFIX` 참조 | KCP 사이트코드 정책이 바뀌면 상수 1곳만 갱신해야 런타임 로직 전체에 반영된다 |
| 모바일 리다이렉트 복귀 시 `redirectStash`를 검증 없이 신뢰 | 복귀 정보의 challenge 컨텍스트를 서버측 상태와 대조 후 사용 | `sessionStorage`는 클라이언트가 임의로 조작할 수 있는 저장소다 |
| 팝업을 사용자 클릭 이벤트 핸들러 밖에서 `window.open` | 사용자 제스처 컨텍스트 안에서 직접 호출 (데스크톱 경로) | Chrome 등 브라우저는 사용자 제스처 없이 열리는 팝업을 자동 차단한다 |
| 라이브 암호화 키를 로그·에러 메시지에 노출 | 운영 키는 항상 마스킹하거나 로그 대상에서 제외 | 노출되면 제3자가 본인확인 API 를 위조 호출할 수 있다 |
<!-- @intent END -->
## 7. 테스트 실행
<!-- @generated:test-commands START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 20개 | `plugins/_bundled/sirsoft-verification_nhnkcp/tests` |
| Vitest | 7개 | `vitest.config.ts` |
| Playwright | 2개 | `tests/Playwright` |
| 시나리오 매니페스트 | 14개 | `tests/scenarios` |
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
```bash
# PHPUnit (변경 범위만) (Bash)
php vendor/bin/phpunit plugins/_bundled/sirsoft-verification_nhnkcp/tests --filter='<대상클래스>'
# Vitest (확장 디렉토리에서) (PowerShell)
cd plugins/_bundled/sirsoft-verification_nhnkcp && powershell -Command "npm run test:run -- <대상>"
# Playwright E2E (Bash)
npx playwright test plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/<대상>.spec.ts
```
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
<!-- @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 -->
@@ -0,0 +1,186 @@
# NHN KCP 휴대폰 본인확인
**G7 플러그인 · sirsoft-verification_nhnkcp**
NHN KCP 휴대폰 본인확인(V2 REST)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인
<!-- @generated:badges START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.1-0066FF?style=flat-square" alt="version 1.0.1">
<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.6-1F883D?style=flat-square" alt="G7 &gt;=7.0.6">
<img src="https://img.shields.io/badge/license-MIT-8250DF?style=flat-square" alt="license MIT">
</p>
<!-- @generated:badges END -->
---
[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스)
---
## 소개
<!-- @intent START -->
NHN KCP 휴대폰 본인확인을 G7 코어의 본인인증(IDV) 체계에 연결하는 플러그인입니다.
`sirsoft-verification_kginicis`와 같은 부류(코어 표준 인터페이스 구현)지만 다른 벤더이며,
운영자는 둘 중 하나 또는 둘 다 설치해 사용할 수 있습니다.
이 플러그인만의 특징은 인증 화면 진입 방식이 기기별로 갈린다는 점입니다 — 데스크톱은
팝업, 모바일은 전체 페이지 리다이렉트를 씁니다. 모바일 브라우저는 팝업 UX 가 나쁘고
문자 인증 같은 앱 전환이 얽히면 팝업 컨텍스트가 끊기기 쉽기 때문입니다.
kginicis 와 마찬가지로 이 플러그인은 실제 개인식별정보(PII)를 직접 보관하며, 결제 PG
플러그인들과 달리 `sirsoft-ecommerce`를 비롯한 어떤 확장에도 의존하지 않습니다.
<!-- @intent END -->
## 주요 기능
<!-- @intent START -->
| 영역 | 설명 |
|---|---|
| 본인확인 | NHN KCP 휴대폰 본인확인 팝업(데스크톱)/리다이렉트(모바일) |
| 성인인증 | 만 19세 이상 여부만 확인하는 별도 purpose |
| 중복가입 차단 | DI 또는 CI 기준으로 동일인 재가입 차단 (선택) |
| 게스트 처리 | 비로그인 사용자의 본인확인 결과를 임시 보관 후 가입 완료 시 흡수 |
| 개인정보 정리 | 사용자 탈퇴/삭제 시 보관 중인 PII 레코드 자동 파기 |
| 마이페이지 | 본인확인 완료 상태 카드 노출 |
| 관리자 설정 | 테스트/라이브 모드 전환, 중복가입 판정 기준 설정 |
<!-- @intent END -->
## 동작 방식
<!-- @intent START -->
```mermaid
flowchart LR
A[코어가 IDV 요구 · 428] --> B{기기 판별}
B -->|데스크톱| C[사용자 클릭 → 팝업 오픈]
B -->|모바일| D[복귀 정보 저장 → 페이지 리다이렉트]
C --> E[KCP 인증 완료 → 팝업 종료 감지]
D --> F[KCP 인증 완료 → 콜백 복귀]
E --> G[결과 회수 → PII 확보]
F --> G
G -->|로그인 사용자| H[레코드 즉시 저장]
G -->|비로그인 사용자| I[Cache 임시 보관 → 가입 완료 시 흡수]
```
데스크톱 팝업은 반드시 사용자 클릭 이벤트 안에서 열립니다 — 비동기 콜백 이후에 열면
브라우저 팝업 차단기에 걸립니다. 모바일 리다이렉트는 이 제약이 없는 대신, 복귀 후
원래 상태를 되살리기 위한 정보를 브라우저에 임시 저장합니다.
<!-- @intent END -->
## 요구 사항
<!-- @generated:requirements START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 항목 | 값 |
|---|---|
| G7 코어 | `>=7.0.6` |
| PHP | `^8.2` |
<!-- @generated:requirements END -->
## 설치
<!-- @generated:install START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
```bash
# 번들 설치 (코어에 동봉된 소스에서 설치)
php artisan plugin:install sirsoft-verification_nhnkcp
# 활성화
php artisan plugin:activate sirsoft-verification_nhnkcp
# 업데이트 (번들 소스 기준 강제 반영)
php artisan plugin:update sirsoft-verification_nhnkcp --force
```
저장소: https://github.com/gnuboard/g7-plugin-sirsoft-verification_nhnkcp
<!-- @generated:install END -->
## 관리자 설정
<!-- @generated:settings-summary START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 키 | 의미 | 기본값 |
|---|---|---|
| `is_test_mode` | 테스트 모드 | `true` |
| `test_site_cd` | 테스트 사이트코드 | `AO7F3` |
| `test_enc_key` | 테스트 암호화 키 | `c2a22fa3ebe4698075bcac6b433d52e351c881b02fb83488d4283a43385b1f8e` |
| `live_site_cd` | 운영 사이트코드 | - |
| `live_enc_key` | 운영 암호화 키 | - |
| `web_siteid` | 웹사이트 ID | - |
| `duplicate_field` | 중복 판정 필드 | `di` |
| `duplicate_block_enabled` | 중복 가입 차단 | `true` |
개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요.
<!-- @generated:settings-summary END -->
<!-- @intent START -->
`live_site_cd`는 저장 시 `SM` 프리픽스가 자동으로 붙으므로 프리픽스 없이 입력해도 됩니다.
`live_site_cd`/`live_enc_key`는 `is_test_mode`를 끄는(라이브 모드) 순간부터 필수가
됩니다 — 테스트 모드에서는 비워둘 수 있습니다. `web_siteid`는 테스트/라이브 모드 공통으로
쓰이는 웹사이트 식별자입니다. `duplicate_field`(`di` 또는 `ci`)와
`duplicate_block_enabled`는 다른 IDV provider(예: `sirsoft-verification_kginicis`)와
별개로 이 provider 를 통해 확인한 사용자에게만 적용됩니다. 라이브 암호화 키는 외부에
노출하지 마세요.
<!-- @intent END -->
## 사용 방법
<!-- @intent START -->
**활성화하기**: 플러그인을 활성화하면 자동으로 코어 IDV provider 목록에 등록됩니다.
별도 화면 배치 작업 없이 코어가 이미 정의한 IDV 강제 지점(회원가입 등)에서 즉시
동작합니다.
**중복가입 차단 켜기**: 동일인이 여러 계정을 만드는 것을 막고 싶다면
`duplicate_block_enabled`를 켜고 `duplicate_field`로 DI/CI 중 판정 기준을 고릅니다.
**모바일 동작 확인**: 데스크톱에서는 정상인데 모바일에서만 인증이 실패한다면 리다이렉트
복귀 경로(`redirectStash`)가 원인일 가능성이 높습니다 — §트러블슈팅을 확인하세요.
전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은
[docs/extension-points.md](docs/extension-points.md) 를 참고하세요.
<!-- @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 -->
| 증상 | 원인 | 조치 |
|---|---|---|
| 데스크톱에서 인증 버튼을 눌러도 팝업이 안 뜸 | 브라우저 팝업 차단기 | `startAuth`가 클릭 이벤트 핸들러 안에서 직접 호출되는지 확인 |
| 모바일에서만 인증 완료 후 원래 화면으로 돌아오지 않음 | 리다이렉트 복귀 정보(`sessionStorage`의 `g7.identity.redirectStash`)가 유실됨 | 리다이렉트 도중 다른 탭/앱으로 완전히 전환되어 세션 스토리지가 초기화됐는지 확인 (동일 브라우저 탭 안에서 왕복해야 함) |
| 설정 저장 시 422 오류 | 라이브 모드인데 `live_site_cd`/`live_enc_key` 미입력 | `is_test_mode`를 켜거나 라이브 자격증명을 입력 |
| 이미 가입된 사용자인데 중복 오류 없이 재가입됨 | `duplicate_block_enabled`가 꺼져 있거나 `duplicate_field` 기준이 실제 판정과 다름 | 관리자 설정에서 두 값을 확인 |
| 탈퇴한 사용자의 본인확인 정보가 남아있는 것으로 보임 | 정리 리스너 실행 여부를 별도로 확인하지 않음 | `CleanKcpRecordOnUserWithdraw`/`CleanKcpRecordOnUserDelete` 정상 등록 여부를 훅 캐시에서 확인 |
<!-- @intent END -->
## 변경 이력
[CHANGELOG.md](CHANGELOG.md)
## 라이선스
MIT
@@ -0,0 +1,22 @@
# NHN KCP 휴대폰 본인확인 개발자 문서
> plugins/_bundled/sirsoft-verification_nhnkcp · 플러그인
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
**훅 수**: 0 · **구독 훅 수**: 7 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 2 · **레이아웃 수**: 1 · **핸들러 수**: 1
<!-- @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 -->

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