diff --git a/AGENTS.md b/AGENTS.md index b1135ab7..7ccefc71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ | [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... | | [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) | -### 프론트엔드 [frontend/](docs/frontend/) (50개) +### 프론트엔드 [frontend/](docs/frontend/) (47개) | 문서 | 설명 | TL;DR 핵심 | |------|------|-----------| @@ -95,9 +95,6 @@ | [tailwind-safelist.md](docs/frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | Tailwind는 빌드 시 사용된 클래스만 CSS에 포함 | | [template-development.md](docs/frontend/template-development.md) | 템플릿 개발 가이드라인 | 디렉토리: templates/[vendor-template]/ (예: sirsoft-admin_basic) | | [template-handlers.md](docs/frontend/template-handlers.md) | 템플릿 전용 핸들러 | setLocale: 앱 언어 변경 — 엔진 빌트인 (ActionDispatcher) | -| [components.md](docs/frontend/templates/sirsoft-admin_basic/components.md) | sirsoft-admin_basic 컴포넌트 | Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... | -| [handlers.md](docs/frontend/templates/sirsoft-admin_basic/handlers.md) | sirsoft-admin_basic 핸들러 | setLocale: 앱 언어 변경 (locale 파라미터) | -| [layouts.md](docs/frontend/templates/sirsoft-admin_basic/layouts.md) | sirsoft-admin_basic 레이아웃 | 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯) | | [components.md](docs/frontend/templates/sirsoft-basic/components.md) | sirsoft-basic 컴포넌트 | Basic 26개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... | | [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) | | [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) | @@ -180,6 +177,23 @@ | `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 | + + --- diff --git a/README.ko.md b/README.ko.md index 70098fc3..28e085cf 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,13 +1,9 @@

English | 한국어

-

- 그누보드7 (Gnuboard7) -

+# 그누보드7 -

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

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

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

English | 한국어

-

- Gnuboard7 (그누보드7) -

+# Gnuboard7 -

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

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

Version diff --git a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php index f0c6b9ec..b84d3e2f 100644 --- a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php +++ b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php @@ -149,6 +149,11 @@ class ExtensionDocScaffolder 'blocks' => [ '레이아웃 목록' => 'layouts', '라우트 매핑' => 'layout-map', + // 템플릿의 `extensions/{module-identifier}/*.json` 은 그 모듈/플러그인이 + // 발행한 레이아웃 확장 조각을 이 템플릿이 오버라이드한 것이다(모듈/플러그인 + // 쪽 `resources/extensions/` 와는 반대 방향 — 발행이 아니라 대체). 모듈/플러그인 + // 문서의 `docs/extension-points.md` 는 템플릿에 존재하지 않아 자리가 없었다. + '확장 오버라이드' => 'template-overrides', ], ], 'docs/handlers.md' => [ @@ -538,6 +543,7 @@ class ExtensionDocScaffolder 'hooks-subscribed' => $this->renderHooksSubscribed($ctx), 'listeners' => $this->renderListeners($ctx), 'layout-extensions' => $this->renderLayoutExtensions($ctx), + 'template-overrides' => $this->renderLayoutExtensions($ctx), 'middleware' => $this->renderMiddleware($ctx), 'channels' => $this->renderChannels($ctx), 'schedules' => $this->renderSchedules($ctx), @@ -963,6 +969,7 @@ class ExtensionDocScaffolder ['제공 컴포넌트', (string) $frontend['components']['total'].'개', $this->docLink($ctx, 'docs/components.md', '제공 컴포넌트')], ['레이아웃', (string) count($frontend['layouts']).'개', $this->docLink($ctx, 'docs/layouts.md', '레이아웃 목록')], ['전용 핸들러', (string) count($frontend['handlers']['names']).'개', $this->docLink($ctx, 'docs/handlers.md', '템플릿 전용 핸들러')], + ['확장 오버라이드', (string) count($frontend['layoutExtensions']).'개', $this->docLink($ctx, 'docs/layouts.md', '확장 오버라이드')], ]; } @@ -1250,23 +1257,37 @@ class ExtensionDocScaffolder { $files = $ctx['frontend']['layoutExtensions']; $declared = $ctx['surface']['values']['getLayoutExtensions'] ?? []; + // 템플릿의 `extensions/{module-identifier}/*.json` 은 그 모듈/플러그인이 발행한 + // 조각을 이 템플릿이 대체하는 것이지, 모듈/플러그인처럼 밖으로 발행하는 것이 아니다. + $isTemplate = ($ctx['record']['type'] ?? null) === ExtensionInventory::TYPE_TEMPLATE; if ($files === [] && ! is_array($declared)) { - return $this->none('레이아웃 확장이 없습니다.'); + return $this->none($isTemplate ? '오버라이드하는 레이아웃 확장 조각이 없습니다.' : '레이아웃 확장이 없습니다.'); } if ($files === [] && $declared === []) { - return $this->none('레이아웃 확장이 없습니다.'); + return $this->none($isTemplate ? '오버라이드하는 레이아웃 확장 조각이 없습니다.' : '레이아웃 확장이 없습니다.'); } + $known = array_flip($files); + $rows = []; foreach ($files as $file) { - $rows[] = [$this->code($file), '다른 확장/템플릿 레이아웃에 주입되는 조각']; + $rows[] = [$this->code($file), $isTemplate ? '모듈/플러그인 확장 조각을 대체하는 오버라이드' : '다른 확장/템플릿 레이아웃에 주입되는 조각']; } + // `getLayoutExtensions()` 기본 구현(AbstractModule/AbstractPlugin)은 위 파일 목록과 + // 같은 디렉토리를 glob() 한 절대경로라 100% 중복이다. 확장-상대 경로로 정규화한 뒤 + // 이미 실린 파일은 건너뛰고, 확장이 오버라이드해 다른 대상을 선언한 경우만 싣는다. if (is_array($declared)) { 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 = []; 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; $rows[] = [ @@ -1590,7 +1615,7 @@ class ExtensionDocScaffolder $extra[] = '기본값 파일: '.$this->code($this->relativeToExtension($ctx, $defaultsPath)); } if (is_string($layout) && $layout !== '') { - $extra[] = '설정 화면 레이아웃: '.$this->code($layout); + $extra[] = '설정 화면 레이아웃: '.$this->code($this->relativeToExtension($ctx, $layout)); } if ($extra !== []) { @@ -1624,7 +1649,7 @@ class ExtensionDocScaffolder $layout = $ctx['surface']['values']['getSettingsLayout'] ?? null; if (is_string($layout) && $layout !== '') { - return '관리자 설정 화면이 있습니다 (레이아웃: '.$this->code($layout).'). 설정 항목은 화면에서 확인하세요.' + return '관리자 설정 화면이 있습니다 (레이아웃: '.$this->code($this->relativeToExtension($ctx, $layout)).'). 설정 항목은 화면에서 확인하세요.' .(is_string($route) && $route !== '' ? ' 경로: '.$this->code($route) : ''); } diff --git a/docs/README.md b/docs/README.md index 26b47788..afc294e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,7 @@ | 카테고리 | 문서 수 | 링크 상태 | |----------|---------|----------| | [백엔드](backend/) | 37개 | 정상 | -| [프론트엔드](frontend/) | 51개 | 정상 | +| [프론트엔드](frontend/) | 49개 | 정상 | | [확장 시스템](extension/) | 32개 | 정상 | | 공통 | 20개 | 정상 | | [AI 도구](ai-tools/) | - | 정상 | @@ -43,7 +43,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 | 4 | [레이아웃 JSON - 상속](frontend/layout-json-inheritance.md) | extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입) | | 5 | [컴포넌트 개발 규칙](frontend/components.md) | HTML 태그 직접 사용 금지 | | 6 | [컴포넌트 Props 레퍼런스](frontend/component-props.md) | - | -| 7 | [sirsoft-admin_basic 컴포넌트](frontend/templates/sirsoft-admin_basic/components.md) | Basic (37개), Composite (66개), Layout (8개) | +| 7 | [sirsoft-basic 컴포넌트](frontend/templates/sirsoft-basic/components.md) | Basic (26개), Composite 등 | | 8 | [데이터 바인딩 및 표현식](frontend/data-binding.md) | API 데이터: {{user.name}}, URL 파라미터: {{route.id}} | | 9 | [데이터 바인딩 - 다국어 처리](frontend/data-binding-i18n.md) | - | | 10 | [액션 핸들러 가이드](frontend/actions.md) | 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션) | @@ -52,6 +52,8 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 | 13 | [데이터 소스](frontend/data-sources.md) | data_sources 배열에 API 정의: id, endpoint, method | | 14 | [다크 모드 지원](frontend/dark-mode.md) | Tailwind dark: variant 사용 | +sirsoft-admin_basic 컴포넌트 문서는 확장이 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/components.md](../templates/_bundled/sirsoft-admin_basic/docs/components.md) 를 참고하세요. + ### 컨트롤러 작성 | 순서 | 문서 | TL;DR 핵심 | @@ -167,7 +169,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 | [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | | [validation.md](backend/validation.md) | 검증 (Validation) | -### 프론트엔드 (51개) +### 프론트엔드 (49개) | 문서 | 제목 | |------|------| @@ -216,9 +218,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 | [tailwind-safelist.md](frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | | [template-development.md](frontend/template-development.md) | 템플릿 개발 가이드라인 | | [template-handlers.md](frontend/template-handlers.md) | 템플릿 전용 핸들러 | -| [components.md](frontend/components.md) | sirsoft-admin_basic 컴포넌트 | -| [handlers.md](frontend/handlers.md) | sirsoft-admin_basic 핸들러 | -| [layouts.md](frontend/layouts.md) | sirsoft-admin_basic 레이아웃 | +| [README.md](frontend/README.md) | 템플릿별 컴포넌트·핸들러·레이아웃 문서 | | [components.md](frontend/components.md) | sirsoft-basic 컴포넌트 | | [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 | | [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 | diff --git a/docs/ai-tools/agents/src/prompts/template.ts b/docs/ai-tools/agents/src/prompts/template.ts index 0907cb93..8ff98cd4 100644 --- a/docs/ai-tools/agents/src/prompts/template.ts +++ b/docs/ai-tools/agents/src/prompts/template.ts @@ -173,7 +173,7 @@ global.window = { - docs/frontend/template-development.md - docs/extension/template-basics.md - docs/extension/template-commands.md -- docs/frontend/templates/sirsoft-admin_basic/components.md +- templates/_bundled/sirsoft-admin_basic/docs/components.md - docs/frontend/templates/sirsoft-basic/components.md ## 테스트 실행 diff --git a/docs/frontend/README.md b/docs/frontend/README.md index 75f4fba0..c84822c6 100644 --- a/docs/frontend/README.md +++ b/docs/frontend/README.md @@ -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) | ### 컴포넌트 개발 diff --git a/docs/frontend/component-props-composite.md b/docs/frontend/component-props-composite.md index 888e855a..c77d3056 100644 --- a/docs/frontend/component-props-composite.md +++ b/docs/frontend/component-props-composite.md @@ -1,6 +1,6 @@ # 컴포넌트 Props 레퍼런스 - Composite -> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) +> **관련 문서**: [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) | [컴포넌트 개발 규칙](components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) --- @@ -671,7 +671,7 @@ G7에서는 콘텐츠의 렌더링 모드를 DB의 `*_mode` 컬럼(`'text'` / `' - [컴포넌트 Props (Basic/DataGrid/Modal)](component-props.md) - [컴포넌트 개발 규칙](components.md) - [컴포넌트 고급 기능](components-advanced.md) -- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) +- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - [액션 핸들러 - 커스텀 콜백](actions.md#커스텀-이벤트-event-필드) - [보안 가이드 - HTML 렌더링](security.md#htmlcontent--htmleditor-html-렌더링이-필요한-경우) - [에디터 컴포넌트](editors.md) diff --git a/docs/frontend/component-props.md b/docs/frontend/component-props.md index ab9c010d..15005a30 100644 --- a/docs/frontend/component-props.md +++ b/docs/frontend/component-props.md @@ -1144,7 +1144,7 @@ id prop 사용: scrollIntoView 등 DOM selector로 접근해야 할 때 필수 - [컴포넌트 개발 규칙](components.md) - basic, composite, layout 컴포넌트 - [레이아웃 JSON 스키마](layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법 -- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 (111개) +- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록 - [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) - User 컴포넌트 목록 (58개) - [에디터 컴포넌트](editors.md) - HtmlEditor, CodeEditor 상세 가이드 - [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어 diff --git a/docs/frontend/components-types.md b/docs/frontend/components-types.md index 961daf02..fc3bd2d0 100644 --- a/docs/frontend/components-types.md +++ b/docs/frontend/components-types.md @@ -1,7 +1,7 @@ # 컴포넌트 타입별 개발 규칙 > **메인 문서**: [components.md](components.md) -> **관련 문서**: [layout-json-components.md](layout-json-components.md) | [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) +> **관련 문서**: [layout-json-components.md](layout-json-components.md) | [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) --- @@ -452,5 +452,5 @@ export const Container: React.FC = ({ children }) => ( - [컴포넌트 개발 규칙 인덱스](components.md) - [컴포넌트 패턴](components-patterns.md) - [컴포넌트 고급 기능](components-advanced.md) -- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) +- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - [sirsoft-basic 컴포넌트](templates/sirsoft-basic/components.md) diff --git a/docs/frontend/components.md b/docs/frontend/components.md index ad643981..bace9ebb 100644 --- a/docs/frontend/components.md +++ b/docs/frontend/components.md @@ -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-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 레퍼런스 - [레이아웃 JSON 스키마](./layout-json.md) - 컴포넌트를 레이아웃 JSON에서 사용하는 방법 - [데이터 바인딩](./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개) - [다크 모드](./dark-mode.md) - 컴포넌트 다크 모드 지원 가이드 - [상태 관리](./state-management.md) - 전역/로컬 상태 관리 및 동기화 패턴 diff --git a/docs/frontend/dark-mode.md b/docs/frontend/dark-mode.md index 6b6b3cf0..a6237de1 100644 --- a/docs/frontend/dark-mode.md +++ b/docs/frontend/dark-mode.md @@ -25,7 +25,7 @@ | `sirsoft-admin_basic` | 미선언 | ThemeToggle 존재, dark: variant 동작 | | `sirsoft-basic` | `true` | template.json에 선언, 완전 지원 | -> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md) +> 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md) --- diff --git a/docs/frontend/editors.md b/docs/frontend/editors.md index 82eaea03..d31a14a9 100644 --- a/docs/frontend/editors.md +++ b/docs/frontend/editors.md @@ -366,7 +366,7 @@ HtmlEditor는 내부적으로 **DOMPurify**를 사용하여 HTML을 정화합니 ## 관련 문서 - [컴포넌트 Props 레퍼런스](component-props.md) - Select, Input, Button Props -- [sirsoft-admin_basic 컴포넌트](templates/sirsoft-admin_basic/components.md) - Admin 컴포넌트 목록 +- [sirsoft-admin_basic 컴포넌트](../../templates/_bundled/sirsoft-admin_basic/docs/components.md) - Admin 컴포넌트 목록 - [레이아웃 JSON](layout-json.md) - 레이아웃 JSON 스키마 - [데이터 바인딩](data-binding.md) - {{}} 표현식, $t: 다국어 - [상태 관리](state-management.md) - _local, setState diff --git a/docs/frontend/responsive-layout.md b/docs/frontend/responsive-layout.md index bbccfe3c..bfb6a302 100644 --- a/docs/frontend/responsive-layout.md +++ b/docs/frontend/responsive-layout.md @@ -23,7 +23,7 @@ | `sirsoft-admin_basic` | 미선언 | 데스크톱 중심, Tailwind responsive 클래스는 동작 | | `sirsoft-basic` | `true` | MobileNav 포함, portable preset 지원 | -> 상세 컴포넌트 목록: [sirsoft-admin_basic](templates/sirsoft-admin_basic/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md) +> 상세 컴포넌트 목록: [sirsoft-admin_basic](../../templates/_bundled/sirsoft-admin_basic/docs/components.md), [sirsoft-basic](templates/sirsoft-basic/components.md) --- diff --git a/docs/frontend/template-handlers.md b/docs/frontend/template-handlers.md index f3a0c278..b6e9e466 100644 --- a/docs/frontend/template-handlers.md +++ b/docs/frontend/template-handlers.md @@ -22,11 +22,13 @@ | 템플릿 식별자 | 핸들러 문서 | TL;DR 핵심 | |--------------|-----------|----------| -| `sirsoft-admin_basic` | [handlers.md](templates/sirsoft-admin_basic/handlers.md) | setLocale: 앱 언어 변경 (locale 파라미터) | | `sirsoft-basic` | [handlers.md](templates/sirsoft-basic/handlers.md) | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) | +> `sirsoft-admin_basic` 은 문서를 그 템플릿이 직접 소유합니다 — [templates/_bundled/sirsoft-admin_basic/docs/](../../templates/_bundled/sirsoft-admin_basic/docs/README.md). +> 템플릿별 문서 위치는 [templates/README.md](templates/README.md) 를 따릅니다. + --- ## 엔진 빌트인 핸들러 @@ -55,5 +57,5 @@ - [액션 핸들러 개요](actions-handlers.md) - [템플릿 개발 가이드](template-development.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) diff --git a/docs/frontend/templates/README.md b/docs/frontend/templates/README.md new file mode 100644 index 00000000..b46d7ef4 --- /dev/null +++ b/docs/frontend/templates/README.md @@ -0,0 +1,13 @@ +# 템플릿별 컴포넌트·핸들러·레이아웃 문서 + +번들 템플릿의 컴포넌트/핸들러/레이아웃 상세 문서는 그 템플릿이 소유합니다(#601). 어느 +템플릿이 어디에 문서를 갖는지는 아래 표를 따릅니다 — 이관이 끝난 템플릿은 코어에 사본을 +남기지 않습니다. + +| 템플릿 | 상태 | 문서 위치 | +|---|---|---| +| `sirsoft-admin_basic` | 이관 완료 | [templates/_bundled/sirsoft-admin_basic/docs/](../../../templates/_bundled/sirsoft-admin_basic/docs/README.md) | +| `sirsoft-basic` | 이관 대기 (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) +를 참고하세요. diff --git a/docs/frontend/templates/sirsoft-admin_basic/handlers.md b/docs/frontend/templates/sirsoft-admin_basic/handlers.md deleted file mode 100644 index a9aa7d89..00000000 --- a/docs/frontend/templates/sirsoft-admin_basic/handlers.md +++ /dev/null @@ -1,445 +0,0 @@ -# sirsoft-admin_basic 핸들러 - -> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin) -> **관련 문서**: [액션 핸들러 개요](../../actions-handlers.md) | [컴포넌트](./components.md) | [레이아웃](./layouts.md) - ---- - -## TL;DR (5초 요약) - -```text -1. setLocale: 앱 언어 변경 (locale 파라미터) -2. setTheme/initTheme: 다크/라이트 모드 전환 및 초기화 -3. scrollToSection: 특정 섹션으로 스크롤 이동 (offset 지원) -4. initMenuFromUrl: URL 기반 메뉴 활성 상태 초기화 -5. filterVisibility 4종: 필터 패널 가시성 저장/토글/초기화 -6. multilingualTag 3종: 다국어 태그 저장/취소/업데이트 -``` - ---- - -## 목차 - -1. [setLocale](#setlocale) -2. [setTheme / initTheme](#settheme--inittheme) -3. [scrollToSection](#scrolltosection) -4. [initMenuFromUrl](#initmenuefromurl) -5. [필터 가시성 핸들러](#필터-가시성-핸들러) -6. [다국어 태그 핸들러](#다국어-태그-핸들러) -7. [핸들러 소스 파일 매핑](#핸들러-소스-파일-매핑) - ---- - -## setLocale - -앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다. - -**소스**: `src/handlers/setLocaleHandler.ts` - -```json -{ - "type": "click", - "handler": "setLocale", - "params": { - "locale": "en" - } -} -``` - -### params - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) | - -### 동작 - -```text -1. 로케일 변경 → 번역 파일 다시 로드 -2. _global.locale 자동 업데이트 -3. 모든 $t: 표현식 재평가 -``` - -### 사용 예시 - -```json -{ - "id": "lang_en_btn", - "type": "basic", - "name": "Button", - "props": { - "text": "English" - }, - "actions": [ - { - "type": "click", - "handler": "setLocale", - "params": { - "locale": "en" - } - } - ] -} -``` - ---- - -## setTheme / initTheme - -### setTheme - -다크/라이트 모드를 전환합니다. - -**소스**: `src/handlers/setThemeHandler.ts` - -```json -{ - "type": "click", - "handler": "setTheme", - "params": { - "theme": "dark" - } -} -``` - -### setTheme params - -| 필드 | 타입 | 필수 | 설명 | -|------|------|------|------| -| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) | - -### 동작 - -```text -1. localStorage에 테마 설정 저장 -2. document.documentElement에 class 적용 (dark/light) -3. Tailwind dark: variant 활성화/비활성화 -``` - -### initTheme - -앱 시작 시 저장된 테마 설정을 적용합니다. 주로 `init_actions`에서 사용합니다. - -```json -{ - "init_actions": [ - { - "handler": "initTheme" - } - ] -} -``` - -params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다. - -### 사용 예시 - -```json -{ - "id": "theme_toggle", - "type": "composite", - "name": "ThemeToggle", - "actions": [ - { - "type": "click", - "handler": "setTheme", - "params": { - "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" - } - } - ] -} -``` - ---- - -## scrollToSection - -특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다. - -**소스**: `src/handlers/scrollToSectionHandler.ts` - -```json -{ - "type": "click", - "handler": "scrollToSection", - "params": { - "selector": "#features", - "offset": -80 - } -} -``` - -### params - -| 필드 | 타입 | 필수 | 기본값 | 설명 | -|------|------|------|--------|------| -| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) | -| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 | - -### 동작 - -```text -1. document.querySelector(selector)로 대상 요소 검색 -2. 요소의 위치 계산 + offset 적용 -3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤 -``` - -### 사용 예시 - -```json -{ - "id": "nav_features", - "type": "basic", - "name": "A", - "props": { - "text": "$t:common.features", - "className": "cursor-pointer" - }, - "actions": [ - { - "type": "click", - "handler": "scrollToSection", - "params": { - "selector": "#features", - "offset": -80 - } - } - ] -} -``` - ---- - -## initMenuFromUrl - -현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다. - -**소스**: `src/handlers/initMenuFromUrlHandler.ts` - -```json -{ - "init_actions": [ - { - "handler": "initMenuFromUrl" - } - ] -} -``` - -### params - -없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다. - -### 동작 - -```text -1. 현재 URL 경로 (window.location.pathname) 추출 -2. 사이드바 메뉴 데이터에서 URL 매칭 -3. 매칭된 메뉴 항목의 is_active 상태 설정 -4. 부모 메뉴도 자동으로 펼침 상태 설정 -``` - -### 사용 예시 (_admin_base.json) - -```json -{ - "init_actions": [ - { "handler": "initTheme" }, - { "handler": "initMenuFromUrl" } - ] -} -``` - ---- - -## 필터 가시성 핸들러 - -목록 화면에서 필터 패널의 가시성(표시/숨김)을 관리합니다. localStorage에 상태를 저장하여 새로고침 후에도 유지합니다. - -**소스**: `src/handlers/filterVisibilityHandler.ts` - -### initFilterVisibility - -저장된 필터 가시성 상태를 `_local`에 복원합니다. - -```json -{ - "init_actions": [ - { - "handler": "initFilterVisibility" - } - ] -} -``` - -### saveFilterVisibility - -현재 필터 가시성 상태를 localStorage에 저장합니다. - -```json -{ - "handler": "saveFilterVisibility", - "params": { - "filters": "{{_local.filterVisibility}}" - } -} -``` - -### toggleFilterVisibility - -특정 필터 키의 가시성을 토글합니다. - -```json -{ - "type": "click", - "handler": "toggleFilterVisibility", - "params": { - "key": "advancedFilters" - } -} -``` - -### resetFilterVisibility - -모든 필터 가시성을 초기 상태로 리셋합니다. - -```json -{ - "type": "click", - "handler": "resetFilterVisibility" -} -``` - -### 핸들러 params 요약 - -| 핸들러 | params | 설명 | -|--------|--------|------| -| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 | -| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 | -| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 | -| `resetFilterVisibility` | 없음 | 전체 초기화 | - -### 사용 예시 (목록 페이지) - -```json -{ - "init_actions": [ - { "handler": "initFilterVisibility" } - ], - "components": [ - { - "id": "filter_toggle_btn", - "type": "basic", - "name": "Button", - "props": { - "text": "$t:common.toggle_filters" - }, - "actions": [ - { - "type": "click", - "handler": "toggleFilterVisibility", - "params": { "key": "advancedFilters" } - } - ] - }, - { - "id": "filter_section", - "type": "basic", - "name": "Div", - "if": "{{_local.filterVisibility?.advancedFilters}}", - "children": [ - { "comment": "필터 컴포넌트들" } - ] - } - ] -} -``` - ---- - -## 다국어 태그 핸들러 - -다국어 입력 컴포넌트(MultilingualInput)에서 사용하는 태그 관리 핸들러입니다. - -**소스**: `src/handlers/multilingualTagHandler.ts` - -### saveMultilingualTag - -다국어 태그를 저장합니다. - -```json -{ - "handler": "saveMultilingualTag", - "params": { - "field": "tags", - "locale": "{{_global.locale}}" - } -} -``` - -### cancelMultilingualTag - -다국어 태그 편집을 취소합니다. - -```json -{ - "handler": "cancelMultilingualTag" -} -``` - -### updateMultilingualTagValue - -다국어 태그 값을 업데이트합니다. - -```json -{ - "handler": "updateMultilingualTagValue", - "params": { - "field": "tags", - "locale": "ko", - "value": "{{$event.target.value}}" - } -} -``` - -### 태그 핸들러 params 요약 - -| 핸들러 | params | 설명 | -|--------|--------|------| -| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 | -| `cancelMultilingualTag` | 없음 | 편집 취소 | -| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 | - ---- - -## 핸들러 소스 파일 매핑 - -| 핸들러명 | 소스 파일 | 등록 함수 | -|---------|----------|----------| -| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` | -| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` | -| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` | -| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` | -| `initFilterVisibility`, `saveFilterVisibility`, `toggleFilterVisibility`, `resetFilterVisibility` | `src/handlers/filterVisibilityHandler.ts` | `initFilterVisibilityHandler` | -| `saveMultilingualTag`, `cancelMultilingualTag`, `updateMultilingualTagValue` | `src/handlers/multilingualTagHandler.ts` | `saveMultilingualTagHandler` | - ---- - -## 주의사항 - -```text -이 핸들러들은 sirsoft-admin_basic 템플릿에서만 등록됨 (다른 템플릿에서 미지원 가능) -범용 핸들러(navigate, apiCall, setState 등)와 달리 템플릿 의존적 -✅ 커스텀 핸들러이므로 template.json의 핸들러 등록 확인 필요 -✅ 범용 핸들러는 actions-handlers.md 참조 -``` - ---- - -## 관련 문서 - -- [액션 핸들러 개요](../../actions-handlers.md) -- [sirsoft-admin_basic 컴포넌트](./components.md) -- [sirsoft-admin_basic 레이아웃](./layouts.md) -- [sirsoft-basic 핸들러](../sirsoft-basic/handlers.md) diff --git a/docs/frontend/templates/sirsoft-admin_basic/layouts.md b/docs/frontend/templates/sirsoft-admin_basic/layouts.md deleted file mode 100644 index 46a49173..00000000 --- a/docs/frontend/templates/sirsoft-admin_basic/layouts.md +++ /dev/null @@ -1,368 +0,0 @@ -# sirsoft-admin_basic 레이아웃 - -> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin) -> **관련 문서**: [컴포넌트](./components.md) | [핸들러](./handlers.md) | [레이아웃 JSON 스키마](../../layout-json.md) - ---- - -## TL;DR (5초 요약) - -```text -1. 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯) -2. 코어 페이지 17개: 대시보드, 사용자, 역할, 설정, 확장 관리 등 -3. Partial 70개+: 탭, 모달, 패널, 필터 등 -4. 에러 페이지 6개: 401, 403, 404, 500, 503, maintenance -5. 패턴: 목록(DataGrid+Pagination), 상세(Card), 폼(Form+FormField), 설정(Tab) -``` - ---- - -## 목차 - -1. [페이지 맵 (트리 구조)](#페이지-맵-트리-구조) -2. [카테고리별 가이드](#카테고리별-가이드) - - [목록 페이지 패턴](#목록-페이지-패턴) - - [상세 페이지 패턴](#상세-페이지-패턴) - - [폼 페이지 패턴](#폼-페이지-패턴) - - [설정 페이지 패턴](#설정-페이지-패턴) - - [확장 관리 패턴](#확장-관리-패턴) - - [에러 페이지 패턴](#에러-페이지-패턴) - ---- - -## 페이지 맵 (트리 구조) - -```text -_admin_base.json (베이스 레이아웃) -│ -├── admin_dashboard.json (대시보드) -├── admin_login.json (로그인 — _admin_base 미상속) -│ -├── 사용자 관리 -│ ├── admin_user_list.json (목록) -│ ├── admin_user_form.json (생성/수정) -│ └── admin_user_detail.json (상세) -│ -├── 역할 관리 -│ ├── admin_role_list.json (목록) -│ │ └── partials/admin_role_list/_modal_delete.json -│ └── admin_role_form.json (생성/수정) -│ -├── 메뉴 관리 -│ └── admin_menu_list.json (3패널 레이아웃) -│ └── partials/admin_menu_list/ -│ ├── _panel_menu_list.json (좌측: 메뉴 트리) -│ ├── _panel_form.json (중앙: 편집 폼) -│ ├── _panel_detail.json (중앙: 상세 보기) -│ ├── _panel_view.json (우측: 미리보기) -│ └── _modal_delete.json -│ -├── 환경 설정 -│ └── admin_settings.json (탭 네비게이션) -│ └── partials/admin_settings/ -│ ├── _tab_general.json (일반) -│ ├── _tab_mail.json (메일 발송 SMTP 설정) -│ ├── _tab_notification_definitions.json (알림 정의) -│ ├── _tab_security.json (보안) -│ ├── _tab_upload.json (업로드) -│ ├── _tab_drivers.json (드라이버) -│ ├── _tab_seo.json (SEO) -│ ├── _tab_advanced.json (고급) -│ ├── _tab_info.json (시스템 정보) -│ ├── _modal_cache_delete.json -│ ├── _modal_core_changelog.json -│ ├── _modal_core_update_guide.json -│ ├── _modal_core_update_result.json -│ ├── _modal_notification_template_form.json -│ ├── _modal_notification_template_preview.json -│ └── _modal_password_confirm.json -│ -├── 모듈 관리 -│ └── admin_module_list.json -│ └── partials/admin_module_list/ -│ ├── _modal_detail.json -│ ├── _modal_install.json -│ ├── _modal_manual_install.json -│ ├── _modal_uninstall.json -│ ├── _modal_update.json -│ ├── _modal_deactivate_warning.json -│ ├── _modal_force_activate.json -│ ├── _modal_force_deactivate.json -│ ├── _modal_extension_license.json -│ └── _modal_refresh_layouts.json -│ -├── 플러그인 관리 -│ └── admin_plugin_list.json -│ └── partials/admin_plugin_list/ -│ ├── (모듈과 동일 구조 — 10개 모달) -│ └── ... -│ -├── 템플릿 관리 -│ ├── admin_template_list.json -│ │ └── partials/admin_template_list/ -│ │ ├── _tab_admin.json (Admin 템플릿 탭) -│ │ ├── _tab_user.json (User 템플릿 탭) -│ │ ├── _modal_detail.json -│ │ ├── _modal_install.json -│ │ ├── _modal_manual_install.json -│ │ ├── _modal_uninstall.json -│ │ ├── _modal_update.json -│ │ ├── _modal_activate.json -│ │ ├── _modal_deactivate.json -│ │ ├── _modal_force_activate.json -│ │ ├── _modal_extension_license.json -│ │ └── _modal_refresh_layouts.json -│ └── admin_template_layout_edit.json (레이아웃 편집기) -│ └── partials/admin_template_layout_edit/_modal_version_history.json -│ -├── 스케줄 관리 -│ └── admin_schedule_list.json -│ └── partials/admin_schedule_list/ -│ ├── _tab_schedules.json -│ ├── _modal_form.json -│ ├── _modal_delete.json -│ ├── _modal_duplicate.json -│ ├── _modal_history.json -│ └── _modal_run.json -│ -├── 메일 발송 로그 -│ └── admin_mail_send_log_list.json -│ └── partials/admin_mail_send_log_list/ -│ ├── _partial_datagrid.json -│ └── _partial_filter.json -│ -├── 공통 Partial -│ ├── partials/_modal_changelog.json -│ └── partials/_modal_license.json -│ -├── 에러 페이지 -│ └── errors/ -│ ├── 401.json (인증 필요) -│ ├── 403.json (접근 거부) -│ ├── 404.json (페이지 없음) -│ ├── 500.json (서버 오류) -│ ├── 503.json (서비스 불가) -│ └── maintenance.json (점검 중) -│ -├── 오버라이드 -│ └── overrides/sirsoft-sample/index.json -│ -└── 테스트 - └── template_partial_test.json - └── partials/template_partial_test/ - ├── _content_section.json - ├── _header_section.json - └── _info_card.json -``` - ---- - -## 카테고리별 가이드 - -### 목록 페이지 패턴 - -**대표**: `admin_user_list.json`, `admin_role_list.json` - -**구성**: -```text -extends: _admin_base -slots.content: - └── PageHeader (제목, 액션 버튼) - └── FilterGroup (선택적) - └── DataGrid (columns, data, pagination) - └── Pagination -``` - -**data_sources**: -```json -{ - "id": "users", - "endpoint": "/api/admin/users", - "method": "GET", - "auto_fetch": true, - "params": { "page": "{{_local.page ?? 1}}", "per_page": "{{_local.per_page ?? 15}}" } -} -``` - -**핸들러 패턴**: -- `apiCall` — 삭제, 상태 변경 -- `navigate` — 상세/수정 페이지 이동 -- `setState` — 필터, 페이지네이션 상태 - -**Partial 구조**: 모달 (삭제 확인, 상세 보기 등) - ---- - -### 상세 페이지 패턴 - -**대표**: `admin_user_detail.json` - -**구성**: -```text -extends: _admin_base -data_sources: [상세 API (route.id 기반)] -slots.content: - └── PageHeader - └── Card (기본 정보 섹션) - └── Card (활동 내역 섹션) - └── Card (권한 정보 섹션) -``` - -**data_sources**: -```json -{ - "id": "user", - "endpoint": "/api/admin/users/{{route.id}}", - "method": "GET", - "auto_fetch": true -} -``` - -**핸들러 패턴**: -- `apiCall` — 상태 변경 (활성화/비활성화, 역할 변경) -- `navigate` — 목록으로 이동, 수정 페이지 이동 - ---- - -### 폼 페이지 패턴 - -**대표**: `admin_user_form.json`, `admin_role_form.json` - -**구성**: -```text -extends: _admin_base -data_sources: [상세 API (수정 시), 참조 데이터 (Select 옵션)] -slots.content: - └── PageHeader - └── Form - ├── FormField + Input (텍스트) - ├── FormField + Select (선택) - ├── FormField + Toggle (토글) - └── Button (저장/취소) -``` - -**핸들러 패턴**: -- `apiCall` — 생성 (POST) / 수정 (PUT) -- `navigate` — 성공 후 목록/상세로 이동 - -**주의사항**: -```text -✅ Form 내 Button에 type="button" 명시 (submit 방지) -✅ 수정 폼은 route.id 존재 여부로 생성/수정 구분 -✅ FormField에 error prop으로 서버 검증 에러 표시 -``` - ---- - -### 설정 페이지 패턴 - -**대표**: `admin_settings.json` - -**구성**: -```text -extends: _admin_base -data_sources: [설정 API] -slots.content: - └── PageHeader - └── TabNavigation (tabs) - └── Div (탭별 partial 조건부 렌더링) - ├── if: activeTab === 'general' → partial: _tab_general.json - ├── if: activeTab === 'mail' → partial: _tab_mail.json - ├── if: activeTab === 'security' → partial: _tab_security.json - └── ... -``` - -**Partial 구조**: `partials/admin_settings/_tab_*.json` (9개 탭) - -**핸들러 패턴**: -- `setState` — 탭 전환 -- `apiCall` — 설정 저장 -- `openModal` — 확인 다이얼로그 - ---- - -### 확장 관리 패턴 - -**대표**: `admin_module_list.json`, `admin_plugin_list.json`, `admin_template_list.json` - -**구성**: -```text -extends: _admin_base -data_sources: [확장 목록 API] -slots.content: - └── PageHeader (새로고침, 수동 설치 버튼) - └── DataGrid/CardGrid (확장 목록) - ├── StatusBadge (상태) - ├── ActionMenu (설치/활성화/비활성화/삭제/업데이트) - └── ExtensionBadge (모듈 식별) -modals: - ├── _modal_detail.json (상세 정보) - ├── _modal_install.json (설치 확인) - ├── _modal_uninstall.json (삭제 확인) - ├── _modal_update.json (업데이트) - └── _modal_force_activate.json 등 -``` - -**핸들러 패턴**: -- `apiCall` — 설치, 활성화, 비활성화, 삭제, 업데이트 -- `openModal` — 확인 다이얼로그 -- `setState` — 선택된 확장 정보 저장 - -**특수사항**: -- 템플릿 관리는 Admin/User 탭 분리 (`_tab_admin.json`, `_tab_user.json`) -- 레이아웃 편집기 (`admin_template_layout_edit.json`)는 CodeEditor + 실시간 미리보기 - ---- - -### 에러 페이지 패턴 - -**대표**: `errors/404.json` - -**구성**: -```text -(extends 없음 — 독립 레이아웃) -components: - └── Div (전체 화면 중앙 정렬) - ├── Icon (에러 아이콘) - ├── H1 (에러 코드) - ├── P (에러 메시지) - └── Button (홈으로 이동) -``` - -**핸들러 패턴**: -- `navigate` — 대시보드/홈으로 이동 - ---- - -## 베이스 레이아웃 구조 - -### _admin_base.json - -모든 관리자 페이지의 공통 구조를 정의합니다. - -```text -_admin_base.json -├── init_actions: [initTheme, initMenuFromUrl] -├── data_sources: [admin_menu, notifications] -├── components: -│ ├── AdminSidebar (menu: admin_menu.data) -│ ├── AdminHeader (user, notifications) -│ ├── Toast -│ ├── PageTransitionIndicator -│ └── Div (content area) -│ └── slot: "content" (← 하위 레이아웃이 채움) -└── AdminFooter -``` - -**슬롯**: -- `content` — 각 페이지의 메인 콘텐츠가 삽입되는 위치 - ---- - -## 관련 문서 - -- [sirsoft-admin_basic 컴포넌트](./components.md) -- [sirsoft-admin_basic 핸들러](./handlers.md) -- [레이아웃 JSON 스키마](../../layout-json.md) -- [레이아웃 상속](../../layout-json-inheritance.md) -- [sirsoft-basic 레이아웃](../sirsoft-basic/layouts.md) diff --git a/docs/frontend/templates/sirsoft-basic/components.md b/docs/frontend/templates/sirsoft-basic/components.md index 1556fcf4..ab099b4f 100644 --- a/docs/frontend/templates/sirsoft-basic/components.md +++ b/docs/frontend/templates/sirsoft-basic/components.md @@ -242,6 +242,6 @@ AdminSidebar, AdminHeader, AdminFooter, PageHeader, DataGrid, CodeEditor, Dynami - [sirsoft-basic 핸들러](./handlers.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) - [컴포넌트 Props 레퍼런스](../../component-props.md) diff --git a/docs/frontend/templates/sirsoft-basic/handlers.md b/docs/frontend/templates/sirsoft-basic/handlers.md index 78269e8d..30380f17 100644 --- a/docs/frontend/templates/sirsoft-basic/handlers.md +++ b/docs/frontend/templates/sirsoft-basic/handlers.md @@ -428,4 +428,4 @@ setLocale은 엔진 레벨(ActionDispatcher) 빌트인 — 별도 등록 불필 - [액션 핸들러 개요](../../actions-handlers.md) - [sirsoft-basic 컴포넌트](./components.md) - [sirsoft-basic 레이아웃](./layouts.md) -- [sirsoft-admin_basic 핸들러](../sirsoft-admin_basic/handlers.md) +- [sirsoft-admin_basic 핸들러](../../../../templates/_bundled/sirsoft-admin_basic/docs/handlers.md) diff --git a/docs/frontend/templates/sirsoft-basic/layouts.md b/docs/frontend/templates/sirsoft-basic/layouts.md index d9d1ec57..4ae239d8 100644 --- a/docs/frontend/templates/sirsoft-basic/layouts.md +++ b/docs/frontend/templates/sirsoft-basic/layouts.md @@ -642,6 +642,6 @@ _user_base.json - [sirsoft-basic 컴포넌트](./components.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) - [레이아웃 상속](../../layout-json-inheritance.md) diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md index 580bd675..68e73ecb 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-08-31 + +### Added + +- 동의 이력의 출처 "会員退会"(회원탈퇴) 일본어 라벨 추가 + ## [1.0.1] - 2026-08-10 ### Added diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php index 1f01be16..ab8d69cf 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/backend/ja/messages.php @@ -203,6 +203,7 @@ return [ 'register' => '会員登録', 'mypage' => 'マイページ', 'mypage_renew_all' => 'マイページ一括再同意', + 'withdraw' => '会員退会', ], 'col' => [ 'created_at' => '時点', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json index 917a2e3f..ee0b4268 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/frontend/ja.json @@ -238,7 +238,8 @@ "preference_center": "環境設定", "mypage": "マイページ", "register": "会員登録", - "mypage_renew_all": "マイページ一括再同意" + "mypage_renew_all": "マイページ一括再同意", + "withdraw": "会員退会" }, "col": { "created_at": "時点", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json index 799fe5b4..393526fc 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-gdpr-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 plugin (sirsoft-gdpr) Japanese language pack (bundled)", "ja": "G7 プラグイン (sirsoft-gdpr) 日本語 言語パック(バンドル)" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "scope": "plugin", "target_identifier": "sirsoft-gdpr", diff --git a/modules/_bundled/sirsoft-board/AGENTS.md b/modules/_bundled/sirsoft-board/AGENTS.md new file mode 100644 index 00000000..d8989f2a --- /dev/null +++ b/modules/_bundled/sirsoft-board/AGENTS.md @@ -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. 이 확장은 무엇인가 + + +게시판·게시글·댓글·신고·게시판별 알림설정을 소유하는 콘텐츠 도메인 모듈입니다. 운영자가 +`/admin/boards`에서 게시판을 자유롭게 생성(게시판 유형 `basic`/`gallery`/`card` 선택, 비밀글· +답변형·트리거 기반 관리자 알림 등 게시판별 세부 설정)하면, 그 게시판마다 동적 권한 +(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`/`{slug}.step`)·메뉴가 자동 생성됩니다 — +게시판 하나하나가 사실상 독립된 작은 확장처럼 자기 권한 체계를 갖습니다. + +**소유 범위는 관리자 CRUD + 공개 API 까지입니다.** 방문자가 실제로 보는 목록/상세/글쓰기 +화면은 이 모듈이 그리지 않습니다 — `resources/layouts/` 46개가 전부 `admin` 그룹인 것이 +그 증거입니다. 공개 조회·작성은 `routes/api.php` 의 `boards.*` 라우트(비회원도 접근하는 +`optional.sanctum`)로만 나가고, 그 API 를 소비해 실제 화면을 그리는 것은 템플릿(`sirsoft-basic`) +쪽 책임입니다. 그래서 이 모듈에 의존하는 확장은 지금 템플릿 하나뿐입니다 — 새 방문자 화면이 +필요하면 이 모듈이 아니라 그 화면을 쓰는 템플릿/모듈 쪽에 레이아웃을 추가합니다. + +**설계 원칙**: 게시판은 이커머스 문의·후기처럼 "게시판을 흉내 낸" 다른 도메인의 콘텐츠 저장소로도 +쓰입니다. 그래서 이 모듈을 다른 도메인이 재사용하는 지점은 코드 결합이 아니라 **필터 훅 +위임**(`EcommerceInquiryHookListener`)입니다 — 이커머스 모듈이 자기 문의 게시판을 만들 때 +`sirsoft-board` 를 직접 `use` 하지 않고, board 쪽 흐름 중간에서 자기 로직으로 갈아끼웁니다. + +**의도적으로 하지 않는 것**: 게시판 콘텐츠에 대한 실시간 브로드캐스트(WebSocket)는 제공하지 +않습니다 — 알림은 전부 코어 `GenericNotification`(mail/database) 경유이며, 새로고침 없이 +갱신되는 목록 같은 기능은 이 모듈의 범위 밖입니다. 또한 비밀글 판정은 이 모듈이 API 계층에서 +전량 서버측으로 강제합니다(`SecretContentGate`, KVE-2026-1914) — 클라이언트가 비밀글 여부를 +판단해 화면만 가리는 방식은 쓰지 않습니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-board --force` (빌드 불필요) | +| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-board --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-board --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-board --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-board --force` | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | + + +## 3. 핵심 흐름 + + +**게시판 생성**: `Admin\BoardController` → `StoreBoardRequest`(다국어 이름·slug 유일성·게시판 +유형 검증) → `BoardService::createBoard()` → `BoardRepository` 로 `boards` 행 생성 후, 같은 +트랜잭션 안에서 `BoardPermissionService`(동적 권한 3계층 생성) → 역할(manager/step) 생성 → +관리자 메뉴 등록까지 연쇄 실행됩니다. 이 연쇄 때문에 `getDynamicPermissionIdentifiers()` / +`getDynamicRoleIdentifiers()` / `getDynamicMenuSlugs()` 가 `boards` 테이블을 다시 조회해 +전수를 재구성합니다 — 게시판 삭제·정리(clean-up) 시 "stale 판정"의 기준이 이 세 메서드입니다. + +**게시글 작성 → 비밀글 게이팅**: `User\PostController` → `StorePostRequest` +(`sirsoft-board.post.store_validation_rules` 필터로 게시판별 커스텀 규칙 추가) → +`PostService::createPost()` (`before_create`→`filter_create_data`→`after_create` 훅 순서, +`sirsoft-board.post.user_create` IDV 정책 게이트 통과 후) → `PostRepository`. 조회 시에는 +`PostResource` 가 `SecretContentGate` 로 비밀글 여부·열람 권한을 판정해 `content`/`title`/ +`reply`/`attachments` 를 마스킹합니다 — 이 판정은 댓글 목록·첨부 다운로드에도 **개별 +재적용**됩니다(부모 글에서 한 번 판정하고 끝나지 않음, KVE-2026-1914). + +**신고 접수 → 처리**: `User\ReportController` → `StoreReportRequest` → `ReportService::create()` +(`before_create`→`filter_create_data`→`after_create`) → 신고 접수 관리자 알림 발송. 관리자가 +`Admin\ReportController` 에서 처리(블라인드/삭제/복원)하면 `ReportService` 가 대상 게시글/댓글 +서비스(`PostService`/`CommentService`)를 호출해 실제 콘텐츠 상태를 바꾸고, 그 결과가 +`report_action`/`post_action` 알림으로 원 작성자에게 통지됩니다. 신고 삭제·일괄 처리는 +관리자 민감 작업 IDV 정책(`report.delete`/`report.bulk_action`)이 걸려 있습니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 90개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 77개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 16개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 5개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 0개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 2개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 7개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +발행 훅 90개는 전부 `action`/`filter` 이며 코어 브로드캐스트 채널은 쓰지 않습니다(구독 훅 +목록의 이커머스 8개가 예로 보여주듯, **콘텐츠를 다른 도메인이 재사용하는 자리는 훅이지 상속이 +아닙니다**). 새 게시판 세부 검증 규칙을 추가하려면 `board.store_validation_rules`/ +`update_validation_rules` filter 를, 게시판 생성 후 부가 리소스를 함께 만들려면 +`board.after_create` action 을 잡습니다. 게시글/댓글의 `before_*`→`filter_*_data`→`after_*` +3단 패턴은 전 도메인(board/comment/report/attachment)에 동일하게 반복되므로, 하나를 배우면 +나머지에 그대로 적용됩니다. 훅 리스너 16개 중 `EcommerceInquiryHookListener` 는 이 모듈의 +CRUD 흐름 자체를 **자기 도메인으로 대체**하는 가장 무거운 형태의 확장 사례입니다 — 새로운 +"게시판을 흉내 낸 도메인"을 만들 때 참고할 선례입니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan module:update sirsoft-board --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=module:sirsoft-board` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 비밀글이 관여하는 새 조회 경로(댓글·첨부·검색 결과 등)를 추가할 때 `SecretContentGate` 재적용 (KVE-2026-1914 — 부모에서 한 번 판정하고 끝나지 않는다) +- [ ] `boards`/`board_posts`/`board_comments` 의 count 컬럼(`posts_count`/`comments_count`/`replies_count`/`attachments_count`)은 훅 리스너(`*CountSyncListener`)가 갱신 — Service 에서 직접 증감 금지 +- [ ] 게시판 삭제 시 `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()`/`getDynamicMenuSlugs()` 가 최신 상태를 반영하도록 동일 트랜잭션에서 정리 (module.php `uninstall()` 의 `chunkById` 패턴 참고 — OFFSET 순회로 삭제 금지) + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| 비밀글 상세만 `SecretContentGate` 로 막고 댓글 목록·첨부 다운로드는 그대로 노출 | 댓글 목록은 부모 글 비밀 여부 확인 후 빈 배열, 첨부 서빙은 403 — 두 경로 모두 게이트 재적용 | 상세 API 하나만 막으면 같은 정보가 형제 엔드포인트로 새어나간다 (KVE-2026-1914) | +| 게시글/댓글 삭제·복원 시 `posts_count`/`comments_count` 를 Service 에서 `increment()`/`decrement()` | 훅(`after_create`/`after_delete`/`after_restore`) 리스너의 count 동기화에 맡긴다 | 직접 증감은 훅 기반 동기화와 이중 집계되어 카운트가 어긋난다 | +| 게시판별 동적 권한/역할을 board 삭제와 별도 시점에 정리 | `BoardService` 삭제 흐름 안에서 즉시 정리(또는 stale cleanup 이 `getDynamicPermissionIdentifiers()` 로 정확히 판정하게 유지) | 정리가 늦으면 존재하지 않는 게시판의 권한이 역할에 남아 관리 화면에 유령 항목이 뜬다 | +| 새 콘텐츠 타입을 board 코드에 `if ($type === 'inquiry')` 로 직접 분기 | `EcommerceInquiryHookListener` 처럼 필터 훅으로 CRUD 를 위임 | board 코드가 알지 못하는 도메인이 늘어날수록 분기가 무한 증식한다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 155개 | `modules/_bundled/sirsoft-board/tests` | +| Vitest | 33개 | `vitest.config.ts` | +| Playwright | 26개 | `tests/Playwright` | +| 시나리오 매니페스트 | 34개 | `tests/scenarios` | + +기저 TestCase: `tests/ModuleTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit modules/_bundled/sirsoft-board/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd modules/_bundled/sirsoft-board && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test modules/_bundled/sirsoft-board/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ | +| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ | +| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ | +| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ | +| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + diff --git a/modules/_bundled/sirsoft-board/README.md b/modules/_bundled/sirsoft-board/README.md new file mode 100644 index 00000000..51628325 --- /dev/null +++ b/modules/_bundled/sirsoft-board/README.md @@ -0,0 +1,171 @@ +# 게시판 + +**G7 모듈 · sirsoft-board** +게시판 관리를 위한 모듈 + + +

+ version 1.1.0 + type 모듈 + G7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +게시판·게시글·댓글·신고를 관리하는 콘텐츠 모듈입니다. 운영자가 관리자 화면에서 자유형(가로형/ +갤러리형/카드형) 게시판을 원하는 개수만큼 만들고, 게시판마다 비밀글·답변형·본인인증·자동 알림 +같은 세부 정책을 독립적으로 설정할 수 있습니다. + +이 모듈은 관리자 화면과 공개 API 만 제공합니다. 방문자가 보는 목록·상세·글쓰기 화면은 +템플릿(`sirsoft-basic`)이 이 모듈의 API 를 호출해 그립니다 — 운영자 입장에서는 "게시판 콘텐츠는 +여기서 관리하고, 화면 디자인은 템플릿이 담당한다"로 이해하면 됩니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 게시판 관리 | 게시판 생성/수정/삭제, 게시판 유형(기본/갤러리/카드) 선택, 게시판별 세부 설정 일괄 적용 | +| 게시글·댓글 | 작성/수정/삭제/블라인드/복원, 답변형 게시판(원글-답변 트리), 대댓글, 비밀글 | +| 신고 처리 | 사용자 신고 접수 → 관리자 검토 → 블라인드/삭제/복원 처리, 처리 결과 알림 | +| 첨부파일 | 업로드/다운로드/순서 변경, 게시판별 허용 확장자·용량 제한 | +| 대시보드 | 게시판별 게시글·댓글·신고 현황과 추세, 미처리 신고 요약 | +| 알림 | 새 댓글/대댓글/답변글/신고 접수/처리 결과를 메일·앱 내 알림으로 발송, 회원별 수신 여부 설정 | +| 본인인증 연동 | 게시글/댓글 삭제, 신고 작성, 첫 글 작성 등 민감 작업에 코어 IDV 정책 적용(기본은 비활성) | + + +## 동작 방식 + + +```mermaid +flowchart LR + V[방문자] -->|공개 API 호출| T[템플릿 화면] + T -->|GET boards/posts| API[게시판 공개 API] + API --> SVC[PostService/CommentService] + SVC --> DB[(board_posts 등)] + + A[운영자] -->|관리자 화면| ADM[게시판 관리 UI] + ADM -->|CRUD·블라인드·신고 처리| SVC + SVC -->|훅 발행| N[알림/집계/검색색인] +``` + +방문자는 템플릿이 그린 화면에서 공개 API 만 호출하고, 운영자는 이 모듈이 직접 제공하는 +관리자 화면을 씁니다. 두 경로 모두 결국 같은 Service 계층을 거치므로 비밀글 게이팅·카운트 +동기화·훅 발행은 어느 쪽에서 들어오든 동일하게 적용됩니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| G7 코어 | `>=7.0.10` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan module:install sirsoft-board + +# 활성화 +php artisan module:activate sirsoft-board + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan module:update sirsoft-board --force +``` + +저장소: https://github.com/gnuboard/g7-module-sirsoft-board + + +## 관리자 설정 + + +_별도의 관리자 설정 항목이 없습니다._ + + + +위 표가 비어 있는 이유는 이 모듈에 전역 환경설정(`getSettingsSchema()`) 이 없기 때문입니다 — +설정은 전역이 아니라 **게시판 하나하나**에 딸려 있습니다(`/admin/boards/{slug}/settings`). +게시판을 만들 때 기본/갤러리/카드 유형을 고르면 그 유형의 기본값이 채워지고, 이후 기본 +정보·목록 표시·게시글 정책·댓글 정책·첨부 정책·본인인증·알림·SEO 탭에서 게시판별로 따로 +조정합니다. 여러 게시판에 같은 값을 한 번에 반영하려면 게시판 목록 화면의 "설정 일괄 적용"을 +씁니다(`settings.before_bulk_apply`/`after_bulk_apply` 훅으로 계측 가능). + + +## 사용 방법 + + +**게시판 신설**: `/admin/boards` → "게시판 추가" → 이름·slug·유형 지정 → 저장. 저장 즉시 +관리자 메뉴·동적 권한(`sirsoft-board.{slug}.*`)·역할(`{slug}.manager`)이 자동 생성되므로, +바로 이어서 "권한" 탭에서 그 게시판을 담당할 운영자에게 `{slug}.manager` 역할을 부여합니다. + +**신고 처리**: 방문자가 게시글/댓글을 신고하면 `/admin/boards/reports` 에 접수되고 담당자에게 +메일이 갑니다. 신고 상세에서 신고 사유·신고 이력을 확인한 뒤 블라인드/삭제/복원 중 하나로 +처리하면, 그 결과가 원 작성자에게 자동으로 통지됩니다 — 별도로 작성자에게 안내 메일을 보낼 +필요가 없습니다. + +**여러 게시판 설정 일괄 변경**: 예를 들어 전체 게시판의 첨부 용량 상한을 한 번에 올리고 싶으면, +게시판 목록에서 대상 게시판을 체크한 뒤 "설정 일괄 적용" 모달에서 첨부 탭 값만 바꿔 적용합니다. +다른 탭 값은 그대로 유지되고, 체크한 게시판에만 반영됩니다. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `sirsoft-basic` | 템플릿 | `>=1.0.0` | + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ | +| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ | +| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ | +| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ | +| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 게시판 삭제 후에도 관리자 화면에 그 게시판 권한/역할이 남아 있음 | 정리 배치가 아직 실행되지 않았거나 삭제 트랜잭션이 중간에 실패 | `getDynamicPermissionIdentifiers()`/`getDynamicRoleIdentifiers()` 는 현재 `boards` 테이블 기준으로 계산되므로, 확장 정리 커맨드를 다시 실행하면 stale 항목이 잡힙니다 | +| 검색어에 `+`, `-`, `"` 를 넣으면 결과가 0건으로 나옴 | 코어 검색 정제기가 FULLTEXT 연산자를 제거한 뒤 검색 — 연산자만 입력하면 빈 결과가 정상 동작 | 오류가 아닙니다. 실제 키워드를 함께 입력하면 정상 매칭됩니다 | +| 비밀글의 댓글 개수가 0으로 보이는데 실제로는 댓글이 있음 | 열람 권한이 없는 요청에는 댓글 목록이 빈 배열(200)로 마스킹됨(KVE-2026-1914) | 정상 동작입니다. 작성자 본인 또는 `posts.read-secret`/관리 권한으로 조회하면 보입니다 | +| 게시판 설정 일괄 적용 후 일부 게시판만 반영됨 | 대상 게시판 중 일부가 적용 도중 실패(예: 유효성 위반) | `settings.after_bulk_apply_aborted` 훅 시점의 로그로 실패한 게시판을 특정한 뒤 개별 재적용 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/modules/_bundled/sirsoft-board/docs/README.md b/modules/_bundled/sirsoft-board/docs/README.md new file mode 100644 index 00000000..f09ee1fe --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/README.md @@ -0,0 +1,22 @@ +# 게시판 개발자 문서 + +> modules/_bundled/sirsoft-board · 모듈 + + +**훅 수**: 90 · **구독 훅 수**: 77 · **라우트 수**: 80 · **모델 수**: 9 · **테이블 수**: 10 · **마이그레이션 수**: 30 · **레이아웃 수**: 46 · **핸들러 수**: 0 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | +| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | +| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum | +| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | +| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | +| [api/](api/README.md) | API 레퍼런스 | +| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 | +| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 | + diff --git a/modules/_bundled/sirsoft-board/docs/architecture.md b/modules/_bundled/sirsoft-board/docs/architecture.md new file mode 100644 index 00000000..659266c2 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/architecture.md @@ -0,0 +1,75 @@ +# 게시판 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +게시판마다 완결된 권한·역할 체계를 갖게 하면서도, 새 게시판을 만드는 데 코드 변경이 필요 없게 +하는 것이 이 모듈의 핵심 설계 목표입니다. `boards` 테이블 한 행이 하나의 확장처럼 동작하도록, +권한·역할·메뉴는 모두 **런타임에 게시판 데이터로부터 파생**됩니다(`getDynamicPermissionIdentifiers()` +등 3개 메서드가 코드가 아니라 DB 를 읽어 계산). 그 대가로 이 세 메서드는 게시판이 하나 +추가/삭제될 때마다 정확해야 하고, 어긋나면 stale 권한이 남거나 존재하는 게시판의 권한이 +정리 대상으로 오판됩니다. + +또한 "관리자 백엔드 모듈 + 방문자 화면은 템플릿" 분리를 의도적으로 유지합니다. 방문자 화면을 +이 모듈 안에 두면 템플릿마다 디자인이 다른 게시판 UI 를 이 모듈이 전부 알아야 하는데, +API 로만 노출하면 템플릿 쪽에서 자유롭게 화면을 구성할 수 있습니다. 이 경계 때문에 +"레이아웃 확장"·"레이아웃"에는 오직 관리자 화면만 나타나며, 그것이 정상입니다. + + +## 계층 지도 + + +``` +Http/Controllers (Admin/ 관리자, User/ 공개 API) + │ + ▼ +FormRequest (검증 + *_validation_rules 필터 훅으로 게시판별 규칙 확장 지점) + │ + ▼ +Services (BoardService/PostService/CommentService/ReportService/AttachmentService 등) + │ before_* → filter_*_data → 실행 → after_* (전 도메인 공통 3단 훅 패턴) + ▼ +Repositories (RepositoryInterface 경유, 정렬 화이트리스트·컬럼 프루닝) + │ + ▼ +Models (SoftDeletes 적용 — Post/Comment/Attachment/Report) +``` + +Listeners(`src/Listeners/`)는 이 흐름과 별도 레인입니다 — Service 가 발행한 훅을 받아 카운트 +동기화(`*CountSyncListener`)·활동 로그·SEO 캐시·검색 색인·알림 데이터 추출을 수행하며, Service +자신은 이 부가효과를 알지 못합니다. 이 분리 덕분에 새 부가효과(예: 신규 카운트 컬럼 동기화)는 +Service 를 건드리지 않고 리스너 추가만으로 끝납니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `module.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `module.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `src/lang/` | 백엔드 다국어 | ko·en 동시 반영 + 번들 ja 팩 동기화 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `database/seeders/` | 시더 | composer autoload 등록 + `extension:update-autoload` | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan module:update sirsoft-board --force` (빌드 불필요) | +| `resources/routes/` | 라우트 → 레이아웃 매핑 (분할) | `php artisan module:update sirsoft-board --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan module:build` → `php artisan module:update sirsoft-board --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan module:update sirsoft-board --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan module:update sirsoft-board --force` | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | + diff --git a/modules/_bundled/sirsoft-board/docs/data-model.md b/modules/_bundled/sirsoft-board/docs/data-model.md new file mode 100644 index 00000000..e052712e --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/data-model.md @@ -0,0 +1,161 @@ +# 게시판 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `Attachment` | `board_attachments` | 15 | board→Board, post→Post, creator→User | SoftDeletes | +| `Board` | `boards` | 37 | creator→User, updater→User, posts→Post, comments→Comment, attachments→Attachment, reports→Report | - | +| `BoardStat` | `board_stats` | 3 | - | - | +| `BoardType` | `board_types` | 3 | - | HasUserOverrides | +| `Comment` | `board_comments` | 14 | board→Board, post→Post, user→User, parent→self, replies→self | SoftDeletes | +| `Post` | `board_posts` | 21 | board→Board, user→User, parent→self, replies→self, comments→Comment, attachments→Attachment, 외 1개 | SoftDeletes, 검색 색인 | +| `Report` | `boards_reports` | 11 | board→Board, author→User, logs→ReportLog, processor→User | SoftDeletes | +| `ReportLog` | `boards_report_logs` | 6 | report→Report, reporter→User | - | +| `UserNotificationSetting` | `board_user_notification_settings` | 5 | user→User | - | + + + +`Post`·`Comment` 모두 `parent`/`replies` 자기참조 관계를 갖습니다 — `Post` 의 자기참조는 +"답변형 게시판"(원글에 대한 관리자 답변)을, `Comment` 의 자기참조는 대댓글을 표현합니다. 둘은 +서로 다른 기능이라 관계 이름은 같아도 코드에서 섞어 쓰지 않습니다. `Board` 는 `HasUserOverrides` +를 쓰지 **않습니다** — 게시판 자체는 운영자가 직접 소유·수정하는 리소스라 "모듈 재설치 시 +운영자 수정 보존"이 필요 없는 반면, `BoardType`(게시판 유형 프리셋)은 모듈이 시딩한 기본값을 +운영자가 손댈 수 있어야 하므로 그 트레이트를 씁니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `board_attachments` | `Attachment` | +| `board_comments` | `Comment` | +| `board_mail_templates` | - | +| `board_posts` | `Post` | +| `board_stats` | `BoardStat` | +| `board_types` | `BoardType` | +| `board_user_notification_settings` | `UserNotificationSetting` | +| `boards` | `Board` | +| `boards_report_logs` | `ReportLog` | +| `boards_reports` | `Report` | + + + +`board_mail_templates` 는 모델이 없는 채로 남아 있습니다 — 아래 마이그레이션 표의 +`drop_board_mail_templates_table`(2026-04-13)이 보여주듯, 메일 템플릿을 자체 테이블로 +관리하던 초기 설계를 코어 `GenericNotification` 알림 정의(§알림 정의)로 이관하며 테이블만 +드롭하고 이름은 이력상 남아 있는 상태입니다. 신규 코드에서 이 이름을 참조하지 않습니다. +테이블 접두어가 `board_*`와 `boards_*` 두 가지로 섞여 있는 것은 설계 의도가 아니라 이력입니다 +— 새 테이블을 추가할 때는 `board_*`(단수)를 따릅니다. + + +## 마이그레이션 + + +마이그레이션 30개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_04_01_000001_create_board_types_table.php` | `board_types` | - | ✅ | +| `2026_04_01_000002_create_boards_table.php` | `boards` | `boards` | ✅ | +| `2026_04_01_000003_create_board_user_notification_settings_table.php` | `board_user_notification_settings` | `board_user_notification_settings` | ✅ | +| `2026_04_01_000004_create_board_posts_table.php` | `board_posts` | - | ✅ | +| `2026_04_01_000005_create_board_comments_table.php` | `board_comments` | - | ✅ | +| `2026_04_01_000006_create_board_attachments_table.php` | `board_attachments` | - | ✅ | +| `2026_04_01_000007_create_boards_reports_table.php` | `boards_reports` | `boards_reports` | ✅ | +| `2026_04_01_000008_create_boards_report_logs_table.php` | `boards_report_logs` | `boards_report_logs` | ✅ | +| `2026_04_01_000009_create_board_mail_templates_table.php` | `board_mail_templates` | - | ✅ | +| `2026_04_01_000010_add_fulltext_indexes_to_boards_table.php` | - | `boards` | ✅ | +| `2026_04_01_000011_add_fulltext_indexes_to_boards_report_logs_table.php` | - | `boards_report_logs` | ✅ | +| `2026_04_01_000012_add_indexes_to_board_posts_table.php` | - | `board_posts` | ✅ | +| `2026_04_13_000001_drop_board_mail_templates_table.php` | `board_mail_templates` | - | ✅ | +| `2026_04_13_000002_add_user_overrides_to_board_types_table.php` | - | `board_types` | ✅ | +| `2026_04_14_000001_drop_channel_columns_from_boards_table.php` | - | `boards` | ✅ | +| `2026_04_17_000001_remove_partitions_from_board_tables.php` | - | - | ✅ | +| `2026_04_17_000002_add_count_columns_to_board_tables.php` | - | `board_posts`, `board_comments` | ✅ | +| `2026_04_17_000003_add_posts_count_and_comments_count_to_boards_table.php` | - | `boards` | ✅ | +| `2026_04_17_000004_update_indexes_in_board_tables.php` | - | `board_posts`, `board_comments`, `board_attachments` | ✅ | +| `2026_05_29_000001_create_board_stats_table.php` | `board_stats` | - | ✅ | +| `2026_06_08_000001_add_recent_across_boards_index_to_board_posts.php` | - | `board_posts` | ✅ | +| `2026_06_26_000001_modify_trigger_type_in_board_comments_table.php` | - | - | ✅ | +| `2026_06_26_000002_add_trigger_type_to_board_attachments_table.php` | - | `board_attachments` | ✅ | +| `2026_08_01_000001_add_tiebreak_to_board_posts_list_index.php` | - | - | ✅ | +| `2026_08_01_000002_add_list_sort_index_to_boards_reports_table.php` | - | - | ✅ | +| `2026_08_06_000001_update_max_reply_depth_comment_in_boards_table.php` | - | - | ✅ | +| `2026_08_06_000002_add_view_count_sort_index_to_board_posts_table.php` | - | - | ✅ | +| `2026_08_17_000001_add_reply_delete_policy_to_boards_table.php` | - | `boards` | ✅ | +| `2026_08_17_000002_modify_trigger_type_in_board_posts_table.php` | - | - | ✅ | +| `2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php` | - | `board_posts` | ✅ | + + + +목록 성능 마이그레이션이 지속적으로 추가되는 것(인덱스 4건 + 정렬 타이브레이크 2건)은 우연이 +아닙니다 — 게시글 목록은 공지 제외·답변 제외 필터가 항상 걸린 채로 `created_at`/`view_count` +2가지 정렬을 지원해야 하므로, 새 정렬 옵션을 추가할 때마다 그 정렬에 맞는 복합 인덱스를 함께 +마이그레이션합니다(`getBenchmarkProfiles()` 의 `board_posts_by_view_count` 프로파일이 바로 이 +목적으로 존재합니다). `remove_partitions_from_board_tables`(2026-04-17)는 `board_posts`/`board_comments`/ +`board_attachments` 3개 테이블에 적용했던 파티셔닝을 되돌린 이력입니다 — 그 마이그레이션의 +`down()` 은 "파티션 복원은 데이터 재배치가 필요해 자동 롤백 불가"라고 명시하므로, 파티셔닝을 +다시 도입할 때는 이 파일을 그대로 재실행하는 방식이 아니라 새 마이그레이션으로 설계해야 합니다. + + +## Enum + + +| Enum | backing | case 수 | case | +|---|---|---|---| +| `BoardOrderBy` | `string` | 4 | `created_at`, `view_count`, `title`, `author` | +| `OrderDirection` | `string` | 2 | `ASC`, `DESC` | +| `PostStatus` | `string` | 3 | `published`, `blinded`, `deleted` | +| `ReplyDeletePolicy` | `string` | 2 | `block`, `cascade` | +| `ReportReasonType` | `string` | 9 | `abuse`, `hate_speech`, `spam`, `copyright`, `privacy`, `misinformation`, `sexual`, `violence`, `외 1개` | +| `ReportStatus` | `string` | 5 | `pending`, `review`, `rejected`, `suspended`, `deleted` | +| `ReportType` | `string` | 2 | `post`, `comment` | +| `SecretMode` | `string` | 3 | `disabled`, `enabled`, `always` | +| `TriggerType` | `string` | 6 | `report`, `admin`, `system`, `auto_hide`, `user`, `cascade` | + + + +`TriggerType`(6 case: report/admin/system/auto_hide/user/cascade)은 "이 콘텐츠가 왜 지금 +상태가 됐는가"를 기록하는 감사(audit) 축입니다 — 예를 들어 게시글 블라인드가 `report`(신고 +처리 결과)인지 `admin`(관리자 직접 조치)인지에 따라 `post_action`/`report_action` 두 알림이 +갈라집니다(§확장점 "알림 정의" 참고). `cascade` 는 부모(게시글)가 지워질 때 자식(댓글)이 +함께 지워진 경우이며, `ReplyDeletePolicy`(`block`/`cascade`)가 게시판별로 부모 삭제 시 +자식을 막을지 함께 지울지를 결정합니다 — 이 정책과 `TriggerType::Cascade` 는 같은 흐름의 +서로 다른 절반(정책 설정 vs 결과 기록)입니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `AttachmentRepository` | 구현 | 게시판 첨부파일 Repository 구현체 | +| `AttachmentRepositoryInterface` | 인터페이스 | 게시판 첨부파일 Repository 인터페이스 | +| `BoardRepository` | 구현 | 게시판 Repository | +| `BoardRepositoryInterface` | 인터페이스 | 게시판 Repository 인터페이스 | +| `BoardStatRepository` | 구현 | 게시판 일별 집계 Repository | +| `BoardStatRepositoryInterface` | 인터페이스 | 게시판 일별 집계 Repository 인터페이스 | +| `BoardTypeRepository` | 구현 | - | +| `BoardTypeRepositoryInterface` | 인터페이스 | - | +| `CommentRepository` | 구현 | 댓글 Repository | +| `CommentRepositoryInterface` | 인터페이스 | 댓글 Repository 인터페이스 | +| `PostRepository` | 구현 | 게시글 Repository | +| `PostRepositoryInterface` | 인터페이스 | 게시글 Repository 인터페이스 | +| `ReportRepository` | 구현 | 신고 Repository | +| `ReportRepositoryInterface` | 인터페이스 | 신고 Repository 인터페이스 | +| `UserNotificationSettingRepository` | 구현 | 사용자 알림 설정 Repository | +| `UserNotificationSettingRepositoryInterface` | 인터페이스 | 사용자 알림 설정 Repository 인터페이스 | + + + +`BoardStatRepository`(일별 집계)는 별도 Repository 로 분리돼 있습니다 — `sirsoft-board:aggregate-stats` +스케줄이 매시간 `board_stats` 를 갱신하는데, 이 집계 쿼리를 `PostRepository`/`CommentRepository` +에 섞으면 대시보드 조회 경로와 실시간 CRUD 경로가 같은 클래스 안에서 뒤엉킵니다. 새 Repository +를 추가할 때는 반드시 인터페이스를 함께 만들고 `CoreServiceProvider`(또는 이 모듈의 +서비스 프로바이더)에서 바인딩합니다 — Service 가 구체 클래스를 직접 타입힌트하면 안 됩니다. + diff --git a/modules/_bundled/sirsoft-board/docs/extension-points.md b/modules/_bundled/sirsoft-board/docs/extension-points.md new file mode 100644 index 00000000..336b8e62 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/extension-points.md @@ -0,0 +1,323 @@ +# 게시판 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +발행 훅 90종 / 호출 지점 91곳. 이 중 90종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다. + +| 훅 이름 | 유형 | 설명 | 발행 위치 | +|---|---|---|---| +| `core.module_settings.after_save` | action | — | `src/Http/Controllers/Admin/BoardSettingsController.php:123` | +| `sirsoft-board.attachment.after_delete` | action | — | `src/Services/AttachmentService.php:463` | +| `sirsoft-board.attachment.after_download` | action | — | `src/Services/AttachmentService.php:776` | +| `sirsoft-board.attachment.after_link` | action | — | `src/Services/AttachmentService.php:217` 외 1곳 | +| `sirsoft-board.attachment.after_reorder` | action | — | `src/Services/AttachmentService.php:631` | +| `sirsoft-board.attachment.after_upload` | action | — | `src/Services/AttachmentService.php:192` | +| `sirsoft-board.attachment.before_delete` | action | — | `src/Services/AttachmentService.php:442` | +| `sirsoft-board.attachment.before_reorder` | action | — | `src/Services/AttachmentService.php:626` | +| `sirsoft-board.attachment.before_upload` | action | — | `src/Services/AttachmentService.php:117` | +| `sirsoft-board.attachment.filter_upload_file` | filter | — | `src/Services/AttachmentService.php:120` | +| `sirsoft-board.attachment.reorder_validation_rules` | filter | — | `src/Http/Requests/ReorderAttachmentsRequest.php:35` | +| `sirsoft-board.attachment.upload_validation_rules` | filter | — | `src/Http/Requests/UploadAttachmentRequest.php:77` | +| `sirsoft-board.board.after_add_to_menu` | action | — | `src/Services/BoardService.php:1059` | +| `sirsoft-board.board.after_create` | action | — | `src/Services/BoardService.php:457` | +| `sirsoft-board.board.after_delete` | action | — | `src/Services/BoardService.php:623` | +| `sirsoft-board.board.after_remove_from_menu` | action | — | `src/Services/BoardService.php:1104` | +| `sirsoft-board.board.after_update` | action | — | `src/Services/BoardService.php:543` | +| `sirsoft-board.board.before_add_to_menu` | action | — | `src/Services/BoardService.php:1023` | +| `sirsoft-board.board.before_copy` | action | — | `src/Services/BoardService.php:646` | +| `sirsoft-board.board.before_create` | action | — | `src/Services/BoardService.php:382` | +| `sirsoft-board.board.before_delete` | action | — | `src/Services/BoardService.php:574` | +| `sirsoft-board.board.before_remove_from_menu` | action | — | `src/Services/BoardService.php:1090` | +| `sirsoft-board.board.before_update` | action | — | `src/Services/BoardService.php:495` | +| `sirsoft-board.board.filter_copy_data` | filter | — | `src/Services/BoardService.php:703` | +| `sirsoft-board.board.filter_create_data` | filter | — | `src/Services/BoardService.php:385` | +| `sirsoft-board.board.filter_menu_data` | filter | — | `src/Services/BoardService.php:1053` | +| `sirsoft-board.board.filter_update_data` | filter | — | `src/Services/BoardService.php:500` | +| `sirsoft-board.board.posts.before_force_delete` | action | — | `src/Services/BoardService.php:593` | +| `sirsoft-board.board.store_validation_rules` | filter | — | `src/Http/Requests/StoreBoardRequest.php:225` | +| `sirsoft-board.board.update_validation_rules` | filter | — | `src/Http/Requests/UpdateBoardRequest.php:228` | +| `sirsoft-board.board_type.after_create` | action | — | `src/Services/BoardTypeService.php:42` | +| `sirsoft-board.board_type.after_delete` | action | — | `src/Services/BoardTypeService.php:107` | +| `sirsoft-board.board_type.after_update` | action | — | `src/Services/BoardTypeService.php:70` | +| `sirsoft-board.board_type.before_create` | action | — | `src/Services/BoardTypeService.php:36` | +| `sirsoft-board.board_type.before_delete` | action | — | `src/Services/BoardTypeService.php:103` | +| `sirsoft-board.board_type.before_update` | action | — | `src/Services/BoardTypeService.php:62` | +| `sirsoft-board.board_type.filter_create_data` | filter | — | `src/Services/BoardTypeService.php:38` | +| `sirsoft-board.board_type.filter_update_data` | filter | — | `src/Services/BoardTypeService.php:66` | +| `sirsoft-board.comment.after_blind` | action | — | `src/Services/CommentService.php:480` | +| `sirsoft-board.comment.after_create` | action | — | `src/Services/CommentService.php:375` | +| `sirsoft-board.comment.after_delete` | action | — | `src/Services/CommentService.php:441` | +| `sirsoft-board.comment.after_restore` | action | — | `src/Services/CommentService.php:516` | +| `sirsoft-board.comment.after_update` | action | — | `src/Services/CommentService.php:410` | +| `sirsoft-board.comment.before_blind` | action | — | `src/Services/CommentService.php:471` | +| `sirsoft-board.comment.before_create` | action | — | `src/Services/CommentService.php:341` | +| `sirsoft-board.comment.before_delete` | action | — | `src/Services/CommentService.php:431` | +| `sirsoft-board.comment.before_restore` | action | — | `src/Services/CommentService.php:507` | +| `sirsoft-board.comment.before_update` | action | — | `src/Services/CommentService.php:399` | +| `sirsoft-board.comment.filter_create_data` | filter | — | `src/Services/CommentService.php:344` | +| `sirsoft-board.comment.filter_update_data` | filter | — | `src/Services/CommentService.php:404` | +| `sirsoft-board.comment.store_validation_rules` | filter | — | `src/Http/Requests/StoreCommentRequest.php:91` | +| `sirsoft-board.comment.update_validation_rules` | filter | — | `src/Http/Requests/UpdateCommentRequest.php:67` | +| `sirsoft-board.permissions.after_create` | action | — | `src/Services/BoardService.php:431` | +| `sirsoft-board.permissions.after_delete` | action | — | `src/Services/BoardService.php:600` | +| `sirsoft-board.permissions.after_update` | action | — | `src/Services/BoardService.php:523` | +| `sirsoft-board.post.after_blind` | action | — | `src/Services/PostService.php:500` | +| `sirsoft-board.post.after_create` | action | — | `src/Services/PostService.php:275` | +| `sirsoft-board.post.after_delete` | action | — | `src/Services/PostService.php:459` | +| `sirsoft-board.post.after_restore` | action | — | `src/Services/PostService.php:564` | +| `sirsoft-board.post.after_update` | action | — | `src/Services/PostService.php:351` | +| `sirsoft-board.post.before_blind` | action | — | `src/Services/PostService.php:491` | +| `sirsoft-board.post.before_create` | action | — | `src/Services/PostService.php:250` | +| `sirsoft-board.post.before_delete` | action | — | `src/Services/PostService.php:426` | +| `sirsoft-board.post.before_restore` | action | — | `src/Services/PostService.php:533` | +| `sirsoft-board.post.before_update` | action | — | `src/Services/PostService.php:324` | +| `sirsoft-board.post.filter_content_thumbnail` | filter | — | `src/Models/Post.php:138` | +| `sirsoft-board.post.filter_create_data` | filter | — | `src/Services/PostService.php:253` | +| `sirsoft-board.post.filter_update_data` | filter | — | `src/Services/PostService.php:329` | +| `sirsoft-board.post.store_validation_rules` | filter | — | `src/Http/Requests/StorePostRequest.php:120` | +| `sirsoft-board.post.update_validation_rules` | filter | — | `src/Http/Requests/UpdatePostRequest.php:77` | +| `sirsoft-board.report.after_blind_content` | action | — | `src/Services/ReportService.php:712` | +| `sirsoft-board.report.after_bulk_update_status` | action | — | `src/Services/ReportService.php:492` | +| `sirsoft-board.report.after_create` | action | — | `src/Services/ReportService.php:292` | +| `sirsoft-board.report.after_delete` | action | — | `src/Services/ReportService.php:559` | +| `sirsoft-board.report.after_delete_content` | action | — | `src/Services/ReportService.php:880` | +| `sirsoft-board.report.after_restore_content` | action | — | `src/Services/ReportService.php:678` | +| `sirsoft-board.report.after_update_status` | action | — | `src/Services/ReportService.php:369` | +| `sirsoft-board.report.before_bulk_update_status` | action | — | `src/Services/ReportService.php:385` | +| `sirsoft-board.report.before_create` | action | — | `src/Services/ReportService.php:203` | +| `sirsoft-board.report.before_delete` | action | — | `src/Services/ReportService.php:554` | +| `sirsoft-board.report.before_update_status` | action | — | `src/Services/ReportService.php:324` | +| `sirsoft-board.report.filter_create_data` | filter | — | `src/Services/ReportService.php:227` | +| `sirsoft-board.roles.after_create` | action | — | `src/Services/BoardService.php:411` | +| `sirsoft-board.roles.after_delete` | action | — | `src/Services/BoardService.php:604` | +| `sirsoft-board.search.post.index_should_update` | filter | — | `src/Models/Post.php:280` | +| `sirsoft-board.settings.after_bulk_apply` | action | — | `src/Services/BoardService.php:1368` | +| `sirsoft-board.settings.after_bulk_apply_aborted` | action | — | `src/Services/BoardService.php:1359` | +| `sirsoft-board.settings.before_bulk_apply` | action | — | `src/Services/BoardService.php:1263` | +| `sirsoft-board.user_post.store_validation_rules` | filter | — | `src/Http/Requests/User/StorePostRequest.php:162` | +| `sirsoft-board.user_post.update_validation_rules` | filter | — | `src/Http/Requests/User/UpdatePostRequest.php:112` | + + + +`{도메인}.{동사}` 이름 규칙 안에서 4개 도메인(board/post/comment/report)이 전부 같은 3단 +패턴(`before_*` → `filter_*_data` → `after_*`)을 반복합니다. 새 검증 규칙을 게시판별로 다르게 +걸고 싶으면 `*.store_validation_rules`/`update_validation_rules` filter 를, 저장 직전 데이터를 +가공하고 싶으면 `filter_*_data` 를, 저장 완료 후 부가 작업(알림·외부 연동)을 붙이고 싶으면 +`after_*` action 을 잡습니다 — `before_*` 에서 예외를 던지면 저장 자체를 막을 수 있습니다. + +`sirsoft-board.post.filter_content_thumbnail`/`sirsoft-board.search.post.index_should_update` +두 필터는 Model(`Post.php`) 안에서 직접 발행되는 예외적인 자리입니다 — Service 를 거치지 않는 +지연 평가(썸네일 추출, 검색 색인 갱신 여부 판단)라 Model 이 직접 훅을 겁니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.activity_log.filter_description_params` | filter | `ActivityLogDescriptionResolver` | `resolveDescriptionParams` | 10 | +| `core.module_settings.after_save` | action (미선언) | `SeoBoardSettingsCacheListener` | `onModuleSettingsSave` | 20 | +| `core.notification.filter_default_definitions` | filter | `BoardNotificationDataListener` | `contributeDefaultDefinitions` | 20 | +| `core.search.build_response` | filter | `SearchPostsListener` | `buildPostsResponse` | 10 | +| `core.search.index_validation_rules` | filter | `SearchPostsListener` | `addValidationRules` | 10 | +| `core.search.results` | filter | `SearchPostsListener` | `searchPosts` | 10 | +| `core.user.after_create` | action (미선언) | `UserNotificationSettingsListener` | `afterCreate` | 10 | +| `core.user.create_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 | +| `core.user.filter_create_data` | filter | `UserNotificationSettingsListener` | `filterCreateData` | 10 | +| `core.user.filter_resource_data` | filter | `UserNotificationSettingsListener` | `filterResourceData` | 10 | +| `core.user.filter_update_data` | filter | `UserNotificationSettingsListener` | `filterUpdateData` | 10 | +| `core.user.update_profile_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 | +| `core.user.update_validation_rules` | filter | `UserNotificationSettingsListener` | `addValidationRules` | 10 | +| `sirsoft-board.attachment.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterDelete` | 20 | +| `sirsoft-board.attachment.after_delete` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 | +| `sirsoft-board.attachment.after_download` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterDownload` | 20 | +| `sirsoft-board.attachment.after_link` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 | +| `sirsoft-board.attachment.after_upload` | action (미선언) | `BoardActivityLogListener` | `handleAttachmentAfterUpload` | 20 | +| `sirsoft-board.attachment.after_upload` | action (미선언) | `PostAttachmentCountSyncListener` | `syncAttachmentsCount` | 10 | +| `sirsoft-board.board.after_add_to_menu` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterAddToMenu` | 20 | +| `sirsoft-board.board.after_create` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterCreate` | 20 | +| `sirsoft-board.board.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterDelete` | 20 | +| `sirsoft-board.board.after_remove_from_menu` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterRemoveFromMenu` | 20 | +| `sirsoft-board.board.after_update` | action (미선언) | `BoardActivityLogListener` | `handleBoardAfterUpdate` | 20 | +| `sirsoft-board.board.after_update` | action (미선언) | `SeoBoardCacheListener` | `onBoardUpdate` | 20 | +| `sirsoft-board.board_type.after_create` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterCreate` | 20 | +| `sirsoft-board.board_type.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterDelete` | 20 | +| `sirsoft-board.board_type.after_update` | action (미선언) | `BoardActivityLogListener` | `handleBoardTypeAfterUpdate` | 20 | +| `sirsoft-board.comment.after_blind` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterBlind` | 20 | +| `sirsoft-board.comment.after_create` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterCreate` | 20 | +| `sirsoft-board.comment.after_create` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 | +| `sirsoft-board.comment.after_create` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 | +| `sirsoft-board.comment.after_create` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 | +| `sirsoft-board.comment.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterDelete` | 20 | +| `sirsoft-board.comment.after_delete` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 | +| `sirsoft-board.comment.after_delete` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 | +| `sirsoft-board.comment.after_delete` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 | +| `sirsoft-board.comment.after_restore` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterRestore` | 20 | +| `sirsoft-board.comment.after_restore` | action (미선언) | `BoardCommentsCountSyncListener` | `syncCommentsCount` | 10 | +| `sirsoft-board.comment.after_restore` | action (미선언) | `CommentReplySyncListener` | `syncRepliesCount` | 10 | +| `sirsoft-board.comment.after_restore` | action (미선언) | `PostCountSyncListener` | `syncCommentsCount` | 10 | +| `sirsoft-board.comment.after_update` | action (미선언) | `BoardActivityLogListener` | `handleCommentAfterUpdate` | 20 | +| `sirsoft-board.notification.channels` | filter | `BoardNotificationChannelListener` | `filterChannels` | 10 | +| `sirsoft-board.notification.extract_data` | filter | `BoardNotificationDataListener` | `extractData` | 20 | +| `sirsoft-board.post.after_blind` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterBlind` | 20 | +| `sirsoft-board.post.after_create` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterCreate` | 20 | +| `sirsoft-board.post.after_create` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 | +| `sirsoft-board.post.after_create` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 | +| `sirsoft-board.post.after_create` | action (미선언) | `SeoBoardCacheListener` | `onPostCreate` | 20 | +| `sirsoft-board.post.after_delete` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterDelete` | 20 | +| `sirsoft-board.post.after_delete` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 | +| `sirsoft-board.post.after_delete` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 | +| `sirsoft-board.post.after_delete` | action (미선언) | `SeoBoardCacheListener` | `onPostDelete` | 20 | +| `sirsoft-board.post.after_restore` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterRestore` | 20 | +| `sirsoft-board.post.after_restore` | action (미선언) | `BoardPostsCountSyncListener` | `syncPostsCount` | 10 | +| `sirsoft-board.post.after_restore` | action (미선언) | `PostReplySyncListener` | `syncRepliesCount` | 10 | +| `sirsoft-board.post.after_update` | action (미선언) | `BoardActivityLogListener` | `handlePostAfterUpdate` | 20 | +| `sirsoft-board.post.after_update` | action (미선언) | `SeoBoardCacheListener` | `onPostUpdate` | 20 | +| `sirsoft-board.report.after_blind_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterBlindContent` | 20 | +| `sirsoft-board.report.after_bulk_update_status` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterBulkUpdateStatus` | 20 | +| `sirsoft-board.report.after_create` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterCreate` | 20 | +| `sirsoft-board.report.after_delete` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterDelete` | 20 | +| `sirsoft-board.report.after_delete_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterDeleteContent` | 20 | +| `sirsoft-board.report.after_restore_content` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterRestoreContent` | 20 | +| `sirsoft-board.report.after_update_status` | action (미선언) | `BoardActivityLogListener` | `handleReportAfterUpdateStatus` | 20 | +| `sirsoft-board.settings.after_bulk_apply` | action (미선언) | `BoardActivityLogListener` | `handleSettingsAfterBulkApply` | 20 | +| `sirsoft-board.settings.after_bulk_apply` | action (미선언) | `SeoBoardSettingsCacheListener` | `onBulkApply` | 20 | +| `sirsoft-board.settings.after_bulk_apply_aborted` | action (미선언) | `BoardActivityLogListener` | `handleSettingsAfterBulkApplyAborted` | 20 | +| `sirsoft-ckeditor5.image.filter_reference_sources` | filter | `Ckeditor5ReferenceSourcesListener` | `addBoardSources` | 10 | +| `sirsoft-ecommerce.inquiry.count_replies` | filter | `EcommerceInquiryHookListener` | `countReplies` | 10 | +| `sirsoft-ecommerce.inquiry.create` | filter | `EcommerceInquiryHookListener` | `createAndReturn` | 10 | +| `sirsoft-ecommerce.inquiry.delete` | filter | `EcommerceInquiryHookListener` | `deletePost` | 10 | +| `sirsoft-ecommerce.inquiry.delete_reply` | filter | `EcommerceInquiryHookListener` | `deleteReplyPost` | 10 | +| `sirsoft-ecommerce.inquiry.get_by_ids` | filter | `EcommerceInquiryHookListener` | `getByIds` | 10 | +| `sirsoft-ecommerce.inquiry.get_settings` | filter | `EcommerceInquiryHookListener` | `getBoardSettings` | 10 | +| `sirsoft-ecommerce.inquiry.update` | filter | `EcommerceInquiryHookListener` | `updatePost` | 10 | +| `sirsoft-ecommerce.inquiry.update_reply` | filter | `EcommerceInquiryHookListener` | `updateReplyPost` | 10 | + + + +`core.user.*` 4종을 구독하는 이유는 회원가입/수정 화면에 "댓글 알림 수신 여부" 필드를 끼워 +넣기 위해서입니다 — 이 필드는 `UserNotificationSetting` 모델(board 소유)에 저장되지만, 입력 +자체는 코어 회원 폼에서 받습니다. `sirsoft-ckeditor5.image.filter_reference_sources` 구독은 +board 글 본문(HTML 에디터)에 삽입된 이미지가 삭제 시 함께 정리되도록 참조 소스 목록에 게시글을 +등록하는 자리입니다. `sirsoft-ecommerce.inquiry.*` 8개는 이커머스 "상품 문의"가 board 의 +Post/Comment CRUD 를 그대로 재사용하되 저장 로직만 이커머스가 대신 처리하는 위임 지점입니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `ActivityLogDescriptionResolver` | 1개 | 명시 등록 | ✅ | `src/Listeners/ActivityLogDescriptionResolver.php` | +| `BoardActivityLogListener` | 30개 | 명시 등록 | ✅ | `src/Listeners/BoardActivityLogListener.php` | +| `BoardCommentsCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/BoardCommentsCountSyncListener.php` | +| `BoardNotificationChannelListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/BoardNotificationChannelListener.php` | +| `BoardNotificationDataListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/BoardNotificationDataListener.php` | +| `BoardPostsCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/BoardPostsCountSyncListener.php` | +| `Ckeditor5ReferenceSourcesListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/Ckeditor5ReferenceSourcesListener.php` | +| `CommentReplySyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/CommentReplySyncListener.php` | +| `EcommerceInquiryHookListener` | 8개 | 명시 등록 | ✅ | `src/Listeners/EcommerceInquiryHookListener.php` | +| `PostAttachmentCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostAttachmentCountSyncListener.php` | +| `PostCountSyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostCountSyncListener.php` | +| `PostReplySyncListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/PostReplySyncListener.php` | +| `SearchPostsListener` | 3개 | 명시 등록 | ✅ | `src/Listeners/SearchPostsListener.php` | +| `SeoBoardCacheListener` | 4개 | 명시 등록 | ✅ | `src/Listeners/SeoBoardCacheListener.php` | +| `SeoBoardSettingsCacheListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/SeoBoardSettingsCacheListener.php` | +| `UserNotificationSettingsListener` | 7개 | 명시 등록 | ✅ | `src/Listeners/UserNotificationSettingsListener.php` | + + + +`BoardActivityLogListener` 하나가 30개 훅을 구독하는 것이 의도된 형태입니다 — 활동 로그는 +"무엇이 언제 왜 바뀌었는가"를 도메인 전체에서 일관된 형식으로 남겨야 하므로, 도메인별로 +리스너를 쪼개면 로그 스키마가 갈라질 위험이 커집니다. 반대로 카운트 동기화(`*CountSyncListener`) +는 목적이 하나씩이라 도메인별로 쪼개져 있습니다 — 첨부 개수와 댓글 개수는 서로 독립적으로 +실패해도 되므로, 한쪽이 예외를 던져도 다른 쪽 동기화는 영향받지 않습니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/admin-ecommerce-inquiry-settings.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin_dashboard_community.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/admin_dashboard_quick_menu.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user-notification-detail.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/user-notification-settings.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +5개 조각 중 `admin-ecommerce-inquiry-settings.json`·`admin_dashboard_community.json`· +`admin_dashboard_quick_menu.json` 은 board 자신의 화면이 아니라 **다른 확장(이커머스 문의 +설정 화면, 관리자 대시보드)에** 게시판 관련 UI 를 끼워 넣는 조각입니다. 이 모듈이 다른 확장의 +레이아웃을 코드로 알지 못한 채(레이아웃 확장 시스템을 통해서만) UI 를 주입한다는 뜻입니다. +나머지 2개(`user-notification-*`)는 코어 회원 알림 설정 화면에 board 알림 수신 옵션을 +끼워 넣는 자리입니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +공개 API 라우트(`optional.sanctum`)와 관리자 API 라우트(코어 `auth`+권한 미들웨어)는 전부 +코어가 이미 등록한 미들웨어로 충분합니다. board 만의 요청 전처리(예: 게시판별 rate limit)가 +필요해지면 이 자리에 선언형으로 추가하되, 대상(targets)을 명시해 자기 라우트에만 부착합니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +실시간 갱신(새 댓글이 열려 있는 화면에 즉시 반영되는 등)은 이 모듈의 범위 밖입니다(§1 참고). +필요해지면 `sirsoft-board.{slug}.*` 채널을 신설하되, 게시판별로 채널을 분리해야 방문자가 +관심 없는 다른 게시판의 이벤트까지 구독하지 않습니다. + + +## 스케줄 + + +| 스케줄 | 주기 | 설명 | +|---|---|---| +| `sirsoft-board:aggregate-stats` | `-` | 대시보드 게시물 현황 집계 | +| `sirsoft-board:prune-attachments --scheduled` | `-` | 방치된 임시 첨부 정리 + 보존기간 경과 삭제 첨부 영구 정리 | + + + +`prune-attachments` 는 두 가지 서로 다른 작업을 한 스케줄에 묶습니다 — "방치된 임시 첨부 +정리"(업로드했지만 게시글 저장까지 이어지지 않은 파일)는 사용자 파일을 지우지 않으므로 항상 +실행되고, "보존기간 경과 삭제 첨부 영구 정리"(이미 삭제 처리된 첨부의 실제 파일 파기)는 +`attachment_settings.purge_enabled` 로 게이트됩니다 — module.php 의 `enabled_config: null` 은 +스케줄 자체는 끌 수 없다는 뜻이고, 실제 파기 여부만 설정으로 조정됩니다. + + +## 알림 정의 + + +| 알림 키 | 채널 | +|---|---| +| `new_comment` | `mail`, `database` | +| `reply_comment` | `mail`, `database` | +| `post_reply` | `mail`, `database` | +| `post_action` | `mail`, `database` | +| `new_post_admin` | `mail`, `database` | +| `report_received_admin` | `mail`, `database` | +| `report_action` | `mail`, `database` | + + + +7종 중 `new_comment`/`reply_comment`/`post_reply` 는 회원이 끌 수 있습니다 +(`UserNotificationSetting`, `core.user.*` 훅으로 회원 폼에 노출) — 반면 관리자 대상 알림 +(`new_post_admin`/`report_received_admin`)과 신고 처리 결과 알림(`post_action`/`report_action`) +은 끌 수 없습니다. `post_action`과 `report_action`이 정확히 같은 훅 6개를 구독하는 것은 +중복이 아니라 **관점의 차이**입니다 — 관리자가 직접 블라인드했는지, 신고 처리 결과로 +블라인드됐는지에 따라 원 작성자에게 보이는 문구(원인 설명)가 갈라져야 하기 때문입니다. + diff --git a/modules/_bundled/sirsoft-board/docs/frontend.md b/modules/_bundled/sirsoft-board/docs/frontend.md new file mode 100644 index 00000000..6efd4025 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/frontend.md @@ -0,0 +1,121 @@ +# 게시판 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 46개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 46개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `admin_board_form` | `admin` | 화면 | `_admin_base` | +| `admin_board_index` | `admin` | 화면 | `_admin_base` | +| `admin_board_post_detail` | `admin` | 화면 | `_admin_base` | +| `admin_board_post_form` | `admin` | 화면 | `_admin_base` | +| `admin_board_posts_index` | `admin` | 화면 | `_admin_base` | +| `admin_board_reports_detail` | `admin` | 화면 | `_admin_base` | +| `admin_board_reports_index` | `admin` | 화면 | `_admin_base` | +| `admin_board_settings` | `admin` | 화면 | `_admin_base` | +| `_board_type_manage_modal` | `admin` | partial | - | +| `_tab_basic` | `admin` | partial | - | +| `_tab_list` | `admin` | partial | - | +| `_tab_notification` | `admin` | partial | - | +| `_tab_permissions` | `admin` | partial | - | +| `_tab_post` | `admin` | partial | - | +| `_comment` | `admin` | partial | - | +| `_comments` | `admin` | partial | - | +| `_post_card_content` | `admin` | partial | - | +| `_reply_card_content` | `admin` | partial | - | +| `_attachments` | `admin` | partial | - | +| `_form_fields` | `admin` | partial | - | +| `_parent_post` | `admin` | partial | - | +| `_alert_status` | `admin` | partial | - | +| `_card_history` | `admin` | partial | - | +| `_card_report_info` | `admin` | partial | - | +| `_bulk_apply_modal` | `admin` | partial | - | +| `_modal_identity_policy_delete` | `admin` | partial | - | +| `_modal_identity_policy_form` | `admin` | partial | - | +| `_modal_mail_template_edit` | `admin` | partial | - | +| `_modal_notification_definition_reset` | `admin` | partial | - | +| `_modal_notification_template_edit` | `admin` | partial | - | +| `_modal_notification_template_preview` | `admin` | partial | - | +| `_tab_board_settings_attachment` | `admin` | partial | - | +| `_tab_board_settings_basic` | `admin` | partial | - | +| `_tab_board_settings_bulk_apply` | `admin` | partial | - | +| `_tab_board_settings_comment` | `admin` | partial | - | +| `_tab_board_settings_list` | `admin` | partial | - | +| `_tab_board_settings_notification` | `admin` | partial | - | +| `_tab_board_settings_permissions` | `admin` | partial | - | +| `_tab_board_settings_post` | `admin` | partial | - | +| `_tab_board_settings_reply` | `admin` | partial | - | +| `_tab_general` | `admin` | partial | - | +| `_tab_identity_policies` | `admin` | partial | - | +| `_tab_notification_definitions` | `admin` | partial | - | +| `_tab_report_policy` | `admin` | partial | - | +| `_tab_seo` | `admin` | partial | - | +| `_tab_spam_security` | `admin` | partial | - | + + + +46개 레이아웃이 **전부** `admin` 그룹입니다 — 이것은 이 모듈이 방문자 화면을 그리지 않는다는 +증거입니다(§1, §architecture.md 참고). 새로 방문자용 게시판 화면(예: 다른 스타일의 목록)이 +필요하면 이 디렉토리가 아니라 그 화면을 쓸 템플릿의 `layouts/` 에 추가합니다. `_tab_*` partial +이 24개로 가장 많은 것은 이 모듈에 탭 구조를 쓰는 화면이 최소 세 곳이기 때문입니다 — +게시판 생성/수정 폼(`_tab_basic`/`list`/`post`/`permissions`/`notification`), 게시판별 +개별 설정 화면(`_tab_board_settings_*` 9개 — attachment/basic/bulk_apply/comment/list/ +notification/permissions/post/reply), 모듈 전역 환경설정 화면(`_tab_general`/ +`identity_policies`/`notification_definitions`/`report_policy`/`seo`/`spam_security`). +세 화면의 탭 이름이 겹치더라도(`_tab_basic`, `_tab_board_settings_basic` 등) 서로 다른 +partial 파일이므로 한쪽만 고치면 다른 화면은 그대로입니다 — 같은 항목을 여러 화면에 +반영해야 한다면 파일을 전부 찾아 고쳐야 합니다. + + +## 액션 핸들러 + + +_등록하는 액션 핸들러가 없습니다._ + + + +이 모듈의 관리자 레이아웃은 코어 빌트인 핸들러(`apiCall`/`navigate`/`setState` 등)만으로 +전부 구성됩니다 — 게시판 CRUD·신고 처리·설정 저장은 결국 REST 호출 + 표준 폼 상태 관리라 +전용 핸들러를 등록할 필요가 없었습니다. 새 관리자 화면을 추가할 때도 먼저 빌트인 핸들러 +조합으로 가능한지 확인하고, 그래도 부족할 때만(예: 파일 업로드 진행률 같은 복잡한 클라이언트 +상태) `resources/js/` 에 전용 핸들러를 신설합니다. + + +## 전역 진입점 + + +_프론트 엔트리포인트가 없습니다._ + + + +액션 핸들러가 없는 것과 같은 이유로 `window.__[Name]` 재등록 진입점도 없습니다 — 로케일 +전환 후 재등록해야 할 자체 핸들러가 이 모듈에는 없기 때문입니다. 프론트 전용 코드 +(`resources/js/`)를 신설하면 그 순간부터 이 자리에 진입점을 만들어야 합니다(§CLAUDE.md +"확장 미들웨어는..." 항목 인근의 재등록 진입점 규정 참고) — 없으면 로케일 전환 후 그 확장의 +액션이 전부 무반응이 됩니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +`editor-spec.json` 이 이 모듈의 유일한 프론트 자산인 것도 위와 같은 이유입니다 — 레이아웃 +편집기가 게시판 관리 화면의 컴포넌트를 인식하려면 이 선언이 필요하지만, 실행 시점에 로드할 +JS/CSS 번들은 없습니다. `priority: 100` 은 다른 확장의 에셋 우선순위와 충돌하지 않는 기본값이며, +`dependencies: []` 는 이 확장의 에디터 스펙이 다른 확장의 스펙 로드를 전제하지 않는다는 뜻입니다. + diff --git a/modules/_bundled/sirsoft-board/docs/settings.md b/modules/_bundled/sirsoft-board/docs/settings.md new file mode 100644 index 00000000..da7c4889 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/settings.md @@ -0,0 +1,98 @@ +# 게시판 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +_`getSettingsSchema()` 선언이 없습니다._ + +기본값 파일: `config/settings/defaults.json` + + + +`getSettingsSchema()` 가 없는 것은 누락이 아니라 설계입니다 — 이 모듈에는 "전역 설정"이라 +부를 만한 것이 없습니다. 운영자가 조정하는 값은 전부 **게시판 하나**에 속한 설정 +(`BoardSettingsService`, `/admin/boards/{slug}/settings`)이라 코어의 전역 설정 스키마 +메커니즘과 맞지 않습니다. `config/board.php` 는 운영자가 바꾸는 자리가 아니라 개발자가 +정의하는 상수(첨부 저장 디스크, 게시판별 동적 권한 정의 템플릿)이며, 이 값은 `.env` 로만 +바꿉니다. + + +## 권한 + + +| 카테고리 | 이름 | 액션 | 라우트 키 | +|---|---|---|---| +| `boards` | 게시판 관리 | `read`, `create`, `update`, `delete` | `board` | +| `settings` | 환경설정 | `read`, `update` | - | +| `identity.policies` | 게시판 본인인증 정책 | `read`, `update` | - | +| `dashboard` | 게시판 대시보드 | `view` | - | +| `reports` | 게시판 신고 관리 | `view`, `manage` | `report` | + + + +위 표는 **모듈 레벨** 권한(게시판 관리 자체를 다루는 관리자 권한)만 보여줍니다. 게시판 하나를 +만들면 그 게시판 전용 권한이 `config/board.php` 의 `board_permission_definitions` 템플릿을 +기반으로 추가 생성됩니다(admin.posts.read/write, posts.read-secret 등 — 게시판마다 독립적인 +권한 묶음). 그 동적 권한은 이 표에 나타나지 않으며 `getDynamicPermissionIdentifiers()` 로만 +전수를 확인할 수 있습니다. `boards`/`reports` 카테고리에 `resource_route_key`/`owner_key` 가 +붙어 있는 것은 소유자 기반 스코프 판정(자기 글만 관리 가능한 `manager` 이하 역할 등)이 걸려 +있다는 뜻입니다. + + +## 메뉴 + + +| 구분 | slug | 이름 | URL | 하위 | +|---|---|---|---|---| +| 관리자 | `sirsoft-board` | 게시판 관리 | - | 3개 | + + + +정적 관리자 메뉴는 "게시판 관리" 3개 하위 메뉴(환경설정/목록/신고현황)뿐입니다. 게시판을 +만들 때마다 생기는 `board-{slug}` 메뉴는 동적 메뉴라 이 표에 없으며 +`getDynamicMenuSlugs()` 로 전수를 확인합니다. 방문자용 메뉴(사이트 상단 게시판 링크 등)는 +이 모듈이 등록하지 않습니다 — 템플릿이 공개 API(`boards.board-menu`)를 호출해 직접 구성합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/modules/sirsoft-board/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +같은 `src/routes/api.php` 파일 안에 관리자 전용 그룹(`/admin/board/{slug}/...`, 권한 미들웨어)과 +공개 그룹(`/boards/...`, `optional.sanctum`)이 함께 있습니다 — 파일을 분리하지 않은 것은 +board 의 라우트가 20개 안팎으로 한 파일에서 관리 가능한 규모이기 때문입니다. 새 공개 +엔드포인트를 추가할 때는 반드시 `optional.sanctum`(비회원도 접근 가능, 회원이면 컨텍스트 +주입)을 쓰고 `auth:sanctum` 을 쓰지 않습니다 — 게시판 열람은 비회원에게도 열려 있어야 합니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +| 확장 | 유형 | 요구 버전 | +|---|---|---| +| `sirsoft-basic` | 템플릿 | `>=1.0.0` | + + + +이 모듈이 의존하는 확장이 "없음"인 것은 board 가 코어 훅·API 만으로 완결되도록 설계됐다는 +뜻입니다. 반대로 이 모듈에 의존하는 쪽은 하나(템플릿)뿐이지만, 그보다 결합이 느슨한 +**필터 훅 위임** 소비자(이커머스 문의)는 `dependencies` 로 선언되지 않습니다 — 이커머스는 +board 를 자기 도메인으로 대체할 뿐 board API 계약에 실제로 묶여 있지 않기 때문입니다. +이 모듈의 공개 표면(라우트·API 응답 구조)을 바꿀 때는 `sirsoft-basic` 의 최소 버전 상향을 +검토해야 합니다(§CLAUDE.md "확장 → 확장 동기화"). + diff --git a/plugins/_bundled/sirsoft-gdpr/AGENTS.md b/plugins/_bundled/sirsoft-gdpr/AGENTS.md new file mode 100644 index 00000000..84ba34ca --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/AGENTS.md @@ -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. 이 확장은 무엇인가 + + +GDPR(EU) 및 한국 개인정보보호법이 요구하는 "동의 전 처리 금지" 원칙을 서버·클라이언트 양쪽에서 +강제하는 플러그인입니다. 두 계층이 서로 다른 것을 막습니다 — 서버 계층(`CookieConsentMiddleware`) +은 백엔드가 심으려는 Set-Cookie 헤더를, 클라이언트 계층(자동 차단 스크립트)은 외부 추적 +스크립트·iframe·1st-party 저장소(localStorage/sessionStorage) 접근을 각각 동의 전까지 막습니다. +"동의했다"는 사실 자체도 상태(`gdpr_user_consents`, mutable)와 이력(`gdpr_user_consent_histories`, +immutable append-only)으로 이중 기록합니다 — 지금 상태 조회와 "언제 무엇에 동의했었는가" 입증 +(Art.7(1))은 서로 다른 질문이라 하나로 합칠 수 없습니다. + +**설계 원칙**: 정책 버전 발행은 수동입니다 — 정책 본문이 바뀌었다고 자동으로 전 회원 재동의를 +트리거하지 않습니다. 운영자가 "이 변경이 재동의가 필요한 변경인가"를 판단해 명시적으로 발행 +버튼을 눌러야 합니다(README "사용 방법" 표 참고). 자동화하면 사소한 오탈자 수정에도 전 회원이 +재동의 화면을 보게 되어 UX 를 해칩니다. + +**의도적으로 하지 않는 것**: 게스트 → 회원 동의 자동 승계(§README 소개 참고), 그리고 운영자가 +등록한 "허용" functional 쿠키 화이트리스트도 두지 않습니다 — functional 미동의 시 strictly +necessary 4종(`XSRF-TOKEN`/세션/`laravel_maintenance`/`gdpr_session`)을 제외한 **모든** 쿠키를 +차단합니다. EDPB Guidelines 2/2023 §16 원칙이 "비필수는 동의 전 전면 차단"이지 "등록된 것만 +차단"이 아니기 때문입니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `upgrades/` | 업그레이드 스텝 | DB·설정 구조 변경 시 작성 (모듈/플러그인 전용) | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-gdpr --force` (빌드 불필요) | +| `resources/routes.json` | 라우트 → 레이아웃 매핑 | `php artisan plugin:update sirsoft-gdpr --force` | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-gdpr --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-gdpr --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-gdpr --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-gdpr --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**동의 부여**: `Public\GdprCookieConsentController` → `GdprConsentService::grantConsent()` — +회원이면 `user_id`, 게스트면 서명된 `gdpr_session` 쿠키로 식별한 뒤 `GdprUserConsent`(현재 +상태, upsert)와 `GdprUserConsentHistory`(이력, insert-only) 를 같은 트랜잭션에서 함께 기록하고 +`sirsoft-gdpr.consent.granted` 훅을 발행합니다. 이 흐름은 배너·마이페이지·회원가입·전체 +재동의 4개 진입점이 전부 공유합니다 — 진입점마다 다른 저장 로직을 만들지 않습니다. + +**요청마다 반복되는 게이팅**: `CookieConsentMiddleware`(web/api 그룹에 prepend)가 응답 직전 +`GdprConsentService::getCurrentCookieConsents()` 로 현재 functional 동의 여부를 조회하고, +미동의면 strictly-necessary 4종을 제외한 모든 Set-Cookie 헤더를 응답에서 제거합니다. 이 +서버측 게이팅과 별개로, 클라이언트에서는 `data-gdpr-category` 속성이 붙은 스크립트/iframe 과 +분석/마케팅 카테고리 도메인 매칭 리소스가 동의 전까지 로드되지 않습니다. + +**회원탈퇴와 완전삭제는 다른 훅, 다른 처리**입니다 — `GdprUserWithdrawListener` +(`core.user.after_withdraw`)는 코어가 user 행 자체를 보존하는 "탈퇴"에 반응해 활성 동의를 +전부 철회 처리(UPDATE + `source=withdraw` revoked 이력 INSERT)할 뿐 신원 정보는 그대로 +남깁니다 — 탈퇴는 "의사 표시 종료"이지 신원 삭제가 아니기 때문입니다. 반대로 +`GdprUserDeleteListener`(`core.user.before_delete`, 완전 삭제/hard delete)는 이력 행의 +`user_id`/IP/User-Agent 만 NULL 로 **익명화**하고 행 자체는 남깁니다(Art.17 삭제권과 Art.7(1) +입증 의무 양립). 두 훅을 헷갈리면 탈퇴 시점에 신원이 조기 삭제되거나, 완전삭제 시점에 입증 +자료 행까지 통째로 사라집니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 발행 훅 | 2개 | [발행 훅](docs/extension-points.md#발행-훅) | +| 구독 훅 | 4개 | [구독 훅](docs/extension-points.md#구독-훅) | +| 훅 리스너 | 4개 | [훅 리스너](docs/extension-points.md#훅-리스너) | +| 레이아웃 확장 | 2개 | [레이아웃 확장](docs/extension-points.md#레이아웃-확장) | +| 미들웨어 | 1개 | [미들웨어](docs/extension-points.md#미들웨어) | +| 브로드캐스트 채널 | 0개 | [브로드캐스트 채널](docs/extension-points.md#브로드캐스트-채널) | +| 스케줄 | 0개 | [스케줄](docs/extension-points.md#스케줄) | +| 알림 정의 | 0개 | [알림 정의](docs/extension-points.md#알림-정의) | + + + +다른 확장이 이 플러그인 없이는 몰랐을 사실(방문자가 방금 분석 카테고리에 동의/철회했다)을 +알아야 할 때 `sirsoft-gdpr.consent.granted`/`revoked` 훅을 잡습니다 — 예: 분석 SDK 초기화를 +"페이지 로드 시 무조건"이 아니라 "동의 부여 시에만" 하고 싶은 확장. 반대로 자체 호스팅 +추적 자원을 이 플러그인의 자동 차단·복원 대상에 포함시키고 싶다면 훅이 아니라 HTML 속성 +(`data-gdpr-category="analytics"`)을 붙이는 쪽이 맞습니다 — 이 플러그인이 그 속성을 스캔해 +차단/복원을 대신 수행하므로 소비 측 코드가 필요 없습니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan plugin:update sirsoft-gdpr --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재 +- [ ] 스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝 +- [ ] 발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다) +- [ ] API 표면 변경 시 `php artisan api:docgen --scope=plugin:sirsoft-gdpr` 재실행 + `docs/api/**` 갱신 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] `gdpr_user_consent_histories` 는 append-only — UPDATE/DELETE 로 기존 행을 고치지 않는다 (완전삭제 시 익명화 UPDATE 예외는 `GdprUserDeleteListener` 단일 지점에서만 수행) +- [ ] 새 자동 차단 카테고리(기능/분석/마케팅 외)를 추가하면 배너 UI·`blocked_domains` 스키마·차단 스크립트 3곳 동기화 +- [ ] `CookieConsentMiddleware` 의 strictly-necessary allowlist(4종)를 확장할 때는 ePrivacy Art.5(3) 면제 항목인지 먼저 검토 — 임의로 늘리면 동의 전 차단 원칙이 무력화된다 + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| `gdpr_user_consent_histories` 행을 UPDATE/DELETE 로 직접 정정 | 정정이 필요하면 새 이력 행을 INSERT | 이력은 시점별 스냅샷이 생명 — 과거 행을 고치면 Art.7(1) 입증 자료로서 효력을 잃는다 | +| 회원탈퇴(`after_withdraw`)에서 신원 정보(user_id 등)를 제거 | 활성 동의만 철회 처리, 신원은 완전삭제(`before_delete`) 시점에만 익명화 | 두 이벤트를 섞으면 탈퇴 회원의 재가입·이력 조회가 깨진다 | +| 운영자가 등록하지 않은 functional 쿠키를 화이트리스트에 추가 | strictly necessary 4종 고정 목록만 예외 | GDPR 은 "동의 전 전면 차단"이 원칙이지 "등록된 것만 차단"이 아니다 | +| 정책 버전 발행을 코드/배치로 자동화 | 운영자가 매번 명시적으로 "+ 새 버전 발행" 클릭 | 자동화하면 사소한 문구 수정에도 전 회원이 재동의 화면을 보게 된다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 24개 | `plugins/_bundled/sirsoft-gdpr/tests` | +| Vitest | 12개 | `vitest.config.ts` | +| Playwright | 3개 | `tests/Playwright` | +| 시나리오 매니페스트 | 5개 | `tests/scenarios` | + +기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지). + +```bash +# PHPUnit (변경 범위만) (Bash) +php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests --filter='<대상클래스>' + +# Vitest (확장 디렉토리에서) (PowerShell) +cd plugins/_bundled/sirsoft-gdpr && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ | +| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ | +| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ | +| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ | +| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 255bdaa4..3751f453 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-08-31 + +### Fixed + +- 회원탈퇴로 자동 철회된 동의 이력이 관리자 「GDPR 동의 이력」 화면의 출처 필터로 걸러지지 않던 문제를 수정했습니다. + ## [1.0.3] - 2026-08-19 ### Fixed diff --git a/plugins/_bundled/sirsoft-gdpr/README.md b/plugins/_bundled/sirsoft-gdpr/README.md index 729a99b7..bf22c348 100644 --- a/plugins/_bundled/sirsoft-gdpr/README.md +++ b/plugins/_bundled/sirsoft-gdpr/README.md @@ -1,151 +1,209 @@ -# GDPR Plugin for G7 +# GDPR -GDPR(유럽 일반 데이터 보호 규정) 및 한국 개인정보보호법 대응 핵심 기능을 제공하는 G7 플러그인입니다. 쿠키 동의 배너·동의 전 자동 차단·동의 이력 영구 저장·마이페이지 동의 철회를 한 패키지로 제공합니다. +**G7 플러그인 · sirsoft-gdpr** +GDPR·개인정보보호법 대응 쿠키 동의 배너와 동의 이력 관리를 제공하는 플러그인 -## 핵심 기능 + +

+ version 1.0.4 + type 플러그인 + G7 >=7.0.6 + license MIT +

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

+ version 1.1.2 + type 플러그인 + G7 >=7.0.10 + license MIT + requires sirsoft-ecommerce +

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

+ version 1.0.3 + type 플러그인 + G7 >=7.0.10 + license MIT + requires sirsoft-ecommerce +

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

+ version 1.0.2 + type 플러그인 + G7 >=7.0.10 + license MIT + requires sirsoft-ecommerce +

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

+ version 1.0.2 + type 플러그인 + G7 >=7.0.10 + license MIT + requires sirsoft-ecommerce +

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

+ version 1.0.4 + type 플러그인 + G7 >=7.0.8 + license MIT +

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

+ version 1.0.1 + type 플러그인 + G7 >=7.0.6 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +NHN KCP 휴대폰 본인확인을 G7 코어의 본인인증(IDV) 체계에 연결하는 플러그인입니다. +`sirsoft-verification_kginicis`와 같은 부류(코어 표준 인터페이스 구현)지만 다른 벤더이며, +운영자는 둘 중 하나 또는 둘 다 설치해 사용할 수 있습니다. + +이 플러그인만의 특징은 인증 화면 진입 방식이 기기별로 갈린다는 점입니다 — 데스크톱은 +팝업, 모바일은 전체 페이지 리다이렉트를 씁니다. 모바일 브라우저는 팝업 UX 가 나쁘고 +문자 인증 같은 앱 전환이 얽히면 팝업 컨텍스트가 끊기기 쉽기 때문입니다. + +kginicis 와 마찬가지로 이 플러그인은 실제 개인식별정보(PII)를 직접 보관하며, 결제 PG +플러그인들과 달리 `sirsoft-ecommerce`를 비롯한 어떤 확장에도 의존하지 않습니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 본인확인 | NHN KCP 휴대폰 본인확인 팝업(데스크톱)/리다이렉트(모바일) | +| 성인인증 | 만 19세 이상 여부만 확인하는 별도 purpose | +| 중복가입 차단 | DI 또는 CI 기준으로 동일인 재가입 차단 (선택) | +| 게스트 처리 | 비로그인 사용자의 본인확인 결과를 임시 보관 후 가입 완료 시 흡수 | +| 개인정보 정리 | 사용자 탈퇴/삭제 시 보관 중인 PII 레코드 자동 파기 | +| 마이페이지 | 본인확인 완료 상태 카드 노출 | +| 관리자 설정 | 테스트/라이브 모드 전환, 중복가입 판정 기준 설정 | + + +## 동작 방식 + + +```mermaid +flowchart LR + A[코어가 IDV 요구 · 428] --> B{기기 판별} + B -->|데스크톱| C[사용자 클릭 → 팝업 오픈] + B -->|모바일| D[복귀 정보 저장 → 페이지 리다이렉트] + C --> E[KCP 인증 완료 → 팝업 종료 감지] + D --> F[KCP 인증 완료 → 콜백 복귀] + E --> G[결과 회수 → PII 확보] + F --> G + G -->|로그인 사용자| H[레코드 즉시 저장] + G -->|비로그인 사용자| I[Cache 임시 보관 → 가입 완료 시 흡수] +``` + +데스크톱 팝업은 반드시 사용자 클릭 이벤트 안에서 열립니다 — 비동기 콜백 이후에 열면 +브라우저 팝업 차단기에 걸립니다. 모바일 리다이렉트는 이 제약이 없는 대신, 복귀 후 +원래 상태를 되살리기 위한 정보를 브라우저에 임시 저장합니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| G7 코어 | `>=7.0.6` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan plugin:install sirsoft-verification_nhnkcp + +# 활성화 +php artisan plugin:activate sirsoft-verification_nhnkcp + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan plugin:update sirsoft-verification_nhnkcp --force +``` + +저장소: https://github.com/gnuboard/g7-plugin-sirsoft-verification_nhnkcp + + +## 관리자 설정 + + +| 키 | 의미 | 기본값 | +|---|---|---| +| `is_test_mode` | 테스트 모드 | `true` | +| `test_site_cd` | 테스트 사이트코드 | `AO7F3` | +| `test_enc_key` | 테스트 암호화 키 | `c2a22fa3ebe4698075bcac6b433d52e351c881b02fb83488d4283a43385b1f8e` | +| `live_site_cd` | 운영 사이트코드 | - | +| `live_enc_key` | 운영 암호화 키 | - | +| `web_siteid` | 웹사이트 ID | - | +| `duplicate_field` | 중복 판정 필드 | `di` | +| `duplicate_block_enabled` | 중복 가입 차단 | `true` | + +개발자용 상세(타입·검증·저장 위치)는 [설정 스키마](docs/settings.md#설정-스키마) 를 보세요. + + + +`live_site_cd`는 저장 시 `SM` 프리픽스가 자동으로 붙으므로 프리픽스 없이 입력해도 됩니다. +`live_site_cd`/`live_enc_key`는 `is_test_mode`를 끄는(라이브 모드) 순간부터 필수가 +됩니다 — 테스트 모드에서는 비워둘 수 있습니다. `web_siteid`는 테스트/라이브 모드 공통으로 +쓰이는 웹사이트 식별자입니다. `duplicate_field`(`di` 또는 `ci`)와 +`duplicate_block_enabled`는 다른 IDV provider(예: `sirsoft-verification_kginicis`)와 +별개로 이 provider 를 통해 확인한 사용자에게만 적용됩니다. 라이브 암호화 키는 외부에 +노출하지 마세요. + + +## 사용 방법 + + +**활성화하기**: 플러그인을 활성화하면 자동으로 코어 IDV provider 목록에 등록됩니다. +별도 화면 배치 작업 없이 코어가 이미 정의한 IDV 강제 지점(회원가입 등)에서 즉시 +동작합니다. + +**중복가입 차단 켜기**: 동일인이 여러 계정을 만드는 것을 막고 싶다면 +`duplicate_block_enabled`를 켜고 `duplicate_field`로 DI/CI 중 판정 기준을 고릅니다. + +**모바일 동작 확인**: 데스크톱에서는 정상인데 모바일에서만 인증이 실패한다면 리다이렉트 +복귀 경로(`redirectStash`)가 원인일 가능성이 높습니다 — §트러블슈팅을 확인하세요. + +전체 API 목록은 [docs/api/](docs/api/README.md) 를, 발행/구독 훅 목록은 +[docs/extension-points.md](docs/extension-points.md) 를 참고하세요. + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/extension-points.md](docs/extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | ✅ | +| [docs/data-model.md](docs/data-model.md) | 모델·소유 테이블·마이그레이션·Enum | ✅ | +| [docs/settings.md](docs/settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | ✅ | +| [docs/frontend.md](docs/frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | ✅ | +| [docs/api/](docs/api/README.md) | API 레퍼런스 (엔드포인트별 파라미터·응답 필드) | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 데스크톱에서 인증 버튼을 눌러도 팝업이 안 뜸 | 브라우저 팝업 차단기 | `startAuth`가 클릭 이벤트 핸들러 안에서 직접 호출되는지 확인 | +| 모바일에서만 인증 완료 후 원래 화면으로 돌아오지 않음 | 리다이렉트 복귀 정보(`sessionStorage`의 `g7.identity.redirectStash`)가 유실됨 | 리다이렉트 도중 다른 탭/앱으로 완전히 전환되어 세션 스토리지가 초기화됐는지 확인 (동일 브라우저 탭 안에서 왕복해야 함) | +| 설정 저장 시 422 오류 | 라이브 모드인데 `live_site_cd`/`live_enc_key` 미입력 | `is_test_mode`를 켜거나 라이브 자격증명을 입력 | +| 이미 가입된 사용자인데 중복 오류 없이 재가입됨 | `duplicate_block_enabled`가 꺼져 있거나 `duplicate_field` 기준이 실제 판정과 다름 | 관리자 설정에서 두 값을 확인 | +| 탈퇴한 사용자의 본인확인 정보가 남아있는 것으로 보임 | 정리 리스너 실행 여부를 별도로 확인하지 않음 | `CleanKcpRecordOnUserWithdraw`/`CleanKcpRecordOnUserDelete` 정상 등록 여부를 훅 캐시에서 확인 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md new file mode 100644 index 00000000..a9c0e86d --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/README.md @@ -0,0 +1,22 @@ +# NHN KCP 휴대폰 본인확인 개발자 문서 + +> plugins/_bundled/sirsoft-verification_nhnkcp · 플러그인 + + +**훅 수**: 0 · **구독 훅 수**: 7 · **라우트 수**: 2 · **모델 수**: 2 · **테이블 수**: 2 · **마이그레이션 수**: 2 · **레이아웃 수**: 1 · **핸들러 수**: 1 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | +| [extension-points.md](extension-points.md) | 발행/구독 훅·미들웨어·채널·스케줄 | +| [data-model.md](data-model.md) | 모델·소유 테이블·마이그레이션·Enum | +| [settings.md](settings.md) | 설정 스키마·권한·메뉴·라우트·의존 관계 | +| [frontend.md](frontend.md) | 레이아웃·액션 핸들러·전역 진입점·에셋 | +| [api/](api/README.md) | API 레퍼런스 | +| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 | +| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 | + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md new file mode 100644 index 00000000..9f838741 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/architecture.md @@ -0,0 +1,70 @@ +# NHN KCP 휴대폰 본인확인 — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +NHN KCP 휴대폰 본인확인을 코어 IDV 체계에 연결하는 Provider 입니다. +`sirsoft-verification_kginicis`와 계약(코어 `IdentityVerificationInterface`)·PII 소유 +구조는 같지만, 인증 화면 진입 방식이 기기별로 갈립니다 — 데스크톱은 팝업, 모바일은 전체 +페이지 리다이렉트입니다. 이 분기가 이 플러그인 아키텍처 전반(프론트 상태 복원, +`sessionStorage` 사용)에 스며 있습니다. + + +## 계층 지도 + + +```text +Controller (Http/Controllers) → FormRequest (Http/Requests) + → KcpIdentityProvider (IdentityVerificationInterface 구현 — verify/challenge 표준 진입점) + → KcpCertClientInterface (외부 통신 + 암호화/복호화) + → KcpCertTransactionRepositoryInterface (진행 중인 인증 거래) + → KcpIdentityRecordRepositoryInterface (완료된 PII record) + → IdentityVerificationLogRepositoryInterface (코어 IDV 로그 조회) + → KcpDuplicateIdentityChecker (중복가입 판정 로직) + → CacheInterface (비로그인 verify PII 임시 stash) + +Listener (RegisterKcpProviderListener 등) + → 코어 identity/auth/user/settings 훅에 등록 (컴파일 타임 결합 없음) +``` + +`KcpDuplicateIdentityChecker`가 별도 협력자로 분리된 것은 `sirsoft-verification_kginicis`와의 +작은 차이입니다 — kginicis 는 그 판정 로직을 `AssertNoDuplicateInicisIdentity` 리스너 +안에 두는 반면, 이 플러그인은 Provider 자신도 같은 판정 로직을 재사용할 수 있도록 별도 +클래스로 뽑았습니다. + +`sirsoft-verification_kginicis`와 계층 구조가 거의 동일한 것은 둘 다 같은 코어 계약을 +구현하기 때문입니다 — 새 IDV provider 를 추가할 때 이 두 플러그인을 참조 구현으로 삼을 수 +있습니다. + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `plugin.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json·composer.json 동기화 | +| `plugin.php` | 진입 클래스 (선언형 표면 SSoT) | 표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토 | +| `src/Http/Controllers/` | 컨트롤러 | API 표면 변경 시 `api:docgen` 재실행 | +| `src/Http/Requests/` | FormRequest (검증 SSoT) | 검증 규칙은 Service 가 아니라 여기에 둔다 | +| `src/Http/Resources/` | API 리소스 | 목록 응답은 화면이 실제로 그리는 것만 싣는다 | +| `src/Services/` | 비즈니스 로직 | Repository 인터페이스 주입 (구체 클래스 금지) | +| `src/Repositories/` | 데이터 접근 | 목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인 | +| `src/Models/` | Eloquent 모델 | 스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반 | +| `src/Listeners/` | 훅 리스너 | Repository 경유 (Model·DB 파사드 직접 접근 금지) | +| `src/Enums/` | 상태·타입·분류 | 문자열 리터럴 대신 Enum 을 SSoT 로 둔다 | +| `src/routes/` | 라우트 | 모든 라우트에 `name()` 필수 | +| `database/migrations/` | 마이그레이션 | 한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필 | +| `resources/layouts/` | 레이아웃 JSON | `php artisan plugin:update sirsoft-verification_nhnkcp --force` (빌드 불필요) | +| `resources/js/` | 프론트 엔트리·핸들러 | `php artisan plugin:build` → `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `resources/extensions/` | 다른 확장 레이아웃에 주입하는 조각 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `config/` | 확장 config | 설정 기본값은 settings 스키마와 어긋나지 않게 | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan plugin:update sirsoft-verification_nhnkcp --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md new file mode 100644 index 00000000..9f8dd713 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/data-model.md @@ -0,0 +1,89 @@ +# NHN KCP 휴대폰 본인확인 — 데이터 모델 + +> 모델·소유 테이블·마이그레이션·Enum · 진입점: [AGENTS.md](../AGENTS.md) + +## 모델 + + +| 모델 | 테이블 | fillable | 관계 | 특성 | +|---|---|---|---|---| +| `KcpCertTransaction` | `nhnkcp_cert_transactions` | 5 | challenge→IdentityVerificationLog | - | +| `KcpIdentityRecord` | `nhnkcp_identity_records` | 15 | user→User | - | + + + +`sirsoft-verification_kginicis`의 `InicisIdentityRecord`/`InicisChallengeMapping`과 대칭인 +구조입니다 — `KcpIdentityRecord`가 PII 를 직접 보관하고(결제 플러그인들과의 근본적 차이), +`KcpCertTransaction`은 진행 중인 인증 시도만 다룹니다. 두 모델을 분리한 이유도 동일합니다 +— 인증 세션(거래 하나)과 확인 결과(사용자당 최신 1건)는 생명주기가 다릅니다. + + +## 소유 테이블 + + +| 테이블 | 모델 | +|---|---| +| `nhnkcp_cert_transactions` | `KcpCertTransaction` | +| `nhnkcp_identity_records` | `KcpIdentityRecord` | + + + +`nhnkcp_identity_records.user_id`는 `unique` 제약을 갖습니다 — 한 사용자는 본인확인 결과를 +1건만 보유하며, 재인증 시 기존 레코드를 갱신합니다. `sirsoft-verification_kginicis`와 +동일한 설계로, "이 사용자가 본인확인을 완료했는가"를 단순 존재 조회 하나로 판정합니다. + + +## 마이그레이션 + + +마이그레이션 2개. + +| 파일 | 생성 테이블 | 변경 테이블 | down() | +|---|---|---|---| +| `2026_07_28_000001_create_nhnkcp_identity_records_table.php` | `nhnkcp_identity_records` | `nhnkcp_identity_records` | ✅ | +| `2026_07_28_000002_create_nhnkcp_cert_transactions_table.php` | `nhnkcp_cert_transactions` | `nhnkcp_cert_transactions` | ✅ | + + + +`sirsoft-verification_kginicis`가 세 번째 마이그레이션으로 부가 필드를 nullable 완화한 +것과 달리, 이 플러그인은 두 테이블 생성만으로 끝났습니다 — 아직 같은 종류의 스키마 보정이 +필요했던 적이 없습니다. 향후 유사한 nullable 완화가 필요해지면 kginicis 의 사례(§CLAUDE.md +"소스 교정만으로는 기설치본이 낫지 않는다")를 참고합니다. + + +## Enum + + +| Enum | backing | case 수 | case | +|---|---|---|---| +| `KcpDuplicateField` | `string` | 2 | `di`, `ci` | + + + +`sirsoft-verification_kginicis`의 `InicisDuplicateField`와 case 구성이 동일합니다(`di`/`ci`) +— 두 벤더 모두 같은 개념(연계정보/연계정보 상위값)을 제공하기 때문입니다. `duplicate_field` +설정값과 `KcpDuplicateIdentityChecker`의 판정 컬럼 화이트리스트 양쪽에서 재사용되므로 Enum +으로 닫아 오타로 인한 "중복 없음" 오판정을 방지합니다. + + +## Repository + + +| 클래스 | 종류 | 설명 | +|---|---|---| +| `KcpCertTransactionRepository` | 구현 | `nhnkcp_cert_transactions` 테이블 Repository 구현체. | +| `KcpCertTransactionRepositoryInterface` | 인터페이스 | `nhnkcp_cert_transactions` 테이블 Repository 계약. | +| `KcpIdentityLogQueryRepository` | 구현 | KcpIdentityLogQueryRepositoryInterface 구현체. | +| `KcpIdentityLogQueryRepositoryInterface` | 인터페이스 | 본 plugin 이 발행한 코어 IDV 로그의 보조 조회/갱신 계약. | +| `KcpIdentityRecordRepository` | 구현 | `nhnkcp_identity_records` 테이블 Repository 구현체. | +| `KcpIdentityRecordRepositoryInterface` | 인터페이스 | `nhnkcp_identity_records` 테이블 Repository 계약. | + + + +3쌍으로 나뉜 것은 `sirsoft-verification_kginicis`와 같은 이유입니다 — +`KcpCertTransactionRepository`(진행 중인 인증 거래), `KcpIdentityRecordRepository`(완료된 +PII), `KcpIdentityLogQueryRepository`(코어 IDV 로그를 이 플러그인 관점으로 좁혀 읽는 +어댑터)가 각자 다른 데이터를 다룹니다. `KcpDuplicateIdentityChecker`(§architecture.md)는 +Repository 가 아니라 판정 로직 클래스입니다 — 두 Repository 를 조합해 판정만 하고 +데이터를 소유하지 않습니다. + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md new file mode 100644 index 00000000..576fee13 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/extension-points.md @@ -0,0 +1,123 @@ +# NHN KCP 휴대폰 본인확인 — 확장점 + +> 발행/구독 훅·미들웨어·채널·스케줄 · 진입점: [AGENTS.md](../AGENTS.md) + +## 발행 훅 + + +_이 확장은 훅을 발행하지 않습니다._ + + + +`sirsoft-verification_kginicis`는 같은 표에 3건이 잡히지만 실제로는 발행하는 훅이 +아닙니다(코어 호출부를 보여주는 docblock 예시를 소스 자동 감지가 오인한 결과) — 이 +플러그인은 그런 예시 서술 방식을 쓰지 않아 표가 정확히 비어 있습니다. 두 플러그인 모두 +실제로 발행하는 도메인 전용 훅은 없습니다. + + +## 구독 훅 + + +| 훅 이름 | 유형 | 리스너 | 메서드 | 우선순위 | +|---|---|---|---|---| +| `core.auth.after_register` | action (미선언) | `CompleteKcpRecordAfterRegister` | `handle` | 50 | +| `core.auth.before_register` | action (미선언) | `AssertNoDuplicateKcpIdentity` | `handle` | 20 | +| `core.identity.registered_providers` | filter | `RegisterKcpProviderListener` | `register` | 20 | +| `core.plugin_settings.filter_save_data` | filter | `ValidateKcpSettingsListener` | `normalizeLiveSiteCd` | 10 | +| `core.plugin_settings.update_validation_rules` | filter | `ValidateKcpSettingsListener` | `addLiveModeRules` | 10 | +| `core.user.after_withdraw` | action (미선언) | `CleanKcpRecordOnUserWithdraw` | `handle` | 50 | +| `core.user.before_delete` | action (미선언) | `CleanKcpRecordOnUserDelete` | `handle` | 50 | + + + +`ValidateKcpSettingsListener`가 2개 훅(`filter_save_data`/`update_validation_rules`)을 +동시에 구독하는 것은 `sirsoft-verification_kginicis`의 `ValidateInicisSettingsListener`(1개 +훅만 구독)와의 차이입니다 — kginicis 는 라이브 MID 프리픽스를 Provider 가 값을 쓸 때 +동적으로 붙이는 반면, 이 플러그인은 저장 시점에 정규화해 DB 에 프리픽스 붙은 값을 +남깁니다(§AGENTS.md "핵심 흐름"). `AssertNoDuplicateKcpIdentity`가 +`core.auth.before_register`(가입 **전**)을 구독하는 이유와 `core.user.before_delete`가 +`sync: true`인 이유는 kginicis 쪽과 동일합니다. + + +## 훅 리스너 + + +| 리스너 | 구독 훅 | 등록 방식 | HookListenerInterface | 파일 | +|---|---|---|---|---| +| `AssertNoDuplicateKcpIdentity` | 1개 | 명시 등록 | ✅ | `src/Listeners/AssertNoDuplicateKcpIdentity.php` | +| `CleanKcpRecordOnUserDelete` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanKcpRecordOnUserDelete.php` | +| `CleanKcpRecordOnUserWithdraw` | 1개 | 명시 등록 | ✅ | `src/Listeners/CleanKcpRecordOnUserWithdraw.php` | +| `CompleteKcpRecordAfterRegister` | 1개 | 명시 등록 | ✅ | `src/Listeners/CompleteKcpRecordAfterRegister.php` | +| `RegisterKcpProviderListener` | 1개 | 명시 등록 | ✅ | `src/Listeners/RegisterKcpProviderListener.php` | +| `ValidateKcpSettingsListener` | 2개 | 명시 등록 | ✅ | `src/Listeners/ValidateKcpSettingsListener.php` | + + + +6개 리스너 중 3개(`CompleteKcpRecordAfterRegister`, `CleanKcpRecordOnUserWithdraw`, +`CleanKcpRecordOnUserDelete`)는 `sirsoft-verification_kginicis`와 동일하게 사용자 +생명주기 각 단계에 맞춰 PII 레코드를 흡수하거나 정리하는 대칭 구조입니다 — 하나를 고칠 +때 나머지 둘도 같은 PII 필드를 다루고 있는지 확인해야 합니다. + + +## 레이아웃 확장 + + +| 대상 | 설명 | +|---|---| +| `resources/extensions/identity_provider_nhnkcp.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | +| `resources/extensions/mypage_identity_card.json` | 다른 확장/템플릿 레이아웃에 주입되는 조각 | + + + +`mypage_identity_card.json`은 `sirsoft-verification_kginicis`의 같은 이름 조각과 동일한 +역할(마이페이지 "본인확인 완료" 상태 카드)입니다 — 여러 IDV provider 가 동시에 설치돼도 +각자 자기 카드만 주입하므로 충돌하지 않습니다. `identity_provider_nhnkcp.json`은 코어 +IDV 팝업이 이 provider 고유의 안내 문구·로고를 보여줄 때 쓰는 조각입니다. + + +## 미들웨어 + + +_등록하는 미들웨어가 없습니다._ + + + +결제 플러그인들과 달리 이 플러그인은 PG 서버가 직접 호출하는 웹훅/통보 엔드포인트가 +없습니다 — 본인확인 결과는 팝업 콜백 또는 모바일 리다이렉트 콜백(둘 다 사용자 브라우저 +경유)으로만 도달하므로 IP 화이트리스트 같은 서버간 통신 검증이 필요 없습니다. + + +## 브로드캐스트 채널 + + +_등록하는 브로드캐스트 채널이 없습니다._ + + + +인증 결과는 팝업의 종료 감지 또는 모바일 리다이렉트 복귀로 프론트가 직접 회수합니다 +(§AGENTS.md "핵심 흐름") — 서버가 다른 클라이언트에 실시간으로 알려야 할 상태 변화가 +없어 브로드캐스트 채널이 필요 없습니다. + + +## 스케줄 + + +_등록하는 스케줄이 없습니다._ + + + +`nhnkcp_cert_transactions`의 만료된 인증 거래나 `redirectStash`의 잔존 세션 데이터를 +별도 배치로 청소하지 않습니다 — `sessionStorage`는 브라우저 세션 종료로 자연 소멸하고, +서버측 거래 레코드 정리는 사용자 삭제/탈퇴 시점 리스너가 담당합니다(§훅 리스너). + + +## 알림 정의 + + +_등록하는 알림 정의가 없습니다._ + + + +본인확인 성공/실패는 사용자가 즉시 확인하는 동기적 상호작용이라, 별도 알림(이메일/SMS +등)을 발송할 지점이 없습니다. + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md new file mode 100644 index 00000000..b2e5cfe1 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/frontend.md @@ -0,0 +1,80 @@ +# NHN KCP 휴대폰 본인확인 — 프론트엔드 + +> 레이아웃·액션 핸들러·전역 진입점·에셋 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 + + +레이아웃 1개 (루트: `resources/layouts`). + +| 그룹 | 개수 | +|---|---| +| `admin` | 1개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `plugin_settings` | `admin` | 화면 | `_admin_base` | + + + +`sirsoft-verification_kginicis`와 마찬가지로 이 플러그인이 소유한 화면 레이아웃은 관리자 +설정 화면 하나뿐입니다 — 회원가입·마이페이지의 본인확인 UI는 §레이아웃 확장 조각 및 코어 +IDV 공통 팝업 UI로 존재합니다. + + +## 액션 핸들러 + + +핸들러 1개 (정의: `resources/js/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `startAuth` | `sirsoft-verification_nhnkcp.startAuth` | + + + +`startAuth`는 `isMobileEnvironment()`로 기기를 판별해 데스크톱에서는 +`sirsoft-verification_kginicis`와 같은 팝업 패턴(사용자 클릭 컨텍스트 안에서 +`window.open`)을, 모바일에서는 `sessionStorage`(`g7.identity.redirectStash`)에 복귀 +정보를 저장한 뒤 전체 페이지 리다이렉트를 씁니다. 반드시 클릭 이벤트 핸들러 안에서 +호출돼야 팝업 차단을 피할 수 있다는 제약은 데스크톱 경로에만 해당합니다 — 모바일 +리다이렉트는 팝업 차단과 무관합니다. + + +## 전역 진입점 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `resources/js/index.ts` | +| 전역 객체 | `window.__SirsoftVerificationNhnkcp` | +| 재등록 진입점 | `initPlugin()` | + +로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다. + + + +`window.__SirsoftVerificationNhnkcp`로 노출되는 이유는 코어가 로케일 전환 시 이 이름으로 +재등록 진입점을 찾기 때문입니다(§CLAUDE.md "재등록 진입점"). `sirsoft-verification_kginicis`와 +마찬가지로 이 플러그인은 결제창 SDK 를 동적 로드하지 않습니다 — 인증 화면 자체가 서버 +렌더링 페이지(팝업 안) 또는 리다이렉트 대상(모바일)이므로 프론트가 별도 스크립트를 불러올 +필요가 없습니다. + + +## 에셋 + + +| 경로 | 구분 | +|---|---| +| `dist/js/plugin.iife.js` | 빌드 산출물 (커밋 대상) | +| `editor-spec.json` | 레이아웃 편집기 스펙 (manifest) | + +로딩 설정: `{"strategy":"global","priority":100,"dependencies":[]}` + + + +제3자 SDK 스크립트가 목록에 없는 이유는 §전역 진입점과 동일합니다 — 인증 화면은 팝업 +안의 서버 렌더링 페이지이거나 모바일 리다이렉트 대상이라 프론트가 동적 로드할 자산이 +없습니다. CSS 산출물이 없는 것은 UI가 버튼·상태 카드 같은 최소한의 코어 컴포넌트로만 +구성되기 때문입니다. + diff --git a/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md new file mode 100644 index 00000000..7b3e68e2 --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_nhnkcp/docs/settings.md @@ -0,0 +1,93 @@ +# NHN KCP 휴대폰 본인확인 — 설정·권한·라우트 + +> 설정 스키마·권한·메뉴·라우트·의존 관계 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설정 스키마 + + +| 키 | 타입 | 기본값 | 설명 | +|---|---|---|---| +| `is_test_mode` | `boolean` | `true` | 테스트 모드 | +| `test_site_cd` | `string` | `AO7F3` | 테스트 사이트코드 | +| `test_enc_key` | `string` | `c2a22fa3ebe4698075bcac6b433d52e351c881b02fb83488d4283a43385b1f8e` | 테스트 암호화 키 | +| `live_site_cd` | `string` | - | 운영 사이트코드 | +| `live_enc_key` | `string` | - | 운영 암호화 키 | +| `web_siteid` | `string` | - | 웹사이트 ID | +| `duplicate_field` | `enum` | `di` | 중복 판정 필드 | +| `duplicate_block_enabled` | `boolean` | `true` | 중복 가입 차단 | + +기본값 파일: `config/settings/defaults.json` · 설정 화면 레이아웃: `resources/layouts/admin/plugin_settings.json` + + + +`sirsoft-verification_kginicis`가 `mid`+`api_key` 2종 키 쌍을 쓰는 것과 달리 이 플러그인은 +`site_cd`+`enc_key`(대칭 암호화 키 1개)만 씁니다 — NHN KCP 본인확인 API 는 서명 검증용 +별도 API 키가 없고 site_cd/enc_key 조합만으로 인증합니다. `web_siteid`는 두 자격증명 +쌍(`test_*`/`live_*`)과 별개로 테스트·라이브 모드 공통으로 쓰이는 웹사이트 식별자입니다. +`live_site_cd`는 `is_test_mode=false`일 때만 `ValidateKcpSettingsListener`가 필수로 +강제하고, 저장 시점에 `SM` 프리픽스를 자동으로 붙입니다(§AGENTS.md "핵심 흐름" — kginicis +와 달리 DB 에 프리픽스가 붙은 값이 저장됩니다). `duplicate_field`/`duplicate_block_enabled`는 +kginicis 와 동일한 개념이지만 서로 다른 provider 를 통해 확인한 사용자에게 각자 독립 +적용됩니다. + + +## 권한 + + +_선언된 권한이 없습니다._ + + + +본인인증 설정 접근 권한은 코어의 관리자 권한 체계 안에서 다뤄집니다 — IDV provider 마다 +별도 권한을 선언하면 provider 를 여러 개 설치했을 때 "본인인증 설정을 볼 수 있는 사람"이 +provider 수만큼 중복 정의됩니다. + + +## 메뉴 + + +_등록하는 메뉴가 없습니다._ + + + +설정 화면(`plugin_settings.json`)은 코어의 "플러그인 관리 > 설정" 공통 진입점을 통해 +접근합니다 — IDV provider 마다 전용 사이드바 메뉴를 만들면 provider 를 여러 개 설치했을 +때 메뉴가 난립합니다. + + +## 라우트 + + +| 종류 | 파일 | URL prefix | +|---|---|---| +| `api` | `src/routes/api.php` | `/api/plugins/sirsoft-verification_nhnkcp/...` | +| `web` | `src/routes/web.php` | `/plugins/sirsoft-verification_nhnkcp/...` | + +확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다. + + + +`sirsoft-verification_kginicis`와 동일한 3-엔드포인트 구조입니다 — `web`의 콜백(CSRF 제외, +KCP 인증 화면이 직접 POST)은 데스크톱 팝업과 모바일 리다이렉트 양쪽 경로가 **같은 +엔드포인트로 수렴**합니다(§AGENTS.md "핵심 흐름" — 기기별 진입 방식만 다를 뿐 콜백 처리는 +공통). `bridge`는 팝업-opener 간 postMessage 중계 페이지, `api`는 로그인 사용자가 자기 +본인확인 상태를 조회하는 마이페이지 엔드포인트입니다. + + +## 의존 관계 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +`sirsoft-verification_kginicis`와 마찬가지로 이 플러그인은 `sirsoft-ecommerce`를 비롯한 +어떤 확장에도 의존하지 않습니다 — 본인확인은 결제와 무관하게 회원가입 등 코어 인증 흐름 +전반에 쓰이는 기능이라, 이커머스가 설치되지 않은 사이트에서도 단독으로 동작해야 합니다. + diff --git a/templates/_bundled/sirsoft-admin_basic/AGENTS.md b/templates/_bundled/sirsoft-admin_basic/AGENTS.md new file mode 100644 index 00000000..27548884 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/AGENTS.md @@ -0,0 +1,150 @@ +# Admin Basic — 에이전트 가이드 + +> 이 문서는 이 템플릿을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요. + +## TL;DR (5초 요약) + +```text +1. 유형: 템플릿 (sirsoft-admin_basic) — 코어·모든 번들 확장의 관리자 화면을 그리는 유일한 admin 템플릿 +2. 확장 방식: 필수 컴포넌트(35개, config/template.php)만 써서 레이아웃 JSON 작성 — 다른 admin 템플릿으로 교체돼도 동작 보장 +3. 건드리면 안 되는 것: `Icon` 컴포넌트로 AdminSidebar 아이콘 렌더(반드시 `I`+FontAwesome 클래스), `_admin_base` 슬롯 구조를 임의로 재배치 +4. 작업 위치: `templates/_bundled/sirsoft-admin_basic` — 활성 디렉토리 직접 수정 금지 +5. 반영: `php artisan template:update sirsoft-admin_basic --force` +``` + +## 1. 이 확장은 무엇인가 + + +G7 이 기본 제공하는 유일한 admin 타입 템플릿입니다 — 코어 관리자 화면(대시보드·사용자·역할· +설정·확장 관리 등)뿐 아니라 **모든 번들 모듈/플러그인의 관리자 레이아웃**(`resources/layouts/ +admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템플릿의 컴포넌트로 그려집니다. +그래서 이 템플릿의 공개 계약(필수 컴포넌트 35개, `_admin_base` 슬롯 구조)을 깨면 코어가 +아니라 **전체 번들 확장의 관리자 화면**이 동시에 영향을 받습니다 — 다른 번들 확장 하나를 +고치는 것과는 파급 범위가 다릅니다. + +**설계 원칙**: 모듈/플러그인 개발자가 "이 컴포넌트만 쓰면 다른 admin 템플릿으로 바꿔도 +안전하다"는 보장을 받도록, 필수 컴포넌트 목록(config/template.php)을 이 템플릿 하나가 아니라 +**admin 템플릿이라면 지켜야 할 계약**으로 취급합니다 — 이 템플릿에만 있는 편의 컴포넌트를 +필수 목록에 넣지 않습니다. + +**의도적으로 하지 않는 것**: 방문자용(user) 화면은 이 템플릿의 범위가 아닙니다(`sirsoft-basic` +소관). 또한 컴포넌트 Props 전체 레퍼런스는 이 문서가 다시 나열하지 않습니다 — 코어 +`docs/frontend/component-props*.md` 가 SSoT 이고, 이 문서는 이 템플릿에서만 유효한 계약 +(필수 컴포넌트·AdminSidebar·SlotContainer·베이스 레이아웃 구조)만 다룹니다. + + +## 2. 디렉토리 지도 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update sirsoft-admin_basic --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update sirsoft-admin_basic --force` (빌드 불필요) | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` | +| `src/handlers/` | 템플릿 전용 액션 핸들러 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` | +| `editor-spec/` | 분할 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update sirsoft-admin_basic --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + + +## 3. 핵심 흐름 + + +**새 관리자 화면 렌더**: 방문자가 `/admin/{path}` 접근 → `routes.json` 이 경로를 레이아웃 +이름으로 매핑 → 그 레이아웃이 `extends: _admin_base` 로 사이드바·헤더·Toast·콘텐츠 슬롯을 +상속 → `slots.content` 에 정의된 컴포넌트(대개 `PageHeader`+`DataGrid`/`Card`/`Form` +조합)가 데이터소스 API 를 호출해 화면을 채웁니다. 이 흐름은 코어 화면과 번들 확장 화면이 +완전히 동일합니다 — 확장이 관리자 화면을 추가할 때 이 템플릿을 복제하지 않고 자기 +`resources/layouts/admin/*.json` 에서 그대로 `_admin_base` 를 extends 합니다. + +**사이드바 접힘 상태 복원**: 페이지 로드 → `src/index.ts` 부트스트랩이 `initSidebar()` 직접 +호출(레이아웃 `init_actions` 아님) → localStorage 값을 `_global.sidebarCollapsed` 에 반영 → +`_admin_base.json` 의 사이드바 영역 className 표현식이 그 값을 읽어 접힘 스타일 적용. + +**로케일 전환**: 코어가 로케일을 바꾸면 이 템플릿의 `initTemplate()` 재등록 진입점이 호출되어 +핸들러 맵을 다시 등록합니다(§docs/handlers.md "부트스트랩"). 사이드바 접힘 등 1회성 부팅 +작업은 이 진입점에 섞지 않습니다 — 로케일 전환마다 중복 실행되면 안 되기 때문입니다. + + +## 4. 확장점 + + +| 확장점 | 수 | 상세 | +|---|---|---| +| 제공 컴포넌트 | 125개 | [제공 컴포넌트](docs/components.md#제공-컴포넌트) | +| 레이아웃 | 145개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) | +| 전용 핸들러 | 17개 | [템플릿 전용 핸들러](docs/handlers.md#템플릿-전용-핸들러) | +| 확장 오버라이드 | 0개 | [확장 오버라이드](docs/layouts.md#확장-오버라이드) | + + + +이 템플릿은 훅을 발행/구독하지 않습니다 — 관리자 화면 확장은 훅이 아니라 **레이아웃 확장 +오버라이드**(`extensions/{module-identifier}/*.json`, 지금은 0개)와 **컴포넌트 재사용** +(공통 컴포넌트를 그대로 쓰는 것) 두 경로로만 이뤄집니다. 다른 확장의 관리자 화면 UI 를 +이 템플릿에서만 다르게 그리고 싶다면 첫 번째 경로를, 새 화면 유형(예: 새로운 카드 스타일)이 +필요하면 `src/components/composite/` 에 컴포넌트를 추가하고 `components.json` 에 등록하는 +쪽을 씁니다. + + +## 5. 수정 시 동반 의무 + +- [ ] `_bundled` 에서만 수정하고 `php artisan template:update sirsoft-admin_basic --force` 로 반영 +- [ ] manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재 +- [ ] 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인 +- [ ] TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지) +- [ ] 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화 +- [ ] 필수 컴포넌트(35개) 의 Props 시그니처를 깨는 변경은 전체 번들 확장 관리자 화면에 영향 — 변경 전 `src/components/{basic,composite}/` 의 실사용처를 넓게 확인 +- [ ] `_admin_base.json` 슬롯 구조(`content` 슬롯 등) 변경 시 그 슬롯에 의존하는 모든 화면(145개 레이아웃 대다수) 영향 검토 +- [ ] AdminSidebar 의 `MenuItem`/`AdminSidebarProps` 인터페이스 확장 시 이 문서의 §docs/components.md "AdminSidebar 상세" 동기화 + +## 6. 금지 패턴 + + +| 금지 | 올바른 사용 | 이유 | +|---|---|---| +| `AdminSidebar` 메뉴 아이콘을 `Icon name="home"` 식으로 렌더 | `I` 컴포넌트 + FontAwesome 클래스 문자열(`icon` 필드가 이미 `"fas fa-home"` 형태) | 메뉴 API 가 클래스 문자열을 내려주므로 `Icon`(IconName enum) 으로 받으면 렌더되지 않는다 | +| 필수 컴포넌트 목록 밖의 이 템플릿 전용 컴포넌트를 모듈 레이아웃에서 사용 | 필수 컴포넌트(config/template.php) 만 사용 | 다른 admin 템플릿으로 교체 시 그 화면만 깨진다 | +| 사이드바 접힘 상태를 레이아웃 `init_actions` 로 매번 복원 | 템플릿 부트스트랩(`src/index.ts`)에서 1회 복원 | `init_actions` 는 화면 진입마다 재실행되어 불필요한 반복 처리가 된다 | +| `_admin_base` 를 상속하는데 로그인 화면처럼 `initTheme`/메뉴 초기화를 다시 호출 | `_admin_base` 상속 화면은 이미 초기화된 전역 상태를 그대로 사용 | 중복 호출은 낭비이며, 두 초기화 지점의 결과가 어긋나면 화면 간 상태 불일치가 생긴다 | + + +## 7. 테스트 실행 + + +| 종류 | 개수 | 위치 | +|---|---|---| +| PHPUnit | 0개 | — | +| Vitest | 203개 | `vitest.config.ts` | +| Playwright | 8개 | `tests/Playwright` | +| 시나리오 매니페스트 | 2개 | `tests/scenarios` | + +```bash +# Vitest (확장 디렉토리에서) (PowerShell) +cd templates/_bundled/sirsoft-admin_basic && powershell -Command "npm run test:run -- <대상>" + +# Playwright E2E (Bash) +npx playwright test templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/<대상>.spec.ts + +``` + +무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다. + + +## 8. 문서 목차 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + diff --git a/templates/_bundled/sirsoft-admin_basic/README.md b/templates/_bundled/sirsoft-admin_basic/README.md new file mode 100644 index 00000000..669ff4fb --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/README.md @@ -0,0 +1,180 @@ +# Admin Basic + +**G7 템플릿 · sirsoft-admin_basic** +그누보드7 기본 관리자 템플릿 + + +

+ version 1.0.8 + type 템플릿 + G7 >=7.0.10 + license MIT +

+ + +--- + +[소개](#소개) · [주요 기능](#주요-기능) · [동작 방식](#동작-방식) · [요구 사항](#요구-사항) · [설치](#설치) · [제공 컴포넌트](#제공-컴포넌트) · [사용 방법](#사용-방법) · [다른 확장과의 연동](#다른-확장과의-연동) · [문서](#문서) · [트러블슈팅](#트러블슈팅) · [변경 이력](#변경-이력) · [라이선스](#라이선스) + +--- + +## 소개 + + +G7 이 기본 제공하는 관리자(admin) 템플릿입니다. 코어 관리자 화면뿐 아니라 설치된 모든 +모듈/플러그인의 관리자 화면이 이 템플릿의 컴포넌트와 베이스 레이아웃을 그대로 사용합니다 — +확장을 설치하면 그 확장의 관리자 UI 도 자동으로 이 템플릿의 디자인(사이드바·헤더·색상·다크 +모드)을 따릅니다. + +이 템플릿은 사용자(방문자)용 화면을 그리지 않습니다 — 방문자 화면은 `sirsoft-basic` 템플릿의 +몫입니다. 또한 완전히 다른 디자인의 관리자 화면이 필요하면 이 템플릿을 고치는 대신 같은 +컴포넌트 계약(필수 컴포넌트 35개)을 구현하는 새 admin 템플릿을 만드는 것이 원칙입니다 — 그래야 +기존 확장들의 관리자 화면이 새 템플릿에서도 깨지지 않습니다. + + +## 주요 기능 + + +| 영역 | 설명 | +|---|---| +| 관리자 셸 | 사이드바(계층형 메뉴, 접힘 지원)·헤더·다크 모드·다국어 전환을 갖춘 공통 화면 골격(`_admin_base`) | +| 컴포넌트 125개 | HTML 래핑 39개(basic) + UI 패턴 캡슐화 80개(composite) + 페이지 구조 5개(layout) + 모달 1개 | +| 필수 컴포넌트 계약 | 35개 컴포넌트만 사용하면 다른 admin 템플릿으로 교체해도 화면이 보장되는 모듈 호환성 기준 | +| 레이아웃 145개 | 대시보드·사용자·역할·메뉴·설정·확장 관리·스케줄·활동/알림 로그 등 코어 관리자 화면 전체 | +| 확장 관리 UI | 모듈/플러그인/템플릿 설치·활성화·업데이트·삭제와 레이아웃 편집기(코드 편집 + 실시간 미리보기) | +| 본인인증(IDV) 챌린지 | 관리자 민감 작업에 걸리는 본인인증 화면 | + + +## 동작 방식 + + +```mermaid +flowchart TD + base["_admin_base (사이드바·헤더·Toast·콘텐츠 슬롯)"] --> list["목록 화면 (PageHeader+DataGrid+Pagination)"] + base --> detail["상세 화면 (PageHeader+Card)"] + base --> form["폼 화면 (Form+FormField)"] + base --> settings["설정 화면 (TabNavigation+_tab_*)"] + auth["인증 화면 (admin_login 등, _admin_base 미상속)"] + errors["에러 화면 (errors/*, 독립 레이아웃)"] +``` + +인증 화면(로그인/비밀번호 찾기·재설정)과 에러 화면은 의도적으로 `_admin_base` 를 상속하지 +않습니다 — 로그인 전이거나 정상 화면 렌더링 자체가 불가능한 상황이라 사이드바·헤더 같은 +"이미 로그인된 관리자" 전제의 UI 를 보여줄 수 없기 때문입니다. + + +## 요구 사항 + + +| 항목 | 값 | +|---|---| +| G7 코어 | `>=7.0.10` | +| PHP | `^8.2` | + + +## 설치 + + +```bash +# 번들 설치 (코어에 동봉된 소스에서 설치) +php artisan template:install sirsoft-admin_basic + +# 활성화 +php artisan template:activate sirsoft-admin_basic + +# 업데이트 (번들 소스 기준 강제 반영) +php artisan template:update sirsoft-admin_basic --force +``` + +저장소: https://github.com/gnuboard/g7-template-sirsoft-admin_basic + + +## 제공 컴포넌트 + + +컴포넌트 125개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 39개 | +| `composite` | 80개 | +| `layout` | 5개 | +| `modals` | 1개 | + + + +운영자가 직접 켜고 끄는 설정 항목은 없습니다 — 이 표는 확장(모듈/플러그인) 개발자가 레이아웃을 +만들 때 참고하는 컴포넌트 인벤토리입니다. 전체 목록·Props 상세는 +[docs/components.md](docs/components.md) 와 코어 +[component-props.md](../../../docs/frontend/component-props.md) 를 참고하세요. + + +## 사용 방법 + + +**새 확장의 관리자 화면 만들기**: 확장의 `resources/layouts/admin/*.json` 에서 +`"extends": "_admin_base"` 로 베이스를 상속하고, `slots.content` 에 필수 컴포넌트만으로 화면을 +구성합니다. 필수 컴포넌트 목록 밖의 컴포넌트를 쓰면 다른 admin 템플릿으로 교체됐을 때 그 +화면만 깨집니다. + +**사이드바 메뉴 등록**: 확장이 `getAdminMenus()` 로 메뉴를 선언하면 이 템플릿의 `AdminSidebar` +가 자동으로 계층에 반영합니다 — 이 템플릿을 직접 수정할 필요가 없습니다. + +**레이아웃 실시간 편집**: `/admin/templates/sirsoft-admin_basic/edit` 에서 레이아웃 편집기로 +관리자 화면 자체를 코드 편집 + 실시간 미리보기로 수정할 수 있습니다(운영 환경에서는 신중하게 +사용). + + +## 다른 확장과의 연동 + + +**이 확장이 의존하는 확장** + +없음 — 코어만으로 동작합니다. + +**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다) + +없음. + + + +formal 의존성이 양쪽 다 "없음"인 것은 이 템플릿이 코어만으로 동작하기 때문이지만, 실질적으로는 +**모든 번들 모듈/플러그인의 관리자 화면**이 이 템플릿의 필수 컴포넌트·`_admin_base` 계약에 +암묵적으로 의존합니다. 이 의존은 manifest 로 선언되지 않습니다 — 어느 admin 템플릿이든 같은 +계약(필수 컴포넌트 35개)만 구현하면 되므로, 확장이 "이 템플릿"이 아니라 "이 계약"에 의존하는 +형태이기 때문입니다. 이 템플릿의 필수 컴포넌트 Props 를 바꿀 때 영향 범위를 이 템플릿 +자신의 `dependencies` 목록으로는 알 수 없다는 뜻이며, 실제로는 활성 모듈/플러그인 전수의 +관리자 레이아웃을 확인해야 합니다. + + +## 문서 + + +| 문서 | 내용 | 상태 | +|---|---|---| +| [docs/README.md](docs/README.md) | 문서 통합 목차와 실측 집계 | ✅ | +| [docs/architecture.md](docs/architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | ✅ | +| [docs/components.md](docs/components.md) | 템플릿이 제공하는 컴포넌트 | ✅ | +| [docs/layouts.md](docs/layouts.md) | 레이아웃 목록과 라우트 매핑 | ✅ | +| [docs/handlers.md](docs/handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | ✅ | +| [CHANGELOG.md](CHANGELOG.md) | 변경 이력 | ✅ | + + +## 트러블슈팅 + + +| 증상 | 원인 | 조치 | +|---|---|---| +| 사이드바 메뉴 아이콘이 안 보임 | `Icon` 컴포넌트로 렌더 시도(API 는 FontAwesome 클래스 문자열을 내려줌) | `I` 컴포넌트로 교체 (`docs/components.md` "AdminSidebar 상세" 참고) | +| 다른 admin 템플릿으로 교체 후 특정 확장 화면이 깨짐 | 그 확장이 필수 컴포넌트 목록 밖의 컴포넌트를 사용 | 그 확장의 레이아웃을 필수 컴포넌트(35개)만으로 재작성하거나, 새 템플릿에 같은 컴포넌트를 구현 | +| 사이드바 접힘 상태가 새로고침 후 풀림 | localStorage 접근 실패(시크릿 모드 등) 또는 부트스트랩 순서 문제 | `initSidebar()` 가 템플릿 부트스트랩에서 호출되는지 확인 (`src/index.ts`) | +| 로그인 화면에 다크 모드/언어 전환이 적용 안 됨 | 로그인 화면은 `_admin_base` 를 상속하지 않아 초기화 경로가 다름 | `admin_login.json` 자체의 초기화 액션을 확인 — `_admin_base` 수정으로는 반영되지 않는다 | + + +## 변경 이력 + +[CHANGELOG.md](CHANGELOG.md) + +## 라이선스 + +MIT diff --git a/templates/_bundled/sirsoft-admin_basic/docs/README.md b/templates/_bundled/sirsoft-admin_basic/docs/README.md new file mode 100644 index 00000000..91fef5af --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/README.md @@ -0,0 +1,20 @@ +# Admin Basic 개발자 문서 + +> templates/_bundled/sirsoft-admin_basic · 템플릿 + + +**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 29 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 145 · **핸들러 수**: 17 + + +## 문서 목차 + + +| 문서 | 내용 | +|---|---| +| [architecture.md](architecture.md) | 설계 의도·계층 지도·디렉토리 맵 | +| [components.md](components.md) | 템플릿이 제공하는 컴포넌트 | +| [layouts.md](layouts.md) | 레이아웃 목록과 라우트 매핑 | +| [handlers.md](handlers.md) | 템플릿 전용 핸들러와 부트스트랩 | +| [../AGENTS.md](../AGENTS.md) | 에이전트·확장개발자 진입점 | +| [../README.md](../README.md) | 사람(도입검토자·운영자) 진입점 | + diff --git a/templates/_bundled/sirsoft-admin_basic/docs/architecture.md b/templates/_bundled/sirsoft-admin_basic/docs/architecture.md new file mode 100644 index 00000000..73f9841d --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/architecture.md @@ -0,0 +1,60 @@ +# Admin Basic — 아키텍처 + +> 설계 의도와 계층 구조 · 진입점: [AGENTS.md](../AGENTS.md) + +## 설계 의도 + + +이 템플릿의 설계는 "관리자 화면 디자인"과 "관리자 화면 구성"을 분리하는 데 집중합니다. +컴포넌트(디자인 구현)는 이 템플릿이 소유하지만, 어떤 화면을 어떻게 구성할지(레이아웃 JSON)는 +코어와 모든 번들 확장이 **각자 소유**합니다 — 이 템플릿은 그 구성을 그릴 부품만 제공합니다. +그래서 이 템플릿의 실질 소스는 컴포넌트(`src/components/`)와 코어 화면 레이아웃뿐이고, 확장 +관리자 화면(모듈/플러그인이 소유)은 이 템플릿 디렉토리 밖(`modules/`, `plugins/`)에 있습니다. + +필수 컴포넌트 계약(§AGENTS.md)이 이 분리를 지탱합니다 — 계약이 없으면 확장 개발자가 이 +템플릿에만 있는 컴포넌트를 무심코 써버려 "관리자 화면 디자인 교체"가 사실상 불가능해집니다. + + +## 계층 지도 + + +``` +routes.json (URL → 레이아웃 이름 매핑) + │ + ▼ +layouts/*.json (extends: _admin_base) + │ + ▼ +_admin_base.json (사이드바·헤더·Toast·PageTransitionIndicator·콘텐츠 슬롯·푸터) + │ + ▼ +src/components/{basic,composite,layout}/*.tsx (필수 컴포넌트 35개 포함 125개) + │ + ▼ +src/handlers/*.ts (이 템플릿 전용 핸들러 17개 — 범용 핸들러는 코어 ActionDispatcher) +``` + +이 트리는 코어 화면에만 적용되는 것이 아닙니다 — 모듈/플러그인의 관리자 레이아웃도 같은 +`_admin_base` → 컴포넌트 경로를 그대로 타므로, 이 템플릿을 고치면 그 확장들의 화면도 같은 +방향으로 바뀝니다(§AGENTS.md "수정 시 동반 의무" 참고). + + +## 디렉토리 + + +| 경로 | 역할 | 수정 시 필요한 절차 | +|---|---|---| +| `template.json` | manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 | +| `routes.json` | 라우트 → 레이아웃 매핑 | `php artisan template:update sirsoft-admin_basic --force` | +| `layouts/` | 레이아웃 JSON | `php artisan template:update sirsoft-admin_basic --force` (빌드 불필요) | +| `src/components/` | React 컴포넌트 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` | +| `src/handlers/` | 템플릿 전용 액션 핸들러 | `php artisan template:build` → `php artisan template:update sirsoft-admin_basic --force` | +| `dist/` | 커밋되는 빌드 산출물 | `--production` 으로 재빌드 (sourceMappingURL 잔존 금지) | +| `editor-spec.json` | 레이아웃 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` | +| `editor-spec/` | 분할 편집기 스펙 | `php artisan template:update sirsoft-admin_basic --force` | +| `tests/` | 테스트 | 변경 범위만 필터 실행 | +| `CHANGELOG.md` | 변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) | +| `components.json` | 편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | `php artisan template:update sirsoft-admin_basic --force` | +| `docs/` | 개발자 문서 | 표면 변경 시 `php artisan ext:docgen` 재실행 | +| `lang/` | 다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 | + diff --git a/docs/frontend/templates/sirsoft-admin_basic/components.md b/templates/_bundled/sirsoft-admin_basic/docs/components.md similarity index 66% rename from docs/frontend/templates/sirsoft-admin_basic/components.md rename to templates/_bundled/sirsoft-admin_basic/docs/components.md index a1478fff..51e75884 100644 --- a/docs/frontend/templates/sirsoft-admin_basic/components.md +++ b/templates/_bundled/sirsoft-admin_basic/docs/components.md @@ -1,35 +1,122 @@ -# sirsoft-admin_basic 컴포넌트 +# Admin Basic — 컴포넌트 -> **템플릿 식별자**: `sirsoft-admin_basic` (type: admin, v0.2.14) -> **관련 문서**: [핸들러](./handlers.md) | [레이아웃](./layouts.md) | [컴포넌트 Props 레퍼런스](../../component-props.md) +> 템플릿이 제공하는 컴포넌트 · 진입점: [AGENTS.md](../AGENTS.md) ---- +## 제공 컴포넌트 -## TL;DR (5초 요약) + +컴포넌트 125개 (루트: `src/components`). + +| 분류 | 개수 | +|---|---| +| `basic` | 39개 | +| `composite` | 80개 | +| `layout` | 5개 | +| `modals` | 1개 | + + + +세 분류(basic/composite/layout)는 "얼마나 많이 조합됐는가"로 나뉩니다 — basic 은 HTML 태그를 +그대로 래핑(`Div`→`
`)한 최소 단위, composite 는 basic 을 여러 개 조합해 UI 패턴을 캡슐화한 +것(`DataGrid`가 `Table`+`Pagination`+정렬 로직을 감싸는 식), layout 은 페이지 구조 자체(Grid/ +Flex/Container)를 정의합니다. 레이아웃 JSON 저작자는 이 구분을 몰라도 되지만("HTML 태그 +직접 사용 금지" 규정만 지키면 됨), 컴포넌트를 새로 추가하는 쪽은 이 구분에 맞는 디렉토리 +(`src/components/{basic,composite,layout}/`)에 넣어야 `components.json` 카탈로그와 +`editor-spec.json` 팔레트 분류가 어긋나지 않습니다. + +위 개수는 코드에서 실측되므로 시간이 지나면 달라집니다 — 이 문서에 구체적인 개수를 하드코딩해 +적지 않습니다(과거 버전 문서가 37/66/8 로 적었다가 실제로는 39/80/5+modals 1 이 되어 있었던 +사례가 있습니다). 정확한 전체 목록·Props 는 [component-props.md](../../../../docs/frontend/component-props.md) +· [component-props-composite.md](../../../../docs/frontend/component-props-composite.md) 를 +따라갑니다 — 이 문서는 "이 템플릿에서만" 유효한 계약(필수 컴포넌트·AdminSidebar·SlotContainer) +만 다룹니다. + + +## 필수 컴포넌트 (모듈 호환성) + +모듈 개발자가 아래 컴포넌트만 사용하면 **다른 Admin 템플릿으로 교체해도 화면이 깨지지 +않습니다**. 목록의 SSoT 는 `config/template.php` 의 `required_admin_components` 이며, 아래 +표는 그 값을 그대로 옮긴 것입니다(코드가 SSoT — 표와 설정이 어긋나면 설정이 맞습니다). + +| 분류 | 컴포넌트 | +|---|---| +| Basic (27개) | `A`, `Button`, `Checkbox`, `Div`, `Form`, `H1`, `H2`, `H3`, `Icon`, `Img`, `Input`, `Label`, `Li`, `Nav`, `P`, `Section`, `Select`, `Span`, `Svg`, `Table`, `Tbody`, `Td`, `Textarea`, `Th`, `Thead`, `Tr`, `Ul` | +| Composite (8개) | `Alert`, `Badge`, `Card`, `DataTable`, `FormField`, `Modal`, `PageHeader`, `Pagination` | ```text -1. Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, H1~H4, Table 등) -2. Composite 66개: UI 패턴 캡슐화 (DataGrid, Modal, PageHeader, Card, FileUploader 등) -3. Layout 8개: 페이지 구조 (Container, Grid, Flex, SectionLayout, ThreeColumnLayout 등) -4. 모듈은 필수 컴포넌트(27 basic + 15 composite)만 사용 → 모든 Admin 템플릿 호환 보장 -5. components.json에 등록 필수, Props는 이 문서 + component-props.md 참조 +✅ 필수 컴포넌트 목록에 있는 것만 사용 (다른 Admin 템플릿 호환 보장) +✅ 템플릿의 베이스 레이아웃(`_admin_base`)을 extends +❌ 이 템플릿에만 있는 커스텀 컴포넌트 사용 금지 ``` ---- +기본은 `validate_on_install: false` — 설치 시 필수 컴포넌트 검증은 기본적으로 수행되지 않고 +경고에 그칩니다(`block_on_failure: false`). 검증을 강제하려면 두 설정을 함께 켭니다. -## 목차 +## AdminSidebar 상세 -1. [컴포넌트 개요](#컴포넌트-개요) -2. [Basic Components (37개)](#basic-components-37개) -3. [Composite Components (66개)](#composite-components-66개) -4. [Layout Components (8개)](#layout-components-8개) -5. [필수 컴포넌트 (모듈 호환성)](#필수-컴포넌트-모듈-호환성) -6. [모듈 개발자 가이드](#모듈-개발자-가이드) -7. [템플릿 개발자 가이드](#템플릿-개발자-가이드) +계층형 관리자 메뉴를 그리는 컴포넌트로, 데이터 소스로 받은 메뉴 트리를 그대로 넘기면 됩니다. ---- +```typescript +interface MenuItem { + id: string | number; + name: string | { ko: string; en: string }; + slug: string; + url?: string | null; + icon?: string; // FontAwesome 클래스 (예: "fas fa-home") + children?: MenuItem[]; + is_active?: boolean; + module_id?: number | null; // 모듈이 등록한 메뉴인지 식별 +} -## 컴포넌트 개요 +interface AdminSidebarProps { + logo?: string; + logoAlt?: string; + menu: MenuItem[]; // 필수 + collapsed?: boolean; + onToggleCollapse?: () => void; + className?: string; + currentLocale?: string; // 미지정 시 G7Core.locale.current() 자동 사용 + id?: string; // 레이아웃 편집기 코어 일괄 ID +} +``` + +아이콘은 `Icon` 컴포넌트(IconName enum)가 아니라 `I` 컴포넌트 + FontAwesome 클래스 문자열로 +렌더링됩니다 — 메뉴 API 가 `icon` 필드를 이미 `"fas fa-home"` 형태 클래스 문자열로 내려주기 +때문입니다. 새 메뉴 아이콘을 추가할 때 `Icon name="home"` 식으로 쓰면 렌더되지 않습니다. + +## SlotContainer 상세 + +동적 슬롯 렌더링 컨테이너입니다 — 다른 컴포넌트가 `slot` prop 으로 자신이 속할 슬롯 ID 를 +표현식으로 지정하면(예: `"{{_local.isVisible ? 'basic_filters' : 'detail_filters'}}"`), 그 +표현식이 상태 변화에 따라 재평가되어 컴포넌트가 슬롯 사이를 동적으로 이동합니다. + +```typescript +interface SlotContainerProps { + slotId: string; // 필수 — 렌더링할 슬롯 ID + className?: string; +} +``` + +```json +{ "id": "category_filter", "type": "basic", "name": "Div", "slot": "{{_local.isVisible ? 'basic_filters' : 'detail_filters'}}", "slotOrder": 1, "children": [] } +``` + +```json +{ "id": "basic_filters_container", "type": "composite", "name": "SlotContainer", "props": { "slotId": "basic_filters" } } +``` + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/components.md` 에 있던 원문을 +> 이 문서로 옮긴 것입니다(#601). 이관 시점 그대로 보존하되, **코드가 SSoT 인 값과 어긋나는 +> 부분에는 정정 주석**을 달았습니다 — 실측 총계는 위 「제공 컴포넌트」 블록이, 필수 컴포넌트 +> 목록은 `config/template.php` 의 `required_admin_components` 가 SSoT 입니다. + +### 컴포넌트 개요 + +> **정정(#601)**: 아래 개수는 이관 시점(2026-03) 문서 값입니다. 코드 실측은 basic 39 · composite 80 · +> layout 5 · modals 1 = **125종**이며 위 「제공 컴포넌트」 블록이 SSoT 입니다. 아래 목록에 없는 +> 컴포넌트가 있을 수 있습니다. | 타입 | 개수 | 설명 | |------|------|------| @@ -43,11 +130,13 @@ --- -## Basic Components (37개) +### Basic Components (37개) + +> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 39종). 목록 자체는 원문 그대로입니다. HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic 컴포넌트는 `className`, `children` props를 공통으로 지원합니다. -### 텍스트/링크 +#### 텍스트/링크 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -62,7 +151,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Pre` | 서식 유지 텍스트 래퍼 | - | - | | `Code` | 인라인 코드 래퍼 | - | - | -### 컨테이너 +#### 컨테이너 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -72,7 +161,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Form` | 폼 컨테이너 (자동 바인딩 지원) | - | - | | `Fragment` | React.Fragment — iterator에서 DOM 래퍼 없이 사용 | - | - | -### 폼 입력 +#### 폼 입력 | 컴포넌트 | 설명 | 주요 Props | 바인딩 | |----------|------|-----------|--------| @@ -85,7 +174,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `FileInput` | 파일 입력 (검증 포함) | accept, maxSize, onChange, onError, buttonText, placeholder, disabled | - | | `Button` | 버튼 | variant (`primary`\|`secondary`\|`danger`\|`success`), size (`sm`\|`md`\|`lg`) | - | -### 테이블 +#### 테이블 | 컴포넌트 | 설명 | |----------|------| @@ -97,7 +186,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Th` | 테이블 헤더 셀 | | `Td` | 테이블 데이터 셀 | -### 리스트 +#### 리스트 | 컴포넌트 | 설명 | |----------|------| @@ -105,7 +194,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Ol` | 순서 있는 리스트 | | `Li` | 리스트 아이템 | -### 미디어/아이콘 +#### 미디어/아이콘 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -116,11 +205,13 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## Composite Components (66개) +### Composite Components (66개) + +> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 80종). 목록 자체는 원문 그대로입니다. 기본 컴포넌트를 조합하여 UI 패턴을 캡슐화한 복합 컴포넌트입니다. -### 데이터 표시 +#### 데이터 표시 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -133,7 +224,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `EmptyState` | 데이터 없음 상태 표시 | title, description, iconName, illustrationSrc, ... | | `HtmlContent` | HTML 안전 렌더링 (DOMPurify XSS 방지) | content, isHtml, purifyConfig, text | -### 폼/입력 +#### 폼/입력 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -152,14 +243,14 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `FileUploader` | 파일 업로드 (드래그앤드롭, 이미지 압축) | attachmentableType, attachmentableId, collection, maxFiles, maxSize, ... | | `IconSelect` | 아이콘 선택 드롭다운 | value, onChange, options, placeholder, searchPlaceholder, ... | -### 에디터 +#### 에디터 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| | `HtmlEditor` | HTML/텍스트 편집기 (편집/미리보기 토글) | content, onChange, isHtml, onHtmlModeChange, previewMode, ... | | `CodeEditor` | JSON 코드 편집기 (Monaco Editor) | value, onChange, language, height, readOnly, ... | -### 네비게이션 +#### 네비게이션 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -169,7 +260,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `ActionMenu` | 드롭다운 액션 메뉴 | items, triggerLabel, triggerIconName, position, style | | `Dropdown` | 드롭다운 메뉴 | label, items, onItemClick, position, style | -### 모달/다이얼로그 +#### 모달/다이얼로그 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -180,7 +271,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `Toast` | 토스트 알림 | toasts, position, onRemove | | `Alert` | 알림 메시지 | type, message, dismissible, onDismiss | -### 관리자 UI +#### 관리자 UI | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -195,7 +286,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `LanguageSelector` | 언어 선택 드롭다운 | availableLocales, languageText, apiEndpoint, onLanguageChange, inline | | `PageTransitionIndicator` | 페이지 전환 로딩 표시 | style | -### 레이아웃 편집 +#### 레이아웃 편집 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -205,7 +296,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `LayoutWarnings` | 레이아웃 경고 표시 | warnings | | `VersionList` | 버전 목록 아이템 | versions, selectedId, onSelect | -### 확장 관리 +#### 확장 관리 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -213,7 +304,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | `ExtensionBadge` | 확장 섹션 뱃지 (identifier로 이름 자동 조회) | type, identifier, name, installedModules, installedPlugins, ... | | `ProductCard` | 상품 카드 (이미지, 제목, 가격, 액션) | imageUrl, imageAlt, title, subtitle, description, ... | -### 고급 기능 +#### 고급 기능 | 컴포넌트 | 설명 | 주요 Props | |----------|------|-----------| @@ -233,7 +324,9 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## Layout Components (8개) +### Layout Components (8개) + +> **정정(#601)**: 제목의 개수는 이관 시점 문서 값입니다(코드 실측 5종). 목록 자체는 원문 그대로입니다. 페이지 구조를 정의하는 레이아웃 컴포넌트입니다. @@ -250,11 +343,11 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## 필수 컴포넌트 (모듈 호환성) +### 필수 컴포넌트 (모듈 호환성) 모듈 개발자가 이 컴포넌트들만 사용하면 **모든 Admin 템플릿에서 동작이 보장**됩니다. -### 필수 Basic (27개) +#### 필수 Basic (27개) | 카테고리 | 컴포넌트 | |----------|----------| @@ -265,7 +358,12 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | 리스트 | `Ul`, `Li` | | 미디어 | `Icon`, `Img`, `Svg` | -### 필수 Composite (15개) +#### 필수 Composite (15개) + +> **정정(#601)**: 아래 표는 `config/template.php` 의 `required_admin_components` 와 일치하지 않습니다 — +> 실제 필수 Composite 는 위 「필수 컴포넌트 (모듈 호환성)」 절의 8종(`Alert` `Badge` `Card` +> `DataTable` `FormField` `Modal` `PageHeader` `Pagination`)이며 설정 파일이 SSoT 입니다. +> 아래 목록은 이관 시점 문서를 보존한 것이므로 필수 여부의 근거로 쓰지 않습니다. | 카테고리 | 컴포넌트 | 설명 | |----------|----------|------| @@ -285,7 +383,10 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic | | `AdminSidebar` | 관리자 사이드바 | | | `SlotContainer` | 동적 슬롯 렌더링 | -### 설정 파일 +#### 설정 파일 + +> **정정(#601)**: 아래 PHP 스니펫은 이관 시점 값입니다. 현재 `config/template.php` 의 실제 배열은 위 +> 「필수 컴포넌트 (모듈 호환성)」 절의 표(Basic 27 + Composite 8)와 일치합니다. 필수 컴포넌트 목록은 `config/template.php`에 정의: @@ -300,9 +401,9 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## 모듈 개발자 가이드 +### 모듈 개발자 가이드 -### 핵심 원칙 +#### 핵심 원칙 ```text ✅ 필수 컴포넌트 목록에 있는 컴포넌트만 사용 @@ -310,7 +411,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic ❌ 특정 템플릿에만 존재하는 커스텀 컴포넌트 사용 금지 ``` -### 레이아웃 작성 예시 +#### 레이아웃 작성 예시 ```json { @@ -335,11 +436,11 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic --- -## 템플릿 개발자 가이드 +### 템플릿 개발자 가이드 Admin 타입 템플릿 개발 시 필수 컴포넌트를 반드시 구현해야 합니다. -### 구현 체크리스트 +#### 구현 체크리스트 ```text □ DataGrid - 정렬, 필터링, 페이지네이션 지원 @@ -358,18 +459,18 @@ Admin 타입 템플릿 개발 시 필수 컴포넌트를 반드시 구현해야 □ AdminSidebar - 계층형 메뉴, 다국어 지원 ``` -### Props 인터페이스 일관성 +#### Props 인터페이스 일관성 모든 Admin 템플릿은 동일한 Props 인터페이스를 구현해야 합니다. 상세 Props는 다음 문서를 참조: -- [컴포넌트 Props 레퍼런스 (Basic)](../../component-props.md) -- [컴포넌트 Props 레퍼런스 (Composite)](../../component-props-composite.md) +- [컴포넌트 Props 레퍼런스 (Basic)](../../../../docs/frontend/component-props.md) +- [컴포넌트 Props 레퍼런스 (Composite)](../../../../docs/frontend/component-props-composite.md) --- -## AdminSidebar 상세 +### AdminSidebar 상세 -### MenuItem 인터페이스 +#### MenuItem 인터페이스 ```typescript interface MenuItem { @@ -383,7 +484,7 @@ interface MenuItem { } ``` -### AdminSidebarProps +#### AdminSidebarProps ```typescript interface AdminSidebarProps { @@ -397,14 +498,14 @@ interface AdminSidebarProps { } ``` -### 아이콘 처리 +#### 아이콘 처리 ```text ✅ I 컴포넌트 + FontAwesome 클래스 () ❌ Icon 컴포넌트 + IconName enum (금지 — API가 FontAwesome 클래스 문자열 직접 제공) ``` -### 레이아웃 JSON 사용 예시 +#### 레이아웃 JSON 사용 예시 ```json { @@ -433,9 +534,9 @@ interface AdminSidebarProps { --- -## SlotContainer 상세 +### SlotContainer 상세 -### SlotContainerProps +#### SlotContainerProps ```typescript interface SlotContainerProps { @@ -444,7 +545,7 @@ interface SlotContainerProps { } ``` -### 슬롯 시스템 동작 +#### 슬롯 시스템 동작 ```text 1. slot 속성 컴포넌트 → SlotContext에 등록 @@ -452,7 +553,7 @@ interface SlotContainerProps { 3. 상태 변화 시 slot 표현식 재평가로 동적 이동 ``` -### 사용 예시 +#### 사용 예시 ```json { @@ -476,11 +577,11 @@ interface SlotContainerProps { --- -## 관련 문서 +### 관련 문서 -- [sirsoft-admin_basic 핸들러](./handlers.md) -- [sirsoft-admin_basic 레이아웃](./layouts.md) -- [sirsoft-basic 컴포넌트](../sirsoft-basic/components.md) -- [컴포넌트 개발 규칙](../../components.md) -- [컴포넌트 Props 레퍼런스](../../component-props.md) -- [컴포넌트 Props 레퍼런스 - Composite](../../component-props-composite.md) +- [sirsoft-admin_basic 핸들러](handlers.md) +- [sirsoft-admin_basic 레이아웃](layouts.md) +- [sirsoft-basic 컴포넌트](../../../../docs/frontend/templates/sirsoft-basic/components.md) +- [컴포넌트 개발 규칙](../../../../docs/frontend/components.md) +- [컴포넌트 Props 레퍼런스](../../../../docs/frontend/component-props.md) +- [컴포넌트 Props 레퍼런스 - Composite](../../../../docs/frontend/component-props-composite.md) diff --git a/templates/_bundled/sirsoft-admin_basic/docs/handlers.md b/templates/_bundled/sirsoft-admin_basic/docs/handlers.md new file mode 100644 index 00000000..58780c4f --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/handlers.md @@ -0,0 +1,584 @@ +# Admin Basic — 핸들러 + +> 템플릿 전용 핸들러와 부트스트랩 · 진입점: [AGENTS.md](../AGENTS.md) + +## 템플릿 전용 핸들러 + + +핸들러 17개 (정의: `src/handlers/index.ts`). + +| 핸들러 | 레이아웃에서 부르는 이름 | +|---|---| +| `detectAssetUrlMode` | (템플릿 전용 — 네임스페이스 없음) | +| `checkAssetUrlModeDrift` | (템플릿 전용 — 네임스페이스 없음) | +| `setTheme` | (템플릿 전용 — 네임스페이스 없음) | +| `initTheme` | (템플릿 전용 — 네임스페이스 없음) | +| `scrollToSection` | (템플릿 전용 — 네임스페이스 없음) | +| `initMenuFromUrl` | (템플릿 전용 — 네임스페이스 없음) | +| `initFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) | +| `saveFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) | +| `toggleFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) | +| `resetFilterVisibility` | (템플릿 전용 — 네임스페이스 없음) | +| `saveMultilingualTag` | (템플릿 전용 — 네임스페이스 없음) | +| `cancelMultilingualTag` | (템플릿 전용 — 네임스페이스 없음) | +| `updateMultilingualTagValue` | (템플릿 전용 — 네임스페이스 없음) | +| `setDateRange` | (템플릿 전용 — 네임스페이스 없음) | +| `toggleSidebar` | (템플릿 전용 — 네임스페이스 없음) | +| `initSidebar` | (템플릿 전용 — 네임스페이스 없음) | +| `downloadAttachment` | (템플릿 전용 — 네임스페이스 없음) | + + + +`setLocale` 이 이 목록에 없는 것이 정상입니다 — 로케일 전환은 엔진 레벨(ActionDispatcher) +빌트인으로 승격되어 더 이상 이 템플릿이 등록하지 않습니다(`src/handlers/index.ts` 상단 주석 +참고). 과거 버전 문서가 `setLocale` 을 이 템플릿의 핸들러로 적었다면 그것은 이관 전 버전 +기준이므로 따르지 않습니다. 새 핸들러를 추가할 때는 `src/handlers/index.ts` 의 맵 객체 한 +곳에만 등록하면 자동으로 ActionDispatcher 에 반영됩니다 — 다른 곳에 흩어 등록하지 않습니다. + +이 핸들러들은 **이 템플릿에서만** 등록되므로 다른 템플릿에서는 미지원일 수 있습니다. 범용 +핸들러(`navigate`/`apiCall`/`setState` 등)는 [actions-handlers.md](../../../../docs/frontend/actions-handlers.md) +를 참고하고, 여기서는 이 템플릿 전용 핸들러만 다룹니다. + +### setTheme / initTheme + +다크/라이트/자동(`auto`, 시스템 설정 따름) 테마를 전환·복원합니다. `setTheme` 은 localStorage +저장 + `document.documentElement` 클래스 적용(Tailwind `dark:` variant 활성화)을, +`initTheme` 은 params 없이 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다. + +```json +{ "type": "click", "handler": "setTheme", "params": { "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" } } +``` + +### scrollToSection + +`params.selector`(CSS 선택자, 필수) 로 지정한 요소로 부드럽게 스크롤합니다. `params.offset` +(기본 `0`, 음수면 위로)은 고정 헤더 높이를 보상할 때 씁니다. + +```json +{ "type": "click", "handler": "scrollToSection", "params": { "selector": "#features", "offset": -80 } } +``` + +### initMenuFromUrl + +현재 URL 경로를 사이드바 메뉴 항목과 매칭해 활성 메뉴(및 부모 메뉴의 펼침 상태)를 자동 +설정합니다. params 없이 `_admin_base.json` 의 `init_actions` 에서 호출합니다. + +### 필터 가시성 핸들러 4종 + +목록 화면 필터 패널의 표시/숨김을 localStorage 에 저장해 새로고침 후에도 유지합니다. + +| 핸들러 | params | 설명 | +|---|---|---| +| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 (`init_actions`에서 호출) | +| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 | +| `toggleFilterVisibility` | `{ key }` | 특정 필터 키 가시성 토글 | +| `resetFilterVisibility` | 없음 | 전체 초기화 | + +### 다국어 태그 핸들러 3종 + +`MultilingualInput` 컴포넌트가 쓰는 태그 편집 핸들러입니다. + +| 핸들러 | params | 설명 | +|---|---|---| +| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 | +| `cancelMultilingualTag` | 없음 | 편집 취소 | +| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 | + +### setDateRange + +날짜 필터 프리셋 버튼(`today`/`week`/`month`/`3months`/`6months`/`1year`) 클릭 시 시작·종료일을 +`YYYY-MM-DDTHH:mm:ss` 형식으로 계산해 **반환**합니다(자체적으로 상태를 갱신하지 않음) — 레이아웃 +JSON 이 `sequence` + `setState` 로 반환값(`$prev.startDate` 등)을 원하는 필드에 반영합니다. + +```json +{ "type": "click", "handler": "sequence", "actions": [ + { "handler": "setDateRange", "params": { "preset": "today" } }, + { "handler": "setState", "params": { "target": "local", "filter.dateFrom": "{{$prev.startDate}}", "filter.dateTo": "{{$prev.endDate}}" } } +] } +``` + +### 사이드바 접힘 핸들러 2종 + +데스크톱 좌측 사이드바 접힘 상태를 `localStorage`(`g7_admin_sidebar_collapsed`) 와 +`_global.sidebarCollapsed` 에 반영합니다. 모바일 슬라이드 사이드바 상태(`_global.sidebarOpen`)와는 +**독립된 상태**이므로 데스크톱 접힘과 모바일 열림/닫힘을 같은 상태로 착각하지 않습니다. + +| 핸들러 | params | 설명 | +|---|---|---| +| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (`init_actions`에서 호출) | +| `toggleSidebar` | 없음 | 접힘 상태 반전 + 저장 | + +### downloadAttachment + +관리자 화면에서 첨부파일을 `` 직접 링크가 아니라 `G7Core.api.get(url, {responseType: +'blob'})` 로 요청한 뒤 objectURL 로 변환해 다운로드합니다. `` 네비게이션은 Authorization +헤더가 실리지 않아 요청이 guest 로 통과하고 활동이력의 행위자(`user_id`)가 비게 되므로, 코어 +ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리자 본인 이력으로 정확히 남습니다. + +```json +{ "type": "click", "handler": "downloadAttachment", "params": { "url": "{{attachment.download_url}}", "filename": "{{attachment.original_name}}" } } +``` + +### 자산 URL 방식 재감지 2종 + +관리자 환경설정 화면에서 정적 자산 URL 방식(확장자 있음/없음)을 **브라우저에서** 재감지합니다. +서버가 자기 자신에게 curl 하면 nginx vhost·프록시 체인을 우회해 오판할 수 있어, 실제 방문자 +경로를 재현하려면 브라우저가 프로브를 던져야 합니다. + +| 핸들러 | 호출 시점 | 동작 | +|---|---|---| +| `detectAssetUrlMode` | 관리자가 "재감지" 버튼 클릭 | 프로브 쌍(확장자 있음/없음)을 던져 판정한 뒤 폼 상태(`general.asset_url_mode`)와 감지 상태 문구를 갱신 — **저장은 하지 않음**(관리자가 결과를 보고 직접 저장) | +| `checkAssetUrlModeDrift` | 대시보드 진입 시 자동 | 저장된 설정값과 실제 감지 결과를 대조해 어긋나면 전역 상태(`assetUrlModeDrift`)에 실어 대시보드에 경고 노출 | + +봇은 JavaScript 를 실행하지 않으므로 클라이언트측 자가 복구가 있어도 저장값이 틀린 채로 +남으면 SEO 렌더링은 계속 깨집니다 — `checkAssetUrlModeDrift` 가 그 간극을 관리자에게 드러내는 +유일한 지점입니다. 판정 불가(`unavailable`)일 때는 아무 것도 표시하지 않습니다 — 일시적 +네트워크 장애를 결함 신호로 오인시키지 않기 위함입니다. + + +## 부트스트랩 + + +| 항목 | 값 | +|---|---| +| 엔트리 파일 | `src/index.ts` | +| 전역 객체 | **미노출** | +| 재등록 진입점 | `initTemplate()` | + +재등록 진입점이 전역에 고정 이름으로 노출되지 않으면 로케일 전환 후 이 확장의 액션이 전부 무반응이 됩니다 (오류·토스트 없음). + + + +전역 객체가 "미노출"인 것은 실수가 아니라 템플릿과 모듈/플러그인의 재등록 규약 차이입니다 — +모듈/플러그인은 여러 개가 동시에 활성화되므로 서로를 식별할 `window.__[Name]` 고정 이름이 +필요하지만, 템플릿은 사이트에 활성 템플릿이 항상 하나뿐이라 그런 식별 필요가 없습니다. +`initTemplate()` 을 고칠 때는 핸들러 재등록 +외의 1회성 부팅 작업(예: 사이드바 초기 상태 복원)을 섞지 않습니다 — 로케일 전환마다 재실행되면 +안 되는 작업이기 때문입니다(사이드바 복원은 `initSidebar` 를 레이아웃 `init_actions` 에서 +별도로 호출하는 이유이기도 합니다). + + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/handlers.md` 에 있던 원문을 +> 이 문서로 옮긴 것입니다(#601). 핸들러별 params·동작·사용 예시가 여기에 있습니다. +> 이관 시점 그대로 보존하되, 현재 코드와 어긋나는 부분에는 정정 주석을 달았습니다 — +> 등록 핸들러의 SSoT 는 위 「템플릿 전용 핸들러」 블록입니다. + +### setLocale + +> **정정(#601)**: `setLocale` 은 더 이상 이 템플릿이 등록하는 핸들러가 아닙니다 — 엔진(ActionDispatcher) +> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, 동작·파라미터는 +> 같지만 **소유 주체가 템플릿이 아니라 엔진**입니다. + +앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다. + +**소스**: `src/handlers/setLocaleHandler.ts` + +```json +{ + "type": "click", + "handler": "setLocale", + "params": { + "locale": "en" + } +} +``` + +#### params + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) | + +#### 동작 + +```text +1. 로케일 변경 → 번역 파일 다시 로드 +2. _global.locale 자동 업데이트 +3. 모든 $t: 표현식 재평가 +``` + +#### 사용 예시 + +```json +{ + "id": "lang_en_btn", + "type": "basic", + "name": "Button", + "props": { + "text": "English" + }, + "actions": [ + { + "type": "click", + "handler": "setLocale", + "params": { + "locale": "en" + } + } + ] +} +``` + +--- + +### setTheme / initTheme + +#### setTheme + +다크/라이트 모드를 전환합니다. + +**소스**: `src/handlers/setThemeHandler.ts` + +```json +{ + "type": "click", + "handler": "setTheme", + "params": { + "theme": "dark" + } +} +``` + +#### setTheme params + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) | + +#### 동작 + +```text +1. localStorage에 테마 설정 저장 +2. document.documentElement에 class 적용 (dark/light) +3. Tailwind dark: variant 활성화/비활성화 +``` + +#### initTheme + +앱 시작 시 저장된 테마 설정을 적용합니다. 주로 `init_actions`에서 사용합니다. + +```json +{ + "init_actions": [ + { + "handler": "initTheme" + } + ] +} +``` + +params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다. + +#### 사용 예시 + +```json +{ + "id": "theme_toggle", + "type": "composite", + "name": "ThemeToggle", + "actions": [ + { + "type": "click", + "handler": "setTheme", + "params": { + "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" + } + } + ] +} +``` + +--- + +### scrollToSection + +특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다. + +**소스**: `src/handlers/scrollToSectionHandler.ts` + +```json +{ + "type": "click", + "handler": "scrollToSection", + "params": { + "selector": "#features", + "offset": -80 + } +} +``` + +#### params + +| 필드 | 타입 | 필수 | 기본값 | 설명 | +|------|------|------|--------|------| +| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) | +| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 | + +#### 동작 + +```text +1. document.querySelector(selector)로 대상 요소 검색 +2. 요소의 위치 계산 + offset 적용 +3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤 +``` + +#### 사용 예시 + +```json +{ + "id": "nav_features", + "type": "basic", + "name": "A", + "props": { + "text": "$t:common.features", + "className": "cursor-pointer" + }, + "actions": [ + { + "type": "click", + "handler": "scrollToSection", + "params": { + "selector": "#features", + "offset": -80 + } + } + ] +} +``` + +--- + +### initMenuFromUrl + +현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다. + +**소스**: `src/handlers/initMenuFromUrlHandler.ts` + +```json +{ + "init_actions": [ + { + "handler": "initMenuFromUrl" + } + ] +} +``` + +#### params + +없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다. + +#### 동작 + +```text +1. 현재 URL 경로 (window.location.pathname) 추출 +2. 사이드바 메뉴 데이터에서 URL 매칭 +3. 매칭된 메뉴 항목의 is_active 상태 설정 +4. 부모 메뉴도 자동으로 펼침 상태 설정 +``` + +#### 사용 예시 (_admin_base.json) + +```json +{ + "init_actions": [ + { "handler": "initTheme" }, + { "handler": "initMenuFromUrl" } + ] +} +``` + +--- + +### 필터 가시성 핸들러 + +목록 화면에서 필터 패널의 가시성(표시/숨김)을 관리합니다. localStorage에 상태를 저장하여 새로고침 후에도 유지합니다. + +**소스**: `src/handlers/filterVisibilityHandler.ts` + +#### initFilterVisibility + +저장된 필터 가시성 상태를 `_local`에 복원합니다. + +```json +{ + "init_actions": [ + { + "handler": "initFilterVisibility" + } + ] +} +``` + +#### saveFilterVisibility + +현재 필터 가시성 상태를 localStorage에 저장합니다. + +```json +{ + "handler": "saveFilterVisibility", + "params": { + "filters": "{{_local.filterVisibility}}" + } +} +``` + +#### toggleFilterVisibility + +특정 필터 키의 가시성을 토글합니다. + +```json +{ + "type": "click", + "handler": "toggleFilterVisibility", + "params": { + "key": "advancedFilters" + } +} +``` + +#### resetFilterVisibility + +모든 필터 가시성을 초기 상태로 리셋합니다. + +```json +{ + "type": "click", + "handler": "resetFilterVisibility" +} +``` + +#### 핸들러 params 요약 + +| 핸들러 | params | 설명 | +|--------|--------|------| +| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 | +| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 | +| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 | +| `resetFilterVisibility` | 없음 | 전체 초기화 | + +#### 사용 예시 (목록 페이지) + +```json +{ + "init_actions": [ + { "handler": "initFilterVisibility" } + ], + "components": [ + { + "id": "filter_toggle_btn", + "type": "basic", + "name": "Button", + "props": { + "text": "$t:common.toggle_filters" + }, + "actions": [ + { + "type": "click", + "handler": "toggleFilterVisibility", + "params": { "key": "advancedFilters" } + } + ] + }, + { + "id": "filter_section", + "type": "basic", + "name": "Div", + "if": "{{_local.filterVisibility?.advancedFilters}}", + "children": [ + { "comment": "필터 컴포넌트들" } + ] + } + ] +} +``` + +--- + +### 다국어 태그 핸들러 + +다국어 입력 컴포넌트(MultilingualInput)에서 사용하는 태그 관리 핸들러입니다. + +**소스**: `src/handlers/multilingualTagHandler.ts` + +#### saveMultilingualTag + +다국어 태그를 저장합니다. + +```json +{ + "handler": "saveMultilingualTag", + "params": { + "field": "tags", + "locale": "{{_global.locale}}" + } +} +``` + +#### cancelMultilingualTag + +다국어 태그 편집을 취소합니다. + +```json +{ + "handler": "cancelMultilingualTag" +} +``` + +#### updateMultilingualTagValue + +다국어 태그 값을 업데이트합니다. + +```json +{ + "handler": "updateMultilingualTagValue", + "params": { + "field": "tags", + "locale": "ko", + "value": "{{$event.target.value}}" + } +} +``` + +#### 태그 핸들러 params 요약 + +| 핸들러 | params | 설명 | +|--------|--------|------| +| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 | +| `cancelMultilingualTag` | 없음 | 편집 취소 | +| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 | + +--- + +### 핸들러 소스 파일 매핑 + +| 핸들러명 | 소스 파일 | 등록 함수 | +|---------|----------|----------| +| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` | +| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` | +| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` | +| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` | +| `initFilterVisibility`, `saveFilterVisibility`, `toggleFilterVisibility`, `resetFilterVisibility` | `src/handlers/filterVisibilityHandler.ts` | `initFilterVisibilityHandler` | +| `saveMultilingualTag`, `cancelMultilingualTag`, `updateMultilingualTagValue` | `src/handlers/multilingualTagHandler.ts` | `saveMultilingualTagHandler` | + +--- + +### 주의사항 + +```text +이 핸들러들은 sirsoft-admin_basic 템플릿에서만 등록됨 (다른 템플릿에서 미지원 가능) +범용 핸들러(navigate, apiCall, setState 등)와 달리 템플릿 의존적 +✅ 커스텀 핸들러이므로 template.json의 핸들러 등록 확인 필요 +✅ 범용 핸들러는 actions-handlers.md 참조 +``` + +--- + +### 관련 문서 + +- [액션 핸들러 개요](../../../../docs/frontend/actions-handlers.md) +- [sirsoft-admin_basic 컴포넌트](components.md) +- [sirsoft-admin_basic 레이아웃](layouts.md) +- [sirsoft-basic 핸들러](../../../../docs/frontend/templates/sirsoft-basic/handlers.md) diff --git a/templates/_bundled/sirsoft-admin_basic/docs/layouts.md b/templates/_bundled/sirsoft-admin_basic/docs/layouts.md new file mode 100644 index 00000000..a701e0f0 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/docs/layouts.md @@ -0,0 +1,621 @@ +# Admin Basic — 레이아웃 + +> 레이아웃 목록과 라우트 매핑 · 진입점: [AGENTS.md](../AGENTS.md) + +## 레이아웃 목록 + + +레이아웃 145개 (루트: `layouts`). + +| 그룹 | 개수 | +|---|---| +| `(root)` | 27개 | +| `auth` | 1개 | +| `errors` | 6개 | +| `overrides` | 1개 | +| `partials` | 110개 | + +| 레이아웃 | 그룹 | 종류 | extends | +|---|---|---|---| +| `_admin_base` | `(root)` | partial | - | +| `admin_activity_log_list` | `(root)` | 화면 | `_admin_base` | +| `admin_dashboard` | `(root)` | 화면 | `_admin_base` | +| `admin_forgot_password` | `(root)` | 화면 | - | +| `admin_identity_logs` | `(root)` | 화면 | `_admin_base` | +| `admin_language_pack_list` | `(root)` | 화면 | `_admin_base` | +| `admin_language_packs` | `(root)` | 화면 | `admin_language_pack_list` | +| `admin_language_packs_install_modal` | `(root)` | 화면 | - | +| `admin_login` | `(root)` | 화면 | - | +| `admin_menu_list` | `(root)` | 화면 | `_admin_base` | +| `admin_module_language_packs` | `(root)` | 화면 | `admin_language_pack_list` | +| `admin_module_list` | `(root)` | 화면 | `_admin_base` | +| `admin_notification_log_list` | `(root)` | 화면 | `_admin_base` | +| `admin_plugin_language_packs` | `(root)` | 화면 | `admin_language_pack_list` | +| `admin_plugin_list` | `(root)` | 화면 | `_admin_base` | +| `admin_reset_password` | `(root)` | 화면 | - | +| `admin_role_form` | `(root)` | 화면 | `_admin_base` | +| `admin_role_list` | `(root)` | 화면 | `_admin_base` | +| `admin_schedule_list` | `(root)` | 화면 | `_admin_base` | +| `admin_settings` | `(root)` | 화면 | `_admin_base` | +| `admin_template_language_packs` | `(root)` | 화면 | `admin_language_pack_list` | +| `admin_template_layout_edit` | `(root)` | 화면 | `_admin_base` | +| `admin_template_list` | `(root)` | 화면 | `_admin_base` | +| `admin_user_detail` | `(root)` | 화면 | `_admin_base` | +| `admin_user_form` | `(root)` | 화면 | `_admin_base` | +| `admin_user_list` | `(root)` | 화면 | `_admin_base` | +| `identity_challenge` | `auth` | 화면 | `_admin_base` | +| `401` | `errors` | 화면 | `_admin_base` | +| `403` | `errors` | 화면 | `_admin_base` | +| `404` | `errors` | 화면 | `_admin_base` | +| `500` | `errors` | 화면 | `_admin_base` | +| `503` | `errors` | 화면 | `_admin_base` | +| `maintenance` | `errors` | 화면 | `_admin_base` | +| `index` | `overrides` | 화면 | `_admin_base` | +| `_identity_challenge_modal` | `partials` | partial | - | +| `_modal_changelog` | `partials` | partial | - | +| `_modal_license` | `partials` | partial | - | +| `_modal_notification_delete_all_confirm` | `partials` | partial | - | +| `_partial_datagrid` | `partials` | partial | - | +| `_partial_filter` | `partials` | partial | - | +| `_content` | `partials` | partial | - | +| `_modal_log_detail` | `partials` | partial | - | +| `_modal_purge_confirm` | `partials` | partial | - | +| `_partial_datagrid` | `partials` | partial | - | +| `_partial_filter` | `partials` | partial | - | +| `_content` | `partials` | partial | - | +| `_drawer_manifest_preview` | `partials` | partial | - | +| `_modal_detail` | `partials` | partial | - | +| `_modal_install` | `partials` | partial | - | +| `_modal_install_bundled` | `partials` | partial | - | +| `_modal_refresh_cache` | `partials` | partial | - | +| `_modal_slot_conflict` | `partials` | partial | - | +| `_modal_uninstall` | `partials` | partial | - | +| `_modal_update` | `partials` | partial | - | +| `_modal_delete` | `partials` | partial | - | +| `_panel_detail` | `partials` | partial | - | +| `_panel_form` | `partials` | partial | - | +| `_panel_menu_list` | `partials` | partial | - | +| `_panel_view` | `partials` | partial | - | +| `_drawer_manifest_preview` | `partials` | partial | - | +| `_modal_deactivate_warning` | `partials` | partial | - | +| `_modal_detail` | `partials` | partial | - | +| `_modal_extension_license` | `partials` | partial | - | +| `_modal_force_activate` | `partials` | partial | - | +| `_modal_force_deactivate` | `partials` | partial | - | +| `_modal_install` | `partials` | partial | - | +| `_modal_manual_install` | `partials` | partial | - | +| `_modal_reactivate_language_packs` | `partials` | partial | - | +| `_modal_refresh_layouts` | `partials` | partial | - | +| `_modal_uninstall` | `partials` | partial | - | +| `_modal_update` | `partials` | partial | - | +| `_modal_log_detail` | `partials` | partial | - | +| `_partial_datagrid` | `partials` | partial | - | +| `_partial_filter` | `partials` | partial | - | +| `_drawer_manifest_preview` | `partials` | partial | - | +| `_modal_detail` | `partials` | partial | - | +| `_modal_extension_license` | `partials` | partial | - | +| `_modal_force_activate` | `partials` | partial | - | +| `_modal_force_deactivate` | `partials` | partial | - | +| `_modal_install` | `partials` | partial | - | +| `_modal_manual_install` | `partials` | partial | - | +| `_modal_reactivate_language_packs` | `partials` | partial | - | +| `_modal_refresh_layouts` | `partials` | partial | - | +| `_modal_uninstall` | `partials` | partial | - | +| `_modal_update` | `partials` | partial | - | +| `_modal_delete` | `partials` | partial | - | +| `_modal_delete` | `partials` | partial | - | +| `_modal_duplicate` | `partials` | partial | - | +| `_modal_form` | `partials` | partial | - | +| `_modal_history` | `partials` | partial | - | +| `_modal_run` | `partials` | partial | - | +| `_tab_schedules` | `partials` | partial | - | +| `_modal_cache_delete` | `partials` | partial | - | +| `_modal_core_changelog` | `partials` | partial | - | +| `_modal_core_update_guide` | `partials` | partial | - | +| `_modal_core_update_result` | `partials` | partial | - | +| `_modal_identity_message_definition_add` | `partials` | partial | - | +| `_modal_identity_message_definition_delete` | `partials` | partial | - | +| `_modal_identity_message_definition_reset` | `partials` | partial | - | +| `_modal_identity_message_template_form` | `partials` | partial | - | +| `_modal_identity_message_template_preview` | `partials` | partial | - | +| `_modal_identity_policy_delete` | `partials` | partial | - | +| `_modal_identity_policy_form` | `partials` | partial | - | +| `_modal_mail_template_form` | `partials` | partial | - | +| `_modal_notification_definition_reset` | `partials` | partial | - | +| `_modal_notification_template_form` | `partials` | partial | - | +| `_modal_notification_template_preview` | `partials` | partial | - | +| `_modal_password_confirm` | `partials` | partial | - | +| `_tab_advanced` | `partials` | partial | - | +| `_tab_drivers` | `partials` | partial | - | +| `_tab_general` | `partials` | partial | - | +| `_tab_identity` | `partials` | partial | - | +| `_tab_identity_basic` | `partials` | partial | - | +| `_tab_identity_messages` | `partials` | partial | - | +| `_tab_identity_policies` | `partials` | partial | - | +| `_tab_identity_providers` | `partials` | partial | - | +| `_tab_info` | `partials` | partial | - | +| `_tab_language_packs` | `partials` | partial | - | +| `_tab_mail` | `partials` | partial | - | +| `_tab_mail_templates` | `partials` | partial | - | +| `_tab_notification_definitions` | `partials` | partial | - | +| `_tab_security` | `partials` | partial | - | +| `_tab_seo` | `partials` | partial | - | +| `_tab_upload` | `partials` | partial | - | +| `_modal_extension_preview_layout` | `partials` | partial | - | +| `_modal_extension_version_history` | `partials` | partial | - | +| `_modal_version_history` | `partials` | partial | - | +| `_drawer_manifest_preview` | `partials` | partial | - | +| `_modal_activate` | `partials` | partial | - | +| `_modal_deactivate` | `partials` | partial | - | +| `_modal_detail` | `partials` | partial | - | +| `_modal_extension_license` | `partials` | partial | - | +| `_modal_force_activate` | `partials` | partial | - | +| `_modal_install` | `partials` | partial | - | +| `_modal_manual_install` | `partials` | partial | - | +| `_modal_reactivate_language_packs` | `partials` | partial | - | +| `_modal_refresh_layouts` | `partials` | partial | - | +| `_modal_uninstall` | `partials` | partial | - | +| `_modal_update` | `partials` | partial | - | +| `_tab_admin` | `partials` | partial | - | +| `_tab_user` | `partials` | partial | - | +| `_content_section` | `partials` | partial | - | +| `_header_section` | `partials` | partial | - | +| `_info_card` | `partials` | partial | - | +| `template_partial_test` | `(root)` | 화면 | `_admin_base` | + + + +그룹은 "이 파일이 독립된 화면 URL을 갖는가"로 나뉩니다 — `(root)`/`auth`/`errors`/`overrides` 는 +라우트에 직접 매핑되는 화면이고, `partials`(110개, 전체의 76%)는 화면에 `extends`/포함되는 +조각(탭·모달·패널)이라 그 자체로는 URL이 없습니다. partial 비중이 압도적으로 큰 것은 관리자 +화면 하나가 보통 탭 여러 개 + 모달 여러 개로 구성되기 때문입니다(예: 확장 관리 화면 하나가 +설치/삭제/업데이트/강제활성화 등 모달을 5~10개씩 갖습니다). + +이 목록은 코드에서 실측되므로 구체적인 개수·파일명을 프로즈에 하드코딩해 반복하지 않습니다 +— 과거 버전 문서가 손으로 그린 전체 페이지 트리(약 20개 화면 기준)는 이미 145개로 늘어난 +현재 상태와 크게 어긋나 있었습니다. 전체 목록은 항상 위 생성 표를 신뢰합니다. + +### 화면 유형별 구성 패턴 + +새 관리자 화면을 만들 때 아래 패턴 중 가장 가까운 것을 참고합니다(구체적 파일명이 아니라 +**구조**를 재사용하는 것이 목적입니다 — 실제 예시 파일은 위 표에서 비슷한 이름을 찾습니다). + +**목록 화면**: `extends: _admin_base`, `slots.content` 에 `PageHeader`(제목·액션) → +`FilterGroup`(선택) → `DataGrid`/`CardGrid`(목록) → `Pagination`. data_sources 는 +`auto_fetch: true` + `params`에 `_local.page`/`per_page` 바인딩. 삭제·상태변경은 `apiCall`, +상세/수정 이동은 `navigate`, 필터·페이지네이션은 `setState`. 목록 컨텍스트 왕복 규약 +(`mergeQuery`)을 지킵니다(§CLAUDE.md). + +**상세 화면**: `extends: _admin_base`, `data_sources`에 `route.id` 기반 상세 API, +`slots.content`에 `PageHeader` + `Card` 여러 개(기본 정보/활동 내역/권한 정보 등 섹션별 분리). + +**폼 화면(생성/수정 겸용)**: `route.id` 존재 여부로 생성/수정을 분기, `Form` 안에 +`FormField`+`Input`/`Select`/`Toggle` 조합. `Button` 은 `type="button"` 명시(submit 방지), +서버 검증 에러는 `FormField` 의 `error` prop 으로 표시. + +**설정(탭) 화면**: `TabNavigation` + `activeTab` 상태에 따라 `_tab_*.json` partial 을 +조건부 렌더링. 탭 전환은 `setState`, 저장은 `apiCall`, 파괴적 동작 확인은 `openModal`. + +**확장 관리 화면**(모듈/플러그인/템플릿 목록): `DataGrid`/`CardGrid` + `StatusBadge`(상태) + +`ActionMenu`(설치/활성화/비활성화/삭제/업데이트) + `ExtensionBadge`. 설치·삭제·업데이트마다 +전용 확인 모달(`_modal_install`/`_modal_uninstall`/`_modal_update` 등)을 개별 파일로 분리 — +한 모달에 여러 동작을 조건 분기로 몰아넣지 않습니다(모달마다 문구·부작용이 다릅니다). + +**에러 화면**(`errors/*.json`): `extends` 없는 독립 레이아웃. `Div`(중앙 정렬) 안에 +`Icon`+`H1`(코드)+`P`(메시지)+`Button`(홈 이동). 독립 레이아웃이므로 `Toast`/`Modal` 같은 +전역 호스트 컴포넌트가 필요하면 직접 마운트해야 합니다(§CLAUDE.md "독립 레이아웃의 글로벌 +호스트 컴포넌트"). + +### `_admin_base.json` 에 대한 정정 + +과거 버전 문서는 `_admin_base.json` 이 `init_actions: [initTheme, initMenuFromUrl]` 를 갖는다고 +적었으나, 현재 `_admin_base.json` 에는 `init_actions` 키 자체가 없습니다 — 두 핸들러는 현재 +`_admin_base` 를 상속하지 않는 인증 화면(`admin_login`/`admin_forgot_password`/ +`admin_reset_password`)에서만 호출됩니다. 사이드바 접힘 상태 복원(`initSidebar`)은 레이아웃이 +아니라 템플릿 부트스트랩(`src/index.ts`)에서 직접 호출됩니다. `_admin_base` 를 고칠 때 이 +문서의 낡은 구조를 그대로 믿지 말고 실제 JSON 을 확인하세요. + + +## 라우트 매핑 + + +| 경로 | 레이아웃 | 이름 | +|---|---|---| +| `*/admin` | `-` | - | +| `*/admin/login` | `admin_login` | - | +| `*/admin/forgot-password` | `admin_forgot_password` | - | +| `*/admin/reset-password` | `admin_reset_password` | - | +| `*/admin/dashboard` | `admin_dashboard` | - | +| `*/admin/users` | `admin_user_list` | - | +| `*/admin/users/create` | `admin_user_form` | - | +| `*/admin/users/:id` | `admin_user_detail` | - | +| `*/admin/users/:id/edit` | `admin_user_form` | - | +| `*/admin/modules` | `admin_module_list` | - | +| `*/admin/settings/language-packs` | `-` | - | +| `*/admin/modules/:identifier/language-packs` | `admin_module_language_packs` | - | +| `*/admin/plugins/:identifier/language-packs` | `admin_plugin_language_packs` | - | +| `*/admin/templates/:identifier/language-packs` | `admin_template_language_packs` | - | +| `*/admin/plugins` | `admin_plugin_list` | - | +| `*/admin/menus` | `admin_menu_list` | - | +| `*/admin/templates` | `-` | - | +| `*/admin/templates/:identifier/edit` | `admin_template_layout_edit` | - | +| `*/admin/templates/:type` | `admin_template_list` | - | +| `*/admin/template/partial` | `template_partial_test` | - | +| `*/admin/activity-logs` | `admin_activity_log_list` | - | +| `*/admin/notification-logs` | `admin_notification_log_list` | - | +| `*/admin/settings` | `admin_settings` | - | +| `*/admin/roles` | `admin_role_list` | - | +| `*/admin/roles/create` | `admin_role_form` | - | +| `*/admin/roles/:id/edit` | `admin_role_form` | - | +| `*/admin/schedules` | `admin_schedule_list` | - | +| `*/admin/identity/logs` | `admin_identity_logs` | - | +| `*/admin/identity/challenge` | `auth/identity_challenge` | - | + + + +`레이아웃` 열이 `-` 인 두 행(`*/admin`, `*/admin/settings/language-packs`)은 레이아웃이 +없다는 뜻이 아니라 **다른 라우트로 리다이렉트되는 진입점**입니다 — 예를 들어 `/admin` 은 +로그인 여부에 따라 `/admin/login` 또는 `/admin/dashboard` 로 넘어가는 게이트 라우트입니다. +새 화면을 추가할 때 이 표에 라우트를 등록하는 것만으로 끝나지 않습니다 — 사이드바 메뉴에서 +그 화면으로 이동하는 진입점도 함께 추가해야 실제로 도달 가능해집니다(라우트만 있고 메뉴 +항목이 없으면 URL을 직접 입력해야만 닿는 화면이 됩니다). + + +## 확장 오버라이드 + + +_오버라이드하는 레이아웃 확장 조각이 없습니다._ + + + +이 템플릿은 현재 어떤 모듈/플러그인의 레이아웃 확장 조각도 오버라이드하지 않습니다 — +`sirsoft-basic` 템플릿과 달리 관리자 화면은 확장이 끼워 넣는 조각(예: 이커머스 문의 설정, +GDPR 배너)을 코어 대시보드 위젯 형태로만 받고, 이 템플릿이 그 조각을 대체할 필요가 아직 +없었기 때문입니다. 특정 확장의 관리자 UI 를 이 템플릿에서만 다르게 보이게 하려면 +`extensions/{module-identifier}/*.json` 을 신설합니다(§docs/extension/layout-extensions.md +"템플릿 오버라이드"). + + +## 이관 원문 상세 + +> 아래는 코어 `docs/frontend/templates/sirsoft-admin_basic/layouts.md` 에 있던 원문을 +> 이 문서로 옮긴 것입니다(#601). 페이지 맵 트리와 화면 유형별 패턴 상세가 여기에 있습니다. +> 이관 시점 그대로 보존하되, 현재 코드와 어긋나는 부분에는 정정 주석을 달았습니다 — +> 레이아웃·라우트의 SSoT 는 위 「레이아웃 목록」·「라우트 매핑」 블록입니다. + +### 페이지 맵 (트리 구조) + +```text +_admin_base.json (베이스 레이아웃) +│ +├── admin_dashboard.json (대시보드) +├── admin_login.json (로그인 — _admin_base 미상속) +│ +├── 사용자 관리 +│ ├── admin_user_list.json (목록) +│ ├── admin_user_form.json (생성/수정) +│ └── admin_user_detail.json (상세) +│ +├── 역할 관리 +│ ├── admin_role_list.json (목록) +│ │ └── partials/admin_role_list/_modal_delete.json +│ └── admin_role_form.json (생성/수정) +│ +├── 메뉴 관리 +│ └── admin_menu_list.json (3패널 레이아웃) +│ └── partials/admin_menu_list/ +│ ├── _panel_menu_list.json (좌측: 메뉴 트리) +│ ├── _panel_form.json (중앙: 편집 폼) +│ ├── _panel_detail.json (중앙: 상세 보기) +│ ├── _panel_view.json (우측: 미리보기) +│ └── _modal_delete.json +│ +├── 환경 설정 +│ └── admin_settings.json (탭 네비게이션) +│ └── partials/admin_settings/ +│ ├── _tab_general.json (일반) +│ ├── _tab_mail.json (메일 발송 SMTP 설정) +│ ├── _tab_notification_definitions.json (알림 정의) +│ ├── _tab_security.json (보안) +│ ├── _tab_upload.json (업로드) +│ ├── _tab_drivers.json (드라이버) +│ ├── _tab_seo.json (SEO) +│ ├── _tab_advanced.json (고급) +│ ├── _tab_info.json (시스템 정보) +│ ├── _modal_cache_delete.json +│ ├── _modal_core_changelog.json +│ ├── _modal_core_update_guide.json +│ ├── _modal_core_update_result.json +│ ├── _modal_notification_template_form.json +│ ├── _modal_notification_template_preview.json +│ └── _modal_password_confirm.json +│ +├── 모듈 관리 +│ └── admin_module_list.json +│ └── partials/admin_module_list/ +│ ├── _modal_detail.json +│ ├── _modal_install.json +│ ├── _modal_manual_install.json +│ ├── _modal_uninstall.json +│ ├── _modal_update.json +│ ├── _modal_deactivate_warning.json +│ ├── _modal_force_activate.json +│ ├── _modal_force_deactivate.json +│ ├── _modal_extension_license.json +│ └── _modal_refresh_layouts.json +│ +├── 플러그인 관리 +│ └── admin_plugin_list.json +│ └── partials/admin_plugin_list/ +│ ├── (모듈과 동일 구조 — 10개 모달) +│ └── ... +│ +├── 템플릿 관리 +│ ├── admin_template_list.json +│ │ └── partials/admin_template_list/ +│ │ ├── _tab_admin.json (Admin 템플릿 탭) +│ │ ├── _tab_user.json (User 템플릿 탭) +│ │ ├── _modal_detail.json +│ │ ├── _modal_install.json +│ │ ├── _modal_manual_install.json +│ │ ├── _modal_uninstall.json +│ │ ├── _modal_update.json +│ │ ├── _modal_activate.json +│ │ ├── _modal_deactivate.json +│ │ ├── _modal_force_activate.json +│ │ ├── _modal_extension_license.json +│ │ └── _modal_refresh_layouts.json +│ └── admin_template_layout_edit.json (레이아웃 편집기) +│ └── partials/admin_template_layout_edit/_modal_version_history.json +│ +├── 스케줄 관리 +│ └── admin_schedule_list.json +│ └── partials/admin_schedule_list/ +│ ├── _tab_schedules.json +│ ├── _modal_form.json +│ ├── _modal_delete.json +│ ├── _modal_duplicate.json +│ ├── _modal_history.json +│ └── _modal_run.json +│ +├── 메일 발송 로그 +│ └── admin_mail_send_log_list.json +│ └── partials/admin_mail_send_log_list/ +│ ├── _partial_datagrid.json +│ └── _partial_filter.json +│ +├── 공통 Partial +│ ├── partials/_modal_changelog.json +│ └── partials/_modal_license.json +│ +├── 에러 페이지 +│ └── errors/ +│ ├── 401.json (인증 필요) +│ ├── 403.json (접근 거부) +│ ├── 404.json (페이지 없음) +│ ├── 500.json (서버 오류) +│ ├── 503.json (서비스 불가) +│ └── maintenance.json (점검 중) +│ +├── 오버라이드 +│ └── overrides/sirsoft-sample/index.json +│ +└── 테스트 + └── template_partial_test.json + └── partials/template_partial_test/ + ├── _content_section.json + ├── _header_section.json + └── _info_card.json +``` + +--- + +### 카테고리별 가이드 + +#### 목록 페이지 패턴 + +**대표**: `admin_user_list.json`, `admin_role_list.json` + +**구성**: +```text +extends: _admin_base +slots.content: + └── PageHeader (제목, 액션 버튼) + └── FilterGroup (선택적) + └── DataGrid (columns, data, pagination) + └── Pagination +``` + +**data_sources**: +```json +{ + "id": "users", + "endpoint": "/api/admin/users", + "method": "GET", + "auto_fetch": true, + "params": { "page": "{{_local.page ?? 1}}", "per_page": "{{_local.per_page ?? 15}}" } +} +``` + +**핸들러 패턴**: +- `apiCall` — 삭제, 상태 변경 +- `navigate` — 상세/수정 페이지 이동 +- `setState` — 필터, 페이지네이션 상태 + +**Partial 구조**: 모달 (삭제 확인, 상세 보기 등) + +--- + +#### 상세 페이지 패턴 + +**대표**: `admin_user_detail.json` + +**구성**: +```text +extends: _admin_base +data_sources: [상세 API (route.id 기반)] +slots.content: + └── PageHeader + └── Card (기본 정보 섹션) + └── Card (활동 내역 섹션) + └── Card (권한 정보 섹션) +``` + +**data_sources**: +```json +{ + "id": "user", + "endpoint": "/api/admin/users/{{route.id}}", + "method": "GET", + "auto_fetch": true +} +``` + +**핸들러 패턴**: +- `apiCall` — 상태 변경 (활성화/비활성화, 역할 변경) +- `navigate` — 목록으로 이동, 수정 페이지 이동 + +--- + +#### 폼 페이지 패턴 + +**대표**: `admin_user_form.json`, `admin_role_form.json` + +**구성**: +```text +extends: _admin_base +data_sources: [상세 API (수정 시), 참조 데이터 (Select 옵션)] +slots.content: + └── PageHeader + └── Form + ├── FormField + Input (텍스트) + ├── FormField + Select (선택) + ├── FormField + Toggle (토글) + └── Button (저장/취소) +``` + +**핸들러 패턴**: +- `apiCall` — 생성 (POST) / 수정 (PUT) +- `navigate` — 성공 후 목록/상세로 이동 + +**주의사항**: +```text +✅ Form 내 Button에 type="button" 명시 (submit 방지) +✅ 수정 폼은 route.id 존재 여부로 생성/수정 구분 +✅ FormField에 error prop으로 서버 검증 에러 표시 +``` + +--- + +#### 설정 페이지 패턴 + +**대표**: `admin_settings.json` + +**구성**: +```text +extends: _admin_base +data_sources: [설정 API] +slots.content: + └── PageHeader + └── TabNavigation (tabs) + └── Div (탭별 partial 조건부 렌더링) + ├── if: activeTab === 'general' → partial: _tab_general.json + ├── if: activeTab === 'mail' → partial: _tab_mail.json + ├── if: activeTab === 'security' → partial: _tab_security.json + └── ... +``` + +**Partial 구조**: `partials/admin_settings/_tab_*.json` (9개 탭) + +**핸들러 패턴**: +- `setState` — 탭 전환 +- `apiCall` — 설정 저장 +- `openModal` — 확인 다이얼로그 + +--- + +#### 확장 관리 패턴 + +**대표**: `admin_module_list.json`, `admin_plugin_list.json`, `admin_template_list.json` + +**구성**: +```text +extends: _admin_base +data_sources: [확장 목록 API] +slots.content: + └── PageHeader (새로고침, 수동 설치 버튼) + └── DataGrid/CardGrid (확장 목록) + ├── StatusBadge (상태) + ├── ActionMenu (설치/활성화/비활성화/삭제/업데이트) + └── ExtensionBadge (모듈 식별) +modals: + ├── _modal_detail.json (상세 정보) + ├── _modal_install.json (설치 확인) + ├── _modal_uninstall.json (삭제 확인) + ├── _modal_update.json (업데이트) + └── _modal_force_activate.json 등 +``` + +**핸들러 패턴**: +- `apiCall` — 설치, 활성화, 비활성화, 삭제, 업데이트 +- `openModal` — 확인 다이얼로그 +- `setState` — 선택된 확장 정보 저장 + +**특수사항**: +- 템플릿 관리는 Admin/User 탭 분리 (`_tab_admin.json`, `_tab_user.json`) +- 레이아웃 편집기 (`admin_template_layout_edit.json`)는 CodeEditor + 실시간 미리보기 + +--- + +#### 에러 페이지 패턴 + +**대표**: `errors/404.json` + +**구성**: +```text +(extends 없음 — 독립 레이아웃) +components: + └── Div (전체 화면 중앙 정렬) + ├── Icon (에러 아이콘) + ├── H1 (에러 코드) + ├── P (에러 메시지) + └── Button (홈으로 이동) +``` + +**핸들러 패턴**: +- `navigate` — 대시보드/홈으로 이동 + +--- + +### 베이스 레이아웃 구조 + +#### _admin_base.json + +모든 관리자 페이지의 공통 구조를 정의합니다. + +```text +_admin_base.json +├── init_actions: [initTheme, initMenuFromUrl] +├── data_sources: [admin_menu, notifications] +├── components: +│ ├── AdminSidebar (menu: admin_menu.data) +│ ├── AdminHeader (user, notifications) +│ ├── Toast +│ ├── PageTransitionIndicator +│ └── Div (content area) +│ └── slot: "content" (← 하위 레이아웃이 채움) +└── AdminFooter +``` + +**슬롯**: +- `content` — 각 페이지의 메인 콘텐츠가 삽입되는 위치 + +--- + +### 관련 문서 + +- [sirsoft-admin_basic 컴포넌트](components.md) +- [sirsoft-admin_basic 핸들러](handlers.md) +- [레이아웃 JSON 스키마](../../../../docs/frontend/layout-json.md) +- [레이아웃 상속](../../../../docs/frontend/layout-json-inheritance.md) +- [sirsoft-basic 레이아웃](../../../../docs/frontend/templates/sirsoft-basic/layouts.md) diff --git a/tests/Feature/Documentation/ExtensionDocContractTest.php b/tests/Feature/Documentation/ExtensionDocContractTest.php index 1edde8fa..a73253ed 100644 --- a/tests/Feature/Documentation/ExtensionDocContractTest.php +++ b/tests/Feature/Documentation/ExtensionDocContractTest.php @@ -37,24 +37,15 @@ class ExtensionDocContractTest extends TestCase */ private const DOC_BACKLOG = [ 'module/gnuboard7-hello_module', - 'module/sirsoft-board', 'module/sirsoft-ecommerce', 'module/sirsoft-page', 'plugin/gnuboard7-hello_plugin', 'plugin/sirsoft-ckeditor5', 'plugin/sirsoft-daum_postcode', - 'plugin/sirsoft-gdpr', 'plugin/sirsoft-marketing', 'plugin/sirsoft-message_bizppurio', - 'plugin/sirsoft-pay_kginicis', - 'plugin/sirsoft-pay_nhnkcp', - 'plugin/sirsoft-pay_nicepayments', - 'plugin/sirsoft-tosspayments', - 'plugin/sirsoft-verification_kginicis', - 'plugin/sirsoft-verification_nhnkcp', 'template/gnuboard7-hello_admin_template', 'template/gnuboard7-hello_user_template', - 'template/sirsoft-admin_basic', 'template/sirsoft-basic', ]; @@ -66,7 +57,7 @@ class ExtensionDocContractTest extends TestCase * 늘리려면 이 숫자를 올리는 **눈에 보이는 행위**를 거치게 합니다. 집필이 진행되면 이 * 숫자도 함께 내려갑니다 (백로그는 줄어들기만 하므로 상한도 단조 감소한다). */ - private const DOC_BACKLOG_CEILING = 20; + private const DOC_BACKLOG_CEILING = 11; /** * `_bundled` 스캔이 번들 확장 전수를 발견하는지 확인합니다. @@ -910,6 +901,58 @@ class ExtensionDocContractTest extends TestCase $ctx['frontend']['layoutExtensions'], "{$record['id']}: {$extRoot}/ 가 실재하는데 레이아웃 확장 조각이 수집되지 않았습니다.", ); + + // getLayoutExtensions() 기본 구현은 위와 같은 디렉토리를 glob() 한 절대경로라 + // 파일 목록과 100% 중복이다. 정규화 없이 실으면 같은 파일이 두 번(상대·절대) 나오고 + // 로컬 머신 절대경로가 커밋되는 확장 저장소에 그대로 남는다. + // 템플릿은 'layout-extensions' 가 아니라 'template-overrides' 블록(docs/layouts.md)이다 + // — 그 개념(오버라이드)이 모듈/플러그인의 발행과 반대 방향이라 문서 자리가 다르다. + $blockKey = $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'template-overrides' : 'layout-extensions'; + $block = $scaffolder->renderBlocks($ctx)[$blockKey] ?? ''; + $this->assertNotSame('', $block, "{$record['id']}: {$blockKey} 블록이 렌더되지 않았습니다."); + $this->assertStringNotContainsString( + str_replace('\\', '/', $record['path']), + str_replace('\\', '/', $block), + "{$record['id']}: 레이아웃 확장 표에 로컬 절대경로가 남아 있습니다.", + ); + $rowCount = substr_count($block, "\n| `"); + $this->assertSame( + count($ctx['frontend']['layoutExtensions']), + $rowCount, + "{$record['id']}: 레이아웃 확장 표 행 수가 실제 파일 수와 다릅니다 (중복 행 의심).", + ); + } + + // getNotificationDefinitions() 의 표준 계약은 리스트 배열 + 원소별 'type' 키다 + // (NotificationSyncHelper::sync() 소비 형태). 'key'/'event' 로 읽으면 정수 인덱스 + // 배열에서 이름을 못 찾아 모든 행이 '-' 로 찍힌다 — 표가 있으나 마나 해진다. + $declaredNotifications = $ctx['surface']['values']['getNotificationDefinitions'] ?? []; + if (is_array($declaredNotifications) && $declaredNotifications !== []) { + $notificationsBlock = $scaffolder->renderBlocks($ctx)['notifications'] ?? ''; + $this->assertStringNotContainsString( + "\n| `-` |", + $notificationsBlock, + "{$record['id']}: 알림 정의 표의 알림 키가 비어 있습니다 ('type' 필드 매핑 확인).", + ); + } + + // getSettingsLayout() 은 getModulePath()/getPluginPath() 기준 절대경로를 돌려준다 + // (§레이아웃 확장과 같은 결함군) — relativeToExtension() 을 거치지 않으면 로컬 머신 + // 절대경로가 그대로 커밋 문서(docs/settings.md · README.md)에 실린다. + $settingsLayout = $ctx['surface']['values']['getSettingsLayout'] ?? null; + if (is_string($settingsLayout) && $settingsLayout !== '') { + $rendered = $scaffolder->renderBlocks($ctx); + // record['path'] 는 Symfony Finder 산출물(슬래시)과 코드 조립 문자열(백슬래시)이 + // 섞인 혼합 구분자다 — 양쪽을 슬래시로 정규화하지 않으면 정규화된 needle 이 + // 정규화되지 않은 haystack 안의 진짜 누출을 놓친다. + $needle = str_replace('\\', '/', $record['path']); + foreach (['settings-schema', 'settings-summary'] as $settingsBlockKey) { + $this->assertStringNotContainsString( + $needle, + str_replace('\\', '/', $rendered[$settingsBlockKey] ?? ''), + "{$record['id']}: {$settingsBlockKey} 블록에 로컬 절대경로가 남아 있습니다.", + ); + } } }