diff --git a/.env.example b/.env.example
index 8fcf8120..2db6325b 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.2
+APP_VERSION=7.0.3
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 96277f5a..7a637226 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.2
+APP_VERSION=7.0.3
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/AGENTS.md b/AGENTS.md
index 4ffdec6a..b70277e3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,13 +6,14 @@
-### 백엔드 [backend/](docs/backend/) (31개)
+### 백엔드 [backend/](docs/backend/) (32개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
+| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... |
| [api-resources.md](docs/backend/api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: BaseApiColl... |
| [authentication.md](docs/backend/authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
@@ -116,7 +117,7 @@
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') |
| [module-identity-settings.md](docs/extension/module-identity-settings.md) | 모듈/플러그인 본인인증(IDV) 설정 통합 가이드 | 정책/목적/메시지: module.php::getIdentity{Policies,Purposes,Mess... |
| [module-layouts.md](docs/extension/module-layouts.md) | 모듈 레이아웃 시스템 | 위치: modules/_bundled/vendor-module/resources/layouts/admi... |
-| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/admin/[vendor-module]/... |
+| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/modules/[vendor-module]/... |
| [module-settings.md](docs/extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 | - |
| [permissions.md](docs/extension/permissions.md) | 권한 시스템 | 구조: User → Role → Permission (기능 레벨) |
| [plugin-development.md](docs/extension/plugin-development.md) | 플러그인 개발 가이드 | 디렉토리: plugins/vendor-plugin (예: sirsoft-payment) |
@@ -143,6 +144,32 @@
| [e2e-testing.md](docs/testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 | - |
+### API 레퍼런스 진입점
+
+> 엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시. 공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428)은 진입점 문서 상단에 정리되어 있다.
+
+| 대상 | 진입점 | 문서/엔드포인트 |
+|------|--------|----------------|
+| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 |
+
+
+### 확장 API 레퍼런스 (9개 확장, 자동 스캔)
+
+> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
+
+| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
+|------|------|--------------|----------------|
+| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 |
+| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
+| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 |
+| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
+| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
+| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
+| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
+| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
+| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
+
+
---
@@ -221,11 +248,22 @@
| 금지 | 올바른 사용 |
|------|------------|
-| `Icon className="w-4 h-4"` | `Icon size="sm"` 또는 `className="text-sm"` |
+| `Icon className="w-4 h-4"` | `className="text-base"` (아래 등가표) 또는 `size` prop |
| `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 |
| Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
| `options={{options}}` | `options={{options ?? []}}` (fallback) |
+Icon 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
+
+| `w-N h-N` | px | 등가 `className` |
+|---|---|---|
+| `w-3 h-3` | 12 | `text-xs` |
+| `w-3.5 h-3.5` | 14 | `text-sm` |
+| `w-4 h-4` | 16 | `text-base` |
+| `w-5 h-5` | 20 | `text-xl` |
+
+`size` prop 은 Font Awesome `fa-*` 클래스로 매핑되며 등가가 아니다 — `size="sm"` → `fa-sm` → `font-size: 0.875em`(상대값) + `line-height` 붕괴로 16px 이 12.25×0.88px 이 된다. 새 아이콘에는 써도 되지만, 기존 `w-N h-N` 의 치환용으로는 쓰지 않는다.
+
### 상태 관리
| 금지 | 올바른 사용 |
@@ -914,7 +952,7 @@ php artisan migrate:rollback
| 수정 대상 파일 패턴 | 작업 전 필수 참조 |
| ------------------- | ------------------ |
-| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md) |
+| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md), [api-documentation.md](docs/backend/api-documentation.md) |
| `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
| `app/Repositories/**` | [service-repository.md](docs/backend/service-repository.md) |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59e6c423..7dc3db24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.0.3] - 2026-07-10
+
+### Fixed
+
+- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
+- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
+- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
+
## [7.0.2] - 2026-07-08
### Added
diff --git a/INSTALL.md b/INSTALL.md
index 4c4d2f2b..ad65f5a1 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.0.2 g7
+# (필요 시) mv g7-7.0.3 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.md b/README.md
index 8514de61..0c1abe3e 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
@@ -16,6 +16,11 @@
+
+ 데모 사이트 ·
+ 관리자 데모
+
+
---
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
@@ -462,7 +467,8 @@ cp .env.example .env
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
-| API 레퍼런스 | 준비 중 |
+| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) |
+| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
---
diff --git a/app/Console/Commands/ApiDocBackfillFieldsCommand.php b/app/Console/Commands/ApiDocBackfillFieldsCommand.php
new file mode 100644
index 00000000..ba9b3a86
--- /dev/null
+++ b/app/Console/Commands/ApiDocBackfillFieldsCommand.php
@@ -0,0 +1,167 @@
+` 셀만 in-place 치환합니다.
+ *
+ * 채움 규칙 SSoT 는 ApiDocScaffolder::responseFieldTable 과 동일한 ResourceFieldDescriber
+ * 입니다 — 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
+ *
+ * 도메인 특이 필드(ResourceFieldDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
+ * 응답 필드 표만 대상으로 하며, 파라미터 표(``)는 건드리지 않습니다.
+ */
+class ApiDocBackfillFieldsCommand extends Command
+{
+ /**
+ * @var string 커맨드 시그니처
+ */
+ protected $signature = 'api:docgen-backfill-fields
+ {--dry-run : 치환하지 않고 채울 건수만 리포트}';
+
+ /**
+ * @var string 커맨드 설명
+ */
+ protected $description = '기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다';
+
+ /**
+ * @var string 응답 필드 표를 식별하는 고유 헤더
+ */
+ private const FIELD_TABLE_HEADER = '| 필드 | 타입 | 실측 예시값 | 용도/설명 |';
+
+ /**
+ * @var string 응답 필드 설명 TODO 마커
+ */
+ private const TODO_MARKER = '';
+
+ /**
+ * 커맨드를 실행합니다.
+ *
+ * @return int 종료 코드
+ */
+ public function handle(): int
+ {
+ $describer = new ResourceFieldDescriber;
+ $dryRun = (bool) $this->option('dry-run');
+
+ $filled = 0;
+ $skipped = 0;
+ $filesChanged = 0;
+
+ foreach ($this->apiDocFiles() as $file) {
+ $content = file_get_contents($file);
+ $result = $this->backfillFile($content, $describer, $filled, $skipped);
+
+ if ($result !== $content) {
+ $filesChanged++;
+ if (! $dryRun) {
+ file_put_contents($file, $result);
+ }
+ }
+ }
+
+ $prefix = $dryRun ? '[dry-run] ' : '';
+ $this->info("{$prefix}응답 필드 설명 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * 대상 API 문서 파일 경로를 수집합니다.
+ *
+ * @return array 파일 경로 목록
+ */
+ private function apiDocFiles(): array
+ {
+ $dirs = array_filter([
+ base_path('docs/backend/api'),
+ ...glob(base_path('modules/_bundled/*/docs/api')),
+ ...glob(base_path('plugins/_bundled/*/docs/api')),
+ ], 'is_dir');
+
+ if ($dirs === []) {
+ return [];
+ }
+
+ $files = [];
+ foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
+ $files[] = $f->getRealPath();
+ }
+
+ return $files;
+ }
+
+ /**
+ * 단일 문서의 응답 필드 표 TODO 를 채웁니다.
+ *
+ * 응답 필드 표 헤더 이후 표 행만 대상으로 하며, `` 셀을
+ * ResourceFieldDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
+ * 파라미터 표는 헤더가 달라 진입하지 않으므로 건드리지 않습니다.
+ *
+ * @param string $content 문서 내용
+ * @param ResourceFieldDescriber $describer 설명기
+ * @param int $filled 채운 건수 (참조 누적)
+ * @param int $skipped 유지 건수 (참조 누적)
+ * @return string 치환된 문서 내용
+ */
+ private function backfillFile(string $content, ResourceFieldDescriber $describer, int &$filled, int &$skipped): string
+ {
+ $lines = explode("\n", $content);
+ $inFieldTable = false;
+
+ foreach ($lines as $i => $line) {
+ // 응답 필드 표 헤더 진입 감지
+ if (str_contains($line, self::FIELD_TABLE_HEADER)) {
+ $inFieldTable = true;
+
+ continue;
+ }
+
+ // 표 구분선(| --- | ...) 은 건너뜀
+ if ($inFieldTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
+ continue;
+ }
+
+ // 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
+ if ($inFieldTable && ! str_starts_with(ltrim($line), '|')) {
+ $inFieldTable = false;
+
+ continue;
+ }
+
+ if (! $inFieldTable || ! str_contains($line, self::TODO_MARKER)) {
+ continue;
+ }
+
+ // 응답 필드 행 파싱: | name | type | `sample` | |
+ $cells = array_map('trim', explode('|', trim($line, '| ')));
+ if (count($cells) < 4) {
+ continue;
+ }
+
+ [$name, $type] = [$cells[0], $cells[1]];
+ $desc = $describer->describe($name, $type);
+
+ if ($desc === null) {
+ $skipped++;
+
+ continue;
+ }
+
+ $safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
+ $lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
+ $filled++;
+ }
+
+ return implode("\n", $lines);
+ }
+}
diff --git a/app/Console/Commands/ApiDocBackfillParamsCommand.php b/app/Console/Commands/ApiDocBackfillParamsCommand.php
new file mode 100644
index 00000000..0b7c8e79
--- /dev/null
+++ b/app/Console/Commands/ApiDocBackfillParamsCommand.php
@@ -0,0 +1,162 @@
+` 셀만 in-place 치환합니다.
+ * 채움 규칙 SSoT 는 ApiDocScaffolder 와 동일한 ParameterDescriber 입니다 —
+ * 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
+ *
+ * 도메인 특이 파라미터(ParameterDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
+ */
+class ApiDocBackfillParamsCommand extends Command
+{
+ /**
+ * @var string 커맨드 시그니처
+ */
+ protected $signature = 'api:docgen-backfill-params
+ {--dry-run : 치환하지 않고 채울 건수만 리포트}';
+
+ /**
+ * @var string 커맨드 설명
+ */
+ protected $description = '기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다';
+
+ /**
+ * @var string 파라미터 표를 식별하는 고유 헤더
+ */
+ private const PARAM_TABLE_HEADER = '| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |';
+
+ /**
+ * @var string 파라미터 용도 TODO 마커
+ */
+ private const TODO_MARKER = '';
+
+ /**
+ * 커맨드를 실행합니다.
+ *
+ * @return int 종료 코드
+ */
+ public function handle(): int
+ {
+ $describer = new ParameterDescriber;
+ $dryRun = (bool) $this->option('dry-run');
+
+ $filled = 0;
+ $skipped = 0;
+ $filesChanged = 0;
+
+ foreach ($this->apiDocFiles() as $file) {
+ $content = file_get_contents($file);
+ $result = $this->backfillFile($content, $describer, $filled, $skipped);
+
+ if ($result !== $content) {
+ $filesChanged++;
+ if (! $dryRun) {
+ file_put_contents($file, $result);
+ }
+ }
+ }
+
+ $prefix = $dryRun ? '[dry-run] ' : '';
+ $this->info("{$prefix}파라미터 용도 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * 대상 API 문서 파일 경로를 수집합니다.
+ *
+ * @return array 파일 경로 목록
+ */
+ private function apiDocFiles(): array
+ {
+ $dirs = array_filter([
+ base_path('docs/backend/api'),
+ ...glob(base_path('modules/_bundled/*/docs/api')),
+ ...glob(base_path('plugins/_bundled/*/docs/api')),
+ ], 'is_dir');
+
+ if ($dirs === []) {
+ return [];
+ }
+
+ $files = [];
+ foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
+ $files[] = $f->getRealPath();
+ }
+
+ return $files;
+ }
+
+ /**
+ * 단일 문서의 파라미터 표 TODO 를 채웁니다.
+ *
+ * 파라미터 표 헤더 이후 표 행만 대상으로 하며, `` 셀을
+ * ParameterDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
+ *
+ * @param string $content 문서 내용
+ * @param ParameterDescriber $describer 설명기
+ * @param int $filled 채운 건수 (참조 누적)
+ * @param int $skipped 유지 건수 (참조 누적)
+ * @return string 치환된 문서 내용
+ */
+ private function backfillFile(string $content, ParameterDescriber $describer, int &$filled, int &$skipped): string
+ {
+ $lines = explode("\n", $content);
+ $inParamTable = false;
+
+ foreach ($lines as $i => $line) {
+ // 파라미터 표 헤더 진입 감지
+ if (str_contains($line, self::PARAM_TABLE_HEADER)) {
+ $inParamTable = true;
+
+ continue;
+ }
+
+ // 표 구분선(| --- | ...) 은 건너뜀
+ if ($inParamTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
+ continue;
+ }
+
+ // 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
+ if ($inParamTable && ! str_starts_with(ltrim($line), '|')) {
+ $inParamTable = false;
+
+ continue;
+ }
+
+ if (! $inParamTable || ! str_contains($line, self::TODO_MARKER)) {
+ continue;
+ }
+
+ // 파라미터 행 파싱: | name | location | type | 필수 | 허용값 | |
+ $cells = array_map('trim', explode('|', trim($line, '| ')));
+ if (count($cells) < 5) {
+ continue;
+ }
+
+ [$name, $location, $type] = [$cells[0], $cells[1], $cells[2]];
+ $desc = $describer->describe($name, $location, $type);
+
+ if ($desc === null) {
+ $skipped++;
+
+ continue;
+ }
+
+ $safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
+ $lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
+ $filled++;
+ }
+
+ return implode("\n", $lines);
+ }
+}
diff --git a/app/Console/Commands/ApiDocgenCommand.php b/app/Console/Commands/ApiDocgenCommand.php
new file mode 100644
index 00000000..a6596c00
--- /dev/null
+++ b/app/Console/Commands/ApiDocgenCommand.php
@@ -0,0 +1,937 @@
+option('scope');
+ $routes = $inventory->collect($scope);
+
+ if ($routes === []) {
+ $this->warn("범위 '{$scope}' 에 해당하는 API 라우트가 없습니다.");
+
+ return self::SUCCESS;
+ }
+
+ $this->info(count($routes)."개 라우트 수집 (scope={$scope})");
+
+ // 도메인 파일 단위로 그룹핑
+ $grouped = [];
+ foreach ($routes as $route) {
+ $file = $this->targetFile($route);
+ $grouped[$file][] = $route;
+ }
+
+ if ($this->option('dry-run')) {
+ foreach ($grouped as $file => $items) {
+ $this->line(sprintf(' %s (%d endpoints)', $file, count($items)));
+ }
+
+ return self::SUCCESS;
+ }
+
+ // --seed: 도메인별 완전 샘플을 시드해 상세 GET 실측 시 null 응답을 최소화
+ // (인증보다 먼저 수행해 완전 샘플 사용자로 토큰을 발급 → /me 응답도 채워짐)
+ $sampleMap = [];
+ if ($this->option('seed')) {
+ if (app()->environment('production')) {
+ $this->error('--seed 는 개발 환경 전용입니다 (production 차단).');
+
+ return self::FAILURE;
+ }
+
+ // 코어 샘플 우선 시드 — 확장 샘플의 소유자/actor 로 쓰이는 완전 사용자를 확보한다.
+ $sampleMap = (new ApiDocSampleService)->seed();
+ $this->info('완전 샘플 시드: '.count($sampleMap).'개 도메인 (코어)');
+
+ // 확장 소유 라우트가 있으면 그 확장의 규약 시더를 발견해 도메인 샘플을 병합한다.
+ foreach ($this->discoverExtensionSeeders($routes) as $label => $seeder) {
+ $extMap = $seeder->seed();
+ $sampleMap = array_merge($sampleMap, $extMap);
+ $this->info('완전 샘플 시드: '.count($extMap)."개 도메인 ({$label})");
+ }
+ }
+
+ $probe = new ApiEndpointProbe($this->option('base-url') ?: null);
+ // 인증 사용자: --user 명시 > 완전 샘플 사용자(/me 응답 충실) > 첫 사용자
+ $authUserId = $this->option('user')
+ ? (int) $this->option('user')
+ : $this->sampleUserId($sampleMap);
+ $probed = $probe->authenticate($authUserId);
+
+ if (! $probed) {
+ $this->warn('실측 토큰 발급 실패 — 실측 없이 정적 추출만 진행합니다.');
+ } else {
+ $this->info('실측 기준 URL: '.$probe->baseUrl());
+ }
+
+ $stats = ['files' => 0, 'endpoints' => 0, 'probed' => 0, 'skipped' => 0, 'examples' => 0];
+ $checkFindings = [];
+ $examplesOnly = (bool) $this->option('examples-only');
+
+ // 대상(코어/확장)별 README 목차 항목 누적: readmeKey => ['label' => ..., 'entries' => [...]]
+ $readmeIndex = [];
+
+ foreach ($grouped as $file => $items) {
+ $sections = [];
+ $sectionKeys = [];
+ $exampleBlocks = [];
+
+ // 목차용 도메인 파일 항목 (소유별). 예시-only/check 모드에서도 목차는 최신화한다.
+ $owner = $items[0]['owner'];
+ $readmeKey = $this->readmeFile($owner);
+ $readmeIndex[$readmeKey]['label'] = $owner['type'] === 'core'
+ ? '코어'
+ : ($owner['type'] === 'module' ? '모듈' : '플러그인')." `{$owner['id']}`";
+ $readmeIndex[$readmeKey]['entries'][] = [
+ 'domain' => $items[0]['domain_group'],
+ 'file' => basename($file),
+ 'count' => count($items),
+ ];
+
+ foreach ($items as $route) {
+ $request = $introspector->introspect($route['controller'], $route['controller_method']);
+ [$schema, $probeMeta] = $this->probeEndpoint($probe, $inferrer, $route, $probed, $sampleMap, $request);
+
+ if ($probeMeta['skipped_reason'] === null) {
+ $stats['probed']++;
+ } else {
+ $stats['skipped']++;
+ $checkFindings[] = "{$route['method']} {$route['uri']} — {$probeMeta['skipped_reason']}";
+ }
+
+ $key = $route['name'] ?: $route['uri'];
+
+ if ($examplesOnly) {
+ // 표·서술을 건드리지 않고 예시 2블록만 산출 (SSoT=스캐폴더 예시 메서드).
+ // 단, 응답 필드 표가 아직 실측 제외 마커(사람이 채운 셀 없음)인데 이번 실측으로
+ // 스키마가 확보됐으면 그 마커를 실측 필드 표로 갱신한다(쓰기 메서드 실측 도입분).
+ $commentMap = $this->columnComments($route, $commentResolver, $sampleMap);
+ $exampleBlocks[$key] = [
+ 'request' => $scaffolder->requestExampleBlock($route, $request, $probeMeta),
+ 'response' => $scaffolder->responseExampleBlock($schema, $probeMeta),
+ 'response_fields' => $schema !== null
+ ? $scaffolder->responseFieldTableBlock($schema, $probeMeta, $commentMap)
+ : null,
+ ];
+ $stats['endpoints']++;
+
+ continue;
+ }
+
+ $commentMap = $this->columnComments($route, $commentResolver, $sampleMap);
+ $sections[] = $scaffolder->endpointSection($route, $request, $schema, $probeMeta, $commentMap);
+ $sectionKeys[] = $key;
+ $stats['endpoints']++;
+ }
+
+ if ($this->option('check')) {
+ if (! File::exists($file)) {
+ $checkFindings[] = "문서 파일 없음: {$file}";
+ }
+
+ continue;
+ }
+
+ // examples-only: 재생성 없이 기존 문서에 예시 블록만 in-place 삽입.
+ // 전체 재생성은 @generated 블록 내부 표(파라미터/응답 필드)의 사람 서술을
+ // TODO 로 되돌리므로, 예시 방출을 위해 재생성을 쓰지 않는다.
+ if ($examplesOnly) {
+ if (! File::exists($file)) {
+ $this->warn("문서 없음(예시 삽입 건너뜀): {$file}");
+
+ continue;
+ }
+
+ $existing = File::get($file);
+ [$updated, $inserted] = $scaffolder->insertExampleBlocks($existing, $exampleBlocks);
+
+ if ($updated !== $existing) {
+ File::put($file, $updated);
+ $stats['files']++;
+ }
+ $stats['examples'] += $inserted;
+
+ continue;
+ }
+
+ $header = $this->documentHeader($file, $items[0]);
+ $existing = File::exists($file) ? File::get($file) : null;
+ $content = $scaffolder->mergeDocument($existing, $header, $sections, $sectionKeys);
+
+ File::ensureDirectoryExists(dirname($file));
+ File::put($file, $content);
+ $stats['files']++;
+ }
+
+ $probe->cleanup();
+
+ // 대상별 API 문서 목차(README.md) 생성/갱신 — 확장 API 레퍼런스 발견성의 규약 진입점.
+ // check/dry-run 은 파일을 쓰지 않으므로 제외. examples-only 는 목차도 최신화한다.
+ if (! $this->option('check')) {
+ $coreReadme = $this->readmeFile(['type' => 'core', 'id' => null]);
+
+ foreach ($readmeIndex as $readmePath => $meta) {
+ $existing = File::exists($readmePath) ? File::get($readmePath) : null;
+ // 코어 README 는 최상위 README 의 "API 레퍼런스" 진입점이므로 확장 목차를 함께 싣는다.
+ $extensions = $readmePath === $coreReadme ? $this->scanExtensionApiReadmes() : null;
+ $content = $scaffolder->readmeIndex($meta['label'], $meta['entries'], $existing, $extensions);
+ File::ensureDirectoryExists(dirname($readmePath));
+ File::put($readmePath, $content);
+ }
+
+ // 확장 스코프 실행이라 코어 README 를 다시 쓰지 않았어도, 그 확장의 엔드포인트 수가
+ // 바뀌었을 수 있으므로 코어 README 의 확장 목차 블록만 in-place 갱신한다.
+ if (! array_key_exists($coreReadme, $readmeIndex) && File::exists($coreReadme)) {
+ $updated = $scaffolder->refreshExtensionIndex(
+ File::get($coreReadme),
+ $this->scanExtensionApiReadmes()
+ );
+ if ($updated !== null) {
+ File::put($coreReadme, $updated);
+ }
+ }
+ }
+
+ if ($this->option('check')) {
+ if ($checkFindings !== []) {
+ $this->warn('실측 제외/문서 누락 '.count($checkFindings).'건:');
+ foreach (array_slice($checkFindings, 0, 50) as $f) {
+ $this->line(' - '.$f);
+ }
+
+ return self::FAILURE;
+ }
+
+ $this->info('drift 없음.');
+
+ return self::SUCCESS;
+ }
+
+ $this->newLine();
+ if ($examplesOnly) {
+ $this->info(sprintf(
+ '예시 삽입 완료: 파일 %d개, 예시 블록 %d개 (실측 %d, 제외 %d)',
+ $stats['files'],
+ $stats['examples'],
+ $stats['probed'],
+ $stats['skipped']
+ ));
+
+ return self::SUCCESS;
+ }
+
+ $this->info(sprintf(
+ '완료: 파일 %d개, 엔드포인트 %d개 (실측 %d, 제외 %d)',
+ $stats['files'],
+ $stats['endpoints'],
+ $stats['probed'],
+ $stats['skipped']
+ ));
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * 수집된 라우트의 소유 확장에서 규약 위치의 샘플 시더를 발견합니다.
+ *
+ * 확장 컨트롤러 FQCN(`Modules\Vendor\Ext\Http\Controllers\...`)에서 확장 베이스
+ * 네임스페이스를 추출하고, `{Base}\Support\ApiDoc\ApiDocSampleService` 규약 클래스가
+ * 존재하며 ApiDocSampleSeeder 를 구현하면 인스턴스를 반환합니다. 각 확장은 1회만 처리합니다.
+ *
+ * @param array> $routes 수집된 라우트 목록
+ * @return array 확장 라벨(key) => 시더 인스턴스
+ */
+ private function discoverExtensionSeeders(array $routes): array
+ {
+ $seeders = [];
+ $seen = [];
+
+ foreach ($routes as $route) {
+ $owner = $route['owner'];
+
+ if ($owner['type'] === 'core' || $owner['id'] === null) {
+ continue;
+ }
+
+ $label = $owner['key'];
+
+ if (isset($seen[$label])) {
+ continue;
+ }
+ $seen[$label] = true;
+
+ $base = $this->extensionBaseNamespace($route['controller'] ?? null);
+
+ if ($base === null) {
+ continue;
+ }
+
+ $class = $base.'\\Support\\ApiDoc\\ApiDocSampleService';
+
+ if (class_exists($class) && is_subclass_of($class, ApiDocSampleSeeder::class)) {
+ $seeders[$label] = app($class);
+ }
+ }
+
+ return $seeders;
+ }
+
+ /**
+ * 확장 컨트롤러 FQCN 에서 확장 베이스 네임스페이스를 추출합니다.
+ *
+ * 두 컨트롤러 배치 규약을 모두 지원한다:
+ * - `\Http\Controllers\` (대다수 확장): `Modules\Sirsoft\Page\Http\Controllers\Admin\PageController`
+ * → `Modules\Sirsoft\Page`
+ * - `\Controllers\` (일부 플러그인): `Plugins\Sirsoft\PayKginicis\Controllers\PaymentSignatureController`
+ * → `Plugins\Sirsoft\PayKginicis`
+ *
+ * `\Http\Controllers\` 를 우선 판정하고, 없으면 `\Controllers\` 세그먼트 앞까지를 베이스로 본다.
+ *
+ * @param string|null $controller 컨트롤러 FQCN (클로저면 null)
+ * @return string|null 확장 베이스 네임스페이스 (추출 불가 시 null)
+ */
+ private function extensionBaseNamespace(?string $controller): ?string
+ {
+ if ($controller === null) {
+ return null;
+ }
+
+ if (Str::contains($controller, '\\Http\\Controllers\\')) {
+ return Str::before($controller, '\\Http\\Controllers\\');
+ }
+
+ if (Str::contains($controller, '\\Controllers\\')) {
+ return Str::before($controller, '\\Controllers\\');
+ }
+
+ return null;
+ }
+
+ /**
+ * 엔드포인트를 실측하고 응답 스키마를 추론합니다.
+ *
+ * @param ApiEndpointProbe $probe 실측 프로브
+ * @param ResponseSchemaInferrer $inferrer 스키마 추론기
+ * @param array $route 라우트 메타데이터
+ * @param bool $probed 실측 가능 여부
+ * @param array $sampleMap 도메인별 대표 샘플 맵
+ * @param array $request FormRequest 분석 결과 (쓰기 바디 구성용)
+ * @return array{0: array|null, 1: array} 스키마와 실측 메타
+ */
+ private function probeEndpoint(ApiEndpointProbe $probe, ResponseSchemaInferrer $inferrer, array $route, bool $probed, array $sampleMap = [], array $request = []): array
+ {
+ $baseUrl = $probe->baseUrl();
+
+ if (! $probed) {
+ return [null, ['status' => null, 'skipped_reason' => 'no-token', 'base_url' => $baseUrl, 'resolved_uri' => null, 'body' => null]];
+ }
+
+ $uri = $this->resolvePathParams($route, $sampleMap);
+ $isWrite = in_array($route['method'], ['POST', 'PUT', 'PATCH', 'DELETE'], true);
+
+ // 요청 예시에 노출할 정규 URI (실측용 per_page 보정 제외).
+ $exampleUri = $this->exampleUri($route, $sampleMap);
+
+ // 부수효과가 트랜잭션 롤백으로 되돌릴 수 없는 쓰기(확장 install/activate/update,
+ // 언어팩 설치, 코어 업데이트, 파일 업로드 등)는 in-process 실측에서 제외한다.
+ // 이들은 파일시스템·프로세스·외부 네트워크를 건드려 롤백 불가·hang 위험이 있다.
+ if ($isWrite && $this->isSideEffectfulWrite($route)) {
+ return [null, [
+ 'status' => null,
+ 'skipped_reason' => 'side-effectful-write',
+ 'base_url' => $baseUrl,
+ 'resolved_uri' => $exampleUri,
+ 'body' => null,
+ ]];
+ }
+
+ // 쓰기 메서드는 FormRequest 규칙 기반 실측 바디를 만들어 in-process 롤백 실측에 사용한다.
+ $writeBody = in_array($route['method'], ['POST', 'PUT', 'PATCH'], true)
+ ? $this->buildWriteBody($request, $sampleMap)
+ : [];
+ $result = $probe->probe($route['method'], $uri, $writeBody);
+
+ if (! $result['ok'] || $result['body'] === null) {
+ return [null, [
+ 'status' => $result['status'],
+ 'skipped_reason' => $result['skipped_reason'] ?? ('http-'.$result['status']),
+ 'base_url' => $baseUrl,
+ 'resolved_uri' => $exampleUri,
+ 'body' => null,
+ ]];
+ }
+
+ return [$inferrer->infer($result['body']), [
+ 'status' => $result['status'],
+ 'skipped_reason' => null,
+ 'base_url' => $baseUrl,
+ 'resolved_uri' => $exampleUri,
+ 'body' => $result['body'],
+ ]];
+ }
+
+ /**
+ * 부수효과가 트랜잭션 롤백으로 되돌릴 수 없는 쓰기 엔드포인트인지 판별합니다.
+ *
+ * 확장(모듈/플러그인/템플릿) 및 언어팩 설치·활성·업데이트, 코어 업데이트, 파일 업로드,
+ * 캐시/워밍업/생성 등 파일시스템·프로세스·외부 네트워크를 건드리는 쓰기는 in-process 롤백
+ * 실측이 불가능(hang·비가역 부수효과)하므로 실측에서 제외한다. URI 세그먼트로 판정한다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @return bool 부수효과 쓰기 여부
+ */
+ private function isSideEffectfulWrite(array $route): bool
+ {
+ $uri = (string) $route['uri'];
+
+ // 확장/언어팩 관리 도메인의 쓰기는 전량 제외 (install/activate/... 은 실환경 변경).
+ if (preg_match('#/api/admin/(modules|plugins|templates|language-packs|core-update)(/|$)#', $uri)) {
+ return true;
+ }
+
+ // 동작성 세그먼트를 가진 쓰기 (도메인 무관).
+ $unsafeSegments = [
+ 'install', 'install-from-file', 'install-from-github', 'install-from-url', 'install-from-bundled',
+ 'uninstall', 'activate', 'deactivate', 'bulk-activate', 'update', 'check-updates',
+ 'refresh-cache', 'clear', 'warmup', 'generate', 'sync', 'backup', 'restore',
+ 'upload', 'import', 'export', 'manifest-preview', 'preview',
+ ];
+
+ foreach ($unsafeSegments as $seg) {
+ if (str_contains($uri, '/'.$seg)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 요청 예시에 노출할 정규 URI 를 산출합니다.
+ *
+ * `resolvePathParams` 는 목록 GET 실측용으로 `?per_page=25` 를 덧붙이지만,
+ * 요청 예시에는 이 실측 보정을 제외한 순수 경로(치환된 path 파라미터 포함)를 노출한다.
+ * path 파라미터가 치환되지 않은(실측 제외) 경우 `{param}` placeholder 를 그대로 남긴다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @param array $sampleMap 도메인별 대표 샘플 맵
+ * @return string 요청 예시용 URI
+ */
+ private function exampleUri(array $route, array $sampleMap): string
+ {
+ $resolved = $this->resolvePathParams($route, $sampleMap);
+
+ // 실측용 per_page 보정 쿼리를 제거해 정규 경로만 남긴다.
+ return preg_replace('/[?&]per_page=25$/', '', $resolved) ?? $resolved;
+ }
+
+ /**
+ * 쓰기 메서드 in-process 실측용 요청 바디를 FormRequest 규칙 근거로 구성합니다.
+ *
+ * 필수 파라미터를 우선하고, 검증을 통과할 수 있도록 이름·타입·허용값에 맞는 유효값을 채웁니다.
+ * 파일(image/file) 파라미터는 실측 바디에서 제외한다(in-process dispatch 로 파일 전송 불가 →
+ * 해당 엔드포인트는 http-422 로 스킵되고 정적 요청 예시[multipart]만 방출된다).
+ *
+ * @param array $request FormRequest 분석 결과
+ * @param array $sampleMap 도메인별 대표 샘플 맵
+ * @return array 실측 요청 바디
+ */
+ private function buildWriteBody(array $request, array $sampleMap = []): array
+ {
+ $params = $request['params'] ?? [];
+ $body = [];
+
+ foreach ($params as $p) {
+ $name = (string) $p['name'];
+ $type = (string) ($p['type'] ?? 'string');
+
+ // 파일 타입은 in-process 바디로 전송 불가 → 생략.
+ if (in_array($type, ['file', 'image'], true)) {
+ continue;
+ }
+
+ $body[$name] = $this->writeValue($name, $type, (string) ($p['allowed'] ?? ''), $sampleMap);
+ }
+
+ return $body;
+ }
+
+ /**
+ * 쓰기 실측 바디의 파라미터 값을 검증 통과 가능하도록 생성합니다.
+ *
+ * @param string $name 파라미터명
+ * @param string $type 타입
+ * @param string $allowed 허용값 설명 (in: 열거 등)
+ * @param array $sampleMap 도메인 샘플 맵
+ * @return mixed 유효 예시값
+ */
+ private function writeValue(string $name, string $type, string $allowed, array $sampleMap): mixed
+ {
+ $lower = strtolower($name);
+
+ // 허용값 in: 열거(백틱)가 있으면 첫 값을 채택(검증 통과 보장).
+ if (preg_match('/`([^`]+)`/', $allowed, $m)) {
+ return $m[1];
+ }
+
+ return match (true) {
+ $type === 'boolean' => true,
+ $type === 'integer', $type === 'number' => 1,
+ $type === 'array' => [],
+ $type === 'email' || str_contains($lower, 'email') => 'probe_'.uniqid().'@example.com',
+ $type === 'uuid' => (string) Str::uuid(),
+ $type === 'date' => '2026-01-01',
+ str_contains($lower, 'password') => 'Password123!',
+ str_contains($lower, 'url') || str_contains($lower, 'homepage') => 'https://example.com',
+ // identifier/slug 는 소문자/숫자/밑줄만 허용하는 규칙이 흔하므로 하이픈 없이 생성.
+ str_contains($lower, 'identifier') || str_contains($lower, 'slug') => 'probe_'.uniqid(),
+ str_contains($lower, 'locale') || $lower === 'language' => 'ko',
+ str_contains($lower, 'country') => 'KR',
+ str_contains($lower, 'timezone') => 'Asia/Seoul',
+ default => '실측 예시값',
+ };
+ }
+
+ /**
+ * URI 의 path 파라미터를 바인딩된 모델의 실제 레코드로 치환합니다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @param array $sampleMap 도메인별 대표 샘플 맵
+ * @return string 치환된 URI (치환 실패 시 원본 유지 → 프로브가 실측 제외)
+ */
+ private function resolvePathParams(array $route, array $sampleMap = []): string
+ {
+ $uri = $route['uri'];
+ $domain = $route['domain_group'] ?? null;
+
+ // path 파라미터가 있으면: 완전 샘플 대표 레코드 우선, 없으면 첫 레코드 키로 치환
+ foreach ($route['path_params'] as $param) {
+ $modelClass = $route['path_bindings'][$param] ?? null;
+
+ // 라우트-모델 바인딩이 없는 확장 패턴(예: show(int $id))은 도메인 샘플 맵의
+ // 대표 모델로 폴백한다. 단, 파라미터명이 도메인의 단수 리소스명과 일치할 때만
+ // (pages/{page} → Page). {slug}/{hash}/{versionId} 등 route key 가 다른
+ // 문자열/보조 파라미터에는 폴백하지 않아 잘못된 치환(404)을 피한다.
+ if (! $modelClass && $domain !== null && isset($sampleMap[$domain]) && $this->paramMatchesDomain($param, $domain)) {
+ $modelClass = $sampleMap[$domain]['model'];
+ }
+
+ if (! $modelClass) {
+ // route-model binding 도 도메인 폴백도 없는 문자열 path 파라미터
+ // (예: board 의 boards/{slug}/posts/{id}). 도메인 대표 샘플이 명시한
+ // path_params 맵(param 명 => 실제 값)에 해당 param 이 있으면 그 값으로 치환한다.
+ // slug 라우팅을 쓰는 확장이 route key(id) 와 무관한 slug/id 조합을
+ // 실측할 수 있도록 하는 일반 경로다. (파라미터명 정확 일치만 허용 → 오치환 방지)
+ $explicit = $domain !== null ? ($sampleMap[$domain]['path_params'][$param] ?? null) : null;
+
+ if ($explicit !== null) {
+ $uri = str_replace('{'.$param.'}', (string) $explicit, $uri);
+
+ continue;
+ }
+
+ // 코어 다수 라우트는 route-model binding 없이 `{id}`/`{identifier}`/`{key}` 문자열
+ // 파라미터를 쓰고 컨트롤러가 직접 조회한다. 도메인 대표 샘플 모델의 route key(또는
+ // 해당 컬럼)로 치환해 실측 가능하게 한다. (마지막 세그먼트 리소스 파라미터에 한함)
+ $generic = $this->resolveGenericPathParam($param, $domain, $sampleMap);
+
+ if ($generic !== null) {
+ $uri = str_replace('{'.$param.'}', (string) $generic, $uri);
+ }
+
+ continue;
+ }
+
+ // 시드된 완전 샘플 중 같은 모델이 있으면 그 route key 를 우선 사용
+ $value = $this->sampleKeyForModel($modelClass, $sampleMap) ?? $this->firstRouteKey($modelClass);
+
+ if ($value !== null) {
+ $uri = str_replace('{'.$param.'}', (string) $value, $uri);
+ }
+ }
+
+ // 목록 GET 은 여러 행을 받아 필드별 non-null 대표 샘플을 확보한다
+ // (per_page=1 이면 첫 행이 우연히 비어 "항상 null" 처럼 보임).
+ if ($route['method'] === 'GET' && ! Str::contains($uri, '{')) {
+ return $uri.(Str::contains($uri, '?') ? '&' : '?').'per_page=25';
+ }
+
+ return $uri;
+ }
+
+ /**
+ * route-model binding 없는 제네릭 path 파라미터를 도메인 샘플 모델의 실제 키로 치환합니다.
+ *
+ * 코어 다수 라우트는 `{id}`/`{identifier}`/`{key}`/`{slug}` 문자열 파라미터를 쓰고 컨트롤러가
+ * 직접 조회한다. 도메인 대표 샘플 모델을 찾아 파라미터명에 대응하는 컬럼값으로 치환한다:
+ * - `id` → 모델 기본키
+ * - `identifier`/`slug`/`key`/`code` → 같은 이름 컬럼이 있으면 그 값, 없으면 route key
+ * - 그 외 → 치환 안 함(null 반환 → 실측 제외 유지, 오치환 방지)
+ *
+ * @param string $param path 파라미터명
+ * @param string|null $domain 도메인 그룹명
+ * @param array $sampleMap 도메인 샘플 맵
+ * @return string|null 치환값 (없으면 null)
+ */
+ private function resolveGenericPathParam(string $param, ?string $domain, array $sampleMap): ?string
+ {
+ $modelClass = ($domain !== null && isset($sampleMap[$domain]))
+ ? $sampleMap[$domain]['model']
+ : $this->domainModelHint($domain);
+
+ if (! $modelClass) {
+ return null;
+ }
+
+ try {
+ /** @var Model $model */
+ $model = new $modelClass;
+ $record = $modelClass::query()->orderBy($model->getKeyName())->first();
+
+ if (! $record) {
+ return null;
+ }
+
+ $lower = strtolower($param);
+
+ // id 계열 → 기본키
+ if ($lower === 'id' || str_ends_with($lower, '_id')) {
+ return (string) $record->getKey();
+ }
+
+ // identifier/slug/key/code → 동일 컬럼이 있으면 그 값
+ if (in_array($lower, ['identifier', 'slug', 'key', 'code'], true)) {
+ $val = $record->getAttribute($lower);
+
+ return $val !== null ? (string) $val : (string) $record->getRouteKey();
+ }
+
+ // 도메인 단수 리소스명 파라미터 → route key
+ if ($domain !== null && $this->paramMatchesDomain($param, $domain)) {
+ return (string) $record->getRouteKey();
+ }
+
+ return null;
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * path 파라미터명이 도메인의 단수 리소스명과 일치하는지 판정합니다.
+ *
+ * 도메인 그룹은 복수형(pages)이고 라우트 파라미터는 단수(page)이므로,
+ * 파라미터명 == 도메인명 또는 파라미터명 == 도메인 단수형일 때만 매칭으로 본다.
+ * (page ↔ pages). {slug}/{hash}/{versionId} 는 어느 쪽과도 일치하지 않는다.
+ *
+ * @param string $param path 파라미터명
+ * @param string $domain 도메인 그룹명 (복수형 가능)
+ * @return bool 도메인 리소스 파라미터 여부
+ */
+ private function paramMatchesDomain(string $param, string $domain): bool
+ {
+ return $param === $domain
+ || $param === Str::singular($domain)
+ || Str::plural($param) === $domain;
+ }
+
+ /**
+ * 라우트의 주 모델 컬럼 주석 맵을 반환합니다 (응답 필드 설명 기본값).
+ *
+ * path 파라미터에 바인딩된 모델을 우선하고, 없으면 도메인 샘플 맵의 대표
+ * 모델을 주 모델로 보고 그 테이블 주석을 사용합니다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @param ColumnCommentResolver $resolver 컬럼 주석 해석기
+ * @param array $sampleMap 도메인별 대표 샘플 맵
+ * @return array 컬럼명 => 주석
+ */
+ private function columnComments(array $route, ColumnCommentResolver $resolver, array $sampleMap = []): array
+ {
+ $bindings = $route['path_bindings'] ?? [];
+
+ // 마지막 path 바인딩 모델(가장 구체적인 리소스)을 주 모델로 사용
+ $modelClass = null;
+ foreach ($bindings as $class) {
+ $modelClass = $class;
+ }
+
+ // path 바인딩이 없으면(목록/단건 me 등) 도메인 샘플 맵 → 정적 힌트 순으로 유추
+ if (! $modelClass) {
+ $domain = $route['domain_group'] ?? null;
+ $modelClass = ($domain && isset($sampleMap[$domain]) ? $sampleMap[$domain]['model'] : null)
+ ?? $this->domainModelHint($domain);
+ }
+
+ return $modelClass ? $resolver->forModel($modelClass) : [];
+ }
+
+ /**
+ * 샘플 맵으로 유추되지 않는 도메인의 주 모델을 정적 힌트로 매핑합니다.
+ *
+ * me/auth/profile/password 등은 path 바인딩도 없고 sampleMap 키(users)와도
+ * 도메인명이 달라 자동 유추가 안 되지만, 모두 User 필드를 반환합니다.
+ *
+ * @param string|null $domain 도메인 그룹명
+ * @return class-string|null 주 모델 FQCN (없으면 null)
+ */
+ private function domainModelHint(?string $domain): ?string
+ {
+ if ($domain === null) {
+ return null;
+ }
+
+ $hints = [
+ 'me' => User::class,
+ 'auth' => User::class,
+ 'profile' => User::class,
+ 'password' => User::class,
+ 'modules' => Module::class,
+ 'plugins' => Plugin::class,
+ 'templates' => Template::class,
+ ];
+
+ return $hints[$domain] ?? null;
+ }
+
+ /**
+ * 완전 샘플 맵에서 샘플 사용자의 DB id 를 조회합니다 (토큰 발급 대상).
+ *
+ * @param array $sampleMap 대표 샘플 맵
+ * @return int|null 샘플 사용자 id (없으면 null → 첫 사용자로 fallback)
+ */
+ private function sampleUserId(array $sampleMap): ?int
+ {
+ $users = $sampleMap['users'] ?? null;
+
+ if (! $users) {
+ return null;
+ }
+
+ return User::query()
+ ->where($users['key'], $users['value'])
+ ->value('id');
+ }
+
+ /**
+ * 시드된 완전 샘플 맵에서 해당 모델의 대표 route key 를 찾습니다.
+ *
+ * @param class-string $modelClass 모델 FQCN
+ * @param array $sampleMap 대표 샘플 맵
+ * @return string|null 대표 route key 값 (없으면 null)
+ */
+ private function sampleKeyForModel(string $modelClass, array $sampleMap): ?string
+ {
+ foreach ($sampleMap as $sample) {
+ if ($sample['model'] === $modelClass) {
+ return $sample['value'];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 모델의 첫 레코드 route key 값을 반환합니다.
+ *
+ * @param class-string $modelClass 모델 FQCN
+ * @return mixed route key 값 (레코드 없으면 null)
+ */
+ private function firstRouteKey(string $modelClass): mixed
+ {
+ try {
+ /** @var Model $model */
+ $model = new $modelClass;
+ $keyName = $model->getRouteKeyName();
+
+ $record = $modelClass::query()->orderBy($model->getKeyName())->first();
+
+ return $record?->getAttribute($keyName);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * 라우트가 저장될 문서 파일 경로를 산출합니다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @return string 절대 파일 경로
+ */
+ private function targetFile(array $route): string
+ {
+ $owner = $route['owner'];
+ $domain = $route['domain_group'];
+
+ if ($owner['type'] === 'core') {
+ return base_path("docs/backend/api/{$domain}.md");
+ }
+
+ $base = $owner['type'] === 'module' ? 'modules' : 'plugins';
+
+ return base_path("{$base}/_bundled/{$owner['id']}/docs/api/{$domain}.md");
+ }
+
+ /**
+ * 번들 확장이 소유한 API 문서 목차(README.md)를 파일 시스템 패턴 스캔으로 수집합니다.
+ *
+ * 확장명을 하드코딩하지 않고 `{modules,plugins}/_bundled/{*}/docs/api/README.md` 존재
+ * 여부만으로 발견한다(동적 로딩 원칙). 문서 수·엔드포인트 수는 스캐폴더가 방출한
+ * 집계 라인(`**문서 수**: N · **엔드포인트 수**: M`)에서 읽는다.
+ *
+ * 링크 경로는 코어 README(`docs/backend/api/`) 기준 상대경로로 만든다.
+ *
+ * @return array 확장 목록
+ */
+ private function scanExtensionApiReadmes(): array
+ {
+ $results = [];
+
+ foreach (['modules' => 'module', 'plugins' => 'plugin'] as $dir => $type) {
+ $bundled = base_path("{$dir}/_bundled");
+ if (! File::isDirectory($bundled)) {
+ continue;
+ }
+
+ foreach (File::directories($bundled) as $extDir) {
+ $readme = $extDir.DIRECTORY_SEPARATOR.'docs'.DIRECTORY_SEPARATOR.'api'.DIRECTORY_SEPARATOR.'README.md';
+ if (! File::exists($readme)) {
+ continue;
+ }
+
+ $id = basename($extDir);
+ $content = File::get($readme);
+ $docs = 0;
+ $endpoints = 0;
+ if (preg_match('/\*\*문서 수\*\*:\s*(\d+)\s*·\s*\*\*엔드포인트 수\*\*:\s*(\d+)/u', $content, $m)) {
+ $docs = (int) $m[1];
+ $endpoints = (int) $m[2];
+ }
+
+ $results[] = [
+ 'id' => $id,
+ 'type' => $type,
+ // docs/backend/api/README.md → 프로젝트 루트까지 3단계 상위
+ 'path' => "../../../{$dir}/_bundled/{$id}/docs/api/README.md",
+ 'docs' => $docs,
+ 'endpoints' => $endpoints,
+ ];
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * 소유 대상의 API 문서 목차(README.md) 경로를 반환합니다.
+ *
+ * @param array{type: string, id: string|null} $owner 소유 메타
+ * @return string README.md 절대 경로
+ */
+ private function readmeFile(array $owner): string
+ {
+ if ($owner['type'] === 'core') {
+ return base_path('docs/backend/api/README.md');
+ }
+
+ $base = $owner['type'] === 'module' ? 'modules' : 'plugins';
+
+ return base_path("{$base}/_bundled/{$owner['id']}/docs/api/README.md");
+ }
+
+ /**
+ * 문서 헤더(제목 + TL;DR)를 생성합니다.
+ *
+ * @param string $file 문서 파일 경로
+ * @param array $firstRoute 대표 라우트
+ * @return string 문서 헤더
+ */
+ private function documentHeader(string $file, array $firstRoute): string
+ {
+ $domain = Str::headline(pathinfo($file, PATHINFO_FILENAME));
+ $owner = $firstRoute['owner'];
+ $ownerLabel = $owner['type'] === 'core' ? '코어' : "{$owner['type']} `{$owner['id']}`";
+
+ return << **소유**: {$ownerLabel} · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+ ---
+
+ ## TL;DR (5초 요약)
+
+ ```text
+ 1. 이 문서는 실제 API 호출로 실측한 {$domain} 엔드포인트 레퍼런스입니다
+ 2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
+ 3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
+ 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+ 5. 설명(TODO) 칸은 사람이 채웁니다
+ ```
+
+ ---
+
+
+ MD;
+ }
+}
diff --git a/app/Contracts/ApiDoc/ApiDocSampleSeeder.php b/app/Contracts/ApiDoc/ApiDocSampleSeeder.php
new file mode 100644
index 00000000..ffe77aff
--- /dev/null
+++ b/app/Contracts/ApiDoc/ApiDocSampleSeeder.php
@@ -0,0 +1,33 @@
+ 실제 값 맵)를 두면, route-model binding
+ * 도 도메인 폴백도 없는 문자열 path 파라미터(예: 게시판 slug 라우팅
+ * `boards/{slug}/posts/{id}`)를 param 명 정확 일치로 치환해 상세 GET 을
+ * 실측할 수 있습니다. 미제공 시 route key 기반 자동 치환만 적용됩니다.
+ *
+ * @return array}> 도메인 => 대표 레코드 정보
+ */
+ public function seed(): array;
+}
diff --git a/app/Extension/Traits/CachesModuleStatus.php b/app/Extension/Traits/CachesModuleStatus.php
index 95cd4c49..73a1b88e 100644
--- a/app/Extension/Traits/CachesModuleStatus.php
+++ b/app/Extension/Traits/CachesModuleStatus.php
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\CoreCacheDriver;
use App\Models\Module;
+use App\Support\InstallerContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -63,8 +64,6 @@ trait CachesModuleStatus
/**
* 모듈 상태 캐시를 무효화합니다.
* 모듈 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다.
- *
- * @return void
*/
public static function invalidateModuleStatusCache(): void
{
@@ -79,10 +78,14 @@ trait CachesModuleStatus
* 설치 완료 상태(`config('app.installer_completed')`)일 때는 테이블 존재를
* 전제로 하여 `Schema::hasTable()` 호출을 건너뜁니다. 인스톨러 이전 환경이나
* 테스트에서는 기존 체크 경로로 폴백합니다.
+ *
+ * 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이
+ * 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
+ * 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다.
*/
private static function isExtensionTableReady(string $table): bool
{
- if (config('app.installer_completed')) {
+ if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) {
return true;
}
diff --git a/app/Extension/Traits/CachesPluginStatus.php b/app/Extension/Traits/CachesPluginStatus.php
index e6c1f172..de1caa82 100644
--- a/app/Extension/Traits/CachesPluginStatus.php
+++ b/app/Extension/Traits/CachesPluginStatus.php
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\CoreCacheDriver;
use App\Models\Plugin;
+use App\Support\InstallerContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -63,8 +64,6 @@ trait CachesPluginStatus
/**
* 플러그인 상태 캐시를 무효화합니다.
* 플러그인 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다.
- *
- * @return void
*/
public static function invalidatePluginStatusCache(): void
{
@@ -76,10 +75,14 @@ trait CachesPluginStatus
/**
* 설치 완료 상태에서는 `Schema::hasTable()` 호출을 건너뜁니다.
* 인스톨러 이전 환경이나 테스트에서는 기존 체크 경로로 폴백합니다.
+ *
+ * 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이
+ * 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
+ * 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다.
*/
private static function isPluginTableReady(): bool
{
- if (config('app.installer_completed')) {
+ if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) {
return true;
}
diff --git a/app/Extension/Traits/CachesTemplateStatus.php b/app/Extension/Traits/CachesTemplateStatus.php
index 10294afd..746e06cd 100644
--- a/app/Extension/Traits/CachesTemplateStatus.php
+++ b/app/Extension/Traits/CachesTemplateStatus.php
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\CoreCacheDriver;
use App\Models\Template;
+use App\Support\InstallerContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -86,8 +87,6 @@ trait CachesTemplateStatus
/**
* 템플릿 상태 캐시를 무효화합니다.
* 템플릿 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다.
- *
- * @return void
*/
public static function invalidateTemplateStatusCache(): void
{
@@ -101,10 +100,14 @@ trait CachesTemplateStatus
/**
* 설치 완료 상태에서는 `Schema::hasTable()` 호출을 건너뜁니다.
* 인스톨러 이전 환경이나 테스트에서는 기존 체크 경로로 폴백합니다.
+ *
+ * 단, 마이그레이션 계열 명령 실행 중에는 `INSTALLER_COMPLETED=true` 라도 테이블이
+ * 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
+ * 신뢰하지 않고 실제 `Schema::hasTable()` 폴백으로 진입합니다.
*/
private static function isTemplateTableReady(): bool
{
- if (config('app.installer_completed')) {
+ if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) {
return true;
}
diff --git a/app/Providers/LanguagePackServiceProvider.php b/app/Providers/LanguagePackServiceProvider.php
index fe30659a..3454ef02 100644
--- a/app/Providers/LanguagePackServiceProvider.php
+++ b/app/Providers/LanguagePackServiceProvider.php
@@ -18,6 +18,7 @@ use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LanguagePack\LanguagePackRegistry;
use App\Services\LanguagePack\LanguagePackSeedInjector;
use App\Services\LanguagePack\LanguagePackTranslator;
+use App\Support\InstallerContext;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\File;
@@ -477,8 +478,7 @@ class LanguagePackServiceProvider extends ServiceProvider
return false;
}
- if ($this->app->runningInConsole()
- && in_array($_SERVER['argv'][1] ?? null, ['migrate', 'migrate:fresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'], true)) {
+ if (InstallerContext::isSchemaMutatingCommand()) {
return false;
}
diff --git a/app/Providers/ModuleRouteServiceProvider.php b/app/Providers/ModuleRouteServiceProvider.php
index 658610b2..767ebf09 100644
--- a/app/Providers/ModuleRouteServiceProvider.php
+++ b/app/Providers/ModuleRouteServiceProvider.php
@@ -6,6 +6,7 @@ use App\Enums\ExtensionStatus;
use App\Extension\ExtensionManager;
use App\Extension\Testing\ExtensionTestAllowlist;
use App\Models\Module;
+use App\Support\InstallerContext;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
@@ -52,7 +53,12 @@ class ModuleRouteServiceProvider extends ServiceProvider
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 쿼리 제거).
// 인스톨러 이전 환경에서는 기존 체크 경로로 폴백.
- if (! config('app.installer_completed')) {
+ //
+ // 단, 마이그레이션 계열 명령 실행 중에는 INSTALLER_COMPLETED=true 라도 테이블이
+ // 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
+ // 무력화하고 hasTable 검증 경로로 진입한다. 빈 DB 면 여기서 early return 하여
+ // 아래 무방비 pluck 이 table not found 로 부팅을 깨뜨리는 것을 막는다.
+ if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) {
try {
if (! Schema::hasTable('modules')) {
return;
diff --git a/app/Providers/PluginRouteServiceProvider.php b/app/Providers/PluginRouteServiceProvider.php
index d29a1838..0798c3a3 100644
--- a/app/Providers/PluginRouteServiceProvider.php
+++ b/app/Providers/PluginRouteServiceProvider.php
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Extension\ExtensionManager;
use App\Extension\Testing\ExtensionTestAllowlist;
+use App\Support\InstallerContext;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
@@ -48,7 +49,12 @@ class PluginRouteServiceProvider extends ServiceProvider
// 설치 완료 상태에서는 Schema introspection 을 건너뜀 (매 요청 쿼리 제거).
// 인스톨러 이전 환경에서는 기존 체크 경로로 폴백.
- if (! config('app.installer_completed')) {
+ //
+ // 단, 마이그레이션 계열 명령 실행 중에는 INSTALLER_COMPLETED=true 라도 테이블이
+ // 아직 없을 수 있으므로(빈 DB 새 서버에 .env 복사 후 migrate 전) fast-path 를
+ // 무력화하고 hasTable 검증 경로로 진입한다. 빈 DB 면 여기서 early return 하여
+ // 이후 플러그인 부팅/상태 조회가 table not found 로 부팅을 깨뜨리는 것을 막는다.
+ if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) {
try {
if (! Schema::hasTable('plugins')) {
return;
diff --git a/app/Support/ApiDoc/ApiDocSampleService.php b/app/Support/ApiDoc/ApiDocSampleService.php
new file mode 100644
index 00000000..4d871642
--- /dev/null
+++ b/app/Support/ApiDoc/ApiDocSampleService.php
@@ -0,0 +1,385 @@
+ 도메인 => 대표 레코드 정보
+ */
+ public function seed(): array
+ {
+ $map = [];
+
+ // permissions → roles → users 순: user 가 role 을, role 이 permission 을 참조
+ $map['permissions'] = $this->seedPermission();
+ $map['roles'] = $this->seedRole();
+ $map['users'] = $this->seedUser();
+ $map['menus'] = $this->seedMenu();
+ $map['notification-definitions'] = $this->seedNotificationDefinition();
+ $map['schedules'] = $this->seedSchedule();
+ $map['activity-logs'] = $this->seedActivityLog();
+ $map['language-packs'] = $this->seedLanguagePack();
+ $map['notification-logs'] = $this->seedNotificationLog();
+ $map['notification-templates'] = $this->seedNotificationTemplate();
+
+ return array_filter($map);
+ }
+
+ /**
+ * 완전한 사용자 샘플을 생성하고 roles 관계를 연결합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedUser(): array
+ {
+ $user = User::query()->where('email', self::MARKER.'-user@example.com')->first();
+
+ if (! $user) {
+ $user = User::factory()->complete()->create([
+ 'name' => 'API 문서 샘플 사용자',
+ 'email' => self::MARKER.'-user@example.com',
+ ]);
+
+ $role = Role::query()->where('identifier', self::MARKER.'-role')->first()
+ ?? Role::query()->where('identifier', 'admin')->first()
+ ?? Role::query()->first()
+ ?? Role::factory()->create(['identifier' => self::MARKER.'-role-fallback']);
+ $user->roles()->syncWithoutDetaching([$role->id]);
+ }
+
+ return ['model' => User::class, 'key' => $user->getRouteKeyName(), 'value' => (string) $user->getRouteKey()];
+ }
+
+ /**
+ * 완전한 역할 샘플을 생성하고 permissions 관계를 연결합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedRole(): array
+ {
+ $role = Role::query()->where('identifier', self::MARKER.'-role')->first();
+
+ if (! $role) {
+ $role = Role::factory()->create([
+ 'identifier' => self::MARKER.'-role',
+ 'name' => ['ko' => 'API 문서 샘플 역할', 'en' => 'API Doc Sample Role'],
+ 'description' => ['ko' => '문서 실측용 역할', 'en' => 'Sample role for API docs'],
+ 'is_active' => true,
+ ]);
+
+ $permissionIds = Permission::query()->limit(3)->pluck('id')->all();
+ if ($permissionIds !== []) {
+ $role->permissions()->syncWithoutDetaching($permissionIds);
+ }
+ }
+
+ return ['model' => Role::class, 'key' => $role->getRouteKeyName(), 'value' => (string) $role->getRouteKey()];
+ }
+
+ /**
+ * 완전한 권한 샘플(부모-자식 계층)을 생성합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedPermission(): array
+ {
+ $permission = Permission::query()->where('identifier', self::MARKER.'.parent')->first();
+
+ if (! $permission) {
+ $permission = Permission::factory()->create([
+ 'identifier' => self::MARKER.'.parent',
+ 'name' => ['ko' => 'API 문서 샘플 권한', 'en' => 'API Doc Sample Permission'],
+ 'description' => ['ko' => '문서 실측용 권한', 'en' => 'Sample permission'],
+ 'type' => 'admin',
+ ]);
+
+ Permission::factory()->create([
+ 'parent_id' => $permission->id,
+ 'identifier' => self::MARKER.'.child',
+ 'name' => ['ko' => '하위 권한', 'en' => 'Child Permission'],
+ 'type' => 'admin',
+ ]);
+ }
+
+ return ['model' => Permission::class, 'key' => $permission->getRouteKeyName(), 'value' => (string) $permission->getRouteKey()];
+ }
+
+ /**
+ * 완전한 메뉴 샘플(부모-자식)을 생성합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedMenu(): array
+ {
+ $menu = Menu::query()->where('slug', self::MARKER.'-menu')->first();
+
+ if (! $menu) {
+ $creator = User::query()->where('email', self::MARKER.'-user@example.com')->first();
+
+ $menu = Menu::factory()->create([
+ 'name' => ['ko' => 'API 문서 샘플 메뉴', 'en' => 'API Doc Sample Menu'],
+ 'slug' => self::MARKER.'-menu',
+ 'url' => '/admin/apidoc-sample',
+ 'icon' => 'fas fa-book',
+ 'is_active' => true,
+ 'created_by' => $creator?->id,
+ ]);
+
+ Menu::factory()->create([
+ 'name' => ['ko' => '하위 메뉴', 'en' => 'Child Menu'],
+ 'slug' => self::MARKER.'-menu-child',
+ 'parent_id' => $menu->id,
+ 'created_by' => $creator?->id,
+ ]);
+ }
+
+ return ['model' => Menu::class, 'key' => $menu->getRouteKeyName(), 'value' => (string) $menu->getRouteKey()];
+ }
+
+ /**
+ * 완전한 알림 정의 샘플(템플릿 포함)을 생성합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedNotificationDefinition(): array
+ {
+ $definition = NotificationDefinition::query()->where('type', self::MARKER.'.event')->first();
+
+ if (! $definition) {
+ $definition = NotificationDefinition::factory()->create([
+ 'type' => self::MARKER.'.event',
+ 'name' => ['ko' => 'API 문서 샘플 알림', 'en' => 'API Doc Sample Notification'],
+ 'description' => ['ko' => '문서 실측용 알림 정의', 'en' => 'Sample notification'],
+ 'channels' => ['database', 'mail'],
+ 'is_active' => true,
+ ]);
+ }
+
+ return ['model' => NotificationDefinition::class, 'key' => $definition->getRouteKeyName(), 'value' => (string) $definition->getRouteKey()];
+ }
+
+ /**
+ * 완전한 스케줄 샘플(실행 이력 포함)을 생성합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedSchedule(): array
+ {
+ $schedule = Schedule::query()->where('name', 'API 문서 샘플 스케줄')->first();
+
+ if (! $schedule) {
+ $creator = $this->sampleUser();
+
+ $schedule = Schedule::create([
+ 'name' => 'API 문서 샘플 스케줄',
+ 'description' => '문서 실측용 스케줄',
+ 'type' => 'artisan',
+ 'command' => 'cache:clear',
+ 'expression' => '0 3 * * *',
+ 'frequency' => 'daily',
+ 'without_overlapping' => true,
+ 'run_in_maintenance' => false,
+ 'timeout' => 300,
+ 'is_active' => true,
+ 'last_result' => 'success',
+ 'last_run_at' => now()->subDay(),
+ 'next_run_at' => now()->addDay(),
+ 'created_by' => $creator?->id,
+ ]);
+ }
+
+ return ['model' => Schedule::class, 'key' => $schedule->getRouteKeyName(), 'value' => (string) $schedule->getRouteKey()];
+ }
+
+ /**
+ * 완전한 활동 로그 샘플(actor + changes 포함)을 생성합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedActivityLog(): array
+ {
+ $log = ActivityLog::query()->where('description_key', 'apidoc.sample.action')->first();
+
+ if (! $log) {
+ $user = $this->sampleUser();
+
+ $log = ActivityLog::create([
+ 'log_type' => 'admin',
+ 'loggable_type' => User::class,
+ 'loggable_id' => $user?->id,
+ 'user_id' => $user?->id,
+ 'action' => 'user.update',
+ 'description_key' => 'apidoc.sample.action',
+ 'description_params' => ['name' => 'API 문서 샘플'],
+ 'properties' => ['source' => 'apidoc'],
+ 'changes' => ['status' => ['old' => 'inactive', 'new' => 'active']],
+ 'ip_address' => '127.0.0.1',
+ 'user_agent' => 'ApiDocgen/1.0',
+ 'created_at' => now(),
+ ]);
+ }
+
+ return ['model' => ActivityLog::class, 'key' => $log->getRouteKeyName(), 'value' => (string) $log->getRouteKey()];
+ }
+
+ /**
+ * 완전한 언어팩 샘플(manifest 채움)을 생성합니다.
+ *
+ * `scope=core` + `status=active` 조합은 그 locale 을 시스템 전역 지원 로케일로 승격시킨다
+ * (LanguagePackRepository::getActiveCoreLocales → refreshSupportedLocales →
+ * config('app.supported_locales'/'app.translatable_locales')).
+ *
+ * 문서용 샘플은 조회 예시를 만들기 위한 것이지 시스템 동작을 바꿀 자격이 없다. 샘플이 그
+ * 경로를 타면 이후 시드/저장되는 모든 다국어 데이터에 실재하지 않는 로케일 키가 박히고,
+ * 샘플을 비활성화한 뒤에는 그 키가 검증(TranslatableField)을 통과하지 못해 저장이 막힌다.
+ *
+ * 따라서 승격 경로를 벗어난 조합(module 스코프 + installed 상태)으로 생성하며, locale 도
+ * 실재하는 번들 로케일만 사용한다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedLanguagePack(): array
+ {
+ $pack = LanguagePack::query()->where('identifier', 'apidoc-sample-lang')->first();
+
+ if (! $pack) {
+ $installer = $this->sampleUser();
+
+ $pack = LanguagePack::create([
+ 'identifier' => 'apidoc-sample-lang',
+ 'vendor' => 'apidoc',
+ 'scope' => 'module',
+ 'target_identifier' => 'apidoc-sample-module',
+ 'locale' => 'ja',
+ 'locale_name' => 'Japanese',
+ 'locale_native_name' => '日本語',
+ 'text_direction' => 'ltr',
+ 'version' => '1.0.0',
+ 'latest_version' => '1.0.0',
+ 'license' => 'MIT',
+ 'description' => ['ko' => '문서 실측용 언어팩', 'en' => 'Sample language pack'],
+ 'status' => 'installed',
+ 'is_protected' => false,
+ 'manifest' => [
+ 'name' => ['ko' => 'API 문서 샘플 언어팩', 'en' => 'API Doc Sample Pack'],
+ 'version' => '1.0.0',
+ 'locale' => 'ja',
+ ],
+ 'source_type' => 'bundled',
+ 'installed_by' => $installer?->id,
+ 'installed_at' => now()->subDays(3),
+ 'activated_at' => null,
+ ]);
+ }
+
+ return ['model' => LanguagePack::class, 'key' => $pack->getRouteKeyName(), 'value' => (string) $pack->getRouteKey()];
+ }
+
+ /**
+ * 완전한 알림 로그 샘플(수신자/발신자 포함)을 생성합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedNotificationLog(): array
+ {
+ $log = NotificationLog::query()->where('notification_type', 'apidoc.sample.event')->first();
+
+ if (! $log) {
+ $user = $this->sampleUser();
+
+ $log = NotificationLog::create([
+ 'channel' => 'mail',
+ 'notification_type' => 'apidoc.sample.event',
+ 'extension_type' => 'core',
+ 'extension_identifier' => '',
+ 'recipient_user_id' => $user?->id,
+ 'recipient_identifier' => $user?->email,
+ 'recipient_name' => $user?->name,
+ 'sender_user_id' => $user?->id,
+ 'subject' => 'API 문서 샘플 알림',
+ 'body' => '문서 실측용 알림 본문입니다.',
+ 'status' => 'sent',
+ 'error_message' => null,
+ 'source' => 'apidoc',
+ 'sent_at' => now()->subHour(),
+ ]);
+ }
+
+ return ['model' => NotificationLog::class, 'key' => $log->getRouteKeyName(), 'value' => (string) $log->getRouteKey()];
+ }
+
+ /**
+ * 완전한 알림 템플릿 샘플(정의 연결)을 생성합니다.
+ *
+ * @return array{model: class-string, key: string, value: string} 대표 레코드 정보
+ */
+ private function seedNotificationTemplate(): array
+ {
+ $this->seedNotificationDefinition();
+ $definitionId = NotificationDefinition::query()->where('type', self::MARKER.'.event')->value('id');
+
+ $template = NotificationTemplate::query()
+ ->where('definition_id', $definitionId)
+ ->where('channel', 'mail')
+ ->first();
+
+ if (! $template && $definitionId) {
+ $updater = $this->sampleUser();
+
+ $template = NotificationTemplate::create([
+ 'definition_id' => $definitionId,
+ 'channel' => 'mail',
+ 'subject' => 'API 문서 샘플 템플릿 제목',
+ 'body' => '안녕하세요 {{name}} 님, 문서 실측용 본문입니다.',
+ 'click_url' => '/admin/apidoc-sample',
+ 'recipients' => [['type' => 'role', 'value' => 'admin']],
+ 'is_active' => true,
+ 'is_default' => false,
+ 'updated_by' => $updater?->id,
+ ]);
+ }
+
+ if (! $template) {
+ return [];
+ }
+
+ return ['model' => NotificationTemplate::class, 'key' => $template->getRouteKeyName(), 'value' => (string) $template->getRouteKey()];
+ }
+
+ /**
+ * 시드된 완전 샘플 사용자를 반환합니다 (연관 엔티티의 소유자/actor 용).
+ *
+ * @return User|null 샘플 사용자 (없으면 null)
+ */
+ private function sampleUser(): ?User
+ {
+ return User::query()->where('email', self::MARKER.'-user@example.com')->first();
+ }
+}
diff --git a/app/Support/ApiDoc/ApiDocScaffolder.php b/app/Support/ApiDoc/ApiDocScaffolder.php
new file mode 100644
index 00000000..000a2d1f
--- /dev/null
+++ b/app/Support/ApiDoc/ApiDocScaffolder.php
@@ -0,0 +1,1361 @@
+';
+
+ /**
+ * @var int 응답 예시의 목록(`data.data[]`) 최대 표시 항목 수
+ *
+ * 목록 응답 body 는 클 수 있으므로 대표 항목만 남기고 나머지는 절단 주석으로 대체한다.
+ */
+ private const LIST_EXAMPLE_LIMIT = 2;
+
+ /**
+ * @var string 요청 예시의 토큰 마스킹 placeholder
+ *
+ * 실측 토큰은 임시 발급분이므로 문서에 평문 유출하지 않고 placeholder 로 마스킹한다.
+ */
+ private const TOKEN_PLACEHOLDER = '{YOUR_TOKEN}';
+
+ /**
+ * @var string 요청 예시의 공개 Host placeholder
+ *
+ * 실측 기준 URL(로컬 개발 호스트 등)을 공개 문서에 노출하지 않고 중립 placeholder 로 마스킹한다.
+ * RFC 2606 예약 도메인(example.com) 기반이라 어느 환경에도 종속되지 않는다.
+ */
+ private const DOC_HOST = 'api.example.com';
+
+ /**
+ * @param ResourceFieldDescriber $fieldDescriber accessor/computed 필드 설명기
+ * @param ParameterDescriber $paramDescriber 공통 요청 파라미터 설명기
+ */
+ public function __construct(
+ private readonly ResourceFieldDescriber $fieldDescriber = new ResourceFieldDescriber,
+ private readonly ParameterDescriber $paramDescriber = new ParameterDescriber
+ ) {}
+
+ /**
+ * 단일 엔드포인트의 마크다운 섹션을 생성합니다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @param array $request FormRequest 분석 결과
+ * @param array|null $schema 실측 응답 스키마 (null=실측 안 됨)
+ * @param array $probeMeta 실측 메타 (status, skipped_reason, base_url, resolved_uri, body)
+ * @param array $commentMap 컬럼명 => 주석 (필드 설명 기본값)
+ * @return string 마크다운 섹션
+ */
+ public function endpointSection(array $route, array $request, ?array $schema, array $probeMeta, array $commentMap = []): string
+ {
+ $name = $route['name'] ?: '(unnamed)';
+ $heading = "### {$route['method']} {$route['uri']}";
+ $genKey = $name;
+
+ $lines = [];
+ $lines[] = $heading;
+ $lines[] = self::GEN_START.$genKey.' -->';
+ $lines[] = "- **라우트명**: `{$name}`";
+ if ($route['controller']) {
+ $lines[] = "- **컨트롤러**: `{$route['controller']}@{$route['controller_method']}`";
+ }
+ $lines[] = '- **인증/권한**: '.$this->authLine($route);
+ $lines[] = '';
+
+ $lines[] = '**요청 파라미터**';
+ $lines[] = '';
+ $lines[] = $this->requestParamTable($route, $request);
+ if (! empty($request['hook_filters'])) {
+ $hooks = implode('`, `', $request['hook_filters']);
+ $lines[] = '';
+ $lines[] = "> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`{$hooks}`).";
+ }
+ $lines[] = '';
+
+ $lines[] = '**요청 예시**';
+ $lines[] = '';
+ $lines[] = $this->requestExampleBlock($route, $request, $probeMeta);
+ $lines[] = '';
+
+ $lines[] = '**응답 필드** (`data` 내부)';
+ $lines[] = '';
+ $lines[] = $this->responseFieldTable($schema, $probeMeta, $commentMap);
+ $lines[] = '';
+
+ $lines[] = '**응답 예시**';
+ $lines[] = '';
+ $lines[] = $this->responseExampleBlock($schema, $probeMeta);
+ $lines[] = '';
+
+ $lines[] = '**에러 응답**';
+ $lines[] = '';
+ $lines[] = $this->errorTable($route, $request);
+ $lines[] = '';
+ $lines[] = self::GEN_END;
+ $lines[] = '';
+ $lines[] = '**설명** ';
+ $lines[] = '';
+
+ return implode("\n", $lines)."\n";
+ }
+
+ /**
+ * @var string 코어 README 의 확장 API 목차 생성 블록 키
+ */
+ private const GEN_KEY_EXTENSIONS = 'api-readme-extensions';
+
+ /**
+ * @var string 코어/확장 README 의 도메인 목차 생성 블록 키
+ */
+ private const GEN_KEY_INDEX = 'api-readme-index';
+
+ /**
+ * @var string 코어 README 의 확장 API 목차 섹션 헤딩 (사람 서술 보존 경계)
+ */
+ private const EXTENSIONS_HEADING = '확장 API 레퍼런스';
+
+ /**
+ * 대상(코어/확장)의 API 문서 디렉토리 목차(README.md)를 생성합니다.
+ *
+ * 도메인 파일별 링크 + 엔드포인트 수를 표로 나열해, 처음 진입한 개발자/AI 가 이 대상의
+ * API 레퍼런스 전모를 한눈에 파악하도록 한다. 표는 `@generated` 블록 안에 두어 재생성 시
+ * 갱신되며, 블록 밖 사람 서술(개요·주의사항)은 보존한다. 확장 API 문서의 발견성은
+ * 코어 인덱스 생성기가 이 README 를 패턴 스캔(확장명 하드코딩 없이)해 확보한다.
+ *
+ * 코어 README 는 프로젝트 최상위 `README.md` 의 "API 레퍼런스" 진입점이므로,
+ * 도메인 목차 뒤에 확장 API 목차 블록을 함께 싣는다. `$extensions` 가 null 이면
+ * 확장 블록을 생성하지 않고 기존 블록(있으면)을 그대로 보존한다 — `--scope` 축소
+ * 실행이 확장 목차를 소실시키지 않도록 하기 위함이다.
+ *
+ * @param string $ownerLabel 소유 라벨 (예: '코어', '모듈 `sirsoft-ecommerce`')
+ * @param array $entries 도메인 파일 목록
+ * @param string|null $existing 기존 README (사람 서술 보존용, 없으면 null)
+ * @param array|null $extensions 확장 API 목차 항목 (코어 전용, 미갱신 시 null)
+ * @return string README 마크다운
+ */
+ public function readmeIndex(
+ string $ownerLabel,
+ array $entries,
+ ?string $existing = null,
+ ?array $extensions = null
+ ): string {
+ usort($entries, fn ($a, $b) => strcmp($a['domain'], $b['domain']));
+ $total = array_sum(array_column($entries, 'count'));
+
+ $rows = ['| 문서 | 도메인 | 엔드포인트 |', '| --- | --- | --- |'];
+ foreach ($entries as $e) {
+ $rows[] = "| [{$e['file']}]({$e['file']}) | `{$e['domain']}` | {$e['count']} |";
+ }
+
+ $genKey = self::GEN_KEY_INDEX;
+ $lines = [];
+ $lines[] = '# API 레퍼런스 문서 목차';
+ $lines[] = '';
+ $lines[] = "> **소유**: {$ownerLabel} · **생성**: `php artisan api:docgen` (실측 기반).";
+ $lines[] = '> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.';
+ $lines[] = '';
+
+ // 헤더와 목차 표 사이의 사람 개요(공통 규약 등)를 보존/삽입한다.
+ $overview = $existing !== null ? $this->extractReadmeOverview($existing, $genKey) : null;
+ if ($overview !== null && $overview !== '') {
+ $lines[] = $overview;
+ $lines[] = '';
+ }
+
+ $lines[] = self::GEN_START.$genKey.' -->';
+ $lines[] = '- **문서 수**: '.count($entries)." · **엔드포인트 수**: {$total}";
+ $lines[] = '';
+ $lines[] = implode("\n", $rows);
+ $lines[] = '';
+ $lines[] = self::GEN_END;
+ $lines[] = '';
+
+ $generated = implode("\n", $lines)."\n";
+
+ // 목차 블록 밖 사람 서술(주의사항 등)이 있으면 보존한다.
+ // 확장 목차(`## 확장 API 레퍼런스`) 이후는 별도 블록이므로 삼키지 않는다.
+ $prose = $existing !== null ? $this->extractReadmeTrailingProse($existing, $genKey) : null;
+ if ($prose !== null && $prose !== '') {
+ $generated .= "\n".$prose."\n";
+ }
+
+ $extensionBlock = $extensions !== null
+ ? $this->extensionIndexBlock($extensions)
+ : ($existing !== null ? $this->extractGeneratedBlock($existing, self::GEN_KEY_EXTENSIONS) : null);
+
+ if ($extensionBlock !== null) {
+ $generated = rtrim($generated)."\n\n".trim($extensionBlock)."\n";
+ }
+
+ return $generated;
+ }
+
+ /**
+ * 기존 코어 README 에서 확장 API 목차 블록만 in-place 갱신합니다.
+ *
+ * 확장 스코프 실행(`--scope=module:...`)이라 코어 도메인 목차를 재생성하지 않는 경우에도,
+ * 그 확장의 문서 수·엔드포인트 수 변동을 코어 진입점에 반영하기 위해 사용한다.
+ * 코어 README 에 확장 블록이 아직 없으면 문서 끝에 새로 덧붙인다.
+ *
+ * @param string $existing 기존 코어 README
+ * @param array $extensions 확장 목록
+ * @return string|null 갱신된 README (변경 없으면 null)
+ */
+ public function refreshExtensionIndex(string $existing, array $extensions): ?string
+ {
+ $block = $this->extensionIndexBlock($extensions);
+ $current = $this->extractGeneratedBlock($existing, self::GEN_KEY_EXTENSIONS);
+
+ if ($current === null) {
+ return rtrim($existing)."\n\n".$block;
+ }
+
+ if (trim($current) === trim($block)) {
+ return null;
+ }
+
+ return str_replace($current, trim($block), $existing);
+ }
+
+ /**
+ * 코어 README 의 확장 API 목차 블록을 생성합니다.
+ *
+ * 확장명을 하드코딩하지 않고, 커맨드가 `{modules,plugins}/_bundled/*\/docs/api/README.md`
+ * 를 스캔해 넘긴 목록을 그대로 표로 만든다.
+ *
+ * @param array $extensions 확장 목록
+ * @return string 확장 목차 마크다운 블록 (@generated 경계 포함)
+ */
+ private function extensionIndexBlock(array $extensions): string
+ {
+ usort($extensions, function ($a, $b) {
+ return [$a['type'], $a['id']] <=> [$b['type'], $b['id']];
+ });
+
+ $lines = [];
+ $lines[] = '## '.self::EXTENSIONS_HEADING;
+ $lines[] = '';
+ $lines[] = '> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.';
+ $lines[] = '';
+ $lines[] = self::GEN_START.self::GEN_KEY_EXTENSIONS.' -->';
+
+ if ($extensions === []) {
+ $lines[] = '_설치된 번들 확장 중 API 문서를 소유한 확장이 없습니다._';
+ $lines[] = '';
+ $lines[] = self::GEN_END;
+
+ return implode("\n", $lines)."\n";
+ }
+
+ $lines[] = '- **확장 수**: '.count($extensions)
+ .' · **엔드포인트 수**: '.array_sum(array_column($extensions, 'endpoints'));
+ $lines[] = '';
+ $lines[] = '| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |';
+ $lines[] = '| --- | --- | --- | --- |';
+
+ foreach ($extensions as $e) {
+ $type = $e['type'] === 'module' ? '모듈' : '플러그인';
+ $lines[] = "| `{$e['id']}` | {$type} | [docs/api/]({$e['path']}) | {$e['docs']} / {$e['endpoints']} |";
+ }
+
+ $lines[] = '';
+ $lines[] = self::GEN_END;
+
+ return implode("\n", $lines)."\n";
+ }
+
+ /**
+ * 인증/권한 라인을 구성합니다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @return string 인증/권한 설명
+ */
+ private function authLine(array $route): string
+ {
+ $mw = $route['middleware'] ?? [];
+ $parts = [];
+
+ // optional.sanctum(회원/비회원 모두 접근 — Bearer 토큰 있으면 인증, 없으면 guest)은
+ // auth:sanctum(인증 필수)과 계약이 다르므로 별도 표기한다. 'sanctum' 부분일치가
+ // optional.sanctum 까지 auth:sanctum 으로 오표기하던 회귀를 막는다.
+ if ($this->hasMiddleware($mw, 'optional.sanctum')) {
+ $parts[] = '`optional.sanctum` (선택적 인증: 회원/비회원 모두 접근)';
+ } elseif ($this->hasMiddleware($mw, 'sanctum')) {
+ $parts[] = '`auth:sanctum`';
+ }
+ if ($this->hasMiddleware($mw, 'AdminMiddleware')) {
+ $parts[] = '`admin`';
+ }
+ if ($route['permission']) {
+ $parts[] = "`permission:{$route['permission']}`";
+ }
+
+ return $parts === [] ? '공개 (인증 불필요)' : implode(' + ', $parts);
+ }
+
+ /**
+ * 미들웨어 목록에 특정 토큰이 포함되는지 확인합니다.
+ *
+ * @param array $middleware 미들웨어 목록
+ * @param string $needle 검색 토큰
+ * @return bool 포함 여부
+ */
+ private function hasMiddleware(array $middleware, string $needle): bool
+ {
+ foreach ($middleware as $mw) {
+ if (Str::contains($mw, $needle)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 요청 파라미터 표를 생성합니다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @param array $request FormRequest 분석 결과
+ * @return string 마크다운 표
+ */
+ private function requestParamTable(array $route, array $request): string
+ {
+ $rows = [];
+ $pathParams = $route['path_params'];
+
+ foreach ($pathParams as $pathParam) {
+ $desc = $this->paramDescriber->describe($pathParam, 'path', 'string');
+ $descCell = $desc !== null ? $this->escapeCell($desc) : '';
+ $rows[] = "| {$pathParam} | path | string | 예 | — | {$descCell} |";
+ }
+
+ $location = in_array($route['method'], ['GET', 'DELETE'], true) ? 'query' : 'body';
+
+ foreach ($request['params'] as $p) {
+ // path 파라미터로 이미 출력한 이름은 FormRequest 규칙에서 중복 출력하지 않는다
+ // (라우트 바인딩 세그먼트가 FormRequest rules() 에도 있으면 path 행이 우선).
+ if (in_array($p['name'], $pathParams, true)) {
+ continue;
+ }
+
+ $required = $p['required'] ? '예' : '아니오';
+ $allowed = $p['allowed'] !== '' ? $p['allowed'] : '—';
+ $desc = $this->paramDescriber->describe($p['name'], $location, $p['type']);
+ $descCell = $desc !== null ? $this->escapeCell($desc) : '';
+ $rows[] = "| {$p['name']} | {$location} | {$p['type']} | {$required} | {$allowed} | {$descCell} |";
+ }
+
+ if ($rows === []) {
+ return '_요청 파라미터 없음._';
+ }
+
+ return "| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |\n| --- | --- | --- | --- | --- | --- |\n".implode("\n", $rows);
+ }
+
+ /**
+ * 응답 필드 표 블록을 생성합니다 (examples-only 모드의 마커 갱신용 공개 래퍼).
+ *
+ * @param array|null $schema 실측 응답 스키마
+ * @param array $probeMeta 실측 메타
+ * @param array $commentMap 컬럼명 => 주석 (필드 설명 기본값)
+ * @return string 마크다운 표 또는 실측 제외 사유
+ */
+ public function responseFieldTableBlock(?array $schema, array $probeMeta, array $commentMap = []): string
+ {
+ return $this->responseFieldTable($schema, $probeMeta, $commentMap);
+ }
+
+ /**
+ * 응답 필드 표를 생성합니다.
+ *
+ * @param array|null $schema 실측 응답 스키마
+ * @param array $probeMeta 실측 메타
+ * @param array $commentMap 컬럼명 => 주석 (필드 설명 기본값)
+ * @return string 마크다운 표 또는 실측 제외 사유
+ */
+ private function responseFieldTable(?array $schema, array $probeMeta, array $commentMap = []): string
+ {
+ if ($schema === null) {
+ $reason = $probeMeta['skipped_reason'] ?? 'not-probed';
+
+ return "";
+ }
+
+ $note = '';
+ if ($schema['shape'] === 'collection') {
+ $note = '_목록 응답: `data.data[]` 배열 항목의 필드'.($schema['pagination'] ? ' + `data.pagination`' : '').'._';
+ } elseif ($schema['shape'] === 'object') {
+ $note = '_단건 응답: `data` 객체의 필드._';
+ }
+
+ if ($schema['fields'] === []) {
+ return $note."\n\n";
+ }
+
+ $rows = [];
+ foreach ($schema['fields'] as $f) {
+ // 필드 설명 우선순위:
+ // 1) 리소스 계약 사전 (accessor/computed — status_label, is_owner, *_at 등)
+ // 2) 컬럼 주석 (한국어 comment — 테이블 실제 컬럼)
+ // 3) TODO (사람 보강)
+ // 계약 사전이 앞서는 이유: created_at 은 어느 테이블이든 "생성 일시" 이고,
+ // status_label 은 컬럼이 아니라 Enum label() 산물이라 주석이 없기 때문.
+ $desc = $this->fieldDescriber->describe($f['name'], $f['type'] ?? '')
+ ?? ($commentMap[$f['name']] ?? null);
+ $descCell = $desc !== null ? $this->escapeCell($desc) : '';
+ $rows[] = "| {$f['name']} | {$f['type']} | `{$f['sample']}` | {$descCell} |";
+ }
+
+ $table = "| 필드 | 타입 | 실측 예시값 | 용도/설명 |\n| --- | --- | --- | --- |\n".implode("\n", $rows);
+
+ return $note !== '' ? $note."\n\n".$table : $table;
+ }
+
+ /**
+ * 에러 응답 표를 생성합니다.
+ *
+ * 라우트 메타에서 대표 에러 상태코드와 발생 조건을 자동 추론합니다.
+ * - 401: 인증 필수(`auth:sanctum`) 미들웨어. `optional.sanctum`(선택 인증)은 제외.
+ * - 403: `admin` 미들웨어 또는 `permission:` 요구 → 권한 부족 시.
+ * - 422: FormRequest 검증 규칙 존재 → 검증 실패 시.
+ * - 404: path 파라미터 존재 → 대상 리소스 미발견 시.
+ *
+ * 자동 추론은 대표 상태코드의 초안이며, 도메인 특이 에러(409 충돌·429 제한 등)는
+ * `@generated` 블록 밖 사람 서술에서 보강한다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @param array $request FormRequest 분석 결과
+ * @return string 마크다운 표
+ */
+ private function errorTable(array $route, array $request): string
+ {
+ $mw = $route['middleware'] ?? [];
+ $rows = [];
+
+ // 401: 인증 필수. optional.sanctum(선택 인증)은 미인증도 허용하므로 제외.
+ if (! $this->hasMiddleware($mw, 'optional.sanctum') && $this->hasMiddleware($mw, 'sanctum')) {
+ $rows[] = '| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |';
+ }
+
+ // 403: admin 게이트 또는 permission 요구 → 권한 부족.
+ if ($this->hasMiddleware($mw, 'AdminMiddleware') || ! empty($route['permission'])) {
+ $cond = ! empty($route['permission'])
+ ? "요구 권한(`{$route['permission']}`)이 없는 경우"
+ : '관리자 권한이 없는 경우';
+ $rows[] = "| 403 | Forbidden | {$cond} |";
+ }
+
+ // 422: FormRequest 검증 규칙 존재 → 검증 실패. (훅 주입 규칙 포함 가능)
+ $hasValidation = ! empty($request['params']) || ! empty($request['hook_filters']);
+ if ($hasValidation) {
+ $rows[] = '| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |';
+ }
+
+ // 404: path 파라미터 존재 → 대상 리소스 미발견.
+ if (! empty($route['path_params'])) {
+ $rows[] = '| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |';
+ }
+
+ if ($rows === []) {
+ return '_대표 에러 없음 (공개 조회). _';
+ }
+
+ return "| 상태코드 | 의미 | 발생 조건 |\n| --- | --- | --- |\n".implode("\n", $rows);
+ }
+
+ /**
+ * 실제 호출을 재현하는 raw HTTP 요청 예시 블록을 생성합니다.
+ *
+ * 요청 라인(`{METHOD} {path} HTTP/1.1`) + 헤더 + 바디로 raw HTTP 요청을 조립합니다
+ * (응답 예시의 `HTTP/1.1 {status}` 상태줄과 대칭). curl 은 사용하지 않습니다.
+ * - 인증 필요(`auth:sanctum`/`optional.sanctum`) 시 `Authorization: Bearer {YOUR_TOKEN}` 마스킹.
+ * - 바디 있는 메서드(POST/PUT/PATCH)는 요청 파라미터 표의 필수 파라미터 + 타입별 샘플값을
+ * 빈 줄 뒤 JSON 바디로 붙입니다.
+ *
+ * path 파라미터가 실측 치환값(`resolved_uri`)으로 채워졌으면 그 값을, 아니면 `{param}` placeholder 를 쓴다.
+ *
+ * @param array $route 라우트 메타데이터
+ * @param array $request FormRequest 분석 결과
+ * @param array $probeMeta 실측 메타 (base_url, resolved_uri)
+ * @return string 마크다운 코드블록
+ */
+ public function requestExampleBlock(array $route, array $request, array $probeMeta): string
+ {
+ $method = strtoupper((string) $route['method']);
+ $path = (string) ($probeMeta['resolved_uri'] ?? $route['uri']);
+
+ // GET/DELETE 의 query 파라미터는 URL 쿼리스트링으로 반영한다(바디가 아니라 URL).
+ if (in_array($method, ['GET', 'DELETE'], true)) {
+ $path = $this->appendQueryString($path, $request);
+ }
+
+ // 요청 라인 + 헤더로 raw HTTP 요청 예시를 조립한다 (응답 예시의 HTTP/1.1 상태줄과 대칭).
+ // Host 는 실측 기준 URL(로컬 호스트)을 노출하지 않고 공개 placeholder 로 마스킹한다.
+ $lines = ["{$method} {$path} HTTP/1.1"];
+ $lines[] = 'Host: '.self::DOC_HOST;
+ $lines[] = 'Accept: application/json';
+
+ // 인증 필요 시 Bearer 토큰 마스킹 (auth:sanctum 필수 / optional.sanctum 선택).
+ $mw = $route['middleware'] ?? [];
+ if ($this->hasMiddleware($mw, 'sanctum')) {
+ $optional = $this->hasMiddleware($mw, 'optional.sanctum');
+ $suffix = $optional ? ' (optional.sanctum: 비회원은 헤더 생략 가능)' : '';
+ $lines[] = 'Authorization: Bearer '.self::TOKEN_PLACEHOLDER.$suffix;
+ }
+
+ // 바디 있는 메서드(POST/PUT/PATCH)는 바디를 붙인다.
+ if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
+ $bodyParams = $this->bodyParams($request);
+
+ if ($bodyParams !== []) {
+ // 파일 업로드(image/file 타입) 파라미터가 있으면 multipart/form-data 로 표기한다
+ // (application/json 으로는 파일을 전송할 수 없다).
+ if ($this->hasFileParam($bodyParams)) {
+ foreach ($this->multipartBodyLines($bodyParams) as $bl) {
+ $lines[] = $bl;
+ }
+ } else {
+ $lines[] = 'Content-Type: application/json';
+ $lines[] = '';
+ $body = $this->jsonBody($bodyParams);
+ $json = json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ $lines[] = (string) $json;
+ }
+ }
+ }
+
+ return "```http\n".implode("\n", $lines)."\n```";
+ }
+
+ /**
+ * GET/DELETE 의 query 파라미터를 URL 쿼리스트링으로 붙입니다.
+ *
+ * 이미 쿼리(`?per_page=..`)가 있으면 `&` 로 잇고, 각 파라미터는 타입별 대표값으로 채웁니다.
+ * path 파라미터는 URL 세그먼트라 제외합니다.
+ *
+ * @param string $path 경로(치환된 path 파라미터 포함 가능)
+ * @param array $request FormRequest 분석 결과
+ * @return string 쿼리스트링이 붙은 경로
+ */
+ private function appendQueryString(string $path, array $request): string
+ {
+ $params = $request['params'] ?? [];
+ if ($params === []) {
+ return $path;
+ }
+
+ $pathParams = [];
+ preg_match_all('/\{([^}]+)\}/', $path, $m);
+ if (! empty($m[1])) {
+ $pathParams = $m[1];
+ }
+
+ $pairs = [];
+ foreach ($params as $p) {
+ $name = (string) $p['name'];
+ if (in_array($name, $pathParams, true)) {
+ continue;
+ }
+ // 허용값 in: 열거가 있으면 그 첫 값을, 없으면 이름·타입 기반 대표값을 쓴다.
+ $value = $this->exampleValue($name, (string) ($p['type'] ?? 'string'), (string) ($p['allowed'] ?? ''));
+ if (is_array($value)) {
+ $value = $value[0] ?? '';
+ }
+ $pairs[] = $name.'='.rawurlencode((string) $value);
+ }
+
+ if ($pairs === []) {
+ return $path;
+ }
+
+ return $path.(str_contains($path, '?') ? '&' : '?').implode('&', $pairs);
+ }
+
+ /**
+ * body 위치 파라미터 목록을 반환합니다 (전체 — 필수+선택).
+ *
+ * @param array $request FormRequest 분석 결과
+ * @return array> body 파라미터 목록
+ */
+ private function bodyParams(array $request): array
+ {
+ return array_values($request['params'] ?? []);
+ }
+
+ /**
+ * 파라미터 목록에 파일(image/file) 타입이 있는지 판별합니다.
+ *
+ * @param array> $params 파라미터 목록
+ * @return bool 파일 파라미터 존재 여부
+ */
+ private function hasFileParam(array $params): bool
+ {
+ foreach ($params as $p) {
+ if (in_array((string) ($p['type'] ?? ''), ['file', 'image'], true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * multipart/form-data 요청 예시의 헤더 + 파트 라인들을 생성합니다.
+ *
+ * 파일 파라미터는 `filename=` + `Content-Type` 파트로, 그 외는 값 파트로 표현합니다.
+ *
+ * @param array> $params body 파라미터 목록
+ * @return array multipart 요청 라인들
+ */
+ private function multipartBodyLines(array $params): array
+ {
+ $boundary = '----G7ExampleBoundary';
+ $lines = ["Content-Type: multipart/form-data; boundary={$boundary}", ''];
+
+ foreach ($params as $p) {
+ $name = (string) $p['name'];
+ $type = (string) ($p['type'] ?? 'string');
+ $lines[] = "--{$boundary}";
+
+ if (in_array($type, ['file', 'image'], true)) {
+ // 배열 파일(files[]) 은 name 뒤에 [] 를 붙인다.
+ $isArrayFile = str_ends_with($name, 's') || $name === 'files';
+ $field = $isArrayFile ? $name.'[]' : $name;
+ $fileName = $type === 'image' ? 'example.png' : 'example.pdf';
+ $mime = $type === 'image' ? 'image/png' : 'application/octet-stream';
+ $lines[] = "Content-Disposition: form-data; name=\"{$field}\"; filename=\"{$fileName}\"";
+ $lines[] = "Content-Type: {$mime}";
+ $lines[] = '';
+ $lines[] = '(바이너리 파일 내용)';
+ } else {
+ $lines[] = "Content-Disposition: form-data; name=\"{$name}\"";
+ $lines[] = '';
+ $lines[] = (string) $this->exampleScalar($name, $type);
+ }
+ }
+
+ $lines[] = "--{$boundary}--";
+
+ return $lines;
+ }
+
+ /**
+ * JSON 요청 바디 맵을 조립합니다 (전체 파라미터 — 필수+선택).
+ *
+ * 값은 이름·타입에 맞는 현실적인 예시값으로 채웁니다(placeholder `"string"` 남발 방지).
+ *
+ * @param array> $params body 파라미터 목록
+ * @return array 예시 바디 (이름 => 예시값)
+ */
+ private function jsonBody(array $params): array
+ {
+ $body = [];
+ foreach ($params as $p) {
+ $name = (string) $p['name'];
+ $type = (string) ($p['type'] ?? 'string');
+ $body[$name] = $this->exampleValue($name, $type, (string) ($p['allowed'] ?? ''));
+ }
+
+ return $body;
+ }
+
+ /**
+ * 파라미터의 예시값을 이름·타입·허용값 근거로 생성합니다.
+ *
+ * `array` 타입은 빈 배열이 아니라 대표 원소 1개를, boolean/number 는 타입값을,
+ * 그 외는 이름 기반 현실적 문자열(email/password/url 등)을 반환합니다.
+ * 허용값에 `in:` 목록(백틱)이 있으면 그 첫 값을 채택합니다.
+ *
+ * @param string $name 파라미터명
+ * @param string $type 타입
+ * @param string $allowed 허용값 설명
+ * @return mixed 예시값
+ */
+ private function exampleValue(string $name, string $type, string $allowed = ''): mixed
+ {
+ // 허용값에 in: 열거(백틱으로 감싼 첫 값)가 있으면 그것을 우선 채택한다.
+ if (preg_match('/`([^`]+)`/', $allowed, $m)) {
+ return $m[1];
+ }
+
+ return match ($type) {
+ 'array' => [$this->exampleScalar($this->singularName($name), 'string')],
+ default => $this->exampleScalar($name, $type),
+ };
+ }
+
+ /**
+ * 스칼라 파라미터의 예시값을 이름·타입 근거로 생성합니다.
+ *
+ * @param string $name 파라미터명
+ * @param string $type 타입
+ * @return mixed 예시 스칼라값
+ */
+ private function exampleScalar(string $name, string $type): mixed
+ {
+ $lower = strtolower($name);
+
+ // 타입 우선 판정
+ if ($type === 'boolean') {
+ return true;
+ }
+ if (in_array($type, ['integer', 'number'], true)) {
+ return match (true) {
+ str_contains($lower, 'page') => 1,
+ str_ends_with($lower, '_id'), $lower === 'id' => 1,
+ default => 1,
+ };
+ }
+ if ($type === 'email' || str_contains($lower, 'email')) {
+ return 'user@example.com';
+ }
+ if ($type === 'uuid') {
+ return '9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d';
+ }
+ if ($type === 'date') {
+ return '2026-01-01';
+ }
+
+ // 이름 기반 현실적 값
+ return match (true) {
+ str_contains($lower, 'password') => 'Password123!',
+ str_contains($lower, 'url') || str_contains($lower, 'homepage') => 'https://example.com',
+ str_contains($lower, 'identifier') || str_contains($lower, 'slug') => 'example-key',
+ str_contains($lower, 'name') => '예시 이름',
+ str_contains($lower, 'title') => '예시 제목',
+ str_contains($lower, 'mobile') || str_contains($lower, 'phone') => '010-1234-5678',
+ str_contains($lower, 'locale') || $lower === 'language' => 'ko',
+ str_contains($lower, 'country') => 'KR',
+ str_contains($lower, 'timezone') => 'Asia/Seoul',
+ str_contains($lower, 'zipcode') || str_contains($lower, 'postal') => '06234',
+ str_contains($lower, 'address') => '서울특별시 강남구 테헤란로 1',
+ str_contains($lower, 'content') || str_contains($lower, 'description') || str_contains($lower, 'bio') => '예시 내용입니다.',
+ str_contains($lower, 'token') => self::TOKEN_PLACEHOLDER,
+ str_contains($lower, 'color') => '#4F46E5',
+ default => '예시값',
+ };
+ }
+
+ /**
+ * 배열 파라미터명의 단수형 근사값을 반환합니다 (예시 원소 이름용).
+ *
+ * @param string $name 파라미터명 (복수형 가능)
+ * @return string 단수형 근사
+ */
+ private function singularName(string $name): string
+ {
+ return Str::singular($name);
+ }
+
+ /**
+ * 실측 응답 body 전문(envelope 통짜)을 예시 블록으로 방출합니다.
+ *
+ * 실측 body 가 있으면 `{success, data, message, error}` envelope 를 pretty JSON 으로 출력합니다.
+ * 목록 응답의 `data.data[]` 는 대표 항목으로 절단하고 나머지는 절단 주석으로 대체합니다.
+ * 실측 제외(쓰기 메서드·바이너리·미치환 path)면 사람 보강 마커를 남깁니다.
+ *
+ * @param array|null $schema 실측 응답 스키마 (null=실측 안 됨)
+ * @param array $probeMeta 실측 메타 (status, body, skipped_reason)
+ * @return string 마크다운 코드블록 또는 실측 제외 마커
+ */
+ public function responseExampleBlock(?array $schema, array $probeMeta): string
+ {
+ $body = $probeMeta['body'] ?? null;
+
+ if (! is_array($body)) {
+ $reason = $probeMeta['skipped_reason'] ?? 'not-probed';
+
+ return "";
+ }
+
+ $status = $probeMeta['status'] ?? 200;
+ $truncated = $this->truncateListBody($body);
+ // 실측 응답에 섞여 나온 민감값(토큰·비밀번호·시크릿 등)을 마스킹한다.
+ $sanitized = $this->sanitizeSensitive($truncated);
+ // 응답 body 내부의 절대 URL(페이지네이터 링크·콜백 URL 등)에 실측 기준 호스트가
+ // 그대로 직렬화돼 들어올 수 있으므로 공개 문서용 placeholder 호스트로 마스킹한다.
+ $masked = $this->maskResponseHost($sanitized, $probeMeta['base_url'] ?? null);
+ $json = json_encode($masked, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ return "```http\nHTTP/1.1 {$status}\n```\n\n```json\n".(string) $json."\n```";
+ }
+
+ /**
+ * 응답 body 의 절대 URL 에 포함된 실측 기준 호스트를 공개 placeholder 호스트로 마스킹합니다 (재귀).
+ *
+ * Laravel 페이지네이터 메타(`first_page_url`/`last_page_url`/`path`/`url` 등)와 콜백/통보 URL 은
+ * 실측 시 요청 base URL 의 호스트(로컬 개발 호스트 등)를 그대로 직렬화한다. 요청 예시의 `Host:`
+ * 헤더는 {@see self::DOC_HOST} 로 마스킹되지만 응답 body 문자열값은 별도 처리가 없으면 유출된다.
+ * base URL 의 scheme+host 를 `https://{DOC_HOST}` 로 치환해 응답 예시에서도 호스트를 노출하지 않는다.
+ *
+ * @param mixed $value 응답 값 (배열/스칼라)
+ * @param string|null $baseUrl 실측 요청 base URL (없으면 마스킹 생략)
+ * @return mixed 호스트가 마스킹된 값
+ */
+ private function maskResponseHost(mixed $value, ?string $baseUrl): mixed
+ {
+ if ($baseUrl === null || $baseUrl === '') {
+ return $value;
+ }
+
+ $host = parse_url($baseUrl, PHP_URL_HOST);
+ if (! is_string($host) || $host === '' || $host === self::DOC_HOST) {
+ return $value;
+ }
+
+ if (is_string($value)) {
+ // scheme://host 조합을 placeholder 로 치환 (포트 유무·http/https 모두 대응).
+ return preg_replace(
+ '#https?://'.preg_quote($host, '#').'(:\d+)?#i',
+ 'https://'.self::DOC_HOST,
+ $value
+ );
+ }
+
+ if (is_array($value)) {
+ $result = [];
+ foreach ($value as $k => $v) {
+ $result[$k] = $this->maskResponseHost($v, $baseUrl);
+ }
+
+ return $result;
+ }
+
+ return $value;
+ }
+
+ /**
+ * 응답 body 에서 민감한 필드 값을 placeholder 로 마스킹합니다 (재귀).
+ *
+ * 실측 응답에 실제 토큰·비밀번호 해시·시크릿·API 키가 섞여 나올 수 있으므로 공개 문서에
+ * 방출하기 전에 마스킹한다. 필드명(키)에 민감 키워드가 포함되면 값을 placeholder 로 대체.
+ *
+ * @param mixed $value 응답 값 (배열/스칼라)
+ * @return mixed 마스킹된 값
+ */
+ private function sanitizeSensitive(mixed $value): mixed
+ {
+ if (! is_array($value)) {
+ return $value;
+ }
+
+ $sensitiveKeys = [
+ 'token', 'access_token', 'refresh_token', 'plaintexttoken', 'plain_text_token',
+ 'password', 'secret', 'api_key', 'apikey', 'private_key', 'client_secret',
+ 'authorization', 'remember_token', 'app_key',
+ ];
+
+ $result = [];
+ foreach ($value as $k => $v) {
+ $keyLower = is_string($k) ? strtolower($k) : '';
+ $isSensitive = false;
+ foreach ($sensitiveKeys as $needle) {
+ if ($keyLower !== '' && str_contains($keyLower, $needle)) {
+ $isSensitive = true;
+ break;
+ }
+ }
+
+ if ($isSensitive && ! is_array($v) && $v !== null) {
+ // 타입 표기용 접미(_type/_expires 등)는 마스킹 대상이 아니므로 스칼라만 마스킹.
+ $result[$k] = ($keyLower === 'token_type') ? $v : '{MASKED}';
+ } else {
+ $result[$k] = $this->sanitizeSensitive($v);
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * 목록 응답 body 의 `data.data[]` 를 대표 항목으로 절단합니다.
+ *
+ * 목록 body 는 클 수 있으므로 최대 LIST_EXAMPLE_LIMIT 항목만 남기고
+ * 나머지는 `// ... (총 N건 중 K건 표시)` 절단 주석 항목으로 대체합니다.
+ *
+ * @param array $body 실측 응답 body (envelope)
+ * @return array 절단된 body
+ */
+ private function truncateListBody(array $body): array
+ {
+ $data = $body['data'] ?? null;
+
+ if (is_array($data) && isset($data['data']) && is_array($data['data'])) {
+ $rows = $data['data'];
+ $total = count($rows);
+
+ if ($total > self::LIST_EXAMPLE_LIMIT) {
+ $kept = array_slice($rows, 0, self::LIST_EXAMPLE_LIMIT);
+ $kept[] = "... (총 {$total}건 중 ".self::LIST_EXAMPLE_LIMIT.'건 표시)';
+ $body['data']['data'] = $kept;
+ }
+ }
+
+ return $body;
+ }
+
+ /**
+ * 마크다운 표 셀 안에서 안전하도록 파이프/개행을 이스케이프합니다.
+ *
+ * @param string $text 원본 텍스트
+ * @return string 이스케이프된 텍스트
+ */
+ private function escapeCell(string $text): string
+ {
+ return str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $text);
+ }
+
+ /**
+ * 기존 문서에 새 생성 블록을 병합합니다. 사람 서술은 보존합니다.
+ *
+ * @param string|null $existing 기존 문서 내용 (null=신규)
+ * @param string $header 문서 헤더 (제목 + TL;DR 등)
+ * @param array $sections 엔드포인트 섹션 목록 (라우트명 순)
+ * @param array $sectionKeys 각 섹션의 라우트명 키
+ * @return string 병합된 문서 내용
+ */
+ public function mergeDocument(?string $existing, string $header, array $sections, array $sectionKeys): string
+ {
+ if ($existing === null) {
+ return $header."\n".implode("\n", $sections);
+ }
+
+ $merged = $header."\n";
+
+ foreach ($sections as $i => $section) {
+ $key = $sectionKeys[$i];
+ $preserved = $this->extractHumanProse($existing, $key);
+ $merged .= $this->applyPreservedProse($section, $preserved)."\n";
+ }
+
+ return $merged;
+ }
+
+ /**
+ * README 헤더(인용 블록)와 첫 생성 블록 사이의 사람 개요를 추출합니다.
+ *
+ * 개요(공통 규약: 인증·응답 봉투·에러·페이지네이션)는 목차 표보다 먼저 읽혀야 하므로
+ * 생성 블록 앞에 둔다. 재생성 시 이 구간을 원문 그대로 되살려 사람 서술을 보존한다.
+ *
+ * @param string $existing 기존 README
+ * @param string $key 목차 생성 블록 키
+ * @return string|null 보존할 개요 (없으면 null)
+ */
+ private function extractReadmeOverview(string $existing, string $key): ?string
+ {
+ $startPos = strpos($existing, self::GEN_START.$key.' -->');
+ if ($startPos === false) {
+ return null;
+ }
+
+ $head = substr($existing, 0, $startPos);
+
+ // 헤더 인용 블록(`> ...`) 의 마지막 줄 이후가 사람 개요다.
+ if (! preg_match_all('/(?:^|\n)>[^\n]*/', $head, $all, PREG_OFFSET_CAPTURE)) {
+ return null;
+ }
+
+ $last = end($all[0]);
+ $overview = trim(substr($head, $last[1] + strlen($last[0])));
+
+ return $overview === '' ? null : $overview;
+ }
+
+ /**
+ * README 목차 블록 뒤 ~ 확장 목차 헤딩 앞의 사람 서술을 추출합니다.
+ *
+ * @param string $existing 기존 README
+ * @param string $key 목차 생성 블록 키
+ * @return string|null 보존할 사람 서술 (없으면 null)
+ */
+ private function extractReadmeTrailingProse(string $existing, string $key): ?string
+ {
+ $startPos = strpos($existing, self::GEN_START.$key.' -->');
+ if ($startPos === false) {
+ return null;
+ }
+
+ $endPos = strpos($existing, self::GEN_END, $startPos);
+ if ($endPos === false) {
+ return null;
+ }
+
+ $afterGen = substr($existing, $endPos + strlen(self::GEN_END));
+
+ // 확장 목차 섹션(`## 확장 API 레퍼런스`)부터는 생성 블록 소관이므로 제외한다.
+ // 임의의 `## ` 헤딩에서 자르면 사람이 덧붙인 헤딩 섹션이 재생성 때마다 통째로 소실된다.
+ $nextSection = preg_match('/\n## '.preg_quote(self::EXTENSIONS_HEADING, '/').'/', $afterGen, $m, PREG_OFFSET_CAPTURE)
+ ? $m[0][1]
+ : strlen($afterGen);
+
+ $prose = trim(substr($afterGen, 0, $nextSection));
+
+ return $prose === '' ? null : $prose;
+ }
+
+ /**
+ * 기존 문서에서 지정 키의 생성 블록을 헤딩·인용문 포함해 원문 그대로 추출합니다.
+ *
+ * `--scope` 축소 실행 등으로 이번 회차에 갱신 대상이 아닌 블록을 소실 없이 되살린다.
+ *
+ * @param string $existing 기존 문서
+ * @param string $key 생성 블록 키
+ * @return string|null 블록 원문 (없으면 null)
+ */
+ private function extractGeneratedBlock(string $existing, string $key): ?string
+ {
+ $startPos = strpos($existing, self::GEN_START.$key.' -->');
+ if ($startPos === false) {
+ return null;
+ }
+
+ $endPos = strpos($existing, self::GEN_END, $startPos);
+ if ($endPos === false) {
+ return null;
+ }
+
+ // 블록 앞의 섹션 헤딩(`## ...`)부터 함께 되살린다.
+ $head = substr($existing, 0, $startPos);
+ $headingPos = preg_match_all('/(?:^|\n)## [^\n]*/', $head, $all, PREG_OFFSET_CAPTURE)
+ ? end($all[0])[1]
+ : null;
+
+ $from = $headingPos !== null ? $headingPos : $startPos;
+
+ return trim(substr($existing, $from, ($endPos + strlen(self::GEN_END)) - $from));
+ }
+
+ /**
+ * 기존 문서에서 특정 엔드포인트의 사람 서술(생성 블록 밖)을 추출합니다.
+ *
+ * @param string $existing 기존 문서
+ * @param string $key 라우트명 키
+ * @return string|null 보존할 사람 서술 (없으면 null)
+ */
+ private function extractHumanProse(string $existing, string $key): ?string
+ {
+ $startMarker = self::GEN_START.$key.' -->';
+ $startPos = strpos($existing, $startMarker);
+
+ if ($startPos === false) {
+ return null;
+ }
+
+ $endPos = strpos($existing, self::GEN_END, $startPos);
+ if ($endPos === false) {
+ return null;
+ }
+
+ $afterGen = substr($existing, $endPos + strlen(self::GEN_END));
+ // 다음 ### 헤딩 전까지가 이 엔드포인트의 사람 서술
+ $nextHeading = preg_match('/\n### /', $afterGen, $m, PREG_OFFSET_CAPTURE)
+ ? $m[0][1]
+ : strlen($afterGen);
+
+ $prose = trim(substr($afterGen, 0, $nextHeading));
+
+ // 기본 TODO 스텁만 있으면 보존할 것 없음
+ if ($prose === '' || Str::contains($prose, 'TODO: 이 엔드포인트의 용도')) {
+ return null;
+ }
+
+ return $prose;
+ }
+
+ /**
+ * 새 섹션의 기본 서술 스텁을 보존된 사람 서술로 치환합니다.
+ *
+ * @param string $section 새로 생성된 섹션
+ * @param string|null $preserved 보존할 사람 서술
+ * @return string 서술이 반영된 섹션
+ */
+ private function applyPreservedProse(string $section, ?string $preserved): string
+ {
+ if ($preserved === null) {
+ return $section;
+ }
+
+ $stub = '**설명** ';
+
+ return str_replace($stub, $preserved, $section);
+ }
+
+ /**
+ * 기존 문서의 각 엔드포인트 `@generated` 블록에 요청/응답 예시 블록을 in-place 삽입합니다.
+ *
+ * 전체 재생성(`endpointSection` + `mergeDocument`)은 `@generated` 블록 내부의
+ * 파라미터/응답 필드 표를 통째로 다시 조립하므로, 정적 추출로 재현 불가능한 사람의
+ * 도메인 서술 셀이 TODO 로 되돌아갑니다. 이 메서드는 표를 건드리지 않고 예시 2블록만
+ * 표 뒤에 삽입/치환하여 그 퇴행 없이 예시를 방출합니다.
+ *
+ * 삽입 위치(스캐폴더 `endpointSection` 순서와 동일):
+ * - `**요청 예시**` → `**응답 필드**` 헤딩 바로 앞
+ * - `**응답 예시**` → `**에러 응답**` 헤딩 바로 앞
+ *
+ * 이미 예시 블록이 있으면(멱등) 그 블록만 새 내용으로 치환합니다.
+ *
+ * @param string $content 기존 문서 내용
+ * @param array $exampleBlocks 라우트명 키 => 예시 블록
+ * @return array{0: string, 1: int} [갱신된 문서, 삽입/치환한 예시 블록 수]
+ */
+ public function insertExampleBlocks(string $content, array $exampleBlocks): array
+ {
+ $inserted = 0;
+
+ foreach ($exampleBlocks as $key => $blocks) {
+ $startMarker = self::GEN_START.$key.' -->';
+ $startPos = strpos($content, $startMarker);
+ if ($startPos === false) {
+ continue;
+ }
+
+ $endPos = strpos($content, self::GEN_END, $startPos);
+ if ($endPos === false) {
+ continue;
+ }
+
+ $block = substr($content, $startPos, $endPos - $startPos);
+
+ // 이 엔드포인트의 사람 서술 영역(@generated:end 뒤 ~ 다음 ### 전)에 이미 사람이 작성한
+ // `**응답 예시**` 가 있으면, 블록 안에 응답 예시 마커를 넣지 않는다(중복 헤딩 회피).
+ // 요청 예시는 항상 삽입한다.
+ $proseHasResponseExample = $this->proseHasResponseExample($content, $endPos);
+
+ // 응답 필드 표가 실측 제외 마커(사람이 채운 셀 없음)이고 이번에 실측 표가 확보됐으면
+ // 그 마커를 실측 필드 표로 갱신한다(쓰기 메서드 실측 도입분). 이미 필드 표(| 필드 | 타입 |)가
+ // 있으면 건드리지 않아 사람 서술 셀을 보존한다.
+ $updatedBlock = $block;
+
+ // 요청 파라미터 표에서 path 행과 이름이 겹치는 query 행(자동 생성 중복)을 제거한다.
+ // 라우트 바인딩 세그먼트가 FormRequest rules() 에도 있어 path/query 로 2회 출력되던 결함.
+ $updatedBlock = $this->dedupeParamRows($updatedBlock, $inserted);
+
+ if (! empty($blocks['response_fields'])) {
+ $updatedBlock = $this->refreshResponseFieldMarker($updatedBlock, $blocks['response_fields'], $inserted);
+ }
+
+ $updatedBlock = $this->applyExampleBlock($updatedBlock, '**요청 예시**', '**응답 필드**', $blocks['request'], $inserted);
+
+ if ($proseHasResponseExample) {
+ // 서술 영역에 수기 응답 예시가 있으면 블록 안 응답 예시는 두지 않는다(중복 헤딩 회피).
+ // 과거 run 이 삽입한 블록 내 응답 예시가 남아 있으면 제거한다.
+ $updatedBlock = $this->removeExampleBlock($updatedBlock, '**응답 예시**', '**에러 응답**', $inserted);
+ } else {
+ $updatedBlock = $this->applyExampleBlock($updatedBlock, '**응답 예시**', '**에러 응답**', $blocks['response'], $inserted);
+ }
+
+ if ($updatedBlock !== $block) {
+ $content = substr($content, 0, $startPos).$updatedBlock.substr($content, $endPos);
+ }
+ }
+
+ return [$content, $inserted];
+ }
+
+ /**
+ * 엔드포인트의 사람 서술 영역에 이미 사람이 작성한 `**응답 예시**` 가 있는지 확인합니다.
+ *
+ * 서술 영역 = `@generated:end` 뒤부터 다음 `### ` 헤딩(또는 문서 끝) 전까지. 여기에
+ * `**응답 예시**` 가 있으면 블록 안에 응답 예시를 삽입하지 않아 중복 헤딩을 방지한다.
+ * 파일 업로드(바이너리)·미설치 정적 예시처럼 실측 불가라 사람이 미리 채운 응답 예시가 대상.
+ *
+ * @param string $content 전체 문서 내용
+ * @param int $endPos 이 엔드포인트 `@generated:end` 마커의 시작 위치
+ * @return bool 서술 영역에 수기 응답 예시가 있으면 true
+ */
+ private function proseHasResponseExample(string $content, int $endPos): bool
+ {
+ $afterGen = substr($content, $endPos + strlen(self::GEN_END));
+
+ // 다음 ### 헤딩 전까지가 이 엔드포인트의 사람 서술 영역
+ $nextHeading = preg_match('/\n### /', $afterGen, $m, PREG_OFFSET_CAPTURE)
+ ? $m[0][1]
+ : strlen($afterGen);
+
+ $prose = substr($afterGen, 0, $nextHeading);
+
+ return Str::contains($prose, '**응답 예시**');
+ }
+
+ /**
+ * 요청 파라미터 표에서 path 행과 이름이 겹치는 query 행(자동 생성 중복)을 제거합니다.
+ *
+ * 라우트 바인딩 세그먼트가 FormRequest rules() 에도 존재하면 같은 이름이 `| 이름 | path |` 과
+ * `| 이름 | query |` 로 2회 출력되던 결함을 in-place 로 정정한다. path 행을 남기고 query 행만 제거.
+ *
+ * @param string $block `@generated` 블록 문자열
+ * @param int $inserted 정정 카운터 (참조 누적)
+ * @return string 정정된 블록
+ */
+ private function dedupeParamRows(string $block, int &$inserted): string
+ {
+ $lines = explode("\n", $block);
+
+ // path 행의 파라미터명 수집 (| name | path | ...)
+ $pathNames = [];
+ foreach ($lines as $line) {
+ if (preg_match('/^\|\s*([a-zA-Z_0-9]+)\s*\|\s*path\s*\|/', $line, $m)) {
+ $pathNames[$m[1]] = true;
+ }
+ }
+
+ if ($pathNames === []) {
+ return $block;
+ }
+
+ $out = [];
+ $removed = false;
+ foreach ($lines as $line) {
+ // 같은 이름의 query 행이면 제거 (path 행이 우선).
+ if (preg_match('/^\|\s*([a-zA-Z_0-9]+)\s*\|\s*query\s*\|/', $line, $m) && isset($pathNames[$m[1]])) {
+ $removed = true;
+
+ continue;
+ }
+ $out[] = $line;
+ }
+
+ if ($removed) {
+ $inserted++;
+ }
+
+ return implode("\n", $out);
+ }
+
+ /**
+ * 응답 필드 표가 실측 제외 마커일 때만 실측 필드 표로 치환합니다.
+ *
+ * `**응답 필드** (\`data\` 내부)` 헤딩 ~ 다음 헤딩(`**응답 예시**`/`**에러 응답**`) 사이 본문이
+ * `` 마커면 실측 표로 교체하고, 이미 표(`| 필드 | 타입 |`)가 있으면
+ * 사람 서술 셀 보존을 위해 건드리지 않는다.
+ *
+ * @param string $block `@generated` 블록 문자열
+ * @param string $fieldTable 실측 응답 필드 표
+ * @param int $inserted 치환 카운터 (참조 누적)
+ * @return string 갱신된 블록
+ */
+ private function refreshResponseFieldMarker(string $block, string $fieldTable, int &$inserted): string
+ {
+ $label = '**응답 필드** (`data` 내부)';
+ $labelPos = strpos($block, $label);
+ if ($labelPos === false) {
+ return $block;
+ }
+
+ $bodyStart = $labelPos + strlen($label);
+ // 다음 헤딩(응답 예시 또는 에러 응답) 전까지가 응답 필드 본문
+ $nextRespExample = strpos($block, '**응답 예시**', $bodyStart);
+ $nextError = strpos($block, '**에러 응답**', $bodyStart);
+ $candidates = array_filter([$nextRespExample, $nextError], fn ($v) => $v !== false);
+ if ($candidates === []) {
+ return $block;
+ }
+ $bodyEnd = min($candidates);
+
+ $body = substr($block, $bodyStart, $bodyEnd - $bodyStart);
+
+ // 사람이 채운 표(| 필드 | 타입 |)가 이미 있으면 보존 — 마커일 때만 교체.
+ if (! str_contains($body, '실측 제외')) {
+ return $block;
+ }
+
+ $inserted++;
+
+ return substr($block, 0, $bodyStart)."\n\n".$fieldTable."\n\n".substr($block, $bodyEnd);
+ }
+
+ /**
+ * `@generated` 블록 문자열에 예시 블록 하나를 삽입하거나 치환합니다.
+ *
+ * `$label`(예: `**요청 예시**`) 섹션이 이미 있으면 그 본문을 `$body` 로 치환하고,
+ * 없으면 `$anchor`(예: `**응답 필드**`) 헤딩 바로 앞에 `label + body` 를 삽입합니다.
+ *
+ * @param string $block `@generated` 블록 문자열
+ * @param string $label 예시 섹션 헤딩 (`**요청 예시**` / `**응답 예시**`)
+ * @param string $anchor 삽입 기준 헤딩 (`**응답 필드**` / `**에러 응답**`)
+ * @param string $body 예시 블록 본문 (코드블록 또는 실측 제외 마커)
+ * @param int $inserted 삽입/치환 카운터 (참조 누적)
+ * @return string 갱신된 블록 문자열
+ */
+ private function applyExampleBlock(string $block, string $label, string $anchor, string $body, int &$inserted): string
+ {
+ $section = $label."\n\n".$body."\n\n";
+ $labelLine = $label."\n";
+
+ // 이미 예시 섹션이 있으면 그 label ~ (다음 빈 줄 + 다음 헤딩) 까지를 새 섹션으로 치환한다.
+ $labelPos = strpos($block, $labelLine);
+ if ($labelPos !== false) {
+ $anchorPos = strpos($block, $anchor, $labelPos);
+ if ($anchorPos === false) {
+ return $block;
+ }
+
+ $replaced = substr($block, 0, $labelPos).$section.substr($block, $anchorPos);
+ if ($replaced !== $block) {
+ $inserted++;
+ }
+
+ return $replaced;
+ }
+
+ // 없으면 anchor 헤딩 바로 앞에 삽입.
+ $anchorPos = strpos($block, $anchor);
+ if ($anchorPos === false) {
+ return $block;
+ }
+
+ $inserted++;
+
+ return substr($block, 0, $anchorPos).$section.substr($block, $anchorPos);
+ }
+
+ /**
+ * `@generated` 블록 문자열에서 예시 섹션 하나를 제거합니다.
+ *
+ * 서술 영역에 수기 예시가 있어 블록 안 예시가 중복이 되는 경우, `$label` 섹션
+ * (`$label` ~ `$anchor` 직전)을 제거한다. 섹션이 없으면 그대로 반환한다.
+ *
+ * @param string $block `@generated` 블록 문자열
+ * @param string $label 제거할 예시 섹션 헤딩 (`**응답 예시**`)
+ * @param string $anchor 섹션 끝 기준 헤딩 (`**에러 응답**`)
+ * @param int $inserted 변경 카운터 (참조 누적)
+ * @return string 갱신된 블록 문자열
+ */
+ private function removeExampleBlock(string $block, string $label, string $anchor, int &$inserted): string
+ {
+ $labelLine = $label."\n";
+ $labelPos = strpos($block, $labelLine);
+ if ($labelPos === false) {
+ return $block;
+ }
+
+ $anchorPos = strpos($block, $anchor, $labelPos);
+ if ($anchorPos === false) {
+ return $block;
+ }
+
+ $removed = substr($block, 0, $labelPos).substr($block, $anchorPos);
+ if ($removed !== $block) {
+ $inserted++;
+ }
+
+ return $removed;
+ }
+}
diff --git a/app/Support/ApiDoc/ApiEndpointProbe.php b/app/Support/ApiDoc/ApiEndpointProbe.php
new file mode 100644
index 00000000..a50d8d4e
--- /dev/null
+++ b/app/Support/ApiDoc/ApiEndpointProbe.php
@@ -0,0 +1,216 @@
+baseUrl = rtrim($resolved, '/');
+ }
+
+ /**
+ * 실측 기준 URL 을 반환합니다.
+ *
+ * @return string 기준 URL
+ */
+ public function baseUrl(): string
+ {
+ return $this->baseUrl;
+ }
+
+ /**
+ * 실측용 관리자 토큰을 발급합니다.
+ *
+ * @param int|null $userId 토큰 발급 대상 사용자 ID (null 이면 첫 관리자)
+ * @return bool 발급 성공 여부
+ */
+ public function authenticate(?int $userId = null): bool
+ {
+ $user = $userId
+ ? User::find($userId)
+ : User::query()->orderBy('id')->first();
+
+ if (! $user) {
+ return false;
+ }
+
+ $this->user = $user;
+ $this->cleanupTokens();
+ $this->token = $user->createToken($this->tokenName)->plainTextToken;
+
+ return true;
+ }
+
+ /**
+ * 엔드포인트를 실호출하여 응답을 관측합니다.
+ *
+ * GET/HEAD 는 외부 HTTP 로 read-only 실측하고, 쓰기 메서드(POST/PUT/PATCH/DELETE)는
+ * **DB 트랜잭션 안에서 in-process 로 dispatch 한 뒤 롤백**하여(영속 안 함) 응답 shape 만
+ * 관측한다. 외부 HTTP 는 별도 프로세스라 롤백이 불가하므로 쓰기는 in-process 경로를 쓴다.
+ *
+ * @param string $method HTTP 메서드
+ * @param string $uri 라우트 URI (path 파라미터 치환 완료된 실제 경로)
+ * @param array $body 쓰기 요청 바디 (쓰기 메서드에서만 사용)
+ * @return array{ok: bool, status: int|null, body: array|null, skipped_reason: string|null}
+ */
+ public function probe(string $method, string $uri, array $body = []): array
+ {
+ $method = strtoupper($method);
+
+ // path 파라미터가 남아 있으면(치환 실패) 실호출 불가.
+ if (Str::contains($uri, '{')) {
+ return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'unresolved-path-param'];
+ }
+
+ if (! $this->token) {
+ return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'no-token'];
+ }
+
+ if (in_array($method, ['GET', 'HEAD'], true)) {
+ return $this->probeRead($method, $uri);
+ }
+
+ return $this->probeWrite($method, $uri, $body);
+ }
+
+ /**
+ * GET/HEAD 를 외부 HTTP 로 read-only 실측합니다.
+ *
+ * @param string $method HTTP 메서드
+ * @param string $uri 치환된 URI
+ * @return array{ok: bool, status: int|null, body: array|null, skipped_reason: string|null}
+ */
+ private function probeRead(string $method, string $uri): array
+ {
+ try {
+ $response = Http::withoutVerifying()
+ ->withToken($this->token)
+ ->acceptJson()
+ ->timeout(15)
+ ->get($this->baseUrl.$uri);
+
+ $json = $response->json();
+
+ return [
+ 'ok' => $response->successful() && is_array($json),
+ 'status' => $response->status(),
+ 'body' => is_array($json) ? $json : null,
+ 'skipped_reason' => null,
+ ];
+ } catch (\Throwable $e) {
+ return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'request-failed: '.$e->getMessage()];
+ }
+ }
+
+ /**
+ * 쓰기 메서드를 DB 트랜잭션 안에서 in-process dispatch 후 롤백하여 실측합니다.
+ *
+ * 응답 shape 만 관측하고 부수효과(레코드 생성/수정/삭제)는 롤백으로 영속시키지 않는다.
+ * 인증은 샘플 사용자로 로그인(guard)해 수행하며, DELETE 등 바디 없는 메서드도 처리한다.
+ *
+ * @param string $method HTTP 메서드
+ * @param string $uri 치환된 URI
+ * @param array $body 요청 바디
+ * @return array{ok: bool, status: int|null, body: array|null, skipped_reason: string|null}
+ */
+ private function probeWrite(string $method, string $uri, array $body): array
+ {
+ if (! $this->user) {
+ return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'no-token'];
+ }
+
+ DB::beginTransaction();
+
+ try {
+ // in-process dispatch 는 실제 Bearer 토큰 헤더로 인증한다(guard 종류 무관).
+ // Auth::login() 은 API 기본 guard 가 RequestGuard(토큰 전용)라 사용할 수 없다.
+ $request = Request::create($uri, $method, $body);
+ $request->headers->set('Accept', 'application/json');
+ $request->headers->set('Authorization', 'Bearer '.$this->token);
+
+ $response = app()->handle($request);
+ $content = $response->getContent();
+ $json = json_decode(is_string($content) ? $content : '', true);
+ $status = $response->getStatusCode();
+
+ return [
+ 'ok' => $status >= 200 && $status < 300 && is_array($json),
+ 'status' => $status,
+ 'body' => is_array($json) ? $json : null,
+ 'skipped_reason' => (is_array($json) && $status >= 200 && $status < 300) ? null : 'http-'.$status,
+ ];
+ } catch (\Throwable $e) {
+ return ['ok' => false, 'status' => null, 'body' => null, 'skipped_reason' => 'write-probe-failed: '.$e->getMessage()];
+ } finally {
+ // 쓰기 부수효과를 영속시키지 않는다 (문서 생성 목적).
+ DB::rollBack();
+ // 다음 실측에 인증 컨텍스트가 새지 않도록 해석된 guard 를 초기화한다
+ // (API guard 는 RequestGuard 라 logout() 이 없으므로 forgetGuards 사용).
+ Auth::forgetGuards();
+ }
+ }
+
+ /**
+ * 발급한 임시 토큰을 정리합니다.
+ */
+ public function cleanup(): void
+ {
+ $this->cleanupTokens();
+ $this->token = null;
+ }
+
+ /**
+ * 실측용 토큰 레코드를 모두 삭제합니다.
+ */
+ private function cleanupTokens(): void
+ {
+ PersonalAccessToken::query()
+ ->where('name', $this->tokenName)
+ ->delete();
+ }
+}
diff --git a/app/Support/ApiDoc/ApiRouteInventory.php b/app/Support/ApiDoc/ApiRouteInventory.php
new file mode 100644
index 00000000..001b3173
--- /dev/null
+++ b/app/Support/ApiDoc/ApiRouteInventory.php
@@ -0,0 +1,442 @@
+> 정규화된 라우트 메타데이터 목록
+ */
+ public function collect(?string $scope = null): array
+ {
+ $routes = [];
+
+ foreach (RouteFacade::getRoutes() as $route) {
+ /** @var Route $route */
+ $uri = $route->uri();
+
+ if (! Str::startsWith($uri, 'api/')) {
+ continue;
+ }
+
+ $name = $route->getName() ?? '';
+ $owner = $this->resolveOwner($name, $uri);
+
+ if ($scope !== null && ! $this->matchesScope($owner, $scope)) {
+ continue;
+ }
+
+ foreach ($this->normalizeRoute($route, $uri, $name, $owner) as $entry) {
+ $routes[] = $entry;
+ }
+ }
+
+ // route:list 는 활성 확장만 노출한다. 비활성/미설치 확장을 명시 범위로 지정하면
+ // (module:{id} / plugin:{id}) 등록된 라우트가 0건이 되므로, 그 확장의 번들 라우트
+ // 파일을 프로바이더와 동일한 prefix/name 규약으로 임시 라우터에 로드해 폴백 수집한다.
+ if ($routes === [] && $scope !== null && $scope !== 'core' && $scope !== 'all') {
+ $routes = $this->collectFromBundledFiles($scope);
+ }
+
+ usort($routes, fn ($a, $b) => [$a['owner']['key'], $a['uri'], $a['method']] <=> [$b['owner']['key'], $b['uri'], $b['method']]);
+
+ return $routes;
+ }
+
+ /**
+ * 하나의 라우트를 HTTP 메서드별 정규화 엔트리 배열로 변환합니다.
+ *
+ * @param Route $route 라우트 인스턴스
+ * @param string $uri 라우트 URI ('api/' prefix 포함)
+ * @param string $name 라우트명
+ * @param array{type: string, id: string|null, key: string} $owner 소유 주체
+ * @return array> 정규화된 엔트리 목록
+ */
+ private function normalizeRoute(Route $route, string $uri, string $name, array $owner): array
+ {
+ $entries = [];
+
+ $middleware = $this->gatherMiddleware($route);
+
+ foreach ($this->httpMethods($route) as $method) {
+ $entries[] = [
+ 'method' => $method,
+ 'uri' => '/'.ltrim($uri, '/'),
+ 'name' => $name,
+ 'owner' => $owner,
+ 'action' => $route->getActionName(),
+ 'controller' => $this->controllerClass($route),
+ 'controller_method' => $this->controllerMethod($route),
+ 'middleware' => $middleware,
+ 'permission' => $this->resolvePermission($middleware),
+ 'path_params' => $this->pathParams($uri),
+ 'path_bindings' => $this->pathBindings($route),
+ 'domain_group' => $this->domainGroup($name, $uri, $owner),
+ ];
+ }
+
+ return $entries;
+ }
+
+ /**
+ * 라우트의 미들웨어 목록을 안전하게 수집합니다.
+ *
+ * `gatherMiddleware()` 는 컨트롤러 정의 미들웨어를 읽기 위해 컨트롤러를 인스턴스화한다.
+ * 비활성/미설치 확장은 서비스 바인딩이 부팅되지 않아 인스턴스화가 실패할 수 있으므로,
+ * 실패 시 라우트에 직접 부여된 정적 미들웨어(`middleware()`)로 폴백한다.
+ *
+ * @param Route $route 라우트 인스턴스
+ * @return array 미들웨어 목록
+ */
+ private function gatherMiddleware(Route $route): array
+ {
+ try {
+ return $route->gatherMiddleware();
+ } catch (\Throwable) {
+ return $route->middleware();
+ }
+ }
+
+ /**
+ * 비활성/미설치 확장의 번들 라우트 파일을 임시 라우터에 로드해 수집합니다.
+ *
+ * 프로바이더(`ModuleRouteServiceProvider`/`PluginRouteServiceProvider`)와 동일한
+ * prefix(`api/{modules|plugins}/{id}`)·name(`api.{modules|plugins}.{id}.`)·`api`
+ * 미들웨어 그룹으로 `src/routes/api.php` 를 라우터에 로드한다. `api/` 로 시작하는
+ * 라우트만 대상이므로 web(admin) 라우트는 자동 제외된다(그 라우트는 API 문서 대상이 아니다).
+ *
+ * 라우트 파일은 `use Illuminate\Support\Facades\Route` 로 전역 파사드에 등록하므로,
+ * 등록 전 라우트 이름 스냅샷을 떠서 이번 로드로 추가된 라우트만 골라낸다. 이 커맨드는
+ * CLI 단발 프로세스라 전역 라우트 테이블 추가가 웹 요청에 영향을 주지 않는다.
+ *
+ * @param string $scope 범위 필터 (`module:{id}` / `plugin:{id}`)
+ * @return array> 정규화된 라우트 메타데이터 목록
+ */
+ private function collectFromBundledFiles(string $scope): array
+ {
+ if (! preg_match('/^(module|plugin):(.+)$/', $scope, $m)) {
+ return [];
+ }
+
+ [$type, $id] = [$m[1], $m[2]];
+ $dir = $type === 'module' ? 'modules' : 'plugins';
+ $apiRouteFile = base_path("{$dir}/_bundled/{$id}/src/routes/api.php");
+
+ if (! is_file($apiRouteFile)) {
+ return [];
+ }
+
+ $urlPrefix = "api/{$dir}/{$id}";
+ $namePrefix = "api.{$dir}.{$id}.";
+
+ try {
+ RouteFacade::prefix($urlPrefix)
+ ->name($namePrefix)
+ ->middleware('api')
+ ->group($apiRouteFile);
+ RouteFacade::getRoutes()->refreshNameLookups();
+ } catch (\Throwable) {
+ return [];
+ }
+
+ // 이 확장 소유로 확정된 라우트를 전역 라우트 테이블에서 prefix(uri/name) 로 직접 골라낸다.
+ // object-id 스냅샷 방식은 refreshNameLookups() 가 RouteCollection 을 재색인하며 객체를
+ // 다시 만들어 dedup 이 어긋나므로(활성 확장 로드 순서·중복 로드에 취약) 쓰지 않는다.
+ // 대상 확장은 uri 가 `api/{dir}/{id}/`, name 이 `api.{dir}.{id}.` 로 시작해 결정적으로 식별된다.
+ $seen = [];
+ $routes = [];
+
+ foreach (RouteFacade::getRoutes() as $route) {
+ /** @var Route $route */
+ $uri = $route->uri();
+
+ if (! Str::startsWith($uri, 'api/')) {
+ continue;
+ }
+
+ $name = $route->getName() ?? '';
+ $owner = $this->resolveOwner($name, $uri);
+
+ // 폴백 대상 확장 소유로 확정되지 않으면(코어·타 확장) 제외
+ if ($owner['key'] !== $scope) {
+ continue;
+ }
+
+ foreach ($this->normalizeRoute($route, $uri, $name, $owner) as $entry) {
+ // 파일이 이미 한 번 로드돼 있으면 같은 (method, uri) 가 중복될 수 있어 dedup 한다.
+ $dedupKey = $entry['method'].' '.$entry['uri'];
+ if (isset($seen[$dedupKey])) {
+ continue;
+ }
+ $seen[$dedupKey] = true;
+ $routes[] = $entry;
+ }
+ }
+
+ return $routes;
+ }
+
+ /**
+ * 라우트명/URI 로 소유 주체(코어/모듈/플러그인)를 판별합니다.
+ *
+ * @param string $name 라우트명
+ * @param string $uri 라우트 URI
+ * @return array{type: string, id: string|null, key: string} 소유 주체 정보
+ */
+ private function resolveOwner(string $name, string $uri): array
+ {
+ if (preg_match('/^api\.modules\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) {
+ return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]];
+ }
+
+ if (preg_match('/^api\.plugins\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) {
+ return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]];
+ }
+
+ // 라우트명이 비어도 URI prefix 로 보조 판별
+ if (preg_match('#^api/modules/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) {
+ return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]];
+ }
+
+ if (preg_match('#^api/plugins/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) {
+ return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]];
+ }
+
+ // 코어가 제공하는 확장 메타/에셋 라우트(/api/modules/{identifier}/license,
+ // /api/modules/bundle.js 등)는 실제 확장 소유가 아니라 코어 소유다.
+ return ['type' => 'core', 'id' => null, 'key' => 'core'];
+ }
+
+ /**
+ * 세그먼트가 실제 확장 식별자인지 판별합니다 (플레이스홀더/에셋 라우트 제외).
+ *
+ * @param string $segment URI/라우트명 세그먼트
+ * @return bool 실제 확장 식별자 여부
+ */
+ private function isRealExtensionId(string $segment): bool
+ {
+ // {identifier} 같은 라우트 파라미터, bundle/assets 같은 코어 에셋 라우트 제외.
+ // 실제 확장 식별자는 vendor-name 형태로 하이픈을 포함한다.
+ if (Str::startsWith($segment, '{') || in_array($segment, ['assets', 'bundle', 'bundle.js', 'bundle.css'], true)) {
+ return false;
+ }
+
+ return Str::contains($segment, '-');
+ }
+
+ /**
+ * 소유 주체가 지정된 범위 필터에 매칭되는지 확인합니다.
+ *
+ * @param array{type: string, id: string|null, key: string} $owner 소유 주체
+ * @param string $scope 범위 필터
+ * @return bool 매칭 여부
+ */
+ private function matchesScope(array $owner, string $scope): bool
+ {
+ if ($scope === 'all') {
+ return true;
+ }
+
+ if ($scope === 'core') {
+ return $owner['type'] === 'core';
+ }
+
+ return $owner['key'] === $scope;
+ }
+
+ /**
+ * 라우트의 HTTP 메서드 목록을 반환합니다 (HEAD 제외).
+ *
+ * @param Route $route 라우트 인스턴스
+ * @return array HTTP 메서드 목록
+ */
+ private function httpMethods(Route $route): array
+ {
+ return array_values(array_filter(
+ $route->methods(),
+ fn ($m) => $m !== 'HEAD'
+ ));
+ }
+
+ /**
+ * 컨트롤러 클래스명을 반환합니다.
+ *
+ * @param Route $route 라우트 인스턴스
+ * @return string|null 컨트롤러 FQCN (클로저면 null)
+ */
+ private function controllerClass(Route $route): ?string
+ {
+ $action = $route->getActionName();
+
+ if ($action === 'Closure' || ! Str::contains($action, '@')) {
+ return null;
+ }
+
+ return Str::before($action, '@');
+ }
+
+ /**
+ * 컨트롤러 메서드명을 반환합니다.
+ *
+ * @param Route $route 라우트 인스턴스
+ * @return string|null 메서드명 (클로저면 null)
+ */
+ private function controllerMethod(Route $route): ?string
+ {
+ $action = $route->getActionName();
+
+ if ($action === 'Closure' || ! Str::contains($action, '@')) {
+ return null;
+ }
+
+ return Str::after($action, '@');
+ }
+
+ /**
+ * 미들웨어 목록에서 permission 식별자를 추출합니다.
+ *
+ * @param array $middleware 미들웨어 목록
+ * @return string|null permission 식별자 (예: core.users.read)
+ */
+ private function resolvePermission(array $middleware): ?string
+ {
+ foreach ($middleware as $mw) {
+ if (! Str::contains($mw, 'PermissionMiddleware') && ! Str::startsWith($mw, 'permission:')) {
+ continue;
+ }
+
+ $args = Str::after($mw, ':');
+ // permission:admin,core.users.read,except:self:user → core.users.read
+ foreach (explode(',', $args) as $part) {
+ $part = trim($part);
+ if (Str::contains($part, '.') && ! Str::startsWith($part, 'except')) {
+ return $part;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * URI 의 path 파라미터 목록을 추출합니다.
+ *
+ * @param string $uri 라우트 URI
+ * @return array path 파라미터명 목록
+ */
+ private function pathParams(string $uri): array
+ {
+ preg_match_all('/\{([^}?]+)\??\}/', $uri, $m);
+
+ return $m[1] ?? [];
+ }
+
+ /**
+ * path 파라미터명 → 바인딩된 Eloquent 모델 클래스 맵을 추출합니다.
+ *
+ * @param Route $route 라우트 인스턴스
+ * @return array 파라미터명 => 모델 FQCN
+ */
+ private function pathBindings(Route $route): array
+ {
+ $bindings = [];
+
+ try {
+ foreach ($route->signatureParameters() as $param) {
+ $type = $param->getType();
+
+ if ($type instanceof \ReflectionNamedType && ! $type->isBuiltin()) {
+ $class = $type->getName();
+
+ if (is_subclass_of($class, Model::class)) {
+ $bindings[$param->getName()] = $class;
+ }
+ }
+ }
+ } catch (\Throwable) {
+ // signatureParameters 실패 시 빈 맵
+ }
+
+ return $bindings;
+ }
+
+ /**
+ * 문서 파일 그룹핑용 도메인 키를 산출합니다.
+ *
+ * @param string $name 라우트명
+ * @param string $uri 라우트 URI
+ * @param array{type: string, id: string|null, key: string} $owner 소유 주체
+ * @return string 도메인 키 (예: users, products)
+ */
+ private function domainGroup(string $name, string $uri, array $owner): string
+ {
+ // 라우트명 구조: api.{context}.{resource}.{action} 또는
+ // api.modules.{ext-id}.{context}.{resource}.{action}
+ // 도메인 = 앞쪽 연속된 context/소유 세그먼트를 걷어낸 뒤의 첫 세그먼트(리소스).
+ // 위치 기반이므로 'auth' 같은 리소스가 skip 리스트에 걸려 소실되지 않는다.
+ // 'modules'/'plugins' 는 확장 소유 prefix(api.modules.{id}.*)로도, 코어 확장관리
+ // 리소스(api.admin.modules.*)로도 쓰인다. 소유 prefix 는 owner['id'] 스킵으로 이미
+ // 걷히므로, leading 에는 순수 컨텍스트(api/admin/user/me/public)만 둔다.
+ $leading = ['api', 'admin', 'user', 'me', 'public'];
+ $segments = array_values(array_filter(explode('.', $name), fn ($s) => $s !== ''));
+
+ $i = 0;
+ while ($i < count($segments)) {
+ $seg = $segments[$i];
+ // 확장 소유 prefix 세그먼트(modules/plugins + ext-id)만 스킵
+ $isOwnerPrefix = in_array($seg, ['modules', 'plugins'], true) && isset($segments[$i + 1]) && $segments[$i + 1] === $owner['id'];
+ $isLeading = in_array($seg, $leading, true) || $isOwnerPrefix || ($owner['id'] !== null && $seg === $owner['id']);
+
+ if (! $isLeading) {
+ break;
+ }
+ $i++;
+ }
+
+ // context(me 등) 직후가 REST 액션명이면 리소스가 아니라 그 컨텍스트가 도메인이다
+ // (api.me.show → 'me', api.me.destroy → 'me'). 아니면 그 세그먼트가 리소스.
+ $restActions = ['show', 'index', 'store', 'update', 'destroy', 'edit', 'create'];
+ if (isset($segments[$i])) {
+ if (in_array($segments[$i], $restActions, true) && $i > 0 && in_array($segments[$i - 1], $leading, true)) {
+ return Str::slug($segments[$i - 1]) ?: 'misc';
+ }
+
+ return Str::slug($segments[$i]) ?: 'misc';
+ }
+
+ // 리소스 세그먼트가 없으면(context 직속) 마지막 컨텍스트를 도메인으로.
+ if ($i > 0 && isset($segments[$i - 1]) && in_array($segments[$i - 1], $leading, true)) {
+ return Str::slug($segments[$i - 1]) ?: 'misc';
+ }
+
+ // 라우트명이 비었으면 URI 에서 소유/컨텍스트 prefix 를 걷어낸 첫 리소스 세그먼트
+ $parts = array_values(array_filter(explode('/', $uri), fn ($p) => $p !== '' && ! Str::startsWith($p, '{')));
+ $uriLeading = ['api', 'admin', 'user', 'me', 'public'];
+ foreach ($parts as $p) {
+ if (in_array($p, $uriLeading, true) || ($owner['id'] !== null && $p === $owner['id'])) {
+ continue;
+ }
+ if (in_array($p, ['modules', 'plugins'], true) && ($owner['id'] !== null)) {
+ continue;
+ }
+
+ return Str::slug($p) ?: 'misc';
+ }
+
+ return 'misc';
+ }
+}
diff --git a/app/Support/ApiDoc/ColumnCommentResolver.php b/app/Support/ApiDoc/ColumnCommentResolver.php
new file mode 100644
index 00000000..58d95edf
--- /dev/null
+++ b/app/Support/ApiDoc/ColumnCommentResolver.php
@@ -0,0 +1,71 @@
+> 테이블별 컬럼 주석 캐시
+ */
+ private array $cache = [];
+
+ /**
+ * 모델 테이블의 컬럼명 => 주석 맵을 반환합니다.
+ *
+ * @param class-string|null $modelClass 모델 FQCN
+ * @return array 컬럼명 => 주석
+ */
+ public function forModel(?string $modelClass): array
+ {
+ if (! $modelClass || ! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) {
+ return [];
+ }
+
+ try {
+ $table = (new $modelClass)->getTable();
+ } catch (\Throwable) {
+ return [];
+ }
+
+ return $this->forTable($table);
+ }
+
+ /**
+ * 테이블의 컬럼명 => 주석 맵을 반환합니다 (캐시).
+ *
+ * @param string $table 테이블명
+ * @return array 컬럼명 => 주석
+ */
+ public function forTable(string $table): array
+ {
+ if (isset($this->cache[$table])) {
+ return $this->cache[$table];
+ }
+
+ $map = [];
+
+ try {
+ foreach (Schema::getColumns($table) as $column) {
+ $comment = $column['comment'] ?? null;
+
+ if (is_string($comment) && $comment !== '') {
+ $map[$column['name']] = $comment;
+ }
+ }
+ } catch (\Throwable) {
+ // 테이블 부재/드라이버 미지원 시 빈 맵
+ }
+
+ return $this->cache[$table] = $map;
+ }
+}
diff --git a/app/Support/ApiDoc/FormRequestIntrospector.php b/app/Support/ApiDoc/FormRequestIntrospector.php
new file mode 100644
index 00000000..5c52cfc4
--- /dev/null
+++ b/app/Support/ApiDoc/FormRequestIntrospector.php
@@ -0,0 +1,200 @@
+>, hook_filters: array}
+ */
+ public function introspect(?string $controller, ?string $method): array
+ {
+ $empty = ['request_class' => null, 'params' => [], 'hook_filters' => []];
+
+ if (! $controller || ! $method || ! class_exists($controller)) {
+ return $empty;
+ }
+
+ try {
+ $ref = new ReflectionMethod($controller, $method);
+ } catch (\ReflectionException) {
+ return $empty;
+ }
+
+ $requestClass = $this->findFormRequestParam($ref);
+
+ if (! $requestClass) {
+ return $empty;
+ }
+
+ $rules = $this->extractRules($requestClass);
+
+ return [
+ 'request_class' => $requestClass,
+ 'params' => $this->rulesToParams($rules),
+ 'hook_filters' => $this->extractHookFilters($requestClass),
+ ];
+ }
+
+ /**
+ * 메서드 파라미터 중 FormRequest 하위 클래스를 찾습니다.
+ *
+ * @param ReflectionMethod $ref 메서드 리플렉션
+ * @return class-string|null FormRequest FQCN
+ */
+ private function findFormRequestParam(ReflectionMethod $ref): ?string
+ {
+ foreach ($ref->getParameters() as $param) {
+ $type = $param->getType();
+
+ if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) {
+ continue;
+ }
+
+ $class = $type->getName();
+
+ if (is_subclass_of($class, FormRequest::class)) {
+ return $class;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * FormRequest 인스턴스를 만들어 rules() 를 호출합니다.
+ *
+ * @param class-string $requestClass FormRequest FQCN
+ * @return array 검증 규칙 배열
+ */
+ private function extractRules(string $requestClass): array
+ {
+ try {
+ $instance = new $requestClass;
+
+ if (! method_exists($instance, 'rules')) {
+ return [];
+ }
+
+ $rules = $instance->rules();
+
+ return is_array($rules) ? $rules : [];
+ } catch (\Throwable) {
+ // rules() 가 컨테이너/route 의존이면 정적 호출 실패 — 파라미터 표는 비운다.
+ return [];
+ }
+ }
+
+ /**
+ * 검증 규칙 배열을 문서용 파라미터 메타데이터로 변환합니다.
+ *
+ * @param array $rules 검증 규칙 배열
+ * @return array> 파라미터 메타데이터 목록
+ */
+ private function rulesToParams(array $rules): array
+ {
+ $params = [];
+
+ foreach ($rules as $field => $rule) {
+ // 중첩 필드(items.*.id)는 상위만 대표로 노출
+ if (str_contains((string) $field, '.')) {
+ continue;
+ }
+
+ $tokens = is_array($rule) ? $rule : explode('|', (string) $rule);
+ $tokens = array_map(fn ($t) => is_string($t) ? $t : '', $tokens);
+
+ $params[] = [
+ 'name' => $field,
+ 'type' => $this->inferType($tokens),
+ 'required' => in_array('required', $tokens, true),
+ 'allowed' => $this->inferAllowed($tokens),
+ ];
+ }
+
+ return $params;
+ }
+
+ /**
+ * 규칙 토큰에서 파라미터 타입을 유추합니다.
+ *
+ * @param array $tokens 규칙 토큰 목록
+ * @return string 유추된 타입
+ */
+ private function inferType(array $tokens): string
+ {
+ foreach (['integer', 'numeric', 'boolean', 'array', 'string', 'date', 'email', 'file', 'image', 'uuid'] as $type) {
+ foreach ($tokens as $t) {
+ if ($t === $type || str_starts_with($t, $type)) {
+ return $type === 'numeric' ? 'number' : $type;
+ }
+ }
+ }
+
+ return 'string';
+ }
+
+ /**
+ * 규칙 토큰에서 허용값(in:, max:, min: 등)을 유추합니다.
+ *
+ * @param array $tokens 규칙 토큰 목록
+ * @return string 허용값 설명 (없으면 빈 문자열)
+ */
+ private function inferAllowed(array $tokens): string
+ {
+ $parts = [];
+
+ foreach ($tokens as $t) {
+ if (str_starts_with($t, 'in:')) {
+ $parts[] = '`'.str_replace(',', '`, `', substr($t, 3)).'`';
+ } elseif (str_starts_with($t, 'max:')) {
+ $parts[] = 'max '.substr($t, 4);
+ } elseif (str_starts_with($t, 'min:')) {
+ $parts[] = 'min '.substr($t, 4);
+ } elseif (str_starts_with($t, 'between:')) {
+ $parts[] = 'between '.substr($t, 8);
+ }
+ }
+
+ return implode(', ', $parts);
+ }
+
+ /**
+ * FormRequest 소스에서 HookManager::applyFilters 훅 이름을 추출합니다.
+ *
+ * @param class-string $requestClass FormRequest FQCN
+ * @return array 훅 필터 이름 목록
+ */
+ private function extractHookFilters(string $requestClass): array
+ {
+ try {
+ $file = (new ReflectionClass($requestClass))->getFileName();
+
+ if (! $file || ! is_readable($file)) {
+ return [];
+ }
+
+ $source = file_get_contents($file);
+ preg_match_all('/applyFilters\(\s*[\'"]([a-z0-9_.-]+)[\'"]/i', $source, $m);
+
+ return array_values(array_unique($m[1] ?? []));
+ } catch (\Throwable) {
+ return [];
+ }
+ }
+}
diff --git a/app/Support/ApiDoc/ParameterDescriber.php b/app/Support/ApiDoc/ParameterDescriber.php
new file mode 100644
index 00000000..cf96712d
--- /dev/null
+++ b/app/Support/ApiDoc/ParameterDescriber.php
@@ -0,0 +1,394 @@
+ 정확 이름 => 설명 (위치 무관 공통 파라미터)
+ */
+ private const EXACT = [
+ // 페이지네이션
+ 'page' => '조회할 페이지 번호 (1부터 시작)',
+ 'per_page' => '페이지당 항목 수',
+ 'limit' => '반환할 최대 항목 수',
+ 'offset' => '건너뛸 항목 수 (오프셋 페이지네이션)',
+ 'cursor' => '커서 기반 페이지네이션의 다음 페이지 커서',
+
+ // 정렬
+ 'sort' => '정렬 기준 (필드명, `-` 접두 시 내림차순)',
+ 'sort_by' => '정렬 기준 필드명',
+ 'sort_field' => '정렬 기준 필드명',
+ 'sort_direction' => '정렬 방향 (asc / desc)',
+ 'order_by' => '정렬 기준 필드명',
+ 'direction' => '정렬 방향 (asc / desc)',
+ // sort_order / order 는 문맥에 따라 정렬 방향(문자열 asc/desc)과
+ // 표시 순서 값(정수)으로 갈리므로 EXACT 에 두지 않고 describe() 에서
+ // 타입으로 분기한다.
+
+ // 검색/필터
+ 'search' => '검색어 (지정한 검색 대상 필드에서 부분 일치)',
+ 'q' => '검색어 (부분 일치)',
+ 'keyword' => '검색 키워드 (부분 일치)',
+ 'search_keyword' => '검색 키워드 (부분 일치)',
+ 'search_field' => '검색 대상 필드명 (검색어를 적용할 컬럼)',
+ 'search_type' => '검색 유형 (검색 대상/방식 구분)',
+ 'filters' => '추가 필터 조건 맵 (필드별 조건)',
+ 'filter' => '필터 조건',
+ 'start_date' => '조회 기간 시작일 (이 날짜 이후 데이터)',
+ 'end_date' => '조회 기간 종료일 (이 날짜 이전 데이터)',
+ 'date_from' => '조회 기간 시작일',
+ 'date_to' => '조회 기간 종료일',
+ 'from' => '조회 시작 값 (기간/범위 하한)',
+ 'to' => '조회 종료 값 (기간/범위 상한)',
+ 'scope' => '조회 범위 한정 키',
+
+ // 상태 토글/플래그
+ 'is_active' => '활성 여부 (true 활성 / false 비활성)',
+ 'is_default' => '기본값 지정 여부',
+ 'active' => '활성 여부',
+ 'published' => '발행 여부 (발행된 항목만 필터)',
+ 'enabled' => '사용 여부',
+ 'force' => '강제 실행 여부 (안전 확인/선행 검사 우회)',
+ 'with_trashed' => '소프트 삭제된 항목 포함 여부',
+ 'only_trashed' => '소프트 삭제된 항목만 조회 여부',
+
+ // 대량 처리
+ 'ids' => '대상 리소스 식별자 배열 (대량 작업 대상)',
+ 'items' => '처리 대상 항목 배열',
+
+ // 국제화
+ 'locale' => '로케일 코드 (표시 언어/지역)',
+ 'language' => '언어 코드',
+ 'country_code' => '국가 코드 (ISO 3166-1 alpha-2)',
+ 'timezone' => '타임존 식별자',
+
+ // 인증/보안 공통
+ 'password' => '비밀번호',
+ 'current_password' => '현재 비밀번호 (변경 전 확인용)',
+ 'password_confirmation' => '비밀번호 확인 (password 와 일치해야 함)',
+ 'token' => '인증/검증 토큰',
+ 'email' => '이메일 주소',
+
+ // 주소 공통
+ 'zipcode' => '우편번호',
+ 'address' => '기본 주소',
+ 'address_detail' => '상세 주소',
+ 'recipient_name' => '수령인 이름',
+ 'recipient_phone' => '수령인 연락처',
+ 'address_line_1' => '주소 1행 (기본 주소)',
+ 'address_line_2' => '주소 2행 (상세 주소)',
+ 'intl_city' => '도시 (국제 주소)',
+ 'intl_state' => '주/도 (국제 주소)',
+ 'intl_postal_code' => '우편번호 (국제 주소)',
+ 'region' => '지역/권역',
+
+ // SEO 메타 공통 (근거: Seo\* / Page\* / Product\* FormRequest 의
+ // meta_title/meta_description — 검색엔진 노출용 메타 태그 값. 도메인 무관.)
+ 'meta_title' => 'SEO 메타 제목 (검색엔진/소셜 공유 표시 제목)',
+ 'meta_description' => 'SEO 메타 설명 (검색엔진/소셜 공유 표시 요약)',
+ 'alt_text' => '이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구)',
+
+ // 프로필/콘텐츠 공통 필드 (User/프로필/일반 리소스에서 의미 고정)
+ // 근거: User\{Create,Update}UserRequest, UpdateProfileRequest, Auth\RegisterRequest,
+ // Layout\* / Menu\* / Notification* / Schedule\* FormRequest
+ 'name' => '대상의 이름/명칭',
+ 'nickname' => '닉네임',
+ 'description' => '설명',
+ 'content' => '본문 내용',
+ 'body' => '본문',
+ 'subject' => '제목',
+ 'title' => '제목',
+ 'slug' => 'URL 친화 식별자 (slug)',
+ 'label' => '표시용 라벨',
+ 'phone' => '전화번호',
+ 'mobile' => '휴대전화 번호',
+ 'homepage' => '홈페이지 URL',
+ 'bio' => '자기소개',
+ 'signature' => '서명',
+ 'country' => '국가 코드 (ISO 3166-1 alpha-2)',
+ 'url' => 'URL',
+ 'file' => '업로드 파일',
+ 'files' => '업로드 파일 배열',
+ 'collection' => '첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default)',
+ 'avatar' => '아바타 이미지',
+ 'icon' => '아이콘',
+ 'value' => '값',
+ 'values' => '값 배열',
+ 'username' => '사용자명 (로그인/인증 아이디)',
+ 'path' => '경로',
+ 'data' => '데이터 페이로드',
+
+ // 확장/버전 공통 (module/plugin/template/language-pack 설치·업데이트 계약)
+ // 근거: Module/Plugin/Template/LanguagePack Install·Update·Activate FormRequest,
+ // Extension\ChangelogRequest, Menu/Schedule/Notification* Request
+ 'extension_type' => '확장 유형 (core/module/plugin/template)',
+ 'extension_identifier' => '확장 식별자',
+ 'from_version' => '시작 버전 (범위 하한)',
+ 'to_version' => '대상 버전 (범위 상한)',
+ 'github_url' => 'GitHub 저장소 URL',
+ 'vendor' => '벤더명 (확장 제작자 식별자)',
+ 'vendor_mode' => '벤더 설치 모드 (auto/composer/bundled)',
+ 'checksum' => '무결성 검증 체크섬 (SHA-256)',
+ 'target_identifier' => '대상 확장 식별자',
+ 'source_identifier' => '출처 식별자',
+ 'auto_activate' => '설치 후 자동 활성화 여부',
+ 'cascade' => '연쇄 처리 여부 (의존 항목 함께 처리)',
+ 'exclude_protected' => '보호 항목 제외 여부',
+
+ // 스케줄/작업 공통 (근거: Schedule\{Create,Update}ScheduleRequest, ScheduleListRequest)
+ 'command' => '실행할 아티즌 커맨드',
+ 'frequency' => '실행 주기',
+ 'priority' => '우선순위 (작을수록 우선)',
+ 'timeout' => '타임아웃 (초)',
+ 'run_in_maintenance' => '점검 모드 중 실행 여부',
+ 'without_overlapping' => '중복 실행 방지 여부',
+ 'expected_lock_version' => '낙관적 잠금 버전 (동시 편집 충돌 감지)',
+
+ // 메일/드라이버 설정 (근거: Settings\SaveSettingsRequest,
+ // Settings\TestMailRequest, Settings\TestDriverConnectionRequest)
+ 'mailer' => '메일 발송 드라이버 (smtp/mailgun/ses)',
+ 'from_address' => '발신자 주소',
+ 'from_name' => '발신자 이름',
+ 'to_email' => '테스트 수신 주소',
+ 'host' => '호스트 주소',
+ 'port' => '포트 번호',
+ 'encryption' => '전송 암호화 방식 (tls/ssl)',
+ 'storage_driver' => '스토리지 드라이버 (local/s3)',
+ 'cache_driver' => '캐시 드라이버 (file/redis/memcached)',
+ 'session_driver' => '세션 드라이버 (file/database/redis)',
+ 'queue_driver' => '큐 드라이버 (sync/database/redis)',
+ 'redis_host' => 'Redis 호스트 주소',
+ 'redis_port' => 'Redis 포트 번호',
+ 'redis_password' => 'Redis 비밀번호',
+ 'redis_database' => 'Redis 데이터베이스 번호',
+ 'memcached_host' => 'Memcached 호스트 주소',
+ 'memcached_port' => 'Memcached 포트 번호',
+ 's3_bucket' => 'S3 버킷명',
+ 's3_region' => 'S3 리전',
+ 's3_access_key' => 'S3 액세스 키',
+ 's3_secret_key' => 'S3 시크릿 키',
+ 's3_url' => 'S3 엔드포인트 URL',
+ 'ses_key' => 'SES 액세스 키',
+ 'ses_secret' => 'SES 시크릿 키',
+ 'ses_region' => 'SES 리전',
+ 'mailgun_domain' => 'Mailgun 도메인',
+ 'mailgun_secret' => 'Mailgun 시크릿 키',
+ 'mailgun_endpoint' => 'Mailgun 엔드포인트',
+ 'websocket_enabled' => 'WebSocket 사용 여부',
+ 'websocket_host' => 'WebSocket 호스트 주소',
+ 'websocket_port' => 'WebSocket 포트 번호',
+ 'websocket_scheme' => 'WebSocket 스킴 (http/https)',
+ 'websocket_app_key' => 'WebSocket 앱 키',
+ ];
+
+ /**
+ * 파라미터 설명을 반환합니다. 없으면 null (호출자가 TODO 로 폴백).
+ *
+ * @param string $name 파라미터명
+ * @param string $location 위치 (path/query/body)
+ * @param string $type 타입 (integer/string/boolean/array...)
+ * @return string|null 설명 (없으면 null)
+ */
+ public function describe(string $name, string $location = '', string $type = ''): ?string
+ {
+ // sort_order / order 는 타입에 따라 의미가 갈린다:
+ // - 문자열: 정렬 방향(asc/desc)
+ // - 정수: 표시 정렬 순서 값(작을수록 우선 — 컬럼 값)
+ if (in_array($name, ['sort_order', 'order'], true)) {
+ return match ($type) {
+ 'integer', 'number' => '표시 정렬 순서 값 (작을수록 우선)',
+ 'string' => '정렬 방향 (asc 오름차순 / desc 내림차순)',
+ default => null,
+ };
+ }
+
+ // status / type / category 는 query(목록 조회)에서만 필터 의미가 고정된다.
+ // body(생성/수정)에서는 설정할 도메인 값이므로 의미가 도메인마다 달라
+ // 사람 서술(TODO)로 남긴다.
+ if (in_array($name, ['status', 'type', 'category'], true)) {
+ if ($location !== 'query') {
+ return null;
+ }
+ $label = ['status' => '상태', 'type' => '유형', 'category' => '분류'][$name];
+
+ return "{$label} 필터 (해당 {$label}의 항목만 조회)";
+ }
+
+ if (isset(self::EXACT[$name])) {
+ return self::EXACT[$name];
+ }
+
+ // path 파라미터는 대부분 리소스 식별자 — 위치를 근거로 유추.
+ if ($location === 'path') {
+ return $this->describePathParam($name);
+ }
+
+ return $this->byPattern($name, $type);
+ }
+
+ /**
+ * path 파라미터(리소스 식별자)의 설명을 유추합니다.
+ *
+ * @param string $name path 파라미터명
+ * @return string|null 설명 (미매칭 시 null)
+ */
+ private function describePathParam(string $name): ?string
+ {
+ // 순수 id / *_id / *Id: 대상 리소스의 식별자
+ if ($name === 'id') {
+ return '대상 리소스의 식별자';
+ }
+ if (Str::endsWith($name, '_id')) {
+ $base = $this->humanize(Str::beforeLast($name, '_id'));
+
+ return "대상 {$base}의 식별자";
+ }
+ if (Str::endsWith($name, 'Id') && $name !== 'Id') {
+ $base = $this->humanizeCamel(Str::beforeLast($name, 'Id'));
+
+ return "대상 {$base}의 식별자";
+ }
+
+ // slug / identifier / hash / uuid: 리소스 지시 키
+ if (in_array($name, ['slug', 'identifier', 'hash', 'uuid', 'code'], true)) {
+ $labels = [
+ 'slug' => '대상 리소스의 slug (URL 친화 식별자)',
+ 'identifier' => '대상 리소스의 식별자',
+ 'hash' => '대상 리소스의 해시 식별자',
+ 'uuid' => '대상 리소스의 UUID',
+ 'code' => '대상 리소스의 코드',
+ ];
+
+ return $labels[$name];
+ }
+
+ // *Name (templateName, pluginName, moduleName): 확장/리소스 이름 식별자
+ if (Str::endsWith($name, 'Name') && $name !== 'Name') {
+ $base = $this->humanizeCamel(Str::beforeLast($name, 'Name'));
+
+ return "대상 {$base}의 이름 (식별자)";
+ }
+
+ // *Identifier (templateIdentifier 등): 확장/리소스 식별자
+ if (Str::endsWith($name, 'Identifier') && $name !== 'Identifier') {
+ $base = $this->humanizeCamel(Str::beforeLast($name, 'Identifier'));
+
+ return "대상 {$base}의 식별자";
+ }
+
+ // bare 리소스명 path 파라미터: Laravel route-model binding 은
+ // `/{user}`, `/{role}`, `/{definition}` 처럼 대상 모델의 단수형(또는
+ // camelCase)을 그대로 세그먼트로 쓴다. 접미 패턴(_id/slug/*Id 등)에
+ // 걸리지 않은 path 파라미터는 이 바인딩 대상 리소스의 식별자로 본다.
+ // 예외: key/version 은 리소스가 아니라 설정 키/버전 값이므로 EXACT 폴백.
+ $bareExact = [
+ 'key' => '대상 설정/항목의 키',
+ 'version' => '대상 버전 (버전 문자열)',
+ ];
+ if (isset($bareExact[$name])) {
+ return $bareExact[$name];
+ }
+ $base = str_contains($name, '_')
+ ? $this->humanize($name)
+ : $this->humanizeCamel($name);
+
+ return "대상 {$base}의 식별자";
+ }
+
+ /**
+ * query/body 파라미터의 일관된 명명 규칙으로 설명을 유추합니다.
+ *
+ * @param string $name 파라미터명
+ * @param string $type 타입
+ * @return string|null 설명 (미매칭 시 null)
+ */
+ private function byPattern(string $name, string $type): ?string
+ {
+ // identifier: 확장/리소스 지시 식별자 (query/body).
+ // path 위치는 describePathParam 이 먼저 처리하므로 여기 도달하지 않는다.
+ if ($name === 'identifier') {
+ return '대상 확장/리소스의 식별자';
+ }
+
+ // *_name: 확장/리소스 이름 식별자 (template_name/plugin_name/module_name/layout_name 등).
+ // EXACT 의 recipient_name/from_name 은 여기 도달 전에 이미 처리된다.
+ if (Str::endsWith($name, '_name')) {
+ $base = $this->humanize(Str::beforeLast($name, '_name'));
+
+ return "{$base} 이름 (식별자)";
+ }
+
+ // *_id: 연관 리소스 식별자 참조
+ if (Str::endsWith($name, '_id')) {
+ $base = $this->humanize(Str::beforeLast($name, '_id'));
+
+ return "{$base} 식별자";
+ }
+
+ // *_ids: 연관 리소스 식별자 배열
+ if (Str::endsWith($name, '_ids')) {
+ $base = $this->humanize(Str::beforeLast($name, '_ids'));
+
+ return "{$base} 식별자 배열";
+ }
+
+ // is_*/has_*: 불리언 토글
+ if ((Str::startsWith($name, 'is_') || Str::startsWith($name, 'has_')) && $type === 'boolean') {
+ $base = $this->humanize(Str::after($name, '_'));
+
+ return "{$base} 여부";
+ }
+
+ // *_date: 날짜 값
+ if (Str::endsWith($name, '_date')) {
+ $base = $this->humanize(Str::beforeLast($name, '_date'));
+
+ return "{$base} 날짜";
+ }
+
+ // *_at: 일시 값
+ if (Str::endsWith($name, '_at')) {
+ $base = $this->humanize(Str::beforeLast($name, '_at'));
+
+ return "{$base} 일시";
+ }
+
+ return null;
+ }
+
+ /**
+ * snake_case 를 사람이 읽는 문구로 변환합니다.
+ *
+ * @param string $token snake_case 토큰
+ * @return string 공백 구분 문구
+ */
+ private function humanize(string $token): string
+ {
+ return str_replace('_', ' ', $token);
+ }
+
+ /**
+ * camelCase 를 사람이 읽는 문구로 변환합니다.
+ *
+ * @param string $token camelCase 토큰
+ * @return string 공백 구분 소문자 문구
+ */
+ private function humanizeCamel(string $token): string
+ {
+ return Str::lower(trim(preg_replace('/([A-Z])/', ' $1', $token)));
+ }
+}
diff --git a/app/Support/ApiDoc/ResourceFieldDescriber.php b/app/Support/ApiDoc/ResourceFieldDescriber.php
new file mode 100644
index 00000000..c11963d3
--- /dev/null
+++ b/app/Support/ApiDoc/ResourceFieldDescriber.php
@@ -0,0 +1,297 @@
+ 정확 필드명 => 설명 (BaseApiResource/공통 Resource 계약)
+ */
+ private const EXACT = [
+ // BaseApiResource 표준 메타 (resourceMeta)
+ 'is_owner' => '현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타)',
+ 'abilities' => '현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반)',
+
+ // 공통 식별/타임스탬프
+ 'id' => '기본 키 (내부 식별자)',
+ 'uuid' => '외부 노출용 UUID (URL/API 식별자, 내부 id 비노출)',
+ 'created_at' => '생성 일시',
+ 'updated_at' => '최종 수정 일시',
+ 'deleted_at' => '소프트 삭제 일시 (미삭제 시 null)',
+
+ // 공통 콘텐츠/표시 필드 (도메인 무관하게 역할이 고정 — 요청 파라미터
+ // ParameterDescriber 의 name/title/content/description 대응물).
+ // 근거: 실측 시 board 게시글·category·brand·notification 등 전 도메인에서
+ // 동일 역할(명칭/제목/본문/설명)로 관측. 다국어(object)/문자열 무관.
+ 'name' => '대상의 이름/명칭 (다국어 필드는 로케일별 값 객체)',
+ 'title' => '제목',
+ 'content' => '본문 내용',
+ 'description' => '설명 (다국어 필드는 로케일별 값 객체)',
+ 'slug' => 'URL 친화 식별자 (slug)',
+ 'label' => '표시용 라벨',
+ 'icon' => '아이콘 식별자 (아이콘 클래스/이름)',
+ 'thumbnail' => '썸네일 이미지 URL/경로',
+ 'ip_address' => '요청/행위가 발생한 IP 주소',
+
+ // User 계약 파생 필드 (UserResource)
+ 'status_label' => '상태의 사람이 읽는 라벨 (상태 Enum label() 산물)',
+ 'status_variant' => '상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용)',
+ 'language_label' => '언어 코드의 현지화 라벨 (user.language.{code} 번역)',
+ 'country_flag' => '국가 코드의 국기 이모지 (country 값에서 파생)',
+ 'country_name' => '국가 코드의 현지화 국가명 (country 값에서 파생)',
+ 'is_admin' => '관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생)',
+
+ // 확장 상태 계약 필드 (ModuleResource/PluginResource/TemplateResource)
+ 'is_bundled' => '코어에 선탑재된 번들 확장인지 여부',
+ 'is_pending' => '_pending 대기소에 있어 설치 대기 중인지 여부',
+ 'update_available' => '최신 버전 대비 업데이트 가능 여부',
+ 'update_source' => '업데이트 감지 출처 (github, bundled 등)',
+ 'latest_version' => '감지된 최신 배포 버전',
+ 'file_version' => '설치된 파일의 manifest 버전',
+ 'incompatible_required_version' => '요구 코어 버전 미충족 시 필요한 버전 (호환되면 null)',
+ 'status_variant_label' => '상태 표시용 라벨/변형',
+ 'dependencies' => '의존하는 확장 맵 (manifest 파생 — {modules, plugins})',
+ 'assets' => '프론트엔드 에셋 매니페스트 (manifest 파생 — js/css 진입점·로딩 전략)',
+
+ // 관계/연관 객체 (여러 Resource 에서 동일 계약)
+ 'creator' => '생성자 정보 객체 (uuid/name/email — creator 관계 파생)',
+ 'children' => '하위 항목 배열 (계층 트리 — children 관계 파생)',
+ 'parent' => '상위 항목 객체 (parent 관계 파생)',
+ 'permissions' => '연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생)',
+ 'recipient' => '수신자 사용자 객체 (uuid/name/email — recipientUser 관계 파생)',
+ 'sender' => '발신자 사용자 객체 (uuid/name/email — senderUser 관계 파생)',
+ 'author' => '작성자 사용자 객체 (uuid/name — author 관계 파생)',
+ 'actor_name' => '행위를 수행한 주체(사용자/시스템)의 이름',
+
+ // 시스템/집계 공통 (DashboardService/UserRepository/SettingsService)
+ 'total' => '전체 개수 (집계)',
+ 'total_users' => '전체 사용자 수 (통계 객체는 count/추이 포함)',
+ 'time' => '상대 시각 표시 (예: "24초 전" — diffForHumans() 산물)',
+ 'server_time' => '서버 현재 시각 (Y-m-d H:i:s)',
+ 'number' => '목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생)',
+
+ // 로케일 (TemplateResource/LocaleController)
+ 'locales' => '활성 로케일 코드 배열',
+ 'locale_names' => '로케일 코드별 표시명 맵 (config app.locale_names)',
+
+ // 확장/버전 공통 (Module/Plugin/Template/LanguagePack Resource, 확장 Manager)
+ 'extension_type' => '이 리소스를 소유한 확장의 타입 (core/module/plugin/template)',
+ 'extension_identifier' => '이 리소스를 소유한 확장의 식별자',
+ 'extension_name' => '이 리소스를 소유한 확장의 표시 이름 (manifest name)',
+ 'github_url' => 'GitHub 저장소 URL (manifest 파생)',
+ 'github_changelog_url' => 'GitHub 변경 이력(CHANGELOG) URL (manifest 파생)',
+ 'changelog' => '변경 이력 텍스트 (원격/파일 CHANGELOG 본문)',
+ 'current_core_version' => '현재 설치된 코어 버전',
+ 'installed_modules' => '설치된 모듈 집계 객체 (total/active)',
+ 'installed_templates' => '설치된 템플릿 집계 객체 (total/active)',
+ 'active_plugins' => '활성 플러그인 집계 객체 (total/active)',
+ 'bundled_identifier' => '대응하는 번들 확장 식별자 (번들 원본 매칭용)',
+ 'origin' => '출처 (설치/등록 원천 구분 값)',
+ 'target_name' => '대상 확장의 표시 이름 (scope+target_identifier 로 해석)',
+ 'install_blocked_reason' => '설치가 차단된 사유 (차단 없으면 null)',
+
+ // 감사/관계 공통 (도메인 무관하게 역할 고정 — 쓰기 응답에서 흔히 등장)
+ 'updated_by' => '최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null)',
+ 'creator' => '생성자 정보 객체 (uuid/name/email — creator 관계 파생)',
+ 'user' => '대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생)',
+ 'roles' => '보유 역할 목록 (각 원소 id/name/permissions — roles 관계 파생)',
+ 'permissions' => '권한 목록 (각 원소 identifier/name — permissions 관계 파생)',
+ 'avatar' => '아바타 이미지 URL (미등록 시 null)',
+
+ // 인증/토큰 (로그인/토큰 발급 응답)
+ 'token' => '발급된 API 접근 토큰 평문 (Bearer 토큰으로 사용, 발급 시 1회만 노출)',
+ 'token_type' => '토큰 타입 (일반적으로 Bearer)',
+ 'trigger_type' => '동작을 유발한 방식/주체 구분 값',
+
+ // 스케줄/실행 이력 (Schedule 실행 결과)
+ 'last_duration' => '마지막 실행 소요 시간 (초/밀리초 — 실행 이력 파생)',
+ 'duration' => '실행 소요 시간 (초/밀리초)',
+ 'exit_code' => '실행 종료 코드 (0=성공, 그 외=실패)',
+ 'output' => '실행 표준 출력 내용',
+ 'error_output' => '실행 표준 에러 출력 내용 (없으면 빈 문자열/null)',
+ 'memory_usage' => '실행 중 최대 메모리 사용량 (바이트)',
+
+ // 캐시/파일/집계 (일회성 동작 응답)
+ 'ttl' => '캐시 유효 시간 (Time To Live, 초)',
+ 'size_bytes' => '크기 (바이트)',
+ 'older_than_days' => '기준 경과 일수 (이 일수보다 오래된 대상 필터/집계)',
+ 'entries' => '항목 목록 (각 원소는 대상 도메인 레코드)',
+ 'changelog_entries' => '변경 이력 항목 목록 (버전별 변경 내용)',
+ 'templates' => '템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생)',
+ 'spec' => '스펙 정의 객체 (편집기/컴포넌트 선언 스키마 등)',
+ 'source_meta' => '원천 메타데이터 객체 (출처·경로·해석 정보)',
+ 'validation_summary' => '검증 결과 요약 객체 (통과/실패 건수 등)',
+ ];
+
+ /**
+ * 필드명에 대한 설명을 반환합니다. 없으면 null (호출자가 컬럼 주석/TODO 로 폴백).
+ *
+ * @param string $field 필드명
+ * @param string $type 실측 타입 (boolean/integer/string/object/array...)
+ * @return string|null 설명 (없으면 null)
+ */
+ public function describe(string $field, string $type = ''): ?string
+ {
+ // sort_order 는 응답에서 표시 정렬 순서 값(정수 컬럼)으로 고정된다.
+ // 문자열이면 정렬 방향일 수 있어 도메인 특이 → TODO 유지.
+ // (ParameterDescriber 의 sort_order 타입 분기와 동일 계약)
+ if ($field === 'sort_order') {
+ return in_array($type, ['integer', 'number'], true)
+ ? '표시 정렬 순서 값 (작을수록 우선)'
+ : null;
+ }
+
+ // status 는 도메인마다 허용값 집합이 다르나(active/blocked, success/failed 등) 역할은
+ // 공통이다. 문자열 상태값이면 일반 설명을 주고, 구체 허용값은 status_label/변형 필드와
+ // 각 도메인 문서에서 보강한다. (동반 필드 status_label/status_variant 는 EXACT 로 설명됨)
+ if ($field === 'status' && in_array($type, ['string', 'integer'], true)) {
+ return '상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조)';
+ }
+
+ if (isset(self::EXACT[$field])) {
+ return self::EXACT[$field];
+ }
+
+ return $this->byPattern($field, $type);
+ }
+
+ /**
+ * 일관된 파생 규칙(접미/접두 패턴)으로 설명을 유추합니다.
+ *
+ * @param string $field 필드명
+ * @param string $type 실측 타입
+ * @return string|null 설명 (패턴 미매칭 시 null)
+ */
+ private function byPattern(string $field, string $type): ?string
+ {
+ // *_at: 타임스탬프 (UI 포맷 문자열 또는 ISO)
+ if (Str::endsWith($field, '_at')) {
+ $base = $this->humanize(Str::beforeLast($field, '_at'));
+
+ return "{$base} 일시";
+ }
+
+ // *_formatted: 원본 값을 사람이 읽는 문자열로 포맷한 표시용 파생 필드
+ // (근거: size_formatted accessor, formatCurrencyPrice/formatFileSize/
+ // formatCreatedAtFormat — 통화·용량·일시 등을 로케일/단위 포맷). 도메인
+ // 무관하게 "`base` 값의 표시용 포맷 문자열" 로 의미가 고정된다.
+ if (Str::endsWith($field, '_formatted')) {
+ $base = Str::beforeLast($field, '_formatted');
+
+ return "`{$base}` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷)";
+ }
+
+ // *_label: 원본 값의 현지화 라벨 (Enum label() 또는 번역)
+ if (Str::endsWith($field, '_label')) {
+ $base = Str::beforeLast($field, '_label');
+
+ return "`{$base}` 값의 사람이 읽는 라벨 (현지화/Enum 파생)";
+ }
+
+ // *_variant: UI 배지 색상/스타일 변형 키
+ if (Str::endsWith($field, '_variant')) {
+ $base = Str::beforeLast($field, '_variant');
+
+ return "`{$base}` 값의 표시 변형 키 (UI 배지 색상/스타일)";
+ }
+
+ // can_*: abilities 맵 내부 능력 불리언
+ if (Str::startsWith($field, 'can_')) {
+ $action = $this->humanize(Str::after($field, 'can_'));
+
+ return "{$action} 수행 가능 여부 (권한 기반)";
+ }
+
+ // is_*/has_*: 불리언 상태 플래그
+ if ((Str::startsWith($field, 'is_') || Str::startsWith($field, 'has_')) && $type === 'boolean') {
+ $base = $this->humanize(Str::after($field, '_'));
+
+ return "{$base} 여부";
+ }
+
+ // *_count: 집계 개수
+ if (Str::endsWith($field, '_count') && in_array($type, ['integer', 'number'], true)) {
+ $base = $this->humanize(Str::beforeLast($field, '_count'));
+
+ return "{$base} 개수 (집계)";
+ }
+
+ // *_raw: 다국어/현지화 이전 원본 값 (getValue 로 로케일 미해석 원본 반환)
+ // 예: name_raw = name 의 원본, description_raw = description 의 원본
+ if (Str::endsWith($field, '_raw')) {
+ $base = Str::beforeLast($field, '_raw');
+
+ return "`{$base}` 의 원본 값 (현재 로케일 미해석 원본 JSON/문자열)";
+ }
+
+ // localized_* / *_localized: 다국어 필드를 현재 로케일로 해석한 표시용 값
+ // (근거: getLocalizedName() / getLocalizedOptionName() 등 — 다국어 JSON 을
+ // 현재 로케일 문자열로 해석). 도메인 무관 파생 규칙.
+ if (Str::startsWith($field, 'localized_')) {
+ $base = $this->humanize(Str::after($field, 'localized_'));
+
+ return "`{$base}` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석)";
+ }
+ if (Str::endsWith($field, '_localized')) {
+ $base = $this->humanize(Str::beforeLast($field, '_localized'));
+
+ return "`{$base}` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석)";
+ }
+
+ // *_url: 리소스에 접근하는 URL (썸네일/다운로드/이미지 등)
+ // (근거: thumbnail_url => download_url / getThumbnailUrl()). 도메인 무관.
+ if (Str::endsWith($field, '_url')) {
+ $base = $this->humanize(Str::beforeLast($field, '_url'));
+
+ return "{$base} URL";
+ }
+
+ // *_id: 연관 리소스를 참조하는 정수/UUID 식별자 (parent_id/user_id/loggable_id 등).
+ // *_ids: 연관 리소스 식별자 배열.
+ // 예외: login_id 는 참조 식별자가 아니라 로그인 계정 아이디(문자열)이므로 제외
+ // (도메인 특이 → TODO 유지).
+ if (Str::endsWith($field, '_ids')) {
+ $base = $this->humanize(Str::beforeLast($field, '_ids'));
+
+ return "{$base} 식별자 배열 (연관 리소스 참조)";
+ }
+ if (Str::endsWith($field, '_id') && ! Str::endsWith($field, 'login_id')) {
+ $base = $this->humanize(Str::beforeLast($field, '_id'));
+
+ return "{$base} 식별자 (연관 리소스 참조)";
+ }
+
+ // depth: 계층 트리에서의 깊이 (0 = 최상위). children/parent 트리 파생 필드로
+ // 도메인 무관하게 의미가 고정된다 (근거: CommentResource/CategoryResource depth).
+ if ($field === 'depth' && in_array($type, ['integer', 'number'], true)) {
+ return '계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가)';
+ }
+
+ return null;
+ }
+
+ /**
+ * snake_case 를 사람이 읽는 문구로 변환합니다.
+ *
+ * @param string $token snake_case 토큰
+ * @return string 공백 구분 문구
+ */
+ private function humanize(string $token): string
+ {
+ return str_replace('_', ' ', $token);
+ }
+}
diff --git a/app/Support/ApiDoc/ResponseSchemaInferrer.php b/app/Support/ApiDoc/ResponseSchemaInferrer.php
new file mode 100644
index 00000000..adb406aa
--- /dev/null
+++ b/app/Support/ApiDoc/ResponseSchemaInferrer.php
@@ -0,0 +1,220 @@
+ $body 실측 응답 body (envelope)
+ * @return array{envelope: array, shape: string, fields: array>, pagination: bool}
+ */
+ public function infer(array $body): array
+ {
+ $envelope = array_keys($body);
+ $data = $body['data'] ?? null;
+
+ // 목록 응답: data.data 가 배열 (BaseApiCollection)
+ if (is_array($data) && isset($data['data']) && is_array($data['data'])) {
+ $rows = array_values(array_filter($data['data'], 'is_array'));
+
+ return [
+ 'envelope' => $envelope,
+ 'shape' => 'collection',
+ 'fields' => $this->fieldsFromRows($rows),
+ 'pagination' => isset($data['pagination']),
+ ];
+ }
+
+ // 단건 응답: data 가 연관 배열
+ if (is_array($data) && $this->isAssoc($data)) {
+ return [
+ 'envelope' => $envelope,
+ 'shape' => 'object',
+ 'fields' => $this->fieldsFromRow($data),
+ 'pagination' => false,
+ ];
+ }
+
+ // data 가 순수 배열(목록만)
+ if (is_array($data) && ! $this->isAssoc($data) && isset($data[0]) && is_array($data[0])) {
+ $rows = array_values(array_filter($data, 'is_array'));
+
+ return [
+ 'envelope' => $envelope,
+ 'shape' => 'array',
+ 'fields' => $this->fieldsFromRows($rows),
+ 'pagination' => false,
+ ];
+ }
+
+ return [
+ 'envelope' => $envelope,
+ 'shape' => 'scalar',
+ 'fields' => [],
+ 'pagination' => false,
+ ];
+ }
+
+ /**
+ * 한 행(row)에서 필드별 타입·샘플값을 추출합니다.
+ *
+ * @param array $row 응답 데이터 한 행
+ * @return array> 필드 메타데이터 목록
+ */
+ private function fieldsFromRow(array $row): array
+ {
+ $fields = [];
+
+ foreach ($row as $key => $value) {
+ $fields[] = [
+ 'name' => (string) $key,
+ 'type' => $this->typeOf($value),
+ 'sample' => $this->sampleOf($value),
+ ];
+ }
+
+ return $fields;
+ }
+
+ /**
+ * 여러 행을 병합해 필드별로 non-null 대표 샘플과 실제 타입을 선택합니다.
+ *
+ * 첫 행이 우연히 비어있어(null) "항상 null" 처럼 보이는 문제를 방지하기 위해,
+ * 각 필드에서 값이 채워진 행을 우선 채택합니다.
+ *
+ * @param array> $rows 응답 데이터 행 목록
+ * @return array> 필드 메타데이터 목록
+ */
+ private function fieldsFromRows(array $rows): array
+ {
+ if ($rows === []) {
+ return [];
+ }
+
+ // 첫 행의 키 순서를 기준으로 필드 목록 확정
+ $keys = array_keys($rows[0]);
+ $fields = [];
+
+ foreach ($keys as $key) {
+ $chosenValue = null;
+ $chosenType = 'null';
+
+ foreach ($rows as $row) {
+ if (! array_key_exists($key, $row)) {
+ continue;
+ }
+
+ $value = $row[$key];
+ $type = $this->typeOf($value);
+
+ // 값이 채워진(non-null) 첫 행을 대표로 채택하고 탐색 종료
+ if ($type !== 'null') {
+ $chosenValue = $value;
+ $chosenType = $type;
+ break;
+ }
+
+ // 아직 non-null 을 못 찾았으면 null 이라도 후보로 유지
+ $chosenValue = $value;
+ }
+
+ $fields[] = [
+ 'name' => (string) $key,
+ 'type' => $chosenType,
+ 'sample' => $this->sampleOf($chosenValue),
+ ];
+ }
+
+ return $fields;
+ }
+
+ /**
+ * 값의 JSON 타입을 판별합니다.
+ *
+ * @param mixed $value 값
+ * @return string 타입 문자열
+ */
+ private function typeOf(mixed $value): string
+ {
+ return match (true) {
+ is_bool($value) => 'boolean',
+ is_int($value) => 'integer',
+ is_float($value) => 'number',
+ is_string($value) => 'string',
+ is_null($value) => 'null',
+ is_array($value) && $this->isAssoc($value) => 'object',
+ is_array($value) => 'array',
+ default => 'mixed',
+ };
+ }
+
+ /**
+ * 값의 샘플 표현을 반환합니다 (문서 표에 표시할 축약형).
+ *
+ * @param mixed $value 값
+ * @return string 샘플 표현 (마크다운 표 셀 안전)
+ */
+ private function sampleOf(mixed $value): string
+ {
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+
+ if (is_null($value)) {
+ return 'null';
+ }
+
+ if (is_array($value)) {
+ $encoded = json_encode($value, JSON_UNESCAPED_UNICODE);
+ $encoded = (string) $encoded;
+
+ if (mb_strlen($encoded) > 60) {
+ $encoded = mb_substr($encoded, 0, 57).'…';
+ }
+
+ return $this->escapeCell($encoded);
+ }
+
+ $str = (string) $value;
+
+ if (mb_strlen($str) > 40) {
+ $str = mb_substr($str, 0, 37).'…';
+ }
+
+ return $this->escapeCell($str);
+ }
+
+ /**
+ * 마크다운 표 셀 안에서 안전하도록 파이프/개행을 이스케이프합니다.
+ *
+ * @param string $text 원본 텍스트
+ * @return string 이스케이프된 텍스트
+ */
+ private function escapeCell(string $text): string
+ {
+ return str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $text);
+ }
+
+ /**
+ * 배열이 연관 배열(맵)인지 판별합니다.
+ *
+ * @param array $arr 배열
+ * @return bool 연관 배열 여부
+ */
+ private function isAssoc(array $arr): bool
+ {
+ if ($arr === []) {
+ return false;
+ }
+
+ return array_keys($arr) !== range(0, count($arr) - 1);
+ }
+}
diff --git a/app/Support/InstallerContext.php b/app/Support/InstallerContext.php
new file mode 100644
index 00000000..b3c569e2
--- /dev/null
+++ b/app/Support/InstallerContext.php
@@ -0,0 +1,40 @@
+runningInConsole()) {
+ return false;
+ }
+
+ return in_array(
+ $_SERVER['argv'][1] ?? null,
+ ['migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'],
+ true
+ );
+ }
+}
diff --git a/config/app.php b/config/app.php
index a7d6823b..4885bc61 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.0.2'),
+ 'version' => env('APP_VERSION', '7.0.3'),
/*
|--------------------------------------------------------------------------
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
index a763a0de..e6ce38e6 100644
--- a/database/factories/UserFactory.php
+++ b/database/factories/UserFactory.php
@@ -3,12 +3,13 @@
namespace Database\Factories;
use App\Enums\UserStatus;
+use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
+ * @extends Factory
*/
class UserFactory extends Factory
{
@@ -49,4 +50,37 @@ class UserFactory extends Factory
'email_verified_at' => null,
]);
}
+
+ /**
+ * 모든 프로필 필드가 채워진 완전한 상태를 만듭니다.
+ *
+ * API 문서 실측 시 응답 필드의 예시값이 null 이 되지 않도록, 모델 로직상
+ * 유효한 값으로 nullable 프로필 컬럼을 전수 채웁니다.
+ * (language=지원 로케일, country=ISO alpha-2, status=UserStatus enum 등)
+ */
+ public function complete(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'nickname' => fake()->userName(),
+ 'language' => 'ko',
+ 'timezone' => 'Asia/Seoul',
+ 'country' => 'KR',
+ 'homepage' => 'https://example.com',
+ 'mobile' => '010-'.fake()->numerify('####-####'),
+ 'phone' => '02-'.fake()->numerify('###-####'),
+ 'zipcode' => fake()->numerify('#####'),
+ 'address' => fake()->address(),
+ 'address_detail' => fake()->numerify('##동 ###호'),
+ 'signature' => fake()->sentence(),
+ 'bio' => fake()->paragraph(),
+ // avatar 는 users 테이블 컬럼이 아니라 avatarAttachment 관계에서 파생되는
+ // accessor(getAvatarUrl) 이므로 factory 에서 직접 세팅하지 않는다.
+ 'admin_memo' => fake()->sentence(),
+ 'ip_address' => fake()->ipv4(),
+ 'last_login_at' => now()->subDays(1),
+ 'identity_verified_at' => now()->subDays(5),
+ 'mobile_verified_at' => now()->subDays(5),
+ 'failed_login_attempts' => 0,
+ ]);
+ }
}
diff --git a/docs/README.md b/docs/README.md
index cda43fab..28674e30 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -9,7 +9,7 @@
| 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------|
-| [백엔드](backend/) | 32개 | 정상 |
+| [백엔드](backend/) | 33개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 |
| [확장 시스템](extension/) | 31개 | 정상 |
| 공통 | 20개 | 정상 |
@@ -19,6 +19,18 @@
---
+## API 레퍼런스
+
+G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시입니다.
+템플릿의 `data_sources` 를 작성하거나 외부에서 G7 을 연동할 때 참고하세요.
+
+| 문서 | 설명 |
+|------|------|
+| [backend/api/README.md](backend/api/README.md) | **API 레퍼런스 진입점** — 공통 규약(인증·응답 봉투·페이지네이션·에러) + 코어/확장 전체 목차 |
+| [backend/api-documentation.md](backend/api-documentation.md) | API 문서 작성·갱신 규정 (기여자용) |
+
+---
+
## 작업 유형별 필수 문서
### 레이아웃 JSON 작성
@@ -113,13 +125,14 @@
## 카테고리별 전체 문서 목록
-### 백엔드 (32개)
+### 백엔드 (33개)
| 문서 | 제목 |
|------|------|
| [activity-log-hooks.md](backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) |
| [activity-log.md](backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) |
| [admin-settings-access.md](backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) |
+| [api-documentation.md](backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) |
| [api-resources.md](backend/api-resources.md) | API 리소스 |
| [authentication.md](backend/authentication.md) | 인증 및 세션 처리 |
| [broadcasting.md](backend/broadcasting.md) | Broadcasting (실시간 이벤트) |
diff --git a/docs/backend/README.md b/docs/backend/README.md
index 88a60b81..f9b5a83a 100644
--- a/docs/backend/README.md
+++ b/docs/backend/README.md
@@ -15,6 +15,16 @@
---
+## API 레퍼런스
+
+엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시는 [api/README.md](api/README.md) 에 있습니다.
+공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428 에러)도 그 문서 상단에 정리되어 있습니다.
+확장(모듈·플러그인)이 소유한 API 문서 목록도 같은 문서에서 찾을 수 있습니다.
+
+문서 작성·갱신 규정은 [api-documentation.md](api-documentation.md) 를 참고하세요.
+
+---
+
## 문서 목록
@@ -23,6 +33,7 @@
| [activity-log-hooks.md](activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log.md](activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel... |
| [admin-settings-access.md](admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → Setting... |
+| [api-documentation.md](api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 +... |
| [api-resources.md](api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: B... |
| [authentication.md](authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [broadcasting.md](broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
diff --git a/docs/backend/api-documentation.md b/docs/backend/api-documentation.md
new file mode 100644
index 00000000..bf3752f5
--- /dev/null
+++ b/docs/backend/api-documentation.md
@@ -0,0 +1,370 @@
+# API 레퍼런스 문서 규정 (API Documentation)
+
+> **관련 문서**: [routing.md](routing.md) | [api-resources.md](api-resources.md) | [response-helper.md](response-helper.md) | [validation.md](validation.md)
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 전수 기재
+2. 위치: 코어 = docs/backend/api/, 확장 = {modules|plugins}/_bundled/{id}/docs/api/
+3. 생성: php artisan api:docgen — 코드에서 추출한 스캐폴딩 + 사람이 서술 보강 (순수 수기 금지)
+4. 추출 불가분(훅 주입 파라미터·동적 응답)은 마커 남기고 사람이 채움
+5. Swagger/OpenAPI 도구 미사용 — 마크다운 레퍼런스 전용
+```
+
+---
+
+## 목차
+
+1. [왜 이 규정이 필요한가](#왜-이-규정이-필요한가)
+2. [문서 위치 규칙](#문서-위치-규칙)
+3. [표준 문서 포맷](#표준-문서-포맷)
+4. [생성 커맨드 api:docgen](#생성-커맨드-apidocgen)
+5. [문서 갱신 의무](#문서-갱신-의무)
+6. [체크리스트](#체크리스트)
+
+---
+
+## 왜 이 규정이 필요한가
+
+G7 의 REST API 는 라우트 `->name()` 규약은 있으나 엔드포인트별 공개 레퍼런스가 부재했다. 프론트엔드
+(레이아웃 JSON `data_sources`)와 외부 통합 개발자가 소비하는 요청/응답 계약이 코드에만 존재해, 변경 시
+소비처가 침묵 속에서 깨진다(이슈 #64 의 `data_source` `auth_required` 계약 변화 사고가 계기).
+
+문서는 **코드에서 추출한 스캐폴딩 + 사람이 채운 서술의 하이브리드**로 유지한다. 674개 규모에서 완전 수기
+문서는 반드시 drift 하고, 완전 자동 추출은 훅 주입 파라미터·동적 응답을 못 잡으므로 둘 다 단독으로는
+불충분하다.
+
+---
+
+## 문서 위치 규칙
+
+| 대상 | 문서 위치 | 예시 |
+|------|----------|------|
+| 코어 | `docs/backend/api/{도메인}.md` | `docs/backend/api/users.md` |
+| 모듈 | `modules/_bundled/{id}/docs/api/{도메인}.md` | `modules/_bundled/sirsoft-ecommerce/docs/api/products.md` |
+| 플러그인 | `plugins/_bundled/{id}/docs/api/{도메인}.md` | `plugins/_bundled/sirsoft-gdpr/docs/api/consents.md` |
+
+확장 API 문서는 **확장이 소유**한다(코어에 모으지 않음). 확장을 배포/삭제하면 그 API 문서도 함께 이동한다.
+
+도메인 그룹핑은 URI/라우트명 prefix 기준(`api.admin.users.*` → `users.md`)으로 커맨드가 자동 분류한다.
+
+### 문서 목차와 발견성 (README.md 규약)
+
+각 대상(코어·확장)의 API 문서 디렉토리에는 `README.md` 목차가 있어야 한다. `api:docgen` 이 도메인 파일
+목록·엔드포인트 수를 담아 자동 생성한다(`@generated` 블록, 재생성 멱등, 블록 밖 사람 서술은 보존).
+
+- 코어: `docs/backend/api/README.md`
+- 확장: `{modules|plugins}/_bundled/{id}/docs/api/README.md`
+
+코어 README 는 프로젝트 최상위 `README.md` 의 "API 레퍼런스" 진입점이다. 따라서 확장 목차와 달리 세
+부분으로 구성된다. 이 문서(작성 규정)와 혼동되지 않도록 최상위 `README.md` 는 둘을 "API 레퍼런스" 와
+"API 문서 작성 규정" 으로 분리해 링크한다.
+
+| 구성 | 위치 | 소유 |
+| --- | --- | --- |
+| 공통 규약 개요 (인증·응답 봉투·페이지네이션·에러) | 헤더 인용 블록 뒤 ~ 첫 `@generated` 앞 | 사람 (재생성 시 원문 보존) |
+| 코어 도메인 목차 | `@generated:start:api-readme-index` | `api:docgen` |
+| 확장 API 목차 | `@generated:start:api-readme-extensions` | `api:docgen` (코어 README 전용) |
+
+개요를 첫 생성 블록 앞에 두는 이유는 목차 표보다 먼저 읽혀야 하기 때문이다. 확장 README 에는 확장 목차
+블록을 넣지 않는다. `--scope` 를 좁혀 실행해도 이번 회차에 갱신하지 않는 블록과 사람 서술은 원문 그대로
+보존된다.
+
+확장 API 문서의 발견성은 이 목차를 통해 확보한다. `api:docgen` 과 코어 인덱스 생성기
+(`generate-docs-index.cjs`) 는 확장명을 하드코딩하지 않고
+`{modules,plugins}/_bundled/*/docs/api/README.md` 를 **패턴 스캔**해, 코어 README 의 "확장 API 레퍼런스"
+표와 CLAUDE.md·AGENTS.md·docs-index 에 자동 편입한다(동적 로딩 원칙 — 코어는 규약과 스캔 패턴만, 확장
+이름은 파일 시스템에서 발견). 문서 수·엔드포인트 수는 각 README 의 집계 라인
+(`**문서 수**: N · **엔드포인트 수**: M`)에서 읽으므로, 이 라인 형식을 바꾸면 두 스캐너를 함께 갱신한다.
+
+처음 진입하는 개발자/AI 의 도달 경로:
+
+```text
+README.md "API 레퍼런스" 또는 CLAUDE.md "API 레퍼런스 진입점" 표
+ → docs/backend/api/README.md (공통 규약 + 코어 목차 + 확장 목차)
+ → {도메인}.md 또는 {확장}/docs/api/README.md
+ → 엔드포인트별 파라미터·응답·예시
+```
+
+---
+
+## 표준 문서 포맷
+
+엔드포인트 1개당 아래 6개 구성(헤더 · 요청 파라미터 · 요청 예시 · 응답 필드 · 응답 예시 · 에러 응답)을
+따른다. `` ~ `` 사이는 `api:docgen` 이 재생성하는 추출
+블록이며, 그 바깥의 사람 서술(`**설명**`)은 재생성 시 보존된다.
+
+에러 응답 표는 라우트 메타에서 대표 상태코드를 자동 추론한다: 인증 필수(`auth:sanctum`)→401,
+`admin`/`permission:` 요구→403, FormRequest 검증 규칙 존재→422, path 파라미터 존재→404.
+`optional.sanctum`(선택 인증)은 401 을 유발하지 않는다. 도메인 특이 에러(409·429 등)는 사람이 보강한다.
+
+**요청 예시**(raw HTTP 요청)와 **응답 예시**(envelope 전문 JSON)는 실제 호출을 재현할 수 있도록 실측 기반으로
+방출된다(단계 6). 요청 예시는 curl 이 아니라 raw HTTP 요청(요청 라인 + 헤더)으로 표기해 응답 예시의
+`HTTP/1.1 {status}` 상태줄과 대칭을 이룬다. 세부 규칙:
+
+- **요청 예시**: 요청 라인(`{METHOD} {path} HTTP/1.1`) + `Host:` + `Accept: application/json`, 인증 필요 시
+ `Authorization: Bearer {YOUR_TOKEN}`(실측 토큰 평문 유출 방지 마스킹). `Host` 는 실측 기준 URL(로컬 개발
+ 호스트 등)을 노출하지 않고 공개 placeholder(`api.example.com`, RFC 2606 예약 도메인)로 마스킹한다.
+ - **바디 메서드(POST/PUT/PATCH)**: `Content-Type: application/json` + 빈 줄 뒤 JSON 바디. 바디는 필수만이
+ 아니라 **전체 파라미터**를 담고 값은 이름·타입 기반 현실적 예시값으로 채운다(`"string"` placeholder 남발
+ 금지). 허용값 `in:` 열거가 있으면 그 첫 값을 채택한다.
+ - **파일 업로드**(요청 파라미터 타입이 `image`/`file`): `application/json` 이 아니라
+ `Content-Type: multipart/form-data; boundary=...` 로 표기하고 각 파일 파트를 `filename=`+`Content-Type`
+ 으로 나타낸다(JSON 으로는 파일 전송 불가).
+ - **GET/DELETE**: query 파라미터는 URL 쿼리스트링(`?a=..&b=..`)으로 반영한다(바디 아님).
+ - `optional.sanctum`(선택 인증) 엔드포인트는 Authorization 헤더에 "비회원은 생략 가능" 주석을 붙인다.
+- **응답 예시**: `HTTP/1.1 {status}` 상태줄 + 실측 응답 body 전문(`{success, data, message, error}` envelope).
+ 목록 응답의 `data.data[]` 는 대표 **2항목**으로 절단하고 나머지는 `... (총 N건 중 2건 표시)` 항목으로 대체한다.
+ 응답에 섞여 나온 **민감값(토큰·비밀번호·시크릿·API 키)은 `{MASKED}` 로 마스킹**해 방출한다.
+- **쓰기 메서드 실측**: GET/HEAD 는 외부 HTTP 로 read-only 실측하고, 쓰기(POST/PUT/PATCH/DELETE)는 **DB 트랜잭션
+ 안에서 in-process dispatch 후 롤백**하여 응답 shape 만 관측한다(부수효과 미영속). 단, 부수효과가 롤백으로
+ 되돌릴 수 없는 쓰기(확장 install/activate/update, 언어팩 설치, 코어 업데이트, 파일 업로드, 캐시/워밍업/생성
+ 등 파일시스템·프로세스·외부 네트워크 접촉)는 실측에서 제외(`side-effectful-write`)하고 요청 예시만 정적 방출한다.
+- **실측 제외**(부수효과 쓰기·바이너리·미치환 path 파라미터 등) 엔드포인트: 요청 예시는 파라미터 표 기반으로 정적
+ 조립(raw HTTP 요청 골격), 응답 예시는 `` 마커로 남긴다.
+- 두 예시 블록은 전량 `@generated` 블록 **내부**에 있다. 다만 이미 파라미터/응답 필드 표의 사람 서술이
+ 채워진 문서에 예시를 추가할 때는 전체 재생성(`api:docgen`)을 쓰지 않는다 — 전체 재생성은 표를 통째로
+ 다시 조립해 정적 추출로 재현 불가능한 도메인 서술 셀을 TODO 로 되돌린다. 대신 `api:docgen --examples-only`
+ 로 표·서술을 건드리지 않고 예시 2블록만 in-place 삽입/치환한다(측정은 재수행하되 표는 불가침). 백필
+ 커맨드(`backfill-params`/`backfill-fields`)도 예시 블록을 건드리지 않는다.
+- **`--examples-only` 모드**: 기존 문서의 각 엔드포인트 `@generated` 블록에 요청 예시(응답 필드 표 앞)와
+ 응답 예시(에러 응답 표 앞)만 삽입한다. 이미 예시가 있으면 그 블록만 새 실측으로 치환(멱등, 실측 상대시간
+ 필드는 재측정으로 값이 달라질 수 있음). 표·필드 설명·엔드포인트 서술은 전량 보존된다. 최초 스캐폴딩이
+ 없는 신규 문서는 먼저 `api:docgen` 으로 생성한 뒤 서술을 채우고, 이후 예시는 `--examples-only` 로 유지한다.
+
+```markdown
+### GET /api/admin/users
+
+- **라우트명**: `api.admin.users.index`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@index`
+- **인증/권한**: `auth:sanctum` + `admin` + `permission:admin,core.users.read`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+|------|------|------|------|--------|------|
+| keyword | query | string | 아니오 | — | |
+| status | query | string | 아니오 | `active`, `dormant`, `withdrawn` | |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있음 (`core.user.search_validation_rules`).
+
+**요청 예시**
+
+```http
+GET /api/admin/users HTTP/1.1
+Host: example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+|------|------|-------------|-----------|
+| id | integer | `1` | |
+| uuid | string | `a231747f-...` | |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "data": {
+ "data": [
+ { "id": 1, "uuid": "a231747f-..." },
+ { "id": 2, "uuid": "b0f2..." },
+ "... (총 42건 중 2건 표시)"
+ ],
+ "pagination": { "current_page": 1, "total": 42 }
+ },
+ "message": null,
+ "error": null
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`admin,core.users.read`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+**설명**
+```
+
+### 응답 envelope 표준
+
+모든 응답은 `ResponseHelper` 로 `{success, data, message, error}` 로 래핑된다(response-helper.md).
+문서의 "응답 필드" 표는 이 envelope 의 `data` 내부 필드를 기재한다.
+
+- 목록 응답 pagination: `BaseApiCollection::paginationMeta()` →
+ `{current_page, last_page, per_page, total, from, to, has_more_pages}`
+- 권한 메타: `BaseApiResource::resourceMeta()` → `is_owner` + `abilities.can_*`
+
+### 파라미터 위치 판정
+
+| 위치 | 판정 근거 |
+|------|----------|
+| `path` | URI 의 `{param}` 세그먼트 |
+| `query` | GET/DELETE 요청의 FormRequest rule |
+| `body` | POST/PUT/PATCH 요청의 FormRequest rule |
+
+허용값은 FormRequest rule 의 `in:`, `max:`, `min:`, `Rule::in(...)`, `boolean`, `date` 등에서 유추한다.
+
+---
+
+## 생성 커맨드 api:docgen
+
+```bash
+# 코어 스캐폴딩 생성 (docs/backend/api/*.md)
+php artisan api:docgen --scope=core
+
+# 특정 확장 스캐폴딩 생성
+php artisan api:docgen --scope=module:sirsoft-ecommerce
+php artisan api:docgen --scope=plugin:sirsoft-gdpr
+
+# 전체
+php artisan api:docgen --scope=all
+
+# 생성 없이 누락/drift 만 리포트 (하네스가 소비)
+php artisan api:docgen --check
+
+# 이미 서술이 채워진 문서에 요청/응답 예시 블록만 in-place 삽입 (표·서술 불가침, 재생성 금지)
+php artisan api:docgen --scope=module:sirsoft-board --seed --base-url=https://example.dev --examples-only
+
+# 생성될 대상만 미리보기
+php artisan api:docgen --scope=core --dry-run
+```
+
+동작 (실측 기반):
+
+1. `route:list --json` 으로 API 라우트 전수 수집 (method·uri·name·middleware·action).
+2. name prefix 로 소유 확장 판별 (`api.modules.{id}.*` / `api.plugins.{id}.*` / 그 외 코어) → 출력 파일 라우팅.
+3. 컨트롤러 메서드의 FormRequest 타입힌트 → `rules()` 리플렉션 → 요청 파라미터 표 (타입·필수·허용값).
+4. **실측**: 임시 Sanctum 토큰 발급 → 실제 요청 파라미터로 엔드포인트 호출 → **실제 응답 JSON** 관측.
+ - GET/HEAD: 실호출(read-only). 목록이 비면 최소 시드 데이터 자동 생성 후 재호출.
+ - 쓰기(POST/PUT/PATCH/DELETE): DB 트랜잭션 내 실행 후 롤백(응답 shape 만 관측, 영속 안 함).
+ - 외부 부수효과(결제 PG·외부 인증 콜백·메일)가 있는 라우트: allowlist 로 실호출 제외 → 정적+예시 대체.
+5. 실제 응답 JSON 의 키·타입·샘플값 → 응답 필드 표 + 응답 예시. `@generated` 블록만 갱신, 사람 서술 보존.
+6. 실측 후 임시 토큰·시드 데이터 정리.
+
+한계 / 보강:
+
+- FormRequest 가 `HookManager::applyFilters` 로 규칙을 주입하는 경우(163개) 정적 리플렉션은 훅 주입분을
+ 못 읽는다 → 커맨드가 훅 필터 존재 시 주석을 남기고 사람이 보강. 단 **응답 필드는 실측이므로 훅으로
+ 병합된 응답 필드까지 실제로 포착**된다.
+- `route:list`(=`RouteFacade::getRoutes()`)는 활성 확장만 노출한다. 명시 범위(`module:{id}`/`plugin:{id}`)로
+ 지정한 확장이 비활성/미설치여서 등록 라우트가 0건이면, 인벤토리가 그 확장의 번들 라우트 파일
+ (`{modules|plugins}/_bundled/{id}/src/routes/api.php`)을 프로바이더와 동일한 prefix
+ (`api/{modules|plugins}/{id}`)·name(`api.{modules|plugins}.{id}.`)·`api` 미들웨어 규약으로 로드해
+ **정적 폴백 수집**한다. 이때 실측(HTTP 호출)은 불가하므로 응답 필드는 `` + 정적
+ 추정으로 대체되며, 설치 후 `--seed` 실측으로 채운다. 폴백은 `api/` 로 시작하는 라우트만 대상이므로
+ web(admin) 라우트는 자동 제외된다.
+- 실측이 불가한 라우트(외부 의존·allowlist 제외)는 `` 마커 + 정적 추정으로 대체.
+
+### 확장 실측 샘플 시더 (`--seed`)
+
+`--seed` 는 상세 GET 실측 시 응답 필드가 null 로 관측되는 것을 줄이기 위해, 도메인 대표 엔티티에
+완전한 샘플 레코드를 멱등 시드한다. 코어 도메인은 `App\Support\ApiDoc\ApiDocSampleService` 가 담당한다.
+
+확장은 자신의 도메인 샘플을 **확장이 소유**한다. `App\Contracts\ApiDoc\ApiDocSampleSeeder` 를 구현한
+클래스를 규약 위치 `{확장 네임스페이스}\Support\ApiDoc\ApiDocSampleService`
+(예: `Modules\Sirsoft\Page\Support\ApiDoc\ApiDocSampleService`, 파일은 `src/Support/ApiDoc/`)에 두면,
+`api:docgen --scope=module:{id} --seed` 실행 시 커맨드가 자동으로 발견해 코어 시드 뒤에 병합한다.
+
+- `seed()` 반환 맵의 키는 라우트 도메인 그룹명(`pages` 등), 값은 `{model, key, value}`
+ (모델 FQCN·route key 이름·route key 값)이다.
+- 이 맵은 상세 GET 의 path 파라미터 치환에 쓰인다. 라우트-모델 바인딩이 없는 확장 패턴
+ (`show(int $id)`)도, 파라미터명이 도메인의 단수 리소스명과 일치하면(`pages/{page}`) 이 맵으로 실측된다.
+ `{slug}`·`{hash}`·`{versionId}` 처럼 route key 가 다른 문자열/보조 파라미터는 폴백하지 않고 실측 제외된다.
+- 확장에 새 PHP 클래스를 추가했으므로 `_bundled` 작업 후 `{type}:update {id} --force` 로 활성 디렉토리에
+ 반영해야 오토로드된다.
+
+#### 샘플은 시스템 동작을 바꾸지 않는다
+
+`--seed` 로 만든 샘플 레코드는 개발 DB 에 영구 잔존한다. 조회 예시를 만들기 위한 데이터일 뿐이므로,
+전역 설정을 읽는 쿼리에 걸리는 조합으로 생성해서는 안 된다.
+
+| ❌ 금지 | ✅ 올바른 사용 |
+| --- | --- |
+| `LanguagePack::create(['scope' => 'core', 'status' => 'active', ...])` | `['scope' => 'module', 'target_identifier' => '...', 'status' => 'installed']` |
+| 실재하지 않는 로케일/통화/국가 코드를 샘플 값으로 사용 | 실재하는 번들 값만 사용 (`ja` 등) |
+
+`scope=core` + `status=active` 조합은 `LanguagePackRepository::getActiveCoreLocales()` 의 승격 쿼리와
+일치한다. 걸리면 그 locale 이 `config('app.supported_locales')` / `config('app.translatable_locales')` 로
+올라가 시스템 전역이 바뀐다. 그 아래에서 시드·저장되는 모든 다국어 데이터에 실재하지 않는 로케일 키가
+박히고, 샘플을 비활성화한 뒤에는 그 키가 `TranslatableField` 검증을 통과하지 못해 해당 엔티티의 저장이
+막힌다. 오염 시점과 증상 발현 시점이 떨어져 있어 원인 추적이 어렵다.
+
+같은 원리로 `is_default` / `status=active` 같은 승격·활성 플래그를 샘플에 붙이지 않는다. 샘플 레코드를
+만들기 전에 "이 조합이 전역 설정을 읽는 쿼리에 걸리는가" 를 확인한다.
+
+자동 차단: audit 룰 `apidoc-sample-no-global-active-language-pack` (severity: error).
+
+### 설명 백필 커맨드 (`api:docgen-backfill-*`)
+
+파라미터/응답 필드 표의 `` 셀 중 **도메인 무관 공통 필드**(페이지네이션·정렬·검색·
+식별자·기간·토글·공통 파생 필드 등)는 전체 재생성 없이 in-place 로 자동 서술한다. 전체 재생성
+(`api:docgen`)은 `@generated` 블록 표를 통째로 재조립해 실측 예시값·사람 서술 셀을 되돌리므로, TODO
+셀만 치환하는 별도 백필 커맨드를 쓴다(멱등 — 채워진 셀·실측 예시값 불가침).
+
+```bash
+# 요청 파라미터 표 TODO 셀 백필 (SSoT: App\Support\ApiDoc\ParameterDescriber)
+php artisan api:docgen-backfill-params
+
+# 응답 필드 표 TODO 셀 백필 (SSoT: App\Support\ApiDoc\ResourceFieldDescriber)
+php artisan api:docgen-backfill-fields
+```
+
+- 도메인 종속 필드(`status`/`type`/`code`/`category`·훅 주입·조건부 필드)는 값 의미가 도메인마다 달라
+ 자동 채우지 않고 TODO 로 남겨 사람이 컨트롤러/FormRequest/Resource 근거로 서술한다.
+- 새 공통 필드 규칙을 Describer 사전에 추가한 뒤 백필을 재실행하면 그 필드가 전 문서에서 자동 채워진다.
+
+---
+
+## 문서 갱신 의무
+
+컨트롤러/라우트/FormRequest/Resource 를 추가·변경하면 대응 API 문서를 같은 변경 단위에서 갱신한다.
+
+- 트리거: `app/Http/Controllers/**`, `routes/api.php`, `app/Http/Requests/**`, `app/Http/Resources/**`
+ (+ 확장 대응 경로) 편집.
+- 절차: 코드 변경 → `api:docgen --scope=...` 재실행 → `@generated` 블록 갱신 → 신규 TODO 서술 채움.
+- 검증: `api:docgen --check` 로 drift 0 확인. audit 룰 `api-doc-coverage` 가 변경셋에 대응 문서
+ 동반 여부를 검사한다. severity 는 **대상별**로 부여된다 — 문서가 완비된 대상은 `error`(문서
+ 미동반 변경 차단), 진행 중 대상은 `warn`. 코어(`docs/backend/api/`)는 2026-07-08 완료로
+ `error` 승격됨. 즉 코어 API 표면(`routes/api.php`·`app/Http/{Controllers,Requests,Resources}/**`)을
+ 변경하면서 코어 API 문서를 함께 갱신하지 않으면 세션 종료 시 차단된다. 나머지 확장은 문서 완비
+ 시 순차 승격된다(룰의 `ENFORCED_TARGETS`).
+
+---
+
+## 체크리스트
+
+```text
+□ 엔드포인트가 대응 위치(코어 docs/backend/api/ 또는 확장 docs/api/)에 문서화되었는가?
+□ 요청 파라미터 표에 위치/타입/필수/허용값/용도가 모두 기재되었는가?
+□ 요청 예시(raw HTTP 요청) 블록이 방출되었는가? (curl 금지, 인증 필요 시 Bearer {YOUR_TOKEN} 마스킹)
+□ 응답 필드 표가 envelope 의 data 내부 기준으로 작성되었는가?
+□ 응답 예시(envelope 전문 JSON) 블록이 방출되었는가? (목록은 2항목 절단)
+□ 훅 주입 파라미터가 있으면 주석 + 사람 보강이 되었는가?
+□ TODO 마커가 모두 채워졌는가?
+□ api:docgen --check 가 drift 0 인가?
+```
+
+---
+
+## 관련 문서
+
+- [routing.md](routing.md) - 라우트 네이밍/URL 규칙 (확장 URL 스킴은 `/api/modules/{module}/...`)
+- [api-resources.md](api-resources.md) - 응답 필드/pagination/abilities 형태
+- [response-helper.md](response-helper.md) - 응답 envelope 표준
+- [validation.md](validation.md) - FormRequest rule → 파라미터 허용값 유추 근거
diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md
new file mode 100644
index 00000000..43188b71
--- /dev/null
+++ b/docs/backend/api/README.md
@@ -0,0 +1,172 @@
+# API 레퍼런스 문서 목차
+
+> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반).
+> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
+
+G7 의 REST API 레퍼런스입니다. 도메인별 문서에는 엔드포인트마다 메서드·URI·인증/권한, 요청 파라미터 표,
+응답 필드 표, 실제 호출로 관측한 요청·응답 예시가 실려 있습니다. 아래 공통 규약은 모든 엔드포인트에
+동일하게 적용되므로 개별 문서에서 반복하지 않습니다.
+
+문서 작성·갱신 규정은 [api-documentation.md](../api-documentation.md) 를 참고하세요.
+
+## 공통 규약
+
+### 인증
+
+Laravel Sanctum 의 **Bearer 토큰 전용**입니다(세션 쿠키 인증 미사용).
+`POST /api/auth/login` 또는 `POST /api/auth/admin/login` 으로 토큰을 발급받아 모든 후속 요청에 실어 보냅니다.
+
+```http
+GET /api/me HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+각 문서의 **인증/권한** 줄은 다음 네 가지 중 하나입니다.
+
+| 표기 | 의미 |
+| --- | --- |
+| `공개 (인증 불필요)` | 토큰 없이 호출 가능 |
+| `optional.sanctum` | 토큰이 있으면 회원, 없으면 비회원으로 처리 (둘 다 접근 가능) |
+| `auth:sanctum` | 유효한 토큰 필수 (없거나 만료 시 401) |
+| `auth:sanctum + admin + permission:{키}` | 토큰 + 관리자 + 해당 권한 필요 (권한 부족 시 403) |
+
+### 로케일
+
+응답 메시지의 언어는 ① 로그인 사용자의 `users.language` → ② `Accept-Language` 헤더 →
+③ 시스템 기본값(`config('app.locale')`) 순으로 결정됩니다. 지원 언어는 `config('app.supported_locales')` 를 따릅니다.
+
+### 응답 봉투
+
+성공·실패 모두 동일한 최상위 구조를 씁니다. 실제 페이로드는 항상 `data` 안에 들어갑니다.
+
+```json
+{
+ "success": true,
+ "message": "요청이 성공했습니다.",
+ "data": { }
+}
+```
+
+실패 시 `success` 는 `false` 이고, 검증 오류는 `errors` 에 필드별 메시지 배열로 담깁니다.
+
+```json
+{
+ "success": false,
+ "message": "입력값이 올바르지 않습니다.",
+ "errors": {
+ "email": ["이메일 형식이 올바르지 않습니다."]
+ }
+}
+```
+
+### 페이지네이션
+
+목록 엔드포인트는 `data.data[]` 에 항목 배열을, `data.pagination` 에 페이지 정보를 담습니다.
+요청은 `page` 와 `per_page` 쿼리 파라미터로 제어합니다.
+
+```json
+{
+ "pagination": {
+ "current_page": 1,
+ "last_page": 4,
+ "per_page": 25,
+ "total": 87,
+ "from": 1,
+ "to": 25,
+ "has_more_pages": true
+ }
+}
+```
+
+일부 목록은 `data.abilities` 에 컬렉션 레벨 권한(`can_create`, `can_delete` 등)을 함께 반환합니다.
+화면의 버튼 노출 여부를 이 값으로 판정하세요.
+
+### 공통 에러 상태코드
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 엔드포인트가 요구하는 권한이 없는 경우 |
+| 404 | Not Found | 대상 리소스가 없거나 접근 범위를 벗어난 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`errors` 에 필드별 메시지) |
+| 428 | Precondition Required | 본인인증(IDV)이 선행되어야 하는 경우 |
+
+428 응답은 `error_code: "identity_verification_required"` 와 함께 `verification` 객체를 반환합니다.
+클라이언트는 이 값으로 본인인증 화면을 띄운 뒤 원래 요청을 재시도합니다.
+
+```json
+{
+ "success": false,
+ "error_code": "identity_verification_required",
+ "message": "본인인증이 필요합니다.",
+ "verification": { }
+}
+```
+
+## 코어 API 레퍼런스
+
+
+- **문서 수**: 35 · **엔드포인트 수**: 291
+
+| 문서 | 도메인 | 엔드포인트 |
+| --- | --- | --- |
+| [activity-logs.md](activity-logs.md) | `activity-logs` | 3 |
+| [attachment.md](attachment.md) | `attachment` | 1 |
+| [attachments.md](attachments.md) | `attachments` | 4 |
+| [auth.md](auth.md) | `auth` | 15 |
+| [avatar.md](avatar.md) | `avatar` | 2 |
+| [broadcasting.md](broadcasting.md) | `broadcasting` | 1 |
+| [changelog.md](changelog.md) | `changelog` | 1 |
+| [core-update.md](core-update.md) | `core-update` | 2 |
+| [dashboard.md](dashboard.md) | `dashboard` | 5 |
+| [extensions.md](extensions.md) | `extensions` | 3 |
+| [identity.md](identity.md) | `identity` | 27 |
+| [language-packs.md](language-packs.md) | `language-packs` | 15 |
+| [layouts.md](layouts.md) | `layouts` | 2 |
+| [license.md](license.md) | `license` | 1 |
+| [locales.md](locales.md) | `locales` | 1 |
+| [me.md](me.md) | `me` | 3 |
+| [menus.md](menus.md) | `menus` | 10 |
+| [modules.md](modules.md) | `modules` | 25 |
+| [notification-channels.md](notification-channels.md) | `notification-channels` | 1 |
+| [notification-definitions.md](notification-definitions.md) | `notification-definitions` | 5 |
+| [notification-logs.md](notification-logs.md) | `notification-logs` | 3 |
+| [notification-templates.md](notification-templates.md) | `notification-templates` | 4 |
+| [notifications.md](notifications.md) | `notifications` | 14 |
+| [password.md](password.md) | `password` | 1 |
+| [permissions.md](permissions.md) | `permissions` | 1 |
+| [plugins.md](plugins.md) | `plugins` | 27 |
+| [profile.md](profile.md) | `profile` | 4 |
+| [roles.md](roles.md) | `roles` | 7 |
+| [schedules.md](schedules.md) | `schedules` | 12 |
+| [search.md](search.md) | `search` | 1 |
+| [seo.md](seo.md) | `seo` | 5 |
+| [settings.md](settings.md) | `settings` | 15 |
+| [templates.md](templates.md) | `templates` | 57 |
+| [users.md](users.md) | `users` | 12 |
+| [verify-password.md](verify-password.md) | `verify-password` | 1 |
+
+
+
+## 확장 API 레퍼런스
+
+> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
+
+
+- **확장 수**: 9 · **엔드포인트 수**: 372
+
+| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
+| --- | --- | --- | --- |
+| `gnuboard7-hello_module` | 모듈 | [docs/api/](../../../modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 |
+| `sirsoft-board` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
+| `sirsoft-ecommerce` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 |
+| `sirsoft-page` | 모듈 | [docs/api/](../../../modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
+| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
+| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
+| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
+| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
+| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
+
+
diff --git a/docs/backend/api/activity-logs.md b/docs/backend/api/activity-logs.md
new file mode 100644
index 00000000..4e66ebdd
--- /dev/null
+++ b/docs/backend/api/activity-logs.md
@@ -0,0 +1,291 @@
+# Activity Logs API 레퍼런스
+
+> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Activity Logs 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
+3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### GET /api/admin/activity-logs
+
+- **라우트명**: `api.admin.activity-logs.index`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\ActivityLogController@index`
+- **인증/권한**: `auth:sanctum` + `permission:core.activities.read`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| log_type | query | array | 아니오 | — | 로그 유형 필터 (원소별 값: admin 관리자, user 사용자, system 시스템 — ActivityLogType Enum). 배열로 다중 유형 동시 조회 가능 |
+| action | query | string | 아니오 | max 100 | 액션 유형 필터 (예: created, updated, deleted, login — action 필드 부분/일치 검색 대상) |
+| user_id | query | integer | 아니오 | — | user 식별자 |
+| loggable_type | query | string | 아니오 | max 255 | 연관 리소스 모델 클래스명 필터 (예: App\Models\User — 특정 엔티티 유형의 로그만 조회) |
+| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) |
+| search_type | query | string | 아니오 | — | 검색 유형 (검색 대상/방식 구분) |
+| created_by | query | string | 아니오 | max 36 | 로그를 생성한 행위 주체 식별자 필터 (행위자 기준 조회) |
+| date_from | query | date | 아니오 | — | 조회 기간 시작일 |
+| date_to | query | date | 아니오 | — | 조회 기간 종료일 |
+| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
+| sort_by | query | string | 아니오 | — | 정렬 기준 필드명 |
+| sort_order | query | string | 아니오 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.activity_log.index_validation_rules`).
+
+**요청 예시**
+
+```http
+GET /api/admin/activity-logs?log_type=%EC%98%88%EC%8B%9C%EA%B0%92&action=%EC%98%88%EC%8B%9C%EA%B0%92&user_id=1&loggable_type=%EC%98%88%EC%8B%9C%EA%B0%92&search=%EC%98%88%EC%8B%9C%EA%B0%92&search_type=%EC%98%88%EC%8B%9C%EA%B0%92&created_by=%EC%98%88%EC%8B%9C%EA%B0%92&date_from=2026-01-01&date_to=2026-01-01&per_page=1&sort_by=%EC%98%88%EC%8B%9C%EA%B0%92&sort_order=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| number | integer | `87675` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) |
+| id | integer | `210842` | 기본 키 (내부 식별자) |
+| log_type | string | `user` | 로그 유형 (admin: 관리자, user: 사용자, system: 시스템) |
+| log_type_label | string | `사용자` | `log_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
+| loggable_type | string | `Modules\Sirsoft\Board\Models\Attachment` | 로그가 연관된 대상 리소스의 모델 클래스 FQCN (loggable 다형성 관계 타입) |
+| loggable_type_display | string | `Attachment` | `loggable_type` 의 표시용 짧은 이름 (네임스페이스 제외 클래스명 파생) |
+| loggable_id | integer | `155` | loggable 식별자 (연관 리소스 참조) |
+| action | string | `attachment.download` | 액션 유형 (created, updated, deleted, login, export 등) |
+| action_label | string | `다운로드` | `action` 값의 사람이 읽는 라벨 (현지화/Enum 파생) |
+| localized_description | string | `첨부파일 다운로드 (게시물: 237)` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
+| description_key | string | `sirsoft-board::activity_log.descripti…` | 다국어 번역 키 (예: activity_log.description.user_create) |
+| properties | object | `{"original_filename":"apidoc-sample.png","post_id":237,"c…` | 변경 상세 데이터 (old/new 값) |
+| changes | object | `{"status":{"old":"inactive","new":"active","label":""}}` | 구조화된 변경 이력 (필드별 label_key, old, new, type) |
+| bulk_changes | null | `null` | 일괄 수정 로그의 모델별 변경 이력 배열 (원소: model_id + changes[]). 단일 수정 로그이면 null이고 대신 changes 필드가 채워짐 |
+| has_changes | boolean | `false` | changes 여부 |
+| actor_name | string | `API 문서 샘플 사용자` | 행위를 수행한 주체(사용자/시스템)의 이름 |
+| user | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 행위를 수행한 사용자 정보 (uuid/name/email). 시스템 발생 로그로 사용자가 없으면 name 에 "시스템" 라벨만 담김 |
+| ip_address | string | `127.0.0.1` | IP 주소 (IPv6 대응) |
+| created_at | string | `2026-07-07 10:00:47` | 생성 일시 |
+| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
+| abilities | object | `{"can_read":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "활동 로그 정보를 성공적으로 가져왔습니다.",
+ "data": {
+ "data": [
+ {
+ "number": 22,
+ "id": 22,
+ "log_type": "admin",
+ "log_type_label": "관리자",
+ "loggable_type": null,
+ "loggable_type_display": null,
+ "loggable_id": null,
+ "action": "user.index",
+ "action_label": "목록 조회",
+ "localized_description": "사용자 목록 조회",
+ "description_key": "activity_log.description.user_index",
+ "properties": {
+ "result_count": 2
+ },
+ "changes": null,
+ "bulk_changes": null,
+ "has_changes": false,
+ "actor_name": "API 문서 샘플 사용자",
+ "user": {
+ "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
+ "name": "API 문서 샘플 사용자",
+ "email": "apidoc-sample-user@example.com"
+ },
+ "ip_address": "127.0.0.1",
+ "created_at": "2026-07-08 12:12:12",
+ "is_owner": true,
+ "abilities": {
+ "can_read": true,
+ "can_delete": true
+ }
+ },
+ {
+ "number": 21,
+ "id": 21,
+ "log_type": "admin",
+ "log_type_label": "관리자",
+ "loggable_type": null,
+ "loggable_type_display": null,
+ "loggable_id": null,
+ "action": "user.index",
+ "action_label": "목록 조회",
+ "localized_description": "사용자 목록 조회",
+ "description_key": "activity_log.description.user_index",
+ "properties": {
+ "result_count": 2
+ },
+ "changes": null,
+ "bulk_changes": null,
+ "has_changes": false,
+ "actor_name": "API 문서 샘플 사용자",
+ "user": {
+ "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
+ "name": "API 문서 샘플 사용자",
+ "email": "apidoc-sample-user@example.com"
+ },
+ "ip_address": "127.0.0.1",
+ "created_at": "2026-07-08 12:08:26",
+ "is_owner": true,
+ "abilities": {
+ "can_read": true,
+ "can_delete": true
+ }
+ },
+ "... (총 22건 중 2건 표시)"
+ ],
+ "pagination": {
+ "current_page": 1,
+ "last_page": 1,
+ "per_page": 25,
+ "total": 22,
+ "from": 1,
+ "to": 22,
+ "has_more_pages": false
+ },
+ "abilities": {
+ "can_delete": true
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.activities.read`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명** 시스템 활동 로그를 페이지네이션 목록으로 조회합니다. `log_type`(admin/user/system), `action`, `user_id`, `loggable_type`, 기간(`date_from`/`date_to`), 키워드(`search`) 등으로 필터링하고 `sort_by`/`sort_order`로 정렬합니다. null 값 필터는 자동으로 제외됩니다. `core.activities.read` 권한이 필요하며, 각 항목에는 현지화된 액션 라벨·변경 이력(changes)·소유자/권한 메타가 포함됩니다. 확장은 `core.activity_log.index_validation_rules` 훅으로 필터 파라미터를 추가할 수 있습니다.
+
+
+### POST /api/admin/activity-logs/bulk-delete
+
+- **라우트명**: `api.admin.activity-logs.bulk-destroy`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\ActivityLogController@bulkDestroy`
+- **인증/권한**: `auth:sanctum` + `permission:core.activities.delete`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) |
+
+**요청 예시**
+
+```http
+POST /api/admin/activity-logs/bulk-delete HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "ids": [
+ "예시값"
+ ]
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명** 지정한 활동 로그들을 일괄 삭제합니다. `ids` 배열에 삭제할 로그 ID를 담아 요청하며, 서비스가 각 항목을 삭제하고 실제 삭제된 건수(`deleted_count`)를 반환합니다. `core.activities.delete` 권한이 필요합니다. 로그 목록에서 여러 항목을 선택해 한 번에 정리하는 시나리오에 사용합니다.
+
+
+### DELETE /api/admin/activity-logs/{activityLog}
+
+- **라우트명**: `api.admin.activity-logs.destroy`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\ActivityLogController@destroy`
+- **인증/권한**: `auth:sanctum` + `permission:core.activities.delete`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| activityLog | path | string | 예 | — | 대상 activity log의 식별자 |
+
+**요청 예시**
+
+```http
+DELETE /api/admin/activity-logs/1 HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "활동 로그가 삭제되었습니다.",
+ "data": null
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 |
+| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+
+
+
+**설명** 단일 활동 로그를 삭제합니다. 경로의 `{activityLog}`는 라우트 모델 바인딩으로 로그 ID를 받아 해당 레코드를 삭제합니다. `core.activities.delete` 권한이 필요하며, 삭제 실패 시 오류가 로그로 기록되고 500이 반환됩니다.
+
+
diff --git a/docs/backend/api/attachment.md b/docs/backend/api/attachment.md
new file mode 100644
index 00000000..ba6a2986
--- /dev/null
+++ b/docs/backend/api/attachment.md
@@ -0,0 +1,58 @@
+# Attachment API 레퍼런스
+
+> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Attachment 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
+3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### GET /api/attachment/{hash}
+
+- **라우트명**: `api.attachment.download`
+- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicAttachmentController@download`
+- **인증/권한**: 공개 (인증 불필요)
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 |
+
+**요청 예시**
+
+```http
+GET /api/attachment/{hash} HTTP/1.1
+Host: api.example.com
+Accept: application/json
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+
+
+
+**설명** 해시(12자)로 식별되는 첨부파일을 다운로드합니다. 이미지 파일은 캐싱 헤더와 함께 인라인으로 표시하고 그 외 파일은 다운로드 방식으로 제공합니다. 인증이 필요 없는 공개 라우트이지만 접근 권한은 AttachmentService가 로그인/비로그인 사용자 모두를 대상으로 하이브리드 방식으로 검사하며, 파일이 없으면 404, 권한이 없으면 403을 반환합니다. 게시글 첨부·상품 이미지 등 공개 리소스를 URL로 직접 내려받는 시나리오에 사용합니다.
+
+
diff --git a/docs/backend/api/attachments.md b/docs/backend/api/attachments.md
new file mode 100644
index 00000000..279ef002
--- /dev/null
+++ b/docs/backend/api/attachments.md
@@ -0,0 +1,250 @@
+# Attachments API 레퍼런스
+
+> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Attachments 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
+3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### POST /api/admin/attachments
+
+- **라우트명**: `api.admin.attachments.upload`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\AttachmentController@upload`
+- **인증/권한**: `auth:sanctum` + `permission:core.attachments.create`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| file | body | file | 예 | max 10240 | 업로드 파일 |
+| attachmentable_type | body | string | 아니오 | max 255 | 첨부를 연결할 대상 모델의 다형성 타입 (attachmentable morph type, 예 User·Post 등 모델 클래스명). attachmentable_id와 짝을 이뤄 대상을 지정하며 미지정 시 미연결 상태로 저장 |
+| attachmentable_id | body | integer | 아니오 | min 1 | attachmentable 식별자 |
+| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) |
+| source_type | body | string | 아니오 | — | 첨부 생성 출처 구분 (AttachmentSourceType Enum — core: 코어 시스템, module: 모듈, plugin: 플러그인). 미지정 시 core로 기본 설정 |
+| source_identifier | body | string | 아니오 | max 255 | 출처 식별자 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.upload_validation_rules`).
+
+**요청 예시**
+
+```http
+POST /api/admin/attachments HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: multipart/form-data; boundary=----G7ExampleBoundary
+
+------G7ExampleBoundary
+Content-Disposition: form-data; name="file"; filename="example.pdf"
+Content-Type: application/octet-stream
+
+(바이너리 파일 내용)
+------G7ExampleBoundary
+Content-Disposition: form-data; name="attachmentable_type"
+
+예시값
+------G7ExampleBoundary
+Content-Disposition: form-data; name="attachmentable_id"
+
+1
+------G7ExampleBoundary
+Content-Disposition: form-data; name="collection"
+
+예시값
+------G7ExampleBoundary
+Content-Disposition: form-data; name="source_type"
+
+예시값
+------G7ExampleBoundary
+Content-Disposition: form-data; name="source_identifier"
+
+example-key
+------G7ExampleBoundary--
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명** 단일 파일을 업로드해 첨부파일(Attachment) 레코드로 등록합니다. `attachmentable_type`/`attachmentable_id`로 대상 모델과의 다형성 연결을, `collection`으로 그룹을 지정하며 미지정 시 각각 미연결·`default` 컬렉션으로 저장됩니다. `core.attachments.create` 권한이 필요하며, 성공 시 201과 함께 생성된 첨부파일 리소스를 반환합니다. 확장은 `core.attachment.upload_validation_rules` 훅으로 검증 규칙을 추가할 수 있고, `source_type`/`source_identifier`로 업로드 출처(코어/확장)를 식별합니다.
+
+
+### POST /api/admin/attachments/batch
+
+- **라우트명**: `api.admin.attachments.upload_batch`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\AttachmentController@uploadBatch`
+- **인증/권한**: `auth:sanctum` + `permission:core.attachments.create`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| files | body | array | 예 | min 1 | 업로드 파일 배열 |
+| attachmentable_type | body | string | 아니오 | max 255 | 첨부를 연결할 대상 모델의 다형성 타입 (attachmentable morph type, 예 User·Post 등 모델 클래스명). attachmentable_id와 짝을 이뤄 대상을 지정하며 미지정 시 미연결 상태로 저장 |
+| attachmentable_id | body | integer | 아니오 | min 1 | attachmentable 식별자 |
+| collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) |
+| source_type | body | string | 아니오 | — | 첨부 생성 출처 구분 (AttachmentSourceType Enum — core: 코어 시스템, module: 모듈, plugin: 플러그인). 미지정 시 core로 기본 설정 |
+| source_identifier | body | string | 아니오 | max 255 | 출처 식별자 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.upload_batch_validation_rules`).
+
+**요청 예시**
+
+```http
+POST /api/admin/attachments/batch HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "files": [
+ "예시값"
+ ],
+ "attachmentable_type": "예시값",
+ "attachmentable_id": 1,
+ "collection": "예시값",
+ "source_type": "예시값",
+ "source_identifier": "example-key"
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명** 여러 파일을 한 번의 요청으로 일괄 업로드합니다. `files` 배열의 각 파일이 개별 첨부파일 레코드로 등록되며, `attachmentable_type`/`attachmentable_id`/`collection` 등의 옵션은 배치 전체에 공통 적용됩니다. `core.attachments.create` 권한이 필요하고, 성공 시 201과 함께 생성된 첨부파일 리소스 컬렉션을 반환합니다. 갤러리·다중 이미지 첨부처럼 한 대상에 여러 파일을 붙이는 시나리오에 사용합니다.
+
+
+### PATCH /api/admin/attachments/reorder
+
+- **라우트명**: `api.admin.attachments.reorder`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\AttachmentController@reorder`
+- **인증/권한**: `auth:sanctum` + `permission:core.attachments.update`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| order | body | array | 예 | min 1 | 재정렬 대상 목록. 각 원소는 `id`(기존 첨부파일 식별자)와 `order`(새 정렬 순서값, 0 이상 정수)를 가진 객체이며, 이 매핑대로 각 첨부의 정렬 값이 갱신됨 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.attachment.reorder_validation_rules`).
+
+**요청 예시**
+
+```http
+PATCH /api/admin/attachments/reorder HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "order": [
+ "예시값"
+ ]
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.attachments.update`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명** 첨부파일의 표시 순서를 재정렬합니다. `order` 배열에 담긴 순서대로 각 첨부파일의 정렬 값이 갱신됩니다. `core.attachments.update` 권한이 필요합니다. 갤러리에서 드래그 앤 드롭으로 이미지 순서를 바꾸는 등 이미 등록된 첨부파일의 나열 순서만 변경할 때 사용합니다.
+
+
+### DELETE /api/admin/attachments/{attachment}
+
+- **라우트명**: `api.admin.attachments.destroy`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\AttachmentController@destroy`
+- **인증/권한**: `auth:sanctum` + `permission:core.attachments.delete`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| attachment | path | string | 예 | — | 대상 attachment의 식별자 |
+
+**요청 예시**
+
+```http
+DELETE /api/admin/attachments/{attachment} HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.attachments.delete`)이 없는 경우 |
+| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+
+
+
+**설명** 지정한 첨부파일을 삭제합니다. 경로의 `{attachment}`는 라우트 모델 바인딩으로 첨부파일 ID를 받으며, 서비스가 DB 레코드와 실제 저장 파일을 함께 제거합니다. `core.attachments.delete` 권한이 필요합니다. 존재하지 않는 ID면 404가 반환됩니다.
+
+
diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md
new file mode 100644
index 00000000..8c2880f2
--- /dev/null
+++ b/docs/backend/api/auth.md
@@ -0,0 +1,3001 @@
+# Auth API 레퍼런스
+
+> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Auth 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
+3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### POST /api/admin/auth/logout
+
+- **라우트명**: `api.admin.auth.logout`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@logout`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+POST /api/admin/auth/logout HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "로그아웃이 성공했습니다.",
+ "data": null
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+
+
+
+**설명**
+
+현재 관리자의 Sanctum 토큰을 폐기해 로그아웃한다. `AuthService::logout()` 이 3단계(토큰 삭제 → 세션 무효화 → `Auth::logout()`)를 수행하며, `data` 는 없고 `message` 만 `auth.logout_success` 로 내려온다. 프론트는 응답 후 저장된 Bearer 토큰을 폐기하고 로그인 화면으로 전환한다.
+
+
+### POST /api/admin/auth/refresh
+
+- **라우트명**: `api.admin.auth.refresh`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@refresh`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+POST /api/admin/auth/refresh HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| user | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) |
+| token | string | `75\|WgPUplvLGTv8YIj4507uIR6dEOHTXyNUed…` | 발급된 API 접근 토큰 평문 (Bearer 토큰으로 사용, 발급 시 1회만 노출) |
+| token_type | string | `Bearer` | 토큰 타입 (일반적으로 Bearer) |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "성공적으로 처리되었습니다.",
+ "data": {
+ "user": {
+ "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
+ "name": "API 문서 샘플 사용자",
+ "nickname": "song.hyunji",
+ "email": "apidoc-sample-user@example.com",
+ "avatar": null,
+ "language": "ko",
+ "language_label": "한국어",
+ "country": "KR",
+ "status": "active",
+ "status_label": "활성",
+ "status_variant": "success",
+ "is_admin": true,
+ "homepage": "https://example.com",
+ "mobile": "010-9595-2897",
+ "phone": "02-637-5618",
+ "zipcode": "16505",
+ "address": "경기도 안양시 봉은사로 2918",
+ "address_detail": "48동 718호",
+ "signature": "Fugit consequuntur repellendus sed.",
+ "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.",
+ "last_login_at": "2026-07-07 10:41:24",
+ "email_verified_at": "2026-07-08 10:41:24",
+ "timezone": "Asia/Seoul",
+ "created_at": "2026-07-08 10:41:24",
+ "updated_at": "2026-07-08 10:41:24",
+ "is_owner": true,
+ "abilities": {
+ "can_read": true,
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true,
+ "can_assign_roles": true
+ }
+ },
+ "token": "{MASKED}",
+ "token_type": "Bearer"
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+
+
+
+**설명**
+
+현재 관리자 토큰을 새 Sanctum 토큰으로 교체한다. `AuthService::refreshToken()` 이 기존 토큰을 폐기하고 새 토큰을 발급하며, `data` 에는 새 `token` 과 `user`(UserResource) 가 담긴다. 만료 임박 토큰을 재발급하는 용도로, 세션 만료로 재인증이 필요한 경우(토큰 무효)에는 `401 auth.unauthenticated` 를 반환한다.
+
+
+### GET /api/admin/auth/user
+
+- **라우트명**: `api.admin.auth.user`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@user`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/admin/auth/user HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) |
+| name | string | `API 문서 샘플 사용자` | 사용자 이름 |
+| nickname | string | `gunwoo.oh` | 닉네임 |
+| email | string | `apidoc-sample-user@example.com` | 이메일 주소 |
+| avatar | null | `null` | 아바타 이미지 URL (User::getAvatarUrl() — 아바타 미설정 시 null) |
+| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) |
+| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (user.language.{code} 번역) |
+| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) |
+| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) |
+| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) |
+| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) |
+| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) |
+| homepage | string | `https://example.com` | 홈페이지 URL |
+| mobile | string | `010-9070-5662` | 휴대폰 번호 |
+| phone | string | `02-805-4759` | 전화번호 |
+| zipcode | string | `93153` | 우편번호 |
+| address | string | `대구광역시 북구 백제고분로 720` | 기본 주소 |
+| address_detail | string | `40동 835호` | 상세 주소 |
+| signature | string | `Ipsam rem amet expedita est.` | 서명 |
+| bio | string | `Tenetur omnis et amet omnis veniam to…` | 자기소개 |
+| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 |
+| email_verified_at | string | `2026-07-06 19:15:16` | email verified 일시 |
+| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) |
+| roles | array | `[{"id":1,"identifier":"admin","name":"관리자"}]` | 사용자에게 부여된 역할 목록 (원소 id/identifier/name — roles 관계 파생, name 은 현지화 라벨) |
+| permissions | array | `[{"id":3,"identifier":"core.users.read","name":"사용자 조회"},…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) |
+| created_at | string | `2026-07-06 19:15:16` | 생성 일시 |
+| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 |
+| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
+| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "성공적으로 처리되었습니다.",
+ "data": {
+ "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
+ "name": "API 문서 샘플 사용자",
+ "nickname": "song.hyunji",
+ "email": "apidoc-sample-user@example.com",
+ "avatar": null,
+ "language": "ko",
+ "language_label": "한국어",
+ "country": "KR",
+ "status": "active",
+ "status_label": "활성",
+ "status_variant": "success",
+ "is_admin": true,
+ "homepage": "https://example.com",
+ "mobile": "010-9595-2897",
+ "phone": "02-637-5618",
+ "zipcode": "16505",
+ "address": "경기도 안양시 봉은사로 2918",
+ "address_detail": "48동 718호",
+ "signature": "Fugit consequuntur repellendus sed.",
+ "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.",
+ "last_login_at": "2026-07-07 10:41:24",
+ "email_verified_at": "2026-07-08 10:41:24",
+ "timezone": "Asia/Seoul",
+ "roles": [
+ {
+ "id": 1,
+ "identifier": "admin",
+ "name": "관리자"
+ },
+ {
+ "id": 4,
+ "identifier": "apidoc-sample-role",
+ "name": "API 문서 샘플 역할"
+ }
+ ],
+ "permissions": [
+ {
+ "id": 5,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read",
+ "name": "게시글 조회 (관리자)"
+ },
+ {
+ "id": 6,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.write",
+ "name": "게시글 작성/수정/삭제 (관리자)"
+ },
+ {
+ "id": 7,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read-secret",
+ "name": "비밀글 조회 (관리자)"
+ },
+ {
+ "id": 8,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.read",
+ "name": "댓글 조회 (관리자)"
+ },
+ {
+ "id": 9,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.write",
+ "name": "댓글 작성/수정/삭제 (관리자)"
+ },
+ {
+ "id": 10,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.upload",
+ "name": "파일 업로드/삭제 (관리자)"
+ },
+ {
+ "id": 11,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.download",
+ "name": "파일 다운로드 (관리자)"
+ },
+ {
+ "id": 12,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.manage",
+ "name": "게시판 관리 (관리자)"
+ },
+ {
+ "id": 13,
+ "identifier": "sirsoft-board.apidoc-sample-board.posts.read",
+ "name": "게시글 조회"
+ },
+ {
+ "id": 14,
+ "identifier": "sirsoft-board.apidoc-sample-board.posts.write",
+ "name": "게시글 작성/수정/삭제"
+ },
+ {
+ "id": 15,
+ "identifier": "sirsoft-board.apidoc-sample-board.posts.read-secret",
+ "name": "비밀글 조회"
+ },
+ {
+ "id": 16,
+ "identifier": "sirsoft-board.apidoc-sample-board.comments.read",
+ "name": "댓글 조회"
+ },
+ {
+ "id": 17,
+ "identifier": "sirsoft-board.apidoc-sample-board.comments.write",
+ "name": "댓글 작성/수정/삭제"
+ },
+ {
+ "id": 18,
+ "identifier": "sirsoft-board.apidoc-sample-board.attachments.upload",
+ "name": "파일 업로드/삭제"
+ },
+ {
+ "id": 19,
+ "identifier": "sirsoft-board.apidoc-sample-board.attachments.download",
+ "name": "파일 다운로드"
+ },
+ {
+ "id": 20,
+ "identifier": "sirsoft-board.apidoc-sample-board.manager",
+ "name": "게시판 관리 (사용자)"
+ },
+ {
+ "id": 81,
+ "identifier": "sirsoft-ecommerce.user-products.read",
+ "name": "상품 조회"
+ },
+ {
+ "id": 83,
+ "identifier": "sirsoft-ecommerce.user-orders.create",
+ "name": "주문하기"
+ },
+ {
+ "id": 84,
+ "identifier": "sirsoft-ecommerce.user-orders.cancel",
+ "name": "주문 취소"
+ },
+ {
+ "id": 85,
+ "identifier": "sirsoft-ecommerce.user-orders.confirm",
+ "name": "구매확정"
+ },
+ {
+ "id": 87,
+ "identifier": "sirsoft-ecommerce.user-reviews.write",
+ "name": "리뷰 작성"
+ },
+ {
+ "id": 23,
+ "identifier": "sirsoft-ecommerce.products.read",
+ "name": "상품 조회"
+ },
+ {
+ "id": 24,
+ "identifier": "sirsoft-ecommerce.products.create",
+ "name": "상품 생성"
+ },
+ {
+ "id": 25,
+ "identifier": "sirsoft-ecommerce.products.update",
+ "name": "상품 수정"
+ },
+ {
+ "id": 26,
+ "identifier": "sirsoft-ecommerce.products.delete",
+ "name": "상품 삭제"
+ },
+ {
+ "id": 28,
+ "identifier": "sirsoft-ecommerce.orders.read",
+ "name": "주문 조회"
+ },
+ {
+ "id": 29,
+ "identifier": "sirsoft-ecommerce.orders.update",
+ "name": "주문 수정"
+ },
+ {
+ "id": 31,
+ "identifier": "sirsoft-ecommerce.categories.read",
+ "name": "카테고리 조회"
+ },
+ {
+ "id": 32,
+ "identifier": "sirsoft-ecommerce.categories.create",
+ "name": "카테고리 생성"
+ },
+ {
+ "id": 33,
+ "identifier": "sirsoft-ecommerce.categories.update",
+ "name": "카테고리 수정"
+ },
+ {
+ "id": 34,
+ "identifier": "sirsoft-ecommerce.categories.delete",
+ "name": "카테고리 삭제"
+ },
+ {
+ "id": 36,
+ "identifier": "sirsoft-ecommerce.brands.read",
+ "name": "브랜드 조회"
+ },
+ {
+ "id": 37,
+ "identifier": "sirsoft-ecommerce.brands.create",
+ "name": "브랜드 생성"
+ },
+ {
+ "id": 38,
+ "identifier": "sirsoft-ecommerce.brands.update",
+ "name": "브랜드 수정"
+ },
+ {
+ "id": 39,
+ "identifier": "sirsoft-ecommerce.brands.delete",
+ "name": "브랜드 삭제"
+ },
+ {
+ "id": 41,
+ "identifier": "sirsoft-ecommerce.product-notice-templates.read",
+ "name": "조회"
+ },
+ {
+ "id": 42,
+ "identifier": "sirsoft-ecommerce.product-notice-templates.create",
+ "name": "생성"
+ },
+ {
+ "id": 43,
+ "identifier": "sirsoft-ecommerce.product-notice-templates.update",
+ "name": "수정"
+ },
+ {
+ "id": 44,
+ "identifier": "sirsoft-ecommerce.product-notice-templates.delete",
+ "name": "삭제"
+ },
+ {
+ "id": 46,
+ "identifier": "sirsoft-ecommerce.product-common-infos.read",
+ "name": "조회"
+ },
+ {
+ "id": 47,
+ "identifier": "sirsoft-ecommerce.product-common-infos.create",
+ "name": "생성"
+ },
+ {
+ "id": 48,
+ "identifier": "sirsoft-ecommerce.product-common-infos.update",
+ "name": "수정"
+ },
+ {
+ "id": 49,
+ "identifier": "sirsoft-ecommerce.product-common-infos.delete",
+ "name": "삭제"
+ },
+ {
+ "id": 51,
+ "identifier": "sirsoft-ecommerce.settings.read",
+ "name": "환경설정 조회"
+ },
+ {
+ "id": 52,
+ "identifier": "sirsoft-ecommerce.settings.update",
+ "name": "환경설정 수정"
+ },
+ {
+ "id": 54,
+ "identifier": "sirsoft-ecommerce.promotion-coupon.read",
+ "name": "쿠폰 조회"
+ },
+ {
+ "id": 55,
+ "identifier": "sirsoft-ecommerce.promotion-coupon.create",
+ "name": "쿠폰 생성"
+ },
+ {
+ "id": 56,
+ "identifier": "sirsoft-ecommerce.promotion-coupon.update",
+ "name": "쿠폰 수정"
+ },
+ {
+ "id": 57,
+ "identifier": "sirsoft-ecommerce.promotion-coupon.delete",
+ "name": "쿠폰 삭제"
+ },
+ {
+ "id": 59,
+ "identifier": "sirsoft-ecommerce.shipping-policies.read",
+ "name": "배송정책 조회"
+ },
+ {
+ "id": 60,
+ "identifier": "sirsoft-ecommerce.shipping-policies.create",
+ "name": "배송정책 생성"
+ },
+ {
+ "id": 61,
+ "identifier": "sirsoft-ecommerce.shipping-policies.update",
+ "name": "배송정책 수정"
+ },
+ {
+ "id": 62,
+ "identifier": "sirsoft-ecommerce.shipping-policies.delete",
+ "name": "배송정책 삭제"
+ },
+ {
+ "id": 64,
+ "identifier": "sirsoft-ecommerce.product-labels.read",
+ "name": "상품 라벨 조회"
+ },
+ {
+ "id": 65,
+ "identifier": "sirsoft-ecommerce.product-labels.create",
+ "name": "상품 라벨 생성"
+ },
+ {
+ "id": 66,
+ "identifier": "sirsoft-ecommerce.product-labels.update",
+ "name": "상품 라벨 수정"
+ },
+ {
+ "id": 67,
+ "identifier": "sirsoft-ecommerce.product-labels.delete",
+ "name": "상품 라벨 삭제"
+ },
+ {
+ "id": 69,
+ "identifier": "sirsoft-ecommerce.identity.policies.read",
+ "name": "본인인증 정책 조회"
+ },
+ {
+ "id": 70,
+ "identifier": "sirsoft-ecommerce.identity.policies.update",
+ "name": "본인인증 정책 수정"
+ },
+ {
+ "id": 72,
+ "identifier": "sirsoft-ecommerce.reviews.read",
+ "name": "리뷰 조회"
+ },
+ {
+ "id": 73,
+ "identifier": "sirsoft-ecommerce.reviews.update",
+ "name": "리뷰 처리"
+ },
+ {
+ "id": 74,
+ "identifier": "sirsoft-ecommerce.reviews.delete",
+ "name": "리뷰 삭제"
+ },
+ {
+ "id": 76,
+ "identifier": "sirsoft-ecommerce.inquiries.update",
+ "name": "문의 처리"
+ },
+ {
+ "id": 77,
+ "identifier": "sirsoft-ecommerce.inquiries.delete",
+ "name": "문의 삭제"
+ },
+ {
+ "id": 79,
+ "identifier": "sirsoft-ecommerce.dashboard.view",
+ "name": "대시보드 조회"
+ },
+ {
+ "id": 89,
+ "identifier": "sirsoft-ecommerce.mileage.read",
+ "name": "마일리지 내역 조회"
+ },
+ {
+ "id": 90,
+ "identifier": "sirsoft-ecommerce.mileage.manage",
+ "name": "마일리지 수동 처리"
+ },
+ {
+ "id": 92,
+ "identifier": "sirsoft-ecommerce.user-currency.manage",
+ "name": "회원 결제 통화 변경"
+ },
+ {
+ "id": 94,
+ "identifier": "sirsoft-ecommerce.user-shipping-country.manage",
+ "name": "회원 배송국가 변경"
+ },
+ {
+ "id": 96,
+ "identifier": "sirsoft-board.boards.read",
+ "name": "게시판 조회"
+ },
+ {
+ "id": 97,
+ "identifier": "sirsoft-board.boards.create",
+ "name": "게시판 생성"
+ },
+ {
+ "id": 98,
+ "identifier": "sirsoft-board.boards.update",
+ "name": "게시판 수정"
+ },
+ {
+ "id": 99,
+ "identifier": "sirsoft-board.boards.delete",
+ "name": "게시판 삭제"
+ },
+ {
+ "id": 101,
+ "identifier": "sirsoft-board.settings.read",
+ "name": "환경설정 조회"
+ },
+ {
+ "id": 102,
+ "identifier": "sirsoft-board.settings.update",
+ "name": "환경설정 수정"
+ },
+ {
+ "id": 104,
+ "identifier": "sirsoft-board.identity.policies.read",
+ "name": "본인인증 정책 조회"
+ },
+ {
+ "id": 105,
+ "identifier": "sirsoft-board.identity.policies.update",
+ "name": "본인인증 정책 수정"
+ },
+ {
+ "id": 107,
+ "identifier": "sirsoft-board.reports.view",
+ "name": "신고 조회"
+ },
+ {
+ "id": 108,
+ "identifier": "sirsoft-board.reports.manage",
+ "name": "신고 처리"
+ },
+ {
+ "id": 111,
+ "identifier": "sirsoft-page.pages.read",
+ "name": "페이지 조회"
+ },
+ {
+ "id": 112,
+ "identifier": "sirsoft-page.pages.create",
+ "name": "페이지 생성"
+ },
+ {
+ "id": 113,
+ "identifier": "sirsoft-page.pages.update",
+ "name": "페이지 수정"
+ },
+ {
+ "id": 114,
+ "identifier": "sirsoft-page.pages.delete",
+ "name": "페이지 삭제"
+ },
+ {
+ "id": 117,
+ "identifier": "core.users.read",
+ "name": "사용자 조회"
+ },
+ {
+ "id": 118,
+ "identifier": "core.users.create",
+ "name": "사용자 생성"
+ },
+ {
+ "id": 119,
+ "identifier": "core.users.update",
+ "name": "사용자 수정"
+ },
+ {
+ "id": 120,
+ "identifier": "core.users.delete",
+ "name": "사용자 삭제"
+ },
+ {
+ "id": 122,
+ "identifier": "core.menus.read",
+ "name": "메뉴 조회"
+ },
+ {
+ "id": 123,
+ "identifier": "core.menus.create",
+ "name": "메뉴 생성"
+ },
+ {
+ "id": 124,
+ "identifier": "core.menus.update",
+ "name": "메뉴 수정"
+ },
+ {
+ "id": 125,
+ "identifier": "core.menus.delete",
+ "name": "메뉴 삭제"
+ },
+ {
+ "id": 127,
+ "identifier": "core.modules.read",
+ "name": "모듈 조회"
+ },
+ {
+ "id": 128,
+ "identifier": "core.modules.install",
+ "name": "모듈 설치"
+ },
+ {
+ "id": 129,
+ "identifier": "core.modules.activate",
+ "name": "모듈 활성화"
+ },
+ {
+ "id": 130,
+ "identifier": "core.modules.uninstall",
+ "name": "모듈 삭제"
+ },
+ {
+ "id": 132,
+ "identifier": "core.plugins.read",
+ "name": "플러그인 조회"
+ },
+ {
+ "id": 133,
+ "identifier": "core.plugins.install",
+ "name": "플러그인 설치"
+ },
+ {
+ "id": 134,
+ "identifier": "core.plugins.activate",
+ "name": "플러그인 활성화"
+ },
+ {
+ "id": 135,
+ "identifier": "core.plugins.update",
+ "name": "플러그인 설정"
+ },
+ {
+ "id": 136,
+ "identifier": "core.plugins.uninstall",
+ "name": "플러그인 삭제"
+ },
+ {
+ "id": 138,
+ "identifier": "core.templates.read",
+ "name": "템플릿 조회"
+ },
+ {
+ "id": 139,
+ "identifier": "core.templates.install",
+ "name": "템플릿 설치"
+ },
+ {
+ "id": 140,
+ "identifier": "core.templates.activate",
+ "name": "템플릿 활성화"
+ },
+ {
+ "id": 141,
+ "identifier": "core.templates.uninstall",
+ "name": "템플릿 삭제"
+ },
+ {
+ "id": 142,
+ "identifier": "core.templates.layouts.edit",
+ "name": "레이아웃 편집"
+ },
+ {
+ "id": 144,
+ "identifier": "core.permissions.read",
+ "name": "권한 조회"
+ },
+ {
+ "id": 145,
+ "identifier": "core.permissions.create",
+ "name": "역할 생성"
+ },
+ {
+ "id": 146,
+ "identifier": "core.permissions.update",
+ "name": "역할 수정"
+ },
+ {
+ "id": 147,
+ "identifier": "core.permissions.delete",
+ "name": "역할 삭제"
+ },
+ {
+ "id": 149,
+ "identifier": "core.notification-logs.read",
+ "name": "발송 이력 조회"
+ },
+ {
+ "id": 150,
+ "identifier": "core.notification-logs.delete",
+ "name": "발송 이력 삭제"
+ },
+ {
+ "id": 152,
+ "identifier": "core.notifications.read",
+ "name": "알림 조회"
+ },
+ {
+ "id": 153,
+ "identifier": "core.notifications.update",
+ "name": "알림 읽음 처리"
+ },
+ {
+ "id": 154,
+ "identifier": "core.notifications.delete",
+ "name": "알림 삭제"
+ },
+ {
+ "id": 156,
+ "identifier": "core.user-notifications.read",
+ "name": "알림 조회"
+ },
+ {
+ "id": 157,
+ "identifier": "core.user-notifications.update",
+ "name": "알림 읽음 처리"
+ },
+ {
+ "id": 158,
+ "identifier": "core.user-notifications.delete",
+ "name": "알림 삭제"
+ },
+ {
+ "id": 160,
+ "identifier": "core.identity.request",
+ "name": "IDV 요청"
+ },
+ {
+ "id": 161,
+ "identifier": "core.identity.verify",
+ "name": "IDV 검증"
+ },
+ {
+ "id": 162,
+ "identifier": "core.identity.cancel",
+ "name": "IDV 취소"
+ },
+ {
+ "id": 164,
+ "identifier": "core.admin.identity.providers.read",
+ "name": "프로바이더 설정 조회"
+ },
+ {
+ "id": 165,
+ "identifier": "core.admin.identity.providers.update",
+ "name": "프로바이더 설정 수정"
+ },
+ {
+ "id": 166,
+ "identifier": "core.admin.identity.policies.read",
+ "name": "정책 조회"
+ },
+ {
+ "id": 167,
+ "identifier": "core.admin.identity.policies.update",
+ "name": "정책 수정"
+ },
+ {
+ "id": 168,
+ "identifier": "core.admin.identity.logs.read",
+ "name": "로그 열람"
+ },
+ {
+ "id": 169,
+ "identifier": "core.admin.identity.logs.purge",
+ "name": "로그 파기"
+ },
+ {
+ "id": 170,
+ "identifier": "core.admin.identity.messages.read",
+ "name": "메시지 템플릿 조회"
+ },
+ {
+ "id": 171,
+ "identifier": "core.admin.identity.messages.update",
+ "name": "메시지 템플릿 수정"
+ },
+ {
+ "id": 173,
+ "identifier": "core.settings.read",
+ "name": "설정 조회"
+ },
+ {
+ "id": 174,
+ "identifier": "core.settings.update",
+ "name": "설정 수정"
+ },
+ {
+ "id": 176,
+ "identifier": "core.dashboard.read",
+ "name": "대시보드 조회"
+ },
+ {
+ "id": 177,
+ "identifier": "core.dashboard.system-status",
+ "name": "시스템 상태"
+ },
+ {
+ "id": 178,
+ "identifier": "core.dashboard.resources",
+ "name": "시스템 리소스"
+ },
+ {
+ "id": 179,
+ "identifier": "core.dashboard.activities",
+ "name": "최근 활동"
+ },
+ {
+ "id": 180,
+ "identifier": "core.dashboard.alerts",
+ "name": "시스템 알림"
+ },
+ {
+ "id": 182,
+ "identifier": "core.activities.read",
+ "name": "활동 로그 조회"
+ },
+ {
+ "id": 183,
+ "identifier": "core.activities.delete",
+ "name": "활동 로그 삭제"
+ },
+ {
+ "id": 185,
+ "identifier": "core.attachments.create",
+ "name": "첨부파일 업로드"
+ },
+ {
+ "id": 186,
+ "identifier": "core.attachments.update",
+ "name": "첨부파일 수정"
+ },
+ {
+ "id": 187,
+ "identifier": "core.attachments.delete",
+ "name": "첨부파일 삭제"
+ },
+ {
+ "id": 189,
+ "identifier": "core.schedules.read",
+ "name": "스케줄 조회"
+ },
+ {
+ "id": 190,
+ "identifier": "core.schedules.create",
+ "name": "스케줄 생성"
+ },
+ {
+ "id": 191,
+ "identifier": "core.schedules.update",
+ "name": "스케줄 수정"
+ },
+ {
+ "id": 192,
+ "identifier": "core.schedules.delete",
+ "name": "스케줄 삭제"
+ },
+ {
+ "id": 193,
+ "identifier": "core.schedules.run",
+ "name": "스케줄 실행"
+ },
+ {
+ "id": 195,
+ "identifier": "core.language_packs.read",
+ "name": "언어팩 조회"
+ },
+ {
+ "id": 196,
+ "identifier": "core.language_packs.install",
+ "name": "언어팩 설치"
+ },
+ {
+ "id": 197,
+ "identifier": "core.language_packs.manage",
+ "name": "언어팩 관리"
+ },
+ {
+ "id": 198,
+ "identifier": "core.language_packs.update",
+ "name": "언어팩 업데이트"
+ },
+ {
+ "id": 1,
+ "identifier": "apidoc-sample.parent",
+ "name": "API 문서 샘플 권한"
+ },
+ {
+ "id": 2,
+ "identifier": "apidoc-sample.child",
+ "name": "하위 권한"
+ },
+ {
+ "id": 3,
+ "identifier": "sirsoft-board",
+ "name": "게시판"
+ },
+ {
+ "id": 21,
+ "identifier": "sirsoft-ecommerce",
+ "name": "이커머스"
+ },
+ {
+ "id": 109,
+ "identifier": "sirsoft-page",
+ "name": "페이지"
+ },
+ {
+ "id": 4,
+ "identifier": "sirsoft-board.apidoc-sample-board",
+ "name": "API 문서 샘플 게시판 게시판"
+ },
+ {
+ "id": 95,
+ "identifier": "sirsoft-board.boards",
+ "name": "게시판 관리"
+ },
+ {
+ "id": 100,
+ "identifier": "sirsoft-board.settings",
+ "name": "환경설정"
+ },
+ {
+ "id": 103,
+ "identifier": "sirsoft-board.identity.policies",
+ "name": "게시판 본인인증 정책"
+ },
+ {
+ "id": 106,
+ "identifier": "sirsoft-board.reports",
+ "name": "게시판 신고 관리"
+ },
+ {
+ "id": 22,
+ "identifier": "sirsoft-ecommerce.products",
+ "name": "상품 관리"
+ },
+ {
+ "id": 27,
+ "identifier": "sirsoft-ecommerce.orders",
+ "name": "주문 관리"
+ },
+ {
+ "id": 30,
+ "identifier": "sirsoft-ecommerce.categories",
+ "name": "카테고리 관리"
+ },
+ {
+ "id": 35,
+ "identifier": "sirsoft-ecommerce.brands",
+ "name": "브랜드 관리"
+ },
+ {
+ "id": 40,
+ "identifier": "sirsoft-ecommerce.product-notice-templates",
+ "name": "상품정보제공고시 관리"
+ },
+ {
+ "id": 45,
+ "identifier": "sirsoft-ecommerce.product-common-infos",
+ "name": "공통정보 관리"
+ },
+ {
+ "id": 50,
+ "identifier": "sirsoft-ecommerce.settings",
+ "name": "환경설정"
+ },
+ {
+ "id": 53,
+ "identifier": "sirsoft-ecommerce.promotion-coupon",
+ "name": "쿠폰 관리"
+ },
+ {
+ "id": 58,
+ "identifier": "sirsoft-ecommerce.shipping-policies",
+ "name": "배송정책 관리"
+ },
+ {
+ "id": 63,
+ "identifier": "sirsoft-ecommerce.product-labels",
+ "name": "상품 라벨 관리"
+ },
+ {
+ "id": 68,
+ "identifier": "sirsoft-ecommerce.identity.policies",
+ "name": "이커머스 본인인증 정책"
+ },
+ {
+ "id": 71,
+ "identifier": "sirsoft-ecommerce.reviews",
+ "name": "리뷰 관리"
+ },
+ {
+ "id": 75,
+ "identifier": "sirsoft-ecommerce.inquiries",
+ "name": "문의 관리"
+ },
+ {
+ "id": 78,
+ "identifier": "sirsoft-ecommerce.dashboard",
+ "name": "대시보드"
+ },
+ {
+ "id": 80,
+ "identifier": "sirsoft-ecommerce.user-products",
+ "name": "사용자 상품"
+ },
+ {
+ "id": 82,
+ "identifier": "sirsoft-ecommerce.user-orders",
+ "name": "사용자 주문"
+ },
+ {
+ "id": 86,
+ "identifier": "sirsoft-ecommerce.user-reviews",
+ "name": "사용자 리뷰"
+ },
+ {
+ "id": 88,
+ "identifier": "sirsoft-ecommerce.mileage",
+ "name": "마일리지 관리"
+ },
+ {
+ "id": 91,
+ "identifier": "sirsoft-ecommerce.user-currency",
+ "name": "회원 결제 통화 관리"
+ },
+ {
+ "id": 93,
+ "identifier": "sirsoft-ecommerce.user-shipping-country",
+ "name": "회원 배송국가 관리"
+ },
+ {
+ "id": 110,
+ "identifier": "sirsoft-page.pages",
+ "name": "페이지 관리"
+ },
+ {
+ "id": 115,
+ "identifier": "core",
+ "name": "코어"
+ },
+ {
+ "id": 116,
+ "identifier": "core.users",
+ "name": "사용자 관리"
+ },
+ {
+ "id": 121,
+ "identifier": "core.menus",
+ "name": "메뉴 관리"
+ },
+ {
+ "id": 126,
+ "identifier": "core.modules",
+ "name": "모듈 관리"
+ },
+ {
+ "id": 131,
+ "identifier": "core.plugins",
+ "name": "플러그인 관리"
+ },
+ {
+ "id": 137,
+ "identifier": "core.templates",
+ "name": "템플릿 관리"
+ },
+ {
+ "id": 143,
+ "identifier": "core.permissions",
+ "name": "권한 관리"
+ },
+ {
+ "id": 148,
+ "identifier": "core.notification-logs",
+ "name": "알림 발송 이력"
+ },
+ {
+ "id": 151,
+ "identifier": "core.notifications",
+ "name": "알림 (관리자)"
+ },
+ {
+ "id": 155,
+ "identifier": "core.user-notifications",
+ "name": "알림 (사용자)"
+ },
+ {
+ "id": 159,
+ "identifier": "core.identity",
+ "name": "본인인증 (사용자)"
+ },
+ {
+ "id": 163,
+ "identifier": "core.admin.identity",
+ "name": "본인인증 관리 (관리자)"
+ },
+ {
+ "id": 172,
+ "identifier": "core.settings",
+ "name": "환경설정"
+ },
+ {
+ "id": 175,
+ "identifier": "core.dashboard",
+ "name": "대시보드"
+ },
+ {
+ "id": 181,
+ "identifier": "core.activities",
+ "name": "활동 로그"
+ },
+ {
+ "id": 184,
+ "identifier": "core.attachments",
+ "name": "첨부파일 관리"
+ },
+ {
+ "id": 188,
+ "identifier": "core.schedules",
+ "name": "스케줄 관리"
+ },
+ {
+ "id": 194,
+ "identifier": "core.language_packs",
+ "name": "언어팩 관리"
+ }
+ ],
+ "created_at": "2026-07-08 10:41:24",
+ "updated_at": "2026-07-08 10:41:24",
+ "is_owner": true,
+ "abilities": {
+ "can_read": true,
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true,
+ "can_assign_roles": true
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+
+
+
+**설명**
+
+관리자 레이아웃 전역 부트스트랩 엔드포인트. `_admin_base.json` 의 `data_source`(`current_user`)가 모든 관리자 페이지 진입 시 자동 호출해, 헤더/권한 게이트/is_admin 분기의 기준 사용자 정보를 채운다. 응답에는 `roles.permissions` 가 eager load 되어 `permissions` 배열이 함께 내려온다.
+
+**인증 계약**: `auth:sanctum` 필요 — Bearer 토큰이 없거나 만료되면 `401` 을 반환한다(프론트 `data_source` 의 `auth_required: true` 에 대응). 이 계약이 프론트 소비의 SSoT 이므로 미들웨어 체인 변경 시 반드시 프론트 `auth_required`/`auth_mode` 와 함께 검토한다(이슈 #64).
+
+
+### POST /api/auth/admin/login
+
+- **라우트명**: `api.auth.admin.login`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\AuthController@login`
+- **인증/권한**: 공개 (인증 불필요)
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| email | body | email | 예 | — | 이메일 주소 |
+| password | body | string | 예 | min 6 | 비밀번호 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.login_validation_rules`).
+
+**요청 예시**
+
+```http
+POST /api/auth/admin/login HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Content-Type: application/json
+
+{
+ "email": "user@example.com",
+ "password": "Password123!"
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명**
+
+관리자 로그인. `email`/`password` 검증 후 `AuthService::login()` 이 인증하고, 인증 사용자가 `isAdmin()` 이 아니면 `403 auth.admin_required` 로 거부한다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user`(UserResource) 를 반환한다. 계정 잠금 시 `AccountLockedException`, 자격 불일치 시 `422` 검증 오류를 반환한다. 이후 모든 관리자 API 호출은 이 토큰을 `Authorization: Bearer` 헤더로 실어야 한다.
+
+
+### POST /api/auth/forgot-password
+
+- **라우트명**: `api.auth.forgot-password`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@forgotPassword`
+- **인증/권한**: 공개 (인증 불필요)
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| email | body | email | 예 | — | 이메일 주소 |
+| redirect_prefix | body | string | 아니오 | `admin` | 재설정 링크가 향할 화면 구분값 — `admin` 전달 시 관리자 재설정 화면, 미지정 시 사용자 화면 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.forgot_password_validation_rules`).
+
+**요청 예시**
+
+```http
+POST /api/auth/forgot-password HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Content-Type: application/json
+
+{
+ "email": "user@example.com",
+ "redirect_prefix": "admin"
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명**
+
+비밀번호 재설정 메일 발송을 요청한다(공개). `email` 로 계정을 찾아 재설정 링크 메일을 보내고 `message: auth.password_reset_email_sent` 를 반환한다. `redirect_prefix` 는 재설정 링크가 향할 화면을 구분하는 값으로 관리자 흐름(`admin_forgot_password.json`)에서는 `admin` 을 전달해 링크가 관리자 재설정 화면을 가리키게 한다(미지정 시 사용자 화면). 계정 열거 방지를 위해 이메일 존재 여부와 무관하게 동일 응답을 주는 것이 원칙이다.
+
+
+### POST /api/auth/login
+
+- **라우트명**: `api.auth.login`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@login`
+- **인증/권한**: 공개 (인증 불필요)
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| email | body | email | 예 | — | 이메일 주소 |
+| password | body | string | 예 | min 6 | 비밀번호 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.login_validation_rules`).
+
+**요청 예시**
+
+```http
+POST /api/auth/login HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Content-Type: application/json
+
+{
+ "email": "user@example.com",
+ "password": "Password123!"
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명**
+
+일반 사용자 로그인(공개). 관리자 로그인과 달리 `isAdmin()` 검사가 없다. 성공 시 `data.token`(Sanctum Bearer) 과 `data.user` 를 반환하며 `message: auth.login_success`. 계정 잠금 시 `423 auth.account_locked` 를 잠금 해제까지 남은 정보와 함께 반환한다. 프론트 로그인 폼(`partials/auth/_register_form.json` 인접)에서 소비한다.
+
+
+### POST /api/auth/logout
+
+- **라우트명**: `api.auth.logout`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@logout`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+POST /api/auth/logout HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "로그아웃이 성공했습니다.",
+ "data": null
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+
+
+
+**설명**
+
+현재 사용자 토큰을 폐기해 로그아웃한다(`message: auth.logout_success`). 현재 요청에 사용된 토큰만 폐기하며, 모든 기기에서 로그아웃하려면 `/api/user/auth/logout-all-devices` 를 사용한다.
+
+
+### POST /api/auth/register
+
+- **라우트명**: `api.auth.register`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@register`
+- **인증/권한**: 공개 (인증 불필요)
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| name | body | string | 예 | max 255 | 대상의 이름/명칭 |
+| nickname | body | string | 아니오 | max 50 | 닉네임 |
+| email | body | string | 예 | max 255 | 이메일 주소 |
+| password | body | string | 예 | min 8 | 비밀번호 |
+| language | body | string | 아니오 | `ko`, `en`, `fr`, `ja` | 언어 코드 |
+| agree_terms | body | string | 아니오 | — | 이용약관 동의 (코어 필수 동의 — accepted 규칙, 미동의 시 가입 거부) |
+| agree_privacy | body | string | 아니오 | — | 개인정보 처리방침 동의 (코어 필수 동의 — accepted 규칙, 미동의 시 가입 거부) |
+| agree_email_subscription | body | boolean | 아니오 | — | 광고성 이메일 수신 동의 (marketing 플러그인 주입, 선택 항목) |
+| agree_marketing_consent | body | boolean | 아니오 | — | 마케팅 정보 수신 전체 동의 (marketing 플러그인 주입, 선택 항목) |
+| agree_third_party_consent | body | boolean | 아니오 | — | 제3자 정보 제공 동의 (marketing 플러그인 주입, 선택 항목) |
+| agree_info_disclosure | body | boolean | 아니오 | — | 개인정보 이용 안내 동의 (marketing 플러그인 주입, 선택 항목) |
+| preferred_currency | body | string | 아니오 | — | 선호 결제 통화 (ecommerce 모듈 주입, 가입 시 계정 기본 통화로 저장) |
+| preferred_shipping_country | body | string | 아니오 | — | 선호 배송 국가 코드 (ecommerce 모듈 주입, 가입 시 계정 기본 배송 국가로 저장) |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.register_validation_rules`).
+
+**요청 예시**
+
+```http
+POST /api/auth/register HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Content-Type: application/json
+
+{
+ "name": "예시 이름",
+ "nickname": "예시 이름",
+ "email": "user@example.com",
+ "password": "Password123!",
+ "language": "ko",
+ "agree_terms": "예시값",
+ "agree_privacy": "예시값",
+ "preferred_currency": "예시값",
+ "preferred_shipping_country": "KR"
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명**
+
+회원가입(공개). 성공 시 `201 auth.register_success` 와 `data.token`/`data.user` 를 반환해 가입 직후 로그인 상태로 이어진다. `agree_*` 동의 파라미터(약관/개인정보/이메일수신/마케팅/제3자제공/정보공개)는 가입 시점의 동의 이력으로 기록된다 — 그중 `agree_email_subscription`/`agree_marketing_consent`/`agree_third_party_consent`/`agree_info_disclosure` 및 `preferred_currency`/`preferred_shipping_country` 는 marketing·ecommerce 확장이 훅(`core.auth.register_validation_rules`)으로 주입하는 파라미터로, 해당 확장 비활성 시 무시된다. 검증 실패 시 `422 auth.register_failed`.
+
+
+### POST /api/auth/reset-password
+
+- **라우트명**: `api.auth.reset-password`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@resetPassword`
+- **인증/권한**: 공개 (인증 불필요)
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| token | body | string | 예 | — | 인증/검증 토큰 |
+| email | body | email | 예 | — | 이메일 주소 |
+| password | body | string | 예 | min 8 | 비밀번호 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.reset_password_validation_rules`).
+
+**요청 예시**
+
+```http
+POST /api/auth/reset-password HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Content-Type: application/json
+
+{
+ "token": "{YOUR_TOKEN}",
+ "email": "user@example.com",
+ "password": "Password123!"
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명**
+
+비밀번호 재설정을 실제 반영한다(공개). 재설정 메일의 `token` 과 `email`, 새 `password` 를 받아 비밀번호를 갱신하고 `message: auth.password_reset_success`. 토큰 만료/불일치 등 검증 실패 시 `422 auth.password_reset_failed`. 반영 전 토큰 유효성만 먼저 확인하려면 `/api/auth/validate-reset-token` 을 사용한다.
+
+
+### GET /api/auth/user
+
+- **라우트명**: `api.auth.user`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@user`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/auth/user HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 외부 노출용 UUID (URL/API 식별자, 내부 id 비노출) |
+| name | string | `API 문서 샘플 사용자` | 사용자 이름 |
+| nickname | string | `gunwoo.oh` | 닉네임 |
+| email | string | `apidoc-sample-user@example.com` | 이메일 주소 |
+| avatar | null | `null` | 아바타 이미지 URL (User::getAvatarUrl() — 아바타 미설정 시 null) |
+| language | string | `ko` | 사용자 언어 설정 (ko: 한국어, en: 영어) |
+| language_label | string | `한국어` | 언어 코드의 현지화 라벨 (user.language.{code} 번역) |
+| country | string | `KR` | 국가 코드 (ISO 3166-1 alpha-2) |
+| status | string | `active` | 계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴) |
+| status_label | string | `활성` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) |
+| status_variant | string | `success` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) |
+| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) |
+| homepage | string | `https://example.com` | 홈페이지 URL |
+| mobile | string | `010-9070-5662` | 휴대폰 번호 |
+| phone | string | `02-805-4759` | 전화번호 |
+| zipcode | string | `93153` | 우편번호 |
+| address | string | `대구광역시 북구 백제고분로 720` | 기본 주소 |
+| address_detail | string | `40동 835호` | 상세 주소 |
+| signature | string | `Ipsam rem amet expedita est.` | 서명 |
+| bio | string | `Tenetur omnis et amet omnis veniam to…` | 자기소개 |
+| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 |
+| email_verified_at | string | `2026-07-06 19:15:16` | email verified 일시 |
+| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) |
+| modules_count | array | `[]` | 접근 가능 모듈 수 (modules_count 속성이 로드된 경우에만 포함 — whenLoaded 성격의 조건부 필드) |
+| plugins_count | array | `[]` | 접근 가능 플러그인 수 (plugins_count 속성이 로드된 경우에만 포함) |
+| menus_count | array | `[]` | 접근 가능 메뉴 수 (menus_count 속성이 로드된 경우에만 포함) |
+| modules | array | `[]` | 접근 가능 모듈 목록 (원소 id/name/slug/is_active — modules 관계 로드 시에만 포함) |
+| plugins | array | `[]` | 접근 가능 플러그인 목록 (원소 id/name/slug/is_active — plugins 관계 로드 시에만 포함) |
+| menus | array | `[]` | 접근 가능 메뉴 목록 (원소 id/title/url/is_active — menus 관계 로드 시에만 포함) |
+| roles | array | `[{"id":1,"identifier":"admin","name":"관리자"}]` | 사용자에게 부여된 역할 목록 (원소 id/identifier/name — roles 관계 파생, name 은 현지화 라벨) |
+| permissions | array | `[{"id":3,"identifier":"core.users.read","name":"사용자 조회"},…` | 연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생) |
+| consents | array | `[]` | 전체 약관 동의 이력 (원소 consent_type/agreed_at/revoked_at — consents 관계 로드 시 포함, 플러그인 참조용) |
+| terms_consent | array | `[]` | 이용약관 동의 정보 (agreed_at — ConsentType::Terms 동의 이력에서 파생, 미동의 시 null) |
+| privacy_consent | array | `[]` | 개인정보 처리방침 동의 정보 (agreed_at — ConsentType::Privacy 동의 이력에서 파생, 미동의 시 null) |
+| created_at | string | `2026-07-06 19:15:16` | 생성 일시 |
+| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 |
+| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
+| abilities | object | `{"can_read":true,"can_create":true,"can_update":true,"can…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
+| notify_post_complete | boolean | `false` | 게시판 새 글 작성 완료 알림 수신 설정 (marketing 플러그인 주입) |
+| notify_post_reply | boolean | `false` | 내 게시글에 답글 달림 알림 수신 설정 (marketing 플러그인 주입) |
+| notify_comment | boolean | `false` | 내 게시글에 댓글 달림 알림 수신 설정 (marketing 플러그인 주입) |
+| notify_reply_comment | boolean | `false` | 내 댓글에 답글 달림 알림 수신 설정 (marketing 플러그인 주입) |
+| email_subscription | boolean | `false` | 광고성 이메일 수신 동의 여부 (marketing 플러그인 주입) |
+| email_subscription_at | null | `null` | email subscription 일시 (광고성 이메일 수신 동의 시각, 미동의 시 null) |
+| marketing_consent | boolean | `false` | 마케팅 정보 수신 전체 동의 마스터 키 (marketing 플러그인 주입) |
+| marketing_consent_at | null | `null` | marketing consent 일시 (마케팅 정보 수신 동의 시각, 미동의 시 null) |
+| third_party_consent | boolean | `false` | 제3자 정보 제공 동의 여부 (법적 항목 — marketing 플러그인 주입) |
+| third_party_consent_at | null | `null` | third party consent 일시 (제3자 정보 제공 동의 시각, 미동의 시 null) |
+| info_disclosure | boolean | `false` | 개인정보 이용 안내 동의 여부 (법적 항목 — marketing 플러그인 주입) |
+| info_disclosure_at | null | `null` | info disclosure 일시 (개인정보 이용 안내 동의 시각, 미동의 시 null) |
+| marketing_consent_enabled | boolean | `true` | 마케팅 정보 수신 동의 항목 UI 노출 여부 (관리자 활성화 플래그) |
+| marketing_consent_terms_slug | string | `marketing-terms` | 마케팅 정보 수신 동의에 연결된 약관 slug (미설정 시 null) |
+| marketing_consent_terms_slug_set | boolean | `true` | 마케팅 정보 수신 약관 연결 존재 여부 (프론트 링크 표시 판정용) |
+| third_party_consent_enabled | boolean | `true` | 제3자 정보 제공 동의 항목 UI 노출 여부 (관리자 활성화 플래그) |
+| third_party_consent_terms_slug | null | `null` | 제3자 정보 제공 동의에 연결된 약관 slug (미설정 시 null) |
+| third_party_consent_terms_slug_set | boolean | `false` | 제3자 정보 제공 약관 연결 존재 여부 (프론트 링크 표시 판정용) |
+| info_disclosure_enabled | boolean | `true` | 개인정보 이용 안내 동의 항목 UI 노출 여부 (관리자 활성화 플래그) |
+| info_disclosure_terms_slug | null | `null` | 개인정보 이용 안내 동의에 연결된 약관 slug (미설정 시 null) |
+| info_disclosure_terms_slug_set | boolean | `false` | 개인정보 이용 안내 약관 연결 존재 여부 (프론트 링크 표시 판정용) |
+| email_subscription_enabled | boolean | `true` | 광고성 이메일 수신 동의 항목 UI 노출 여부 (관리자 활성화 플래그) |
+| email_subscription_terms_slug | null | `null` | 광고성 이메일 수신 동의에 연결된 약관 slug (미설정 시 null) |
+| email_subscription_terms_slug_set | boolean | `false` | 광고성 이메일 수신 약관 연결 존재 여부 (프론트 링크 표시 판정용) |
+| channels | array | `[{"key":"email_subscription","label":"광고성 이메일 수신","enable…` | 관리자 정의 전체 마케팅 채널 목록 (원소 key/label/enabled/terms_slug — marketing 플러그인 주입) |
+| consent_histories | array | `[]` | 동의 변경 이력 (원소 channel_key/action/source/created_at — marketing 플러그인 주입) |
+| ecommerce_mileage | object | `{"enabled":false}` | 마일리지 정보 (enabled/잔액 — ecommerce 모듈 주입, 모듈 비활성 시 enabled=false) |
+| ecommerce_preferred_currency | null | `null` | 선호 결제 통화 (ecommerce 모듈 주입, 미설정 시 null) |
+| ecommerce_preferred_shipping_country | null | `null` | 선호 배송 국가 코드 (ecommerce 모듈 주입, 미설정 시 null) |
+| ecommerce_preferred_shipping_country_name | null | `null` | 선호 배송 국가 이름 (국가 코드에서 현지화 파생 — ecommerce 모듈 주입, 미설정 시 null) |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "성공적으로 처리되었습니다.",
+ "data": {
+ "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
+ "name": "API 문서 샘플 사용자",
+ "nickname": "song.hyunji",
+ "email": "apidoc-sample-user@example.com",
+ "avatar": null,
+ "language": "ko",
+ "language_label": "한국어",
+ "country": "KR",
+ "status": "active",
+ "status_label": "활성",
+ "status_variant": "success",
+ "is_admin": true,
+ "homepage": "https://example.com",
+ "mobile": "010-9595-2897",
+ "phone": "02-637-5618",
+ "zipcode": "16505",
+ "address": "경기도 안양시 봉은사로 2918",
+ "address_detail": "48동 718호",
+ "signature": "Fugit consequuntur repellendus sed.",
+ "bio": "Ut magni et sunt ducimus error adipisci. Pariatur corporis voluptatem ratione quo non saepe. Illo atque praesentium possimus dolores qui est fugit. Sint fugiat numquam voluptates.",
+ "last_login_at": "2026-07-07 10:41:24",
+ "email_verified_at": "2026-07-08 10:41:24",
+ "timezone": "Asia/Seoul",
+ "modules_count": [],
+ "plugins_count": [],
+ "menus_count": [],
+ "modules": [],
+ "plugins": [],
+ "menus": [],
+ "roles": [
+ {
+ "id": 1,
+ "identifier": "admin",
+ "name": "관리자"
+ },
+ {
+ "id": 4,
+ "identifier": "apidoc-sample-role",
+ "name": "API 문서 샘플 역할"
+ }
+ ],
+ "permissions": [
+ {
+ "id": 5,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read",
+ "name": "게시글 조회 (관리자)"
+ },
+ {
+ "id": 6,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.write",
+ "name": "게시글 작성/수정/삭제 (관리자)"
+ },
+ {
+ "id": 7,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.posts.read-secret",
+ "name": "비밀글 조회 (관리자)"
+ },
+ {
+ "id": 8,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.read",
+ "name": "댓글 조회 (관리자)"
+ },
+ {
+ "id": 9,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.comments.write",
+ "name": "댓글 작성/수정/삭제 (관리자)"
+ },
+ {
+ "id": 10,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.upload",
+ "name": "파일 업로드/삭제 (관리자)"
+ },
+ {
+ "id": 11,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.attachments.download",
+ "name": "파일 다운로드 (관리자)"
+ },
+ {
+ "id": 12,
+ "identifier": "sirsoft-board.apidoc-sample-board.admin.manage",
+ "name": "게시판 관리 (관리자)"
+ },
+ {
+ "id": 13,
+ "identifier": "sirsoft-board.apidoc-sample-board.posts.read",
+ "name": "게시글 조회"
+ },
+ {
+ "id": 14,
+ "identifier": "sirsoft-board.apidoc-sample-board.posts.write",
+ "name": "게시글 작성/수정/삭제"
+ },
+ {
+ "id": 15,
+ "identifier": "sirsoft-board.apidoc-sample-board.posts.read-secret",
+ "name": "비밀글 조회"
+ },
+ {
+ "id": 16,
+ "identifier": "sirsoft-board.apidoc-sample-board.comments.read",
+ "name": "댓글 조회"
+ },
+ {
+ "id": 17,
+ "identifier": "sirsoft-board.apidoc-sample-board.comments.write",
+ "name": "댓글 작성/수정/삭제"
+ },
+ {
+ "id": 18,
+ "identifier": "sirsoft-board.apidoc-sample-board.attachments.upload",
+ "name": "파일 업로드/삭제"
+ },
+ {
+ "id": 19,
+ "identifier": "sirsoft-board.apidoc-sample-board.attachments.download",
+ "name": "파일 다운로드"
+ },
+ {
+ "id": 20,
+ "identifier": "sirsoft-board.apidoc-sample-board.manager",
+ "name": "게시판 관리 (사용자)"
+ },
+ {
+ "id": 81,
+ "identifier": "sirsoft-ecommerce.user-products.read",
+ "name": "상품 조회"
+ },
+ {
+ "id": 83,
+ "identifier": "sirsoft-ecommerce.user-orders.create",
+ "name": "주문하기"
+ },
+ {
+ "id": 84,
+ "identifier": "sirsoft-ecommerce.user-orders.cancel",
+ "name": "주문 취소"
+ },
+ {
+ "id": 85,
+ "identifier": "sirsoft-ecommerce.user-orders.confirm",
+ "name": "구매확정"
+ },
+ {
+ "id": 87,
+ "identifier": "sirsoft-ecommerce.user-reviews.write",
+ "name": "리뷰 작성"
+ },
+ {
+ "id": 23,
+ "identifier": "sirsoft-ecommerce.products.read",
+ "name": "상품 조회"
+ },
+ {
+ "id": 24,
+ "identifier": "sirsoft-ecommerce.products.create",
+ "name": "상품 생성"
+ },
+ {
+ "id": 25,
+ "identifier": "sirsoft-ecommerce.products.update",
+ "name": "상품 수정"
+ },
+ {
+ "id": 26,
+ "identifier": "sirsoft-ecommerce.products.delete",
+ "name": "상품 삭제"
+ },
+ {
+ "id": 28,
+ "identifier": "sirsoft-ecommerce.orders.read",
+ "name": "주문 조회"
+ },
+ {
+ "id": 29,
+ "identifier": "sirsoft-ecommerce.orders.update",
+ "name": "주문 수정"
+ },
+ {
+ "id": 31,
+ "identifier": "sirsoft-ecommerce.categories.read",
+ "name": "카테고리 조회"
+ },
+ {
+ "id": 32,
+ "identifier": "sirsoft-ecommerce.categories.create",
+ "name": "카테고리 생성"
+ },
+ {
+ "id": 33,
+ "identifier": "sirsoft-ecommerce.categories.update",
+ "name": "카테고리 수정"
+ },
+ {
+ "id": 34,
+ "identifier": "sirsoft-ecommerce.categories.delete",
+ "name": "카테고리 삭제"
+ },
+ {
+ "id": 36,
+ "identifier": "sirsoft-ecommerce.brands.read",
+ "name": "브랜드 조회"
+ },
+ {
+ "id": 37,
+ "identifier": "sirsoft-ecommerce.brands.create",
+ "name": "브랜드 생성"
+ },
+ {
+ "id": 38,
+ "identifier": "sirsoft-ecommerce.brands.update",
+ "name": "브랜드 수정"
+ },
+ {
+ "id": 39,
+ "identifier": "sirsoft-ecommerce.brands.delete",
+ "name": "브랜드 삭제"
+ },
+ {
+ "id": 41,
+ "identifier": "sirsoft-ecommerce.product-notice-templates.read",
+ "name": "조회"
+ },
+ {
+ "id": 42,
+ "identifier": "sirsoft-ecommerce.product-notice-templates.create",
+ "name": "생성"
+ },
+ {
+ "id": 43,
+ "identifier": "sirsoft-ecommerce.product-notice-templates.update",
+ "name": "수정"
+ },
+ {
+ "id": 44,
+ "identifier": "sirsoft-ecommerce.product-notice-templates.delete",
+ "name": "삭제"
+ },
+ {
+ "id": 46,
+ "identifier": "sirsoft-ecommerce.product-common-infos.read",
+ "name": "조회"
+ },
+ {
+ "id": 47,
+ "identifier": "sirsoft-ecommerce.product-common-infos.create",
+ "name": "생성"
+ },
+ {
+ "id": 48,
+ "identifier": "sirsoft-ecommerce.product-common-infos.update",
+ "name": "수정"
+ },
+ {
+ "id": 49,
+ "identifier": "sirsoft-ecommerce.product-common-infos.delete",
+ "name": "삭제"
+ },
+ {
+ "id": 51,
+ "identifier": "sirsoft-ecommerce.settings.read",
+ "name": "환경설정 조회"
+ },
+ {
+ "id": 52,
+ "identifier": "sirsoft-ecommerce.settings.update",
+ "name": "환경설정 수정"
+ },
+ {
+ "id": 54,
+ "identifier": "sirsoft-ecommerce.promotion-coupon.read",
+ "name": "쿠폰 조회"
+ },
+ {
+ "id": 55,
+ "identifier": "sirsoft-ecommerce.promotion-coupon.create",
+ "name": "쿠폰 생성"
+ },
+ {
+ "id": 56,
+ "identifier": "sirsoft-ecommerce.promotion-coupon.update",
+ "name": "쿠폰 수정"
+ },
+ {
+ "id": 57,
+ "identifier": "sirsoft-ecommerce.promotion-coupon.delete",
+ "name": "쿠폰 삭제"
+ },
+ {
+ "id": 59,
+ "identifier": "sirsoft-ecommerce.shipping-policies.read",
+ "name": "배송정책 조회"
+ },
+ {
+ "id": 60,
+ "identifier": "sirsoft-ecommerce.shipping-policies.create",
+ "name": "배송정책 생성"
+ },
+ {
+ "id": 61,
+ "identifier": "sirsoft-ecommerce.shipping-policies.update",
+ "name": "배송정책 수정"
+ },
+ {
+ "id": 62,
+ "identifier": "sirsoft-ecommerce.shipping-policies.delete",
+ "name": "배송정책 삭제"
+ },
+ {
+ "id": 64,
+ "identifier": "sirsoft-ecommerce.product-labels.read",
+ "name": "상품 라벨 조회"
+ },
+ {
+ "id": 65,
+ "identifier": "sirsoft-ecommerce.product-labels.create",
+ "name": "상품 라벨 생성"
+ },
+ {
+ "id": 66,
+ "identifier": "sirsoft-ecommerce.product-labels.update",
+ "name": "상품 라벨 수정"
+ },
+ {
+ "id": 67,
+ "identifier": "sirsoft-ecommerce.product-labels.delete",
+ "name": "상품 라벨 삭제"
+ },
+ {
+ "id": 69,
+ "identifier": "sirsoft-ecommerce.identity.policies.read",
+ "name": "본인인증 정책 조회"
+ },
+ {
+ "id": 70,
+ "identifier": "sirsoft-ecommerce.identity.policies.update",
+ "name": "본인인증 정책 수정"
+ },
+ {
+ "id": 72,
+ "identifier": "sirsoft-ecommerce.reviews.read",
+ "name": "리뷰 조회"
+ },
+ {
+ "id": 73,
+ "identifier": "sirsoft-ecommerce.reviews.update",
+ "name": "리뷰 처리"
+ },
+ {
+ "id": 74,
+ "identifier": "sirsoft-ecommerce.reviews.delete",
+ "name": "리뷰 삭제"
+ },
+ {
+ "id": 76,
+ "identifier": "sirsoft-ecommerce.inquiries.update",
+ "name": "문의 처리"
+ },
+ {
+ "id": 77,
+ "identifier": "sirsoft-ecommerce.inquiries.delete",
+ "name": "문의 삭제"
+ },
+ {
+ "id": 79,
+ "identifier": "sirsoft-ecommerce.dashboard.view",
+ "name": "대시보드 조회"
+ },
+ {
+ "id": 89,
+ "identifier": "sirsoft-ecommerce.mileage.read",
+ "name": "마일리지 내역 조회"
+ },
+ {
+ "id": 90,
+ "identifier": "sirsoft-ecommerce.mileage.manage",
+ "name": "마일리지 수동 처리"
+ },
+ {
+ "id": 92,
+ "identifier": "sirsoft-ecommerce.user-currency.manage",
+ "name": "회원 결제 통화 변경"
+ },
+ {
+ "id": 94,
+ "identifier": "sirsoft-ecommerce.user-shipping-country.manage",
+ "name": "회원 배송국가 변경"
+ },
+ {
+ "id": 96,
+ "identifier": "sirsoft-board.boards.read",
+ "name": "게시판 조회"
+ },
+ {
+ "id": 97,
+ "identifier": "sirsoft-board.boards.create",
+ "name": "게시판 생성"
+ },
+ {
+ "id": 98,
+ "identifier": "sirsoft-board.boards.update",
+ "name": "게시판 수정"
+ },
+ {
+ "id": 99,
+ "identifier": "sirsoft-board.boards.delete",
+ "name": "게시판 삭제"
+ },
+ {
+ "id": 101,
+ "identifier": "sirsoft-board.settings.read",
+ "name": "환경설정 조회"
+ },
+ {
+ "id": 102,
+ "identifier": "sirsoft-board.settings.update",
+ "name": "환경설정 수정"
+ },
+ {
+ "id": 104,
+ "identifier": "sirsoft-board.identity.policies.read",
+ "name": "본인인증 정책 조회"
+ },
+ {
+ "id": 105,
+ "identifier": "sirsoft-board.identity.policies.update",
+ "name": "본인인증 정책 수정"
+ },
+ {
+ "id": 107,
+ "identifier": "sirsoft-board.reports.view",
+ "name": "신고 조회"
+ },
+ {
+ "id": 108,
+ "identifier": "sirsoft-board.reports.manage",
+ "name": "신고 처리"
+ },
+ {
+ "id": 111,
+ "identifier": "sirsoft-page.pages.read",
+ "name": "페이지 조회"
+ },
+ {
+ "id": 112,
+ "identifier": "sirsoft-page.pages.create",
+ "name": "페이지 생성"
+ },
+ {
+ "id": 113,
+ "identifier": "sirsoft-page.pages.update",
+ "name": "페이지 수정"
+ },
+ {
+ "id": 114,
+ "identifier": "sirsoft-page.pages.delete",
+ "name": "페이지 삭제"
+ },
+ {
+ "id": 117,
+ "identifier": "core.users.read",
+ "name": "사용자 조회"
+ },
+ {
+ "id": 118,
+ "identifier": "core.users.create",
+ "name": "사용자 생성"
+ },
+ {
+ "id": 119,
+ "identifier": "core.users.update",
+ "name": "사용자 수정"
+ },
+ {
+ "id": 120,
+ "identifier": "core.users.delete",
+ "name": "사용자 삭제"
+ },
+ {
+ "id": 122,
+ "identifier": "core.menus.read",
+ "name": "메뉴 조회"
+ },
+ {
+ "id": 123,
+ "identifier": "core.menus.create",
+ "name": "메뉴 생성"
+ },
+ {
+ "id": 124,
+ "identifier": "core.menus.update",
+ "name": "메뉴 수정"
+ },
+ {
+ "id": 125,
+ "identifier": "core.menus.delete",
+ "name": "메뉴 삭제"
+ },
+ {
+ "id": 127,
+ "identifier": "core.modules.read",
+ "name": "모듈 조회"
+ },
+ {
+ "id": 128,
+ "identifier": "core.modules.install",
+ "name": "모듈 설치"
+ },
+ {
+ "id": 129,
+ "identifier": "core.modules.activate",
+ "name": "모듈 활성화"
+ },
+ {
+ "id": 130,
+ "identifier": "core.modules.uninstall",
+ "name": "모듈 삭제"
+ },
+ {
+ "id": 132,
+ "identifier": "core.plugins.read",
+ "name": "플러그인 조회"
+ },
+ {
+ "id": 133,
+ "identifier": "core.plugins.install",
+ "name": "플러그인 설치"
+ },
+ {
+ "id": 134,
+ "identifier": "core.plugins.activate",
+ "name": "플러그인 활성화"
+ },
+ {
+ "id": 135,
+ "identifier": "core.plugins.update",
+ "name": "플러그인 설정"
+ },
+ {
+ "id": 136,
+ "identifier": "core.plugins.uninstall",
+ "name": "플러그인 삭제"
+ },
+ {
+ "id": 138,
+ "identifier": "core.templates.read",
+ "name": "템플릿 조회"
+ },
+ {
+ "id": 139,
+ "identifier": "core.templates.install",
+ "name": "템플릿 설치"
+ },
+ {
+ "id": 140,
+ "identifier": "core.templates.activate",
+ "name": "템플릿 활성화"
+ },
+ {
+ "id": 141,
+ "identifier": "core.templates.uninstall",
+ "name": "템플릿 삭제"
+ },
+ {
+ "id": 142,
+ "identifier": "core.templates.layouts.edit",
+ "name": "레이아웃 편집"
+ },
+ {
+ "id": 144,
+ "identifier": "core.permissions.read",
+ "name": "권한 조회"
+ },
+ {
+ "id": 145,
+ "identifier": "core.permissions.create",
+ "name": "역할 생성"
+ },
+ {
+ "id": 146,
+ "identifier": "core.permissions.update",
+ "name": "역할 수정"
+ },
+ {
+ "id": 147,
+ "identifier": "core.permissions.delete",
+ "name": "역할 삭제"
+ },
+ {
+ "id": 149,
+ "identifier": "core.notification-logs.read",
+ "name": "발송 이력 조회"
+ },
+ {
+ "id": 150,
+ "identifier": "core.notification-logs.delete",
+ "name": "발송 이력 삭제"
+ },
+ {
+ "id": 152,
+ "identifier": "core.notifications.read",
+ "name": "알림 조회"
+ },
+ {
+ "id": 153,
+ "identifier": "core.notifications.update",
+ "name": "알림 읽음 처리"
+ },
+ {
+ "id": 154,
+ "identifier": "core.notifications.delete",
+ "name": "알림 삭제"
+ },
+ {
+ "id": 156,
+ "identifier": "core.user-notifications.read",
+ "name": "알림 조회"
+ },
+ {
+ "id": 157,
+ "identifier": "core.user-notifications.update",
+ "name": "알림 읽음 처리"
+ },
+ {
+ "id": 158,
+ "identifier": "core.user-notifications.delete",
+ "name": "알림 삭제"
+ },
+ {
+ "id": 160,
+ "identifier": "core.identity.request",
+ "name": "IDV 요청"
+ },
+ {
+ "id": 161,
+ "identifier": "core.identity.verify",
+ "name": "IDV 검증"
+ },
+ {
+ "id": 162,
+ "identifier": "core.identity.cancel",
+ "name": "IDV 취소"
+ },
+ {
+ "id": 164,
+ "identifier": "core.admin.identity.providers.read",
+ "name": "프로바이더 설정 조회"
+ },
+ {
+ "id": 165,
+ "identifier": "core.admin.identity.providers.update",
+ "name": "프로바이더 설정 수정"
+ },
+ {
+ "id": 166,
+ "identifier": "core.admin.identity.policies.read",
+ "name": "정책 조회"
+ },
+ {
+ "id": 167,
+ "identifier": "core.admin.identity.policies.update",
+ "name": "정책 수정"
+ },
+ {
+ "id": 168,
+ "identifier": "core.admin.identity.logs.read",
+ "name": "로그 열람"
+ },
+ {
+ "id": 169,
+ "identifier": "core.admin.identity.logs.purge",
+ "name": "로그 파기"
+ },
+ {
+ "id": 170,
+ "identifier": "core.admin.identity.messages.read",
+ "name": "메시지 템플릿 조회"
+ },
+ {
+ "id": 171,
+ "identifier": "core.admin.identity.messages.update",
+ "name": "메시지 템플릿 수정"
+ },
+ {
+ "id": 173,
+ "identifier": "core.settings.read",
+ "name": "설정 조회"
+ },
+ {
+ "id": 174,
+ "identifier": "core.settings.update",
+ "name": "설정 수정"
+ },
+ {
+ "id": 176,
+ "identifier": "core.dashboard.read",
+ "name": "대시보드 조회"
+ },
+ {
+ "id": 177,
+ "identifier": "core.dashboard.system-status",
+ "name": "시스템 상태"
+ },
+ {
+ "id": 178,
+ "identifier": "core.dashboard.resources",
+ "name": "시스템 리소스"
+ },
+ {
+ "id": 179,
+ "identifier": "core.dashboard.activities",
+ "name": "최근 활동"
+ },
+ {
+ "id": 180,
+ "identifier": "core.dashboard.alerts",
+ "name": "시스템 알림"
+ },
+ {
+ "id": 182,
+ "identifier": "core.activities.read",
+ "name": "활동 로그 조회"
+ },
+ {
+ "id": 183,
+ "identifier": "core.activities.delete",
+ "name": "활동 로그 삭제"
+ },
+ {
+ "id": 185,
+ "identifier": "core.attachments.create",
+ "name": "첨부파일 업로드"
+ },
+ {
+ "id": 186,
+ "identifier": "core.attachments.update",
+ "name": "첨부파일 수정"
+ },
+ {
+ "id": 187,
+ "identifier": "core.attachments.delete",
+ "name": "첨부파일 삭제"
+ },
+ {
+ "id": 189,
+ "identifier": "core.schedules.read",
+ "name": "스케줄 조회"
+ },
+ {
+ "id": 190,
+ "identifier": "core.schedules.create",
+ "name": "스케줄 생성"
+ },
+ {
+ "id": 191,
+ "identifier": "core.schedules.update",
+ "name": "스케줄 수정"
+ },
+ {
+ "id": 192,
+ "identifier": "core.schedules.delete",
+ "name": "스케줄 삭제"
+ },
+ {
+ "id": 193,
+ "identifier": "core.schedules.run",
+ "name": "스케줄 실행"
+ },
+ {
+ "id": 195,
+ "identifier": "core.language_packs.read",
+ "name": "언어팩 조회"
+ },
+ {
+ "id": 196,
+ "identifier": "core.language_packs.install",
+ "name": "언어팩 설치"
+ },
+ {
+ "id": 197,
+ "identifier": "core.language_packs.manage",
+ "name": "언어팩 관리"
+ },
+ {
+ "id": 198,
+ "identifier": "core.language_packs.update",
+ "name": "언어팩 업데이트"
+ },
+ {
+ "id": 1,
+ "identifier": "apidoc-sample.parent",
+ "name": "API 문서 샘플 권한"
+ },
+ {
+ "id": 2,
+ "identifier": "apidoc-sample.child",
+ "name": "하위 권한"
+ },
+ {
+ "id": 3,
+ "identifier": "sirsoft-board",
+ "name": "게시판"
+ },
+ {
+ "id": 21,
+ "identifier": "sirsoft-ecommerce",
+ "name": "이커머스"
+ },
+ {
+ "id": 109,
+ "identifier": "sirsoft-page",
+ "name": "페이지"
+ },
+ {
+ "id": 4,
+ "identifier": "sirsoft-board.apidoc-sample-board",
+ "name": "API 문서 샘플 게시판 게시판"
+ },
+ {
+ "id": 95,
+ "identifier": "sirsoft-board.boards",
+ "name": "게시판 관리"
+ },
+ {
+ "id": 100,
+ "identifier": "sirsoft-board.settings",
+ "name": "환경설정"
+ },
+ {
+ "id": 103,
+ "identifier": "sirsoft-board.identity.policies",
+ "name": "게시판 본인인증 정책"
+ },
+ {
+ "id": 106,
+ "identifier": "sirsoft-board.reports",
+ "name": "게시판 신고 관리"
+ },
+ {
+ "id": 22,
+ "identifier": "sirsoft-ecommerce.products",
+ "name": "상품 관리"
+ },
+ {
+ "id": 27,
+ "identifier": "sirsoft-ecommerce.orders",
+ "name": "주문 관리"
+ },
+ {
+ "id": 30,
+ "identifier": "sirsoft-ecommerce.categories",
+ "name": "카테고리 관리"
+ },
+ {
+ "id": 35,
+ "identifier": "sirsoft-ecommerce.brands",
+ "name": "브랜드 관리"
+ },
+ {
+ "id": 40,
+ "identifier": "sirsoft-ecommerce.product-notice-templates",
+ "name": "상품정보제공고시 관리"
+ },
+ {
+ "id": 45,
+ "identifier": "sirsoft-ecommerce.product-common-infos",
+ "name": "공통정보 관리"
+ },
+ {
+ "id": 50,
+ "identifier": "sirsoft-ecommerce.settings",
+ "name": "환경설정"
+ },
+ {
+ "id": 53,
+ "identifier": "sirsoft-ecommerce.promotion-coupon",
+ "name": "쿠폰 관리"
+ },
+ {
+ "id": 58,
+ "identifier": "sirsoft-ecommerce.shipping-policies",
+ "name": "배송정책 관리"
+ },
+ {
+ "id": 63,
+ "identifier": "sirsoft-ecommerce.product-labels",
+ "name": "상품 라벨 관리"
+ },
+ {
+ "id": 68,
+ "identifier": "sirsoft-ecommerce.identity.policies",
+ "name": "이커머스 본인인증 정책"
+ },
+ {
+ "id": 71,
+ "identifier": "sirsoft-ecommerce.reviews",
+ "name": "리뷰 관리"
+ },
+ {
+ "id": 75,
+ "identifier": "sirsoft-ecommerce.inquiries",
+ "name": "문의 관리"
+ },
+ {
+ "id": 78,
+ "identifier": "sirsoft-ecommerce.dashboard",
+ "name": "대시보드"
+ },
+ {
+ "id": 80,
+ "identifier": "sirsoft-ecommerce.user-products",
+ "name": "사용자 상품"
+ },
+ {
+ "id": 82,
+ "identifier": "sirsoft-ecommerce.user-orders",
+ "name": "사용자 주문"
+ },
+ {
+ "id": 86,
+ "identifier": "sirsoft-ecommerce.user-reviews",
+ "name": "사용자 리뷰"
+ },
+ {
+ "id": 88,
+ "identifier": "sirsoft-ecommerce.mileage",
+ "name": "마일리지 관리"
+ },
+ {
+ "id": 91,
+ "identifier": "sirsoft-ecommerce.user-currency",
+ "name": "회원 결제 통화 관리"
+ },
+ {
+ "id": 93,
+ "identifier": "sirsoft-ecommerce.user-shipping-country",
+ "name": "회원 배송국가 관리"
+ },
+ {
+ "id": 110,
+ "identifier": "sirsoft-page.pages",
+ "name": "페이지 관리"
+ },
+ {
+ "id": 115,
+ "identifier": "core",
+ "name": "코어"
+ },
+ {
+ "id": 116,
+ "identifier": "core.users",
+ "name": "사용자 관리"
+ },
+ {
+ "id": 121,
+ "identifier": "core.menus",
+ "name": "메뉴 관리"
+ },
+ {
+ "id": 126,
+ "identifier": "core.modules",
+ "name": "모듈 관리"
+ },
+ {
+ "id": 131,
+ "identifier": "core.plugins",
+ "name": "플러그인 관리"
+ },
+ {
+ "id": 137,
+ "identifier": "core.templates",
+ "name": "템플릿 관리"
+ },
+ {
+ "id": 143,
+ "identifier": "core.permissions",
+ "name": "권한 관리"
+ },
+ {
+ "id": 148,
+ "identifier": "core.notification-logs",
+ "name": "알림 발송 이력"
+ },
+ {
+ "id": 151,
+ "identifier": "core.notifications",
+ "name": "알림 (관리자)"
+ },
+ {
+ "id": 155,
+ "identifier": "core.user-notifications",
+ "name": "알림 (사용자)"
+ },
+ {
+ "id": 159,
+ "identifier": "core.identity",
+ "name": "본인인증 (사용자)"
+ },
+ {
+ "id": 163,
+ "identifier": "core.admin.identity",
+ "name": "본인인증 관리 (관리자)"
+ },
+ {
+ "id": 172,
+ "identifier": "core.settings",
+ "name": "환경설정"
+ },
+ {
+ "id": 175,
+ "identifier": "core.dashboard",
+ "name": "대시보드"
+ },
+ {
+ "id": 181,
+ "identifier": "core.activities",
+ "name": "활동 로그"
+ },
+ {
+ "id": 184,
+ "identifier": "core.attachments",
+ "name": "첨부파일 관리"
+ },
+ {
+ "id": 188,
+ "identifier": "core.schedules",
+ "name": "스케줄 관리"
+ },
+ {
+ "id": 194,
+ "identifier": "core.language_packs",
+ "name": "언어팩 관리"
+ }
+ ],
+ "consents": [],
+ "terms_consent": [],
+ "privacy_consent": [],
+ "created_at": "2026-07-08 10:41:24",
+ "updated_at": "2026-07-08 10:41:24",
+ "is_owner": true,
+ "abilities": {
+ "can_read": true,
+ "can_create": true,
+ "can_update": true,
+ "can_delete": true,
+ "can_assign_roles": true
+ },
+ "notify_post_complete": false,
+ "notify_post_reply": false,
+ "notify_comment": false,
+ "notify_reply_comment": false,
+ "ecommerce_mileage": {
+ "enabled": false
+ },
+ "ecommerce_preferred_currency": null,
+ "ecommerce_preferred_shipping_country": null,
+ "ecommerce_preferred_shipping_country_name": null
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+
+
+
+**설명**
+
+프론트(사용자) 레이아웃 전역 부트스트랩 엔드포인트. `_user_base.json` 의 `current_user` data_source 가 모든 페이지 진입 시 호출한다. 관리자 `user` 와 달리 응답을 `UserResource::toAuthArray()` 로 만들어 `core.user.filter_resource_data` 필터를 적용하므로, marketing 플러그인·ecommerce 모듈이 훅으로 병합한 필드(`notify_*`, `marketing_consent*`, `channels`, `ecommerce_*` 등)가 함께 내려온다. 이 필드들은 확장 소유이므로 상세 설명은 각 확장 문서를 따른다. 로그인 시 이 응답이 계정 영속 통화를 덮어쓰는 계약(D-LOGIN-CUR)의 출처다.
+
+**인증 계약**: 이 경로(`api.auth.user`)는 `auth:sanctum` 으로 인증이 필수다. 인증 여부와 무관하게 게스트 컨텍스트가 필요한 화면은 `optional.sanctum` 이 걸린 `/api/user/auth/user`(`api.user.auth.user`)를 사용해야 한다 — 프론트 `data_source` 의 `auth_mode: "optional"` 이 이 경로에 대응한다. 두 경로의 미들웨어 차이가 곧 `auth_required`/`auth_mode` 계약이므로 변경 시 프론트와 함께 검토한다(이슈 #64).
+
+
+### POST /api/auth/validate-reset-token
+
+- **라우트명**: `api.auth.validate-reset-token`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@validateResetToken`
+- **인증/권한**: 공개 (인증 불필요)
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| token | body | string | 예 | — | 인증/검증 토큰 |
+| email | body | email | 예 | — | 이메일 주소 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.validate_reset_token_rules`, `core.auth.validate_reset_token_messages`).
+
+**요청 예시**
+
+```http
+POST /api/auth/validate-reset-token HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Content-Type: application/json
+
+{
+ "token": "{YOUR_TOKEN}",
+ "email": "user@example.com"
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명**
+
+비밀번호 재설정 토큰의 유효성만 사전 확인한다(공개, 비밀번호 미변경). 재설정 화면(`admin_reset_password.json`/`auth/reset_password.json`) 진입 시 토큰/이메일이 유효한지 먼저 검사해, 만료·위조 링크면 즉시 오류 화면을 보이고 유효하면 새 비밀번호 입력 폼을 노출하는 용도다. 실제 반영은 `/api/auth/reset-password` 가 담당한다.
+
+
+### POST /api/user/auth/logout
+
+- **라우트명**: `api.user.auth.logout`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@logout`
+- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.auth.logout`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+POST /api/user/auth/logout HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 403 | Forbidden | 요구 권한(`core.auth.logout`)이 없는 경우 |
+
+
+
+**설명**
+
+`/user` prefix 그룹의 사용자 로그아웃. 공용 경로 `/api/auth/logout` 과 동일하게 현재 토큰을 폐기하되, `permission:core.auth.logout` 권한 게이트를 추가로 통과해야 한다. 세션 시작(`start.api.session`)이 걸린 공용 경로와 달리 권한 기반 접근 제어가 필요한 흐름에서 사용한다.
+
+
+### POST /api/user/auth/logout-all-devices
+
+- **라우트명**: `api.user.auth.logout-all-devices`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@logoutFromAllDevices`
+- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.auth.logout`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+POST /api/user/auth/logout-all-devices HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 403 | Forbidden | 요구 권한(`core.auth.logout`)이 없는 경우 |
+
+
+
+**설명**
+
+현재 사용자의 **모든** Sanctum 토큰을 폐기해 전 기기에서 로그아웃한다(`message: auth.logout_all_devices_success`). 비밀번호 변경 후 기존 세션 무효화, 계정 도용 대응 등에 사용한다.
+
+
+### POST /api/user/auth/refresh
+
+- **라우트명**: `api.user.auth.refresh`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@refresh`
+- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.auth.refresh`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+POST /api/user/auth/refresh HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 403 | Forbidden | 요구 권한(`core.auth.refresh`)이 없는 경우 |
+
+
+
+**설명**
+
+`/user` prefix 그룹의 토큰 갱신. 공용 `refresh` 와 동작은 같으나 `permission:core.auth.refresh` 권한 게이트를 추가로 통과해야 한다. 이 그룹(`routes/api.php:271`)은 `optional.sanctum` + `RefreshTokenExpiration` 미들웨어 아래 있어 토큰 만료 정책 갱신과 함께 동작한다.
+
+
+### GET /api/user/auth/user
+
+- **라우트명**: `api.user.auth.user`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\AuthController@user`
+- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.auth.user`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/user/auth/user HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 403 | Forbidden | 요구 권한(`core.auth.user`)이 없는 경우 |
+
+
+
+**설명**
+
+`/user` prefix 그룹(`routes/api.php:271`)의 현재 사용자 정보. 이 그룹은 `optional.sanctum` 미들웨어 아래 있어 **비인증(게스트) 요청도 통과**하며, 게스트 컨텍스트가 필요한 프론트 화면의 `data_source`(`auth_mode: "optional"`)가 이 경로를 소비한다. 응답 필드는 인증된 경우 공용 `/api/auth/user` 와 동일 형태(`toAuthArray` 병합 포함)이며, 이 경로에는 추가로 `permission:core.auth.user` 권한 게이트가 걸린다. 실측이 `403` 으로 제외된 것은 샘플 사용자에 해당 권한이 없었기 때문으로, 응답 shape 은 공용 `user` 경로를 참조한다.
+
+> **인증 계약 요약(이슈 #64)**: `api.auth.user`(`auth:sanctum`, 필수) ↔ `api.user.auth.user`(`optional.sanctum`, 선택). 프론트 `data_source` 의 `auth_required: true` 는 전자에, `auth_mode: "optional"` 은 후자에 대응한다. 어느 한쪽 미들웨어를 바꾸면 프론트 소비 계약이 침묵 속에서 깨지므로 반드시 양쪽 문서를 함께 갱신한다.
+
+
diff --git a/docs/backend/api/avatar.md b/docs/backend/api/avatar.md
new file mode 100644
index 00000000..fa1fe9cf
--- /dev/null
+++ b/docs/backend/api/avatar.md
@@ -0,0 +1,108 @@
+# Avatar API 레퍼런스
+
+> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Avatar 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
+3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### DELETE /api/me/avatar
+
+- **라우트명**: `api.me.avatar.delete`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@deleteAvatar`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+DELETE /api/me/avatar HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+
+
+
+**설명** 현재 인증 사용자의 아바타를 삭제합니다. 사용자에 연결된 아바타 첨부파일(Attachment) 레코드와 실제 파일을 함께 제거하며, 삭제 활동이 로그로 기록됩니다. 아바타가 없으면 404(`user.avatar_not_found`)를 반환합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 사용자가 자신의 프로필 사진을 기본값으로 되돌리는 시나리오에 사용합니다.
+
+
+### POST /api/me/avatar
+
+- **라우트명**: `api.me.avatar.upload`
+- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@uploadAvatar`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| avatar | body | image | 예 | max 2048 | 아바타 이미지 |
+
+> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.upload_avatar_rules`).
+
+**요청 예시**
+
+```http
+POST /api/me/avatar HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: multipart/form-data; boundary=----G7ExampleBoundary
+
+------G7ExampleBoundary
+Content-Disposition: form-data; name="avatar"; filename="example.png"
+Content-Type: image/png
+
+(바이너리 파일 내용)
+------G7ExampleBoundary--
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_rules` 훅으로 검증 규칙을 추가할 수 있습니다.
+
+
diff --git a/docs/backend/api/broadcasting.md b/docs/backend/api/broadcasting.md
new file mode 100644
index 00000000..a2ee94ce
--- /dev/null
+++ b/docs/backend/api/broadcasting.md
@@ -0,0 +1,56 @@
+# Broadcasting API 레퍼런스
+
+> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Broadcasting 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
+3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### POST /api/broadcasting/auth
+
+- **라우트명**: `api.broadcasting.auth`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+POST /api/broadcasting/auth HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+
+
+
+**설명** WebSocket 프라이빗/프레즌스 채널 구독 시 Laravel Broadcast 채널 인증을 수행하는 엔드포인트입니다. `auth:sanctum` 토큰으로 인증하며, 웹소켓 사용이 OFF(`broadcasting.default === 'null'`)이면 채널 인증을 거부해 403을 반환합니다(reverb.key 무력화를 우회한 직접 연결 시도까지 차단). 컨트롤러 없이 라우트 클로저가 토글 가드를 적용한 뒤 `Broadcast::auth`에 위임하며, 실시간 이벤트 구독을 위해 클라이언트 브로드캐스팅 라이브러리가 자동 호출합니다.
+
+
diff --git a/docs/backend/api/changelog.md b/docs/backend/api/changelog.md
new file mode 100644
index 00000000..d903423a
--- /dev/null
+++ b/docs/backend/api/changelog.md
@@ -0,0 +1,73 @@
+# Changelog API 레퍼런스
+
+> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Changelog 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
+3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### GET /api/admin/changelog
+
+- **라우트명**: `api.admin.changelog`
+- **컨트롤러**: `App\Http\Controllers\Api\Admin\LicenseController@changelog`
+- **인증/권한**: `auth:sanctum`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/admin/changelog HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| content | string | `# Changelog 이 프로젝트의 모든 주요 변경사항을 기록합니…` | 본문 내용 |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "성공적으로 처리되었습니다.",
+ "data": {
+ "content": "# Changelog\n\n이 프로젝트의 모든 주요 변경사항을 기록합니다.\n형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,\n[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.\n\n## [7.0.2] - 2026-07-05\n\n### Added\n\n- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다.\n\n### Changed\n\n- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.\n - 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.\n - 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.\n - 화면을 그리는 데 쓰이지 않는 개발용 설명 주석을 화면 정보 응답에서 빼도록 해 응답 용량을 추가로 줄였습니다.\n\n### Fixed\n\n- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.\n- 설치 마법사에서 Vendor 설치 방식을 \"Composer 실행\"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)\n- 설치 마법사의 \"설치 환경 확인\" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다.\n- 설치 시 \"읽기 전용 데이터베이스(Read DB)를 사용하지 않음\"으로 선택했는데도, 읽기 전용 DB 접속 정보를 확인하려다 설치 마지막 단계에서 실패하던 문제를 수정했습니다. 이제 읽기 전용 DB를 사용하지 않으면 쓰기 DB 정보만으로 설치가 정상 완료됩니다. 아울러 설치 이후에도 읽기 전용 DB 접속 정보를 비워 두면 조회 연결이 자동으로 쓰기 DB로 대체되도록 하여, 별도의 읽기 전용 DB 없이도 안정적으로 동작합니다. (#63 @kitrio 님께서 제보해주셨습니다.)\n\n## [7.0.1] - 2026-07-03\n\n### Changed\n\n- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.\n- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다.\n- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다.\n- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다.\n- 코어 업데이트 중 데이터 변환 단계가 현재 환경에서 자동으로 끝까지 실행되지 못하고 중단될 때, 남은 단계를 이어서 실행하는 안내를 실행 권한에 맞게 개선했습니다. 특히 관리자 권한(sudo)으로 업데이트한 경우, 남은 단계를 관리자 권한 그대로 실행하면 그 과정에서 만들어지는 파일이 관리자 소유로 남아 이후 웹사이트가 그 파일에 쓰지 못하는 문제가 생길 수 있습니다. 이제 이런 경우 웹 서버 실행 계정(예: www-data)으로 실행하도록 정확한 명령을 함께 안내하며, 웹 서버 계정을 자동으로 확인하지 못하면 어떤 계정으로 실행해야 하는지 확인 방법을 안내합니다.\n\n### Fixed\n\n- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 \"디렉토리는 복사할 수 없습니다\" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.\n- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, \"실행할 업그레이드 단계가 없다\"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.\n- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 \"알 수 없음\"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)\n\n## [7.0.0] - 2026-07-01\n\n### Added\n\n- 모듈·플러그인이 기존 화면의 초기화 동작에 자신의 초기화 단계를 덧붙일 수 있도록 레이아웃 확장 기능을 확대했습니다. 화면이 특정 모듈을 직접 알지 않아도, 모듈이 설치되어 있을 때만 그 모듈의 초기화(예: 표시 통화 복원)가 함께 실행됩니다.\n\n- 관리자 대시보드 통계에 설치된 템플릿 수(활성/전체)와 활성 언어팩 수(활성/전체)를 추가했습니다.\n- 관리자 대시보드에 최근 발송된 알림 이력을 보여주는 \"최근 알림\" 위젯용 조회 기능을 추가했습니다. 발송 채널·수신자·제목·상태(성공/실패/건너뜀)·발송 시각을 최신순으로 확인할 수 있습니다.\n- 본인인증 challenge 취소(cancel) 와 verification 토큰 소비(consume_token) 시점에 외부 본인인증 플러그인이 자기 데이터 정리 로직을 등록할 수 있는 훅 4건 추가.\n- 플러그인 개발용 표준 베이스 클래스를 추가하여, 모듈 개발과 동일한 방식으로 Repository · 캐시 · 스토리지 의존성을 선언만으로 자동 주입받을 수 있도록 개선. 플러그인이 코어와 같은 표준 위에서 작성되어 일관된 캐시·파일 격리 동작을 제공합니다.\n- 모듈·플러그인이 페이지에 주입하는 레이아웃 확장을 관리자 화면에서 직접 편집할 수 있도록 개선. 편집 내역은 버전으로 기록되어 이전 버전으로 복구할 수 있으며, 저장 전 미리보기를 제공합니다.\n\n#### 위지윅 레이아웃 편집기 (신규 도입)\n\n화면을 직접 보며 편집하는 위지윅 방식의 레이아웃 편집기를 이번 버전에서 새로 도입했습니다. 아래는 도입된 기능 영역별 상세입니다.\n\n- 좌측 트리 — 편집 화면 좌측 목록을 트리 구조로 개편하여 일반 레이아웃과 확장 주입 레이아웃을 구분 표시. 확장 주입 레이아웃은 해당 템플릿에 실제 적용되는 항목만 표시됩니다.\n- 편집 진입과 캔버스 — 템플릿 관리 화면에 코드 편집과 레이아웃 편집 진입을 분리하고, 운영 화면 좌측 하단에 편집 권한 보유자 전용 진입 버튼을 추가했습니다. 편집 캔버스는 실제 페이지와 동일한 렌더링 결과를 보여주며, 데스크탑·태블릿·모바일 폭을 즉시 전환해 반응형 결과를 확인할 수 있습니다. 데이터 소스가 정의된 화면은 샘플 데이터로 자동 채워져 실제 API 없이도 화면을 검토할 수 있고, 본문 영역에 사용된 위지윅 에디터 등 동적 자원도 캔버스 안에서 그대로 동작합니다. 좌측 트리에서 화면을 선택하면 주소창이 즉시 반영되어 새로고침·공유·뒤로가기 동작이 자연스럽고, 같은 주소로 직접 진입하면 해당 화면이 자동 선택됩니다. 편집 중 로그인이 풀리면 안내 후 자동으로 로그인 화면으로 이동하며 재로그인 시 편집 화면으로 복귀합니다. 권한 부족·서버 오류·네트워크 끊김 등은 상황별 안내 카드(아이콘·설명·다음 행동 버튼)로 표시합니다.\n- 요소 추가·재배치 — 컴포넌트를 클릭해 선택하면 외곽에 + 버튼이 나타나고, + 버튼을 누르면 화면 중앙에 모달 팔레트가 열려 좌측 카테고리 사이드바와 우측 그리드 카드(아이콘 + 라벨 + 유형 뱃지)에서 추가할 컴포넌트를 고를 수 있습니다. 부모 컨테이너에 들어갈 수 있는 컴포넌트만 자동 필터링되며, 검색으로 컴포넌트를 빠르게 찾을 수 있습니다. 추가한 컴포넌트는 템플릿이 정의한 기본 모양·기본 텍스트로 캔버스에 바로 보이며, 추가 직후 해당 위치로 화면이 부드럽게 스크롤됩니다. 컴포넌트 우상단 ⓘ 아이콘으로 속성 설정·복사·삭제 메뉴를 열고, 호버/선택 시 외곽선·8방향 리사이즈 핸들 자리·잠금/네비게이션 어포던스가 표시됩니다. 변경 사항은 Ctrl+Z / Ctrl+Shift+Z 또는 툴바 ↶/↷ 로 되돌리거나 다시 실행할 수 있습니다. 편집 모드에서 컴포넌트를 클릭해도 페이지 이동이 일어나지 않고 선택만 되며, 내부 라우트로 가리키는 요소는 \"→ 이 화면 편집\" 어포던스로 목적지 화면 편집 모드로 즉시 전환됩니다.\n- 저장·충돌 방지 — 낙관적 잠금으로 저장 결과는 성공·검증 실패·동시 저장 충돌·네트워크 오류 등 상황별 안내(성공 토스트 / 오류 배너 / 충돌 모달)로 즉시 표시됩니다. 같은 레이아웃을 다른 관리자가 먼저 저장했으면 충돌 안내 모달에서 최신 버전 불러오기를 선택할 수 있고, 신규 추가한 컴포넌트가 그 사이 비활성화된 모듈·플러그인 소속이면 저장이 자동 차단됩니다. 저장 직후에는 되돌리기 이력이 정리되어 저장 시점이 새 기준이 되며, 변경 사항이 있는 상태에서 다른 화면으로 이동하려 하면 저장하지 않은 내용이 있다는 안내가 먼저 표시됩니다. 로그인·회원가입 등 비로그인 전용 화면을 편집할 때는 로그인 상태 미리보기가 적용되지 않아 안내 토스트가 잘못 뜨지 않습니다.\n- 속성 편집 — 요소를 선택하면 정렬·글자 크기·굵기·색상·여백·크기 등을 친화적인 컨트롤(분절 버튼·슬라이더·색상 선택·태그 입력)로 조정할 수 있고, 요소 모서리·변을 직접 드래그해 가로·세로 크기를 바꿀 수 있습니다(드래그 크기 조정도 되돌리기/다시 실행에 반영). 반복 영역(목록처럼 같은 틀이 여러 번 펼쳐지는 자리)의 속성을 편집하면 모든 항목에 함께 적용됩니다. 요소를 편집하는 동안에는 선택한 요소만 밝게 유지되고 나머지 화면은 어둡게 잠겨, 다른 요소를 실수로 건드리지 않습니다. 속성 편집 팝업은 헤더를 잡아 옮길 수 있으며 선택한 요소를 가리지 않도록 빈 공간으로 자동 배치되고, 큰 요소에서도 요소 추가·메뉴 버튼이 크기 조절 핸들과 겹치지 않도록 여백을 둡니다. 고급 설정에서 요소를 볼 수 있는 권한을 권한 목록에서 골라 지정할 수 있습니다. 목록 있음·없음, 회원·비회원, 검증 실패·저장 중 같은 화면 상태를 미리보기로 전환해 데이터 의존 영역을 실제와 가깝게 검토할 수 있고, 같은 데이터 이름을 여러 확장이 쓰더라도 각 화면이 자기 출처의 샘플로 올바르게 표시됩니다. 라우트를 전환해도 저장하지 않은 편집 내용이 유지되며, 미저장 상태로 화면을 떠나거나 새로고침하면 경고가 표시됩니다. 마지막 저장 상태로 한 번에 되돌리는 초기화 버튼도 제공합니다.\n- 이미지 관리 — 배경 이미지 등 레이아웃 첨부 이미지를 업로드·관리할 수 있으며, 상단 툴바와 배경 이미지 컨트롤에서 썸네일 갤러리를 열어 업로드·삭제·배경 적용을 할 수 있으며, 지정한 배경 이미지가 편집 캔버스에 즉시 반영되고(채움·맞춤·타일 표시 모드 포함) 모달을 다시 열어도 현재 설정이 그대로 복원됩니다. 발행된 배경 이미지는 일반 방문자 화면에서도 정상적으로 표시됩니다.\n- 동작·표시조건·정렬 편집 — 요소를 선택하고 속성 편집 팝업에서 (1) 클릭·마우스 오버 시 동작을 \"다른 페이지로 이동\"·\"안내 메시지 보여주기\"·\"화면 상태 바꾸기\"·\"목록 새로고침\"·\"서버에 보내고 결과 처리\"·\"결제 진입\" 같은 친화 명칭으로 조립하고(서버 호출은 성공·실패 후속 동작까지 단계로 구성), (2) 로그인 회원·관리자·데이터 있음/없음·수정/새 화면·입력 오류 등 친화 조건을 \"그리고/또는\"으로 결합해 요소를 보여줄 조건을 정하며, (3) 가로·세로 컨테이너의 자식 배치(방향·정렬·줄바꿈·간격)와 정렬 박스 안 요소의 늘어남·정렬·순서를 시각 컨트롤로 편집할 수 있습니다. 핸들러·코드 용어 없이 친화 입력만 노출되며, 이동할 페이지·데이터 이름은 목록에서 선택합니다. \"결제 진입\" 동작은 특정 결제사에 고정하지 않고, 서버 응답이 알려주는 결제 수단(결제 핸들러·결제 데이터)을 데이터 검색에서 골라 연결합니다 — 결제 수단을 바꾸거나 추가해도 화면 편집 없이 동작합니다.\n- 색 모드·디바이스별 스타일 — 속성 편집에 라이트/다크 색 모드와 디바이스별 편집을 더해, 스타일·표시조건 탭 상단의 서브탭에서 라이트/다크 색 모드와 기본값·PC·태블릿·모바일(+ 직접 지정하는 커스텀 화면 폭)을 선택해, 각 범위에 스타일·정렬·표시 조건을 따로 지정할 수 있습니다. 글자색·배경색은 패널의 프리셋 색을 고르면 라이트와 다크 모두에 적용되어 다크 화면용 색을 비개발자도 지정할 수 있고(컬러 피커로 직접 고른 자유 색은 라이트 화면 전용), 어느 범위에 기본값과 다른 설정이 있으면 탭에 표시점이 뜨며 \"기본값으로 초기화\"로 그 범위만 되돌릴 수 있습니다. 편집기 상단 라이트/다크 토글로 미리보기 색상 테마를 전환하면, 관리자 화면이 다크 테마여도 미리보기가 라이트/다크를 정확히 구분해 보여줍니다.\n- 화면 문구 다국어 편집 — 상단 툴바의 콘텐츠 언어 선택으로 미리보기에 표시되는 화면 문구의 언어를 전환할 수 있고, 캔버스의 텍스트를 더블클릭하면 그 자리에서 바로 고칠 수 있습니다(Enter 로 확정, Esc 로 취소). 문구를 고치면 그 문구가 다국어 키로 자동 등록되어, 입력한 내용이 즉시 화면에 반영됩니다. 편집 중 떠 있는 서식 막대로 굵게·기울임·밑줄을 켜고 끄거나, 글자 크기·정렬·글자색을 목록에서 골라 적용할 수 있으며(색은 색상 미리보기와 함께 선택), 바뀐 서식은 편집 중에도 즉시 보입니다. 편집 안내 배지를 누르면 속성 편집의 번역 탭이 열려 모든 언어의 값을 한 번에 입력할 수 있어, 한 언어만 고치고 다른 언어 번역을 놓치는 일을 줄입니다. (데이터에 연결된 문구·반복 영역 문구는 직접 편집 대상에서 제외됩니다.)\n- 모든 컴포넌트로 속성 편집 확대 — 어떤 요소를 선택해도 속성 탭에 \"요소 ID\" 칸이 항상 제공되어 화면 내 요소에 고유 식별자를 부여할 수 있으며(화면 안전 문자만 입력되고 한글·공백 등은 자동으로 걸러집니다), 컴포넌트별 편집 항목(링크 주소·이미지 주소·입력칸 종류·선택지·정렬 옵션 등)도 친화 컨트롤로 편집할 수 있습니다. 아이콘은 자유 입력 대신 검색 가능한 그리드에서 골라 지정할 수 있어(템플릿이 제공하는 아이콘 목록을 검색으로 빠르게 찾기), 아이콘 이름을 외우지 않아도 됩니다. 입력칸의 선택지처럼 여러 항목으로 된 값은 항목 추가·삭제·순서 변경으로 편집하며, 이미 데이터에 연결된 값은 실수로 덮어쓰지 않도록 보호됩니다.\n- 모달 시각 편집 — 좌측 트리에 각 화면이 사용하는 모달 목록이 \"이 화면의 모달\" 그룹으로 표시되고(공통 레이아웃의 모달, 모듈·플러그인 화면의 모달, 별도 파일로 분리된 모달 포함), 각 모달은 \"주문 취소\"·\"비회원 조회 비밀번호 재설정\"처럼 친화 제목으로 표시됩니다. 모달을 선택하면 실제 화면 위에 모달이 열린 상태로 문구 수정·요소 추가·저장을 할 수 있습니다. 모달 바깥 영역은 어둡게 잠겨 클릭·드래그·편집이 차단되어 실수로 호스트 화면을 건드리지 않습니다.\n- 버전 배지·복원 — 좌측 트리에 각 화면과 확장 주입 조각의 현재 저장 버전 배지가 표시되고, 버전 기록 모달에서 이전 버전과의 비교(diff)와 복원을 확장 주입 조각에서도 동일하게 사용할 수 있으며, 저장·복원 시 배지가 새로고침 없이 즉시 갱신되고 복원 결과가 캔버스에 바로 반영됩니다.\n- 공통 레이아웃 편집 — 각 화면의 콘텐츠가 채워질 슬롯 자리가 점선 박스와 라벨로 표시되고(슬롯 자체는 편집 대상이 아니라 잠금), 헤더·푸터를 클릭해 바로 선택·편집할 수 있습니다. 모달 편집 중에도 편집 캔버스의 스크롤이 그대로 유지됩니다.\n- 화면 상태 미리보기 — 모달·공통 레이아웃까지 확대해, 배송지 직접 입력, 모바일 메뉴 펼침, 쿠키 배너 표시, 본인인증 진행 모달처럼 특정 조작에서만 나타나는 영역을 상태 전환만으로 미리보며 편집할 수 있고, 모달 안에 확장이 주입한 영역도 시각 편집할 수 있습니다.\n- 일부 템플릿(사용자 템플릿 등)의 화면을 미리볼 때 헤더가 \"데이터를 표시할 수 없습니다\"로 표시되지 않고, 미리보기용 샘플 데이터가 모든 템플릿에서 안전하게 렌더됩니다.\n\n- 회원이 아닌 사용자(비회원)에게도 이메일 알림을 보낼 수 있도록 알림 시스템을 확장했습니다. 회원에게 알림을 보내던 것과 동일한 방식으로, 입력한 이메일을 수신자로 알림이 발송되며 발송 내역에도 함께 기록됩니다. 알림 종류별로 비회원 발송을 허용할 수단을 선택할 수 있어, 이메일은 비회원에게 보내되 사이트 안 알림처럼 로그인이 필요한 수단은 보내지 않도록 구분됩니다(새 알림 수단이 추가될 때 비회원 허용 여부를 별도로 정하지 않으면 기본적으로 보내지 않습니다). 비회원에게 가는 알림은 그 사용자가 보던 화면 언어로 발송됩니다.\n\n### Changed\n\n- 모든 번들 플러그인이 위 표준 베이스 클래스로 이관되어 캐시·스토리지 도메인이 코어 및 다른 확장과 명확히 격리되도록 개선. 한 플러그인의 캐시 키가 코어 캐시 영역을 침범하던 잠재 결함을 차단하며, 관리자 화면의 변경사항이 의도대로 즉시 반영됩니다.\n- 템플릿이 정의하는 에러 페이지(404 / 403 / 500 / 503 / 401 / 점검) 의 식별자를 다른 모든 페이지와 동일한 디렉토리 접두사 포함 형식(`errors/{코드}`) 으로 통일. 템플릿 설치 시 에러 페이지가 일관된 식별자로 시드되며, 일부 환경에서 에러 페이지가 \"찾을 수 없음\" 응답으로 떨어지던 회귀를 차단합니다. 번들 템플릿(`sirsoft-basic`, `sirsoft-admin_basic`) 의 에러 페이지 설정도 함께 정합화됩니다.\n- 템플릿 외부 리소스 위임 — Font Awesome·웹폰트 등 외부 CDN 리소스를 페이지 코드에서 하드코딩하지 않고 템플릿의 `externals` 선언으로 위임. 외부 스타일시트·웹폰트·스크립트와 preconnect·preload 등 리소스 힌트를 한 곳에서 관리하며, 관리자 템플릿과 사용자 템플릿에 동일하게 적용\n- 템플릿 컴포넌트 에셋 캐시 버스팅 — `?v=time()` → `?v={{ $extensionCacheVersion }}`로 변경하여 매 요청 무효화 → 확장 변경 시점에만 무효화\n- 모듈·플러그인 업데이트 시 관리자가 편집한 확장 레이아웃을 보존하거나 덮어쓰는 전략을 선택할 수 있도록 개선.\n- 코어 업그레이드 스텝 명령에 보조 단계(권한 정상화·마이그레이션·캐시 정리 등)를 건너뛰고 스텝만 실행하는 옵션 추가.\n- 코어 영역의 프론트엔드 다국어 자원(에러 메시지·시스템 토스트 등) 위치를 정리하여 어떤 템플릿이 부팅되어도 자동 노출되도록 개선. 이전에 코어 에러 화면에서 `core.errors.*` 같은 키가 raw 로 노출되던 결함이 해소됩니다.\n\n### Fixed\n\n#### 본인인증(IDV)\n\n- 정책 관리에서 코어·모듈·플러그인이 제공한 정책의 \"인증 목적\"·\"적용 대상\"·\"우선순위\"를 바꿔 저장해도 반영되지 않던 문제를 수정했습니다 — 이제 정책 키·강제 시점·강제 위치(정책이 걸리는 지점)만 고정되고, 그 외 모든 항목을 운영자가 자유롭게 변경할 수 있으며, 변경한 값은 확장 업데이트 후에도 유지됩니다.\n- 한 화면에 본인인증 정책이 여러 개 걸리고 우선순위가 같을 때, 어느 정책이 먼저 적용될지 정해지지 않아 의도한 인증(예: 성인 인증)이 무시되고 다른 인증이 적용될 수 있던 결함을 수정했습니다. 이제 우선순위가 같아도 항상 동일한 정책이 적용되도록 적용 순서를 고정하고, 정책 추가·수정 화면에 우선순위 입력칸을 제공하며 같은 위치에 같은 우선순위의 활성 정책을 저장하려 하면 저장을 막고 안내합니다. 정책 목록에도 우선순위 열을 추가해 한눈에 확인할 수 있습니다.\n- 성인 인증 같은 추가 조건을 함께 요구할 때, 그 조건을 충족하지 못해 인증이 실패하면 실패 사유 안내와 \"본인 확인이 필요합니다\" 안내가 동시에 뜨던 문제를 수정했습니다. 이제 추가 조건 미충족으로 실패하면 그 사유 안내 하나만 표시되며, 일반 본인인증 실패의 안내는 종전대로 표시됩니다.\n- 환경설정의 기본 프로바이더 / 목적별 프로바이더 / 코드 유효시간 / 최대 시도 횟수 4개 항목이 admin UI 저장값을 무시하던 결함 수정. 변경한 환경설정값이 회원가입·비밀번호 재설정 등 모든 본인인증 흐름에 즉시 반영됩니다.\n- 환경설정 > 본인인증 > 목적별 프로바이더에서 일부 본인인증 목적(예: 성인인증)의 프로바이더를 지정해 저장하면 \"설정 저장에 실패했습니다\" 오류로 전혀 저장되지 않던 결함 수정. 이제 모든 본인인증 목적의 프로바이더 매핑이 정상 저장됩니다. 아울러 저장 검증에 실패하면 문제가 된 목적별 프로바이더 입력칸이 빨갛게 강조되고 그 아래에 사유가 표시되어, 어느 항목을 고쳐야 하는지 바로 확인할 수 있습니다.\n- 정책에 프로바이더가 지정되지 않은 경우 환경설정의 기본 프로바이더가 자동 적용되도록 보강. 운영자가 정책마다 프로바이더를 일일이 지정하지 않아도 환경설정 기본값으로 일관되게 동작합니다.\n- 코드 입력 모달의 \"남은 시도 횟수\" 표시가 환경설정값과 무관하게 항상 5회로 굳어 있던 결함 수정. 운영자가 환경설정에서 지정한 한도가 사용자 화면에 정확히 표시됩니다.\n- 인증 코드를 최대 횟수까지 잘못 입력해 시도가 소진되었을 때, 화면에 \"최대 시도 초과 — 다시 요청해주세요\" 안내 대신 일반 오류 문구(\"인증 코드가 올바르지 않습니다\")가 표시되던 결함 수정. 시도가 소진되면 재요청을 안내하는 메시지가 정확히 표시됩니다.\n- 본인인증을 사용하는 모듈·플러그인을 비활성화해도 해당 확장이 설정한 본인인증 요구가 계속 적용되던 결함 수정. 이제 확장을 비활성화하면 그 확장이 선언한 본인인증 정책도 함께 적용 해제되고, 다시 활성화하면 운영자가 조정한 설정을 유지한 채 정상 복구됩니다.\n\n#### 확장 동기화·캐시·레이아웃 주입\n\n- 확장 메뉴 동기화 시점에 운영자가 수정하지 않은 필드 (아이콘·이름·정렬·URL) 가 잘못 \"사용자 수정\" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 확장 재설치·업데이트 시 메뉴 정의 변경이 정상 반영됩니다.\n- 확장 역할 동기화 시점에 운영자가 수정하지 않은 필드 (역할 이름·설명) 가 잘못 \"사용자 수정\" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 메뉴 동기화와 동일 맥락으로, 확장 재설치·업데이트 시 역할 정의 변경이 정상 반영됩니다.\n- 모듈·플러그인의 레이아웃 확장이 대상이 아닌 템플릿(관리자 확장 ↔ 사용자 템플릿)에도 등록되던 결함 수정. 업그레이드 시 잘못 등록된 항목을 자동 정리하며, 이후로는 대상 레이아웃이 존재하는 템플릿에만 등록됩니다.\n- 모듈·플러그인이 공통 레이아웃(헤더·푸터 등 모든 화면이 공유하는 틀)에 주입한 UI·초기화 동작이 실제 화면에는 전혀 나타나지 않던 결함 수정. 개별 화면이 공통 레이아웃을 물려받을 때 그 \"물려받은 출처\" 정보가 사라져, 공통 레이아웃을 대상으로 한 확장 주입이 어느 화면에도 적용되지 않았습니다. 이제 화면이 물려받은 공통 레이아웃을 대상으로 한 확장도 정상 적용되어, 모듈이 설치되어 있으면 해당 UI(예: 헤더 통화 선택기)와 초기화 동작이 모든 관련 화면에 표시됩니다.\n- 확장(모듈·플러그인) 변경 후 라우트 트리·메뉴·다국어 응답이 옛 값으로 남아 있던 결함 수정. 이전에는 클라이언트가 캐시 버전 신호를 동반하지 않아 백엔드 캐시가 갱신 시점을 놓쳤습니다. 확장 설치/활성화/업데이트가 일어나면 다음 페이지 로드부터 새 값이 즉시 보입니다.\n- 확장을 업데이트하거나 레이아웃을 저장해도 변경 내용이 화면에 반영되지 않고 옛 내용이 계속 보이던 결함 수정. 캐시 갱신 신호가 기록되는 저장소와 읽히는 저장소가 환경설정에 따라 어긋날 수 있어, 올린 갱신 신호가 화면 쪽에서 보이지 않던 문제였습니다. 이제 갱신 신호가 항상 같은 저장소로 일관되게 기록·조회되어 확장 업데이트·레이아웃 변경이 다음 화면 로드부터 즉시 반영됩니다.\n- 코어 업데이트 시 사용자가 모듈·플러그인·템플릿·언어팩의 `_bundled` 폴더 아래에 직접 만들어 둔 커스텀 폴더·파일이 함께 삭제되던 결함 수정. 이제 코어 업데이트는 코어가 제공하는 번들 확장만 갱신하고, 사용자가 추가한 항목은 그대로 보존합니다. (#43 @bigmsg 님께서 제보해주셨습니다.)\n- 관리자 권한(`sudo`)으로 코어를 업데이트한 뒤, 모듈·플러그인·템플릿·언어팩을 업데이트하려 하면 \"백업 생성 중\" 단계에서 `mkdir(): Permission denied` 오류로 실패하던 결함 수정. 관리자 권한 업데이트가 백업 폴더와 업그레이드 기록 파일을 관리자 소유로 남겨, 이후 웹서버 계정이 같은 위치에 쓰지 못하던 문제였습니다. 이제 이들 폴더·파일의 소유권과 그룹 쓰기 권한이 정상화되고, 코어와 확장의 업그레이드 기록을 별도 파일로 분리해 두 작업이 서로의 기록 파일을 두고 충돌하지 않습니다.\n\n#### 알림\n\n- 회원에게 보내는 알림(이메일·사이트 알림)이 보내는 사람(관리자 등)의 화면 언어가 아니라 받는 회원 본인의 언어로 발송되도록 수정. 관리자가 한국어 화면에서 다른 언어를 쓰는 회원의 주문 상태를 바꿔도, 알림은 그 회원의 언어로 전달됩니다.\n- 신규 주문 접수 시 관리자에게 발송되는 알림 메일의 결제금액이 0원으로 표시되던 결함 수정. 결제 예정 금액이 정확히 표시됩니다.\n- 알림 템플릿을 기본값으로 복원할 때 제목·본문만 복원되고 수신자·클릭 주소는 관리자가 수정한 값이 그대로 남던 결함 수정. 이제 복원 시 수신자·클릭 주소까지 기본값으로 정확히 되돌아갑니다.\n\n#### 설치·인스톨러\n\n- 설치 마법사에서 데이터베이스 테이블 접두사를 지나치게 길게 입력하면 일부 확장 설치 시 테이블 인덱스 이름이 데이터베이스 한도를 넘겨 설치가 실패할 수 있던 문제를 예방합니다 — 접두사 입력 길이를 6자로 제한하고, 한도를 넘기면 설치 전 단계에서 안내합니다.\n- 인스톨러에서 \"기존 테이블 삭제 후 설치\"를 선택할 때, 입력한 테이블 접두사와 무관한 다른 테이블까지 모두 삭제되던 결함 수정. 이제 입력한 접두사로 시작하는 테이블만 삭제되어, 같은 데이터베이스를 다른 프로그램과 함께 쓰거나 다른 접두사로 재설치할 때 무관한 테이블이 보존됩니다.\n\n#### 관리자 화면·검색·시스템·보안\n\n- 관리자 대시보드의 최근 활동·모듈/플러그인/템플릿 상태·게시판/이커머스 위젯 등 같은 틀이 여러 번 펼쳐지는 목록에서, 항목마다 고유 식별자를 붙이도록 작성된 화면이 모든 행에 같은 식별자를 그대로 내보내 일부 보조 기능·접근성 도구가 항목을 구분하지 못하고 일부 항목이 화면에서 누락되어 보이던 결함을 수정했습니다 — 이제 반복 목록의 각 행이 고유하게 식별되어 모든 항목이 정상 표시됩니다.\n- 한 화면 안에서 탭이나 필터를 클릭으로 전환할 때, 화면에 따라 표시 조건에 묶인 데이터 목록이 비어 보이던 결함 수정. 새로고침해야 보이던 목록이 이제 탭·필터 전환 즉시 정상 표시됩니다. (표시 조건에 따라 로딩되는 데이터 목록이 전환 시점에 다시 평가되지 않던 문제였습니다.)\n- 환경설정 화면 등에서 별도 표시 텍스트 없이 켜고 끄는 토글·체크박스를 켜서 저장하면 \"참/거짓 값이어야 합니다\" 오류로 저장이 거부되던 결함 수정. 토글의 켜짐/꺼짐 상태가 문자열이 아닌 참/거짓 값으로 정확히 전달되어 정상적으로 저장됩니다.\n- 관리자 목록 화면에서 필터(발급상태·사용여부 등)를 선택하고 검색하면 일부 필터가 검색 직후 \"전체\"로 풀려 보이던 결함 수정. 새로고침해야 선택한 필터가 다시 표시되던 불편을 없애고, 검색 직후에도 선택한 필터가 그대로 유지됩니다.\n- 검색창에 `<`, `>`, `(`, `+` 등 특수문자를 입력하면 오류가 발생하던 결함 수정. 특수문자는 일반 검색어로 처리되어, 어떤 문자를 입력해도 오류 없이 검색 결과(또는 빈 결과)가 표시됩니다.\n- IME(한글·일본어·중국어) 입력 도중 Enter 로 글자를 조합 확정할 때 마지막 글자가 누락되거나 검색·제출이 두 번 실행되던 결함 수정. 이제 조합이 끝난 뒤의 Enter 만 동작합니다. (#54 @devrhee16 님께서 제보해주셨습니다.)\n- 관리자 대시보드의 시스템 리소스(CPU·메모리·디스크) 수집이 일부 호스팅 환경(open_basedir 제한, 일부 시스템 함수 비활성)에서 실패할 때 대시보드 리소스 조회 전체가 오류로 응답하던 결함 수정. 이제 수집할 수 없는 항목만 \"알 수 없음\"으로 표시되고 나머지 화면은 정상 동작합니다. (#40 @glitter-gim 님께서 제보해주셨습니다.)\n- 시스템 정보 화면의 디스크 사용량 조회도 동일한 환경에서 안전하게 \"알 수 없음\"으로 표시되도록 보강했습니다.\n- 웹소켓 사용을 끈 상태에서도 실시간 연결이 일부 동작할 수 있던 문제 수정. 이제 웹소켓 사용을 끄면 실시간 연결·채널 인증이 완전히 비활성화됩니다. (#50 @bigmsg 님께서 제보해주셨습니다.)\n- 사이트 이름을 언어별로 다르게 저장한 환경에서 브라우저 탭 제목과 검색 메타데이터(JSON-LD)가 올바르게 표시되지 않거나 페이지가 깨질 수 있던 문제 수정. 이제 모든 메타 경로가 현재 언어의 사이트 이름을 일관되게 사용합니다. (#49 @glitter-gim 님께서 제보해주셨습니다.)\n- 존재하지 않는 페이지에 접근할 때 정상 페이지처럼 200 응답을 반환하던 문제 수정. 이제 미등록 경로는 올바른 404 응답을 반환해 검색엔진이 없는 페이지를 색인하지 않습니다. (#47 @glitter-gim 님께서 제보해주셨습니다.)\n- 인증이 필요한 관리자 API에 로그인 없이 접근할 때, 브라우저 주소창 직접 입력 등 일부 요청에서 서버 오류로 응답하던 결함 수정. 이제 어떤 방식으로 접근해도 일관되게 \"인증 필요\" 응답이 반환됩니다. (#39 @glitter-gim 님께서 제보해주셨습니다.)\n\n### Security\n\n- 사용되지 않는 파일 서빙 엔드포인트(`/storage` 직접 업로드·다운로드 경로) 노출을 제거했습니다. 실제 파일 업로드·다운로드는 별도의 인증된 경로로 처리되며 영향이 없습니다. (#52 @glitter-gim 님께서 제보해주셨습니다.)\n- 설치가 완료된 시스템에서 설치 상태 관리 엔드포인트(`/install/api/state-management.php`)가 다른 설치 API 와 동일하게 차단되지 않아, 설치 완료 후에도 설치 상태 조회·초기화·중단 요청이 가능하던 문제를 수정했습니다. 설치가 완전히 끝난 환경에서는 해당 요청이 모두 HTTP 410 으로 차단됩니다. 설치 진행 중 완료 화면으로 정상 전환되는 동작은 영향받지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1056)\n\n### Removed\n\n- 기본 제공 결제 수단을 KG이니시스로 단일화했습니다. 그 외 결제 연동(나이스페이먼츠·NHN KCP·토스페이먼츠)은 기본 번들에서 제외되며, 필요한 경우 별도 플러그인으로 설치해 사용할 수 있습니다. 이미 해당 결제 연동을 설치해 사용 중인 사이트는 영향받지 않습니다.\n\n## [7.0.0-beta.7] - 2026-05-15\n\n### Fixed\n\n- 인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.)\n- beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다.\n- 위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다.\n- 인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다.\n- beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다.\n- 위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다.\n\n### Changed\n\n- `INSTALL.md` 에 \"업그레이드\" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.)\n- 관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 \"전체 업그레이드 가이드 보기\" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.)\n\n### Notes — `.env` 권한 권장안\n\n위 업그레이드 스텝은 `.env` 가 이미 존재하는 환경에서는 권한을 임의로 변경하지 않고 기존 상태를 그대로 보존합니다. 보안 위생 강화를 원하시면 다음 권장안을 참고하세요.\n\n- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다.\n- 단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.\n- 명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다.\n\n## [7.0.0-beta.6] - 2026-05-14\n\n### Fixed\n\n- 코어 업데이트 자동 롤백 시 신 버전이 추가한 신규 파일이 활성 디렉토리에 잔존하여 `BindingResolutionException` 등 부정합 부팅 실패가 발생하던 결함 수정. 백업 디렉토리에 신규 파일 manifest 를 기록하고 롤백 시 정확히 그 목록만 정리합니다. 사용자가 코어 영역에 직접 추가한 파일과 모든 symlink (`public/storage` 등) 는 보존됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)\n- 자동 롤백 후 `bootstrap/cache` 잔존 PHP 캐시로 인한 추가 부팅 실패 차단 — 롤백 직후 캐시를 자동으로 정리합니다.\n- beta.5 이전 자동 롤백으로 인해 활성 디렉토리에 잔존했을 수 있는 ServiceProvider 후보를 beta.6 업그레이드 스텝이 진단 로그로 식별하여 운영자 수동 검토를 안내합니다. 자동 삭제하지 않으므로 사용자가 직접 추가한 ServiceProvider 는 보존됩니다.\n- 업그레이드 스텝 단독 실행 명령(`core:execute-upgrade-steps`) 이 마이그레이션·코어 재동기화·버전 갱신·캐시 정리·번들 확장 일괄 업데이트를 자동으로 함께 수행하도록 보강. 자동 업데이트 중단 후 안내된 수동 명령을 그대로 실행하면 별도 보조 작업 없이 업그레이드가 완료됩니다.\n- 인스톨러 Step 3 (PHP CLI / Composer / 자동 감지 / 코어 _pending 경로 검증) 의 프론트엔드 catch 분기에서 사용하는 다국어 키가 언어 파일에 정의되어 있지 않아, 서버 응답 실패 시 화면에 다국어 키 문자열이 그대로 노출되던 결함 수정.\n- 인스톨러 다국어 파일에 중복 정의된 키 4건 정리. POST 전용 API 의 메서드 거부 메시지가 일반 문구로 덮여 표시되던 문제, 데이터베이스 연결 실패 로그에서 placeholder 가 치환되지 않던 문제, HTTPS 카드 라벨이 안내 문장으로 표시되던 문제가 함께 해결됩니다.\n- 인스톨러 Step 3 의 PHP CLI 및 Composer 절대경로 검증이 공유 호스팅 환경(시놀로지 DSM 등 `open_basedir` 가 시스템 binary 영역을 차단하는 환경)과 Windows 환경에서 정상 경로임에도 거부되던 결함 수정. 셸 인자 escape 와 메타문자 차단은 유지하고, 파일 시스템 권한에 의존하던 사전 검사를 실제 실행 결과로 판정하도록 변경합니다. Windows 의 백슬래시 경로 구분자도 정상 입력으로 인식됩니다 (단, `C:\\Program Files\\...` 같은 공백 포함 디렉토리는 공백 없는 경로 또는 8.3 short path 사용 권장). (#33 @glitter-gim 님께서 제보해주셨습니다.)\n- 코어 운영 단계의 composer 바이너리 자동 인식이 같은 `open_basedir` 환경에서 실패하여 모듈/플러그인 설치 시 vendor-bundle 모드만 사용 가능하던 결함 수정. composer 가 정상 설치되어 PATH 에 존재하는 환경에서도 인식되도록 검출 로직을 개선했습니다.\n- 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP 등) 에서 Composer 를 특정 PHP 인터프리터로 실행하려는 \"PHP 절대경로 + Composer 절대경로\" 공백 분리 입력 형식이 거부되던 결함 수정. 입력을 두 토큰으로 분리한 뒤 각 토큰을 개별 escape 처리하여 안전을 유지하면서 멀티 PHP 운영 시나리오를 지원합니다.\n- 인스톨러 세션 쿠키가 일부 환경(비표준 포트 + dynamic DNS 도메인 + 브라우저 추적 보호 등) 에서 브라우저에 의해 차단되어 첫 화면 \"설치하기\" 클릭이 동작하지 않고 새로고침처럼 보이던 결함을 보완. 세션 쿠키 SameSite 정책을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄이고, Step 0 진입 시 쿠키 round-trip 사전 진단을 수행하여 차단이 감지되면 사용자에게 회피 방법(다른 브라우저 / 쿠키 차단 해제 / IP·localhost 접속) 을 명시 안내합니다.\n\n### Added\n\n- `hotfix:rollback-stale-files` Artisan 커맨드 추가 — 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 파일을 운영자가 명시 실행하여 진단/정리할 수 있는 단발성 회복 도구. 기본은 진단 모드 (실제 삭제 없음), `--prune` 옵션 시 확인 프롬프트 후 정리. symlink 와 보호 경로(`storage/`, `vendor/`, `.env*` 등) 는 자동 제외.\n- 본인인증 메시지 정의 목록 API 응답에 `can_create` 권한 키 추가 — 운영자의 수정 권한 보유 여부에 따라 관리자 화면이 \"정의 추가\" 버튼을 정확히 활성화/비활성화할 수 있도록 보강.\n\n## [7.0.0-beta.5] - 2026-05-12\n\n### Upgrade Notice\n\nbeta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)\n\n- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드\n- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)\n\nbeta.5 의 자동 복구 스텝이 `_bundled` 기반으로 번들 확장은 복원하지만, 위 두 항목은 `_bundled` 에 없어 자동 복구가 불가능합니다.\n\n업그레이드 전에 다음 디렉토리 전체를 별도 위치(외장 디스크, 별도 git 브랜치, tar.gz 파일)로 백업해두실 것을 권고합니다.\n\n- `modules/`\n- `plugins/`\n- `templates/`\n- `lang-packs/`\n\n업그레이드 후 자동 복구된 활성 디렉토리에 본인의 수정 사항/외부 확장이 누락되어 있다면, 사전 백업에서 복원하거나 외부 확장은 원본 소스(GitHub 등)에서 재설치하세요. 실패한 외부 확장 식별자는 업그레이드 로그(`storage/logs/core_update_success_*.log`)에 명시됩니다.\n\n#### 3가지 사용자군별 업그레이드 절차\n\n- **사용자군 B — 정상 beta.4 사용자 (대다수)**: `php artisan core:update` 표준 절차. beta.5 의 자동 복구 스텝이 활성 확장 디렉토리 + lang-packs 정합성을 보장합니다.\n\n- **사용자군 A — beta.3 → beta.4 업그레이드 도중 `Call to undefined method` 류 fatal 후 stuck 한 사용자**: `.env` `APP_VERSION` 을 변경하지 마시고 그대로 `php artisan core:update --force` 를 재실행하면 됩니다. 디스크는 이미 beta.4 이므로 새 자식 프로세스가 beta.5 메모리로 부팅되어 남은 업그레이드 스텝이 멱등 적용됩니다. 단, 활성 디렉토리에서 직접 수정한 코드 또는 `_bundled` 에 없는 외부 확장은 자동 복구 대상이 아니므로 업그레이드 전 위 디렉토리 백업이 동일하게 권장됩니다.\n\n- **사용자군 C — beta.1 또는 beta.2 사용자**: `php artisan core:update` 1회 호출로 beta.5 까지 직접 업그레이드는 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 fatal 위험이 있습니다. 다음 단계적 업그레이드를 권장합니다:\n\n ```text\n beta.1 사용자: beta.1 → beta.2 → beta.3 → beta.4 → beta.5 (4 회)\n beta.2 사용자: beta.2 → beta.3 → beta.4 → beta.5 (3 회)\n ```\n\n 각 단계마다 `.env` `APP_VERSION` 이 자동 갱신되므로 다음 호출은 그대로 `php artisan core:update` 실행하면 됩니다. 가까운 릴리즈 zip 으로 `--source=` 또는 `--zip=` 옵션을 사용해 단계별 진행 가능합니다.\n\n#### 자동 롤백 후 `public/storage` 회복\n\n업그레이드 도중 자동 롤백 후 화면에서 업로드 파일이 보이지 않거나 미디어 경로가 깨졌다면 `public/storage` symlink 가 일반 디렉토리로 변질된 상태일 수 있습니다.\n\n**우선 시도 — beta.5 의 자동 복구**: beta.4 → beta.5 업그레이드 자체는 부모 beta.4 메모리 결함으로 symlink 가 깨질 수 있으나, beta.5 의 업그레이드 스텝이 다음 패턴을 자동 감지하여 복구합니다:\n\n- 감지 조건: `public/storage` 가 일반 디렉토리 + `storage/app/public` 이 존재 (Laravel 표준 구성)\n- 복구 동작: 손상된 디렉토리를 `public/storage.broken.YYYYMMDDHHMMSS` 로 보존 후 symlink 재생성\n- 운영자 조치: 자동 복구 후 `.broken` 디렉토리의 콘텐츠를 검증한 뒤 수동 삭제 (false positive 방어를 위해 자동 삭제하지 않음)\n\n**자동 복구가 작동하지 않은 경우 매뉴얼 회복**:\n\n```bash\nrm -rf public/storage\nphp artisan storage:link\n```\n\nbeta.5 부터 백업/복원 과정에서도 symlink 가 보존되므로 beta.5 → beta.6 이후로는 본 결함이 자동 차단됩니다. Windows 환경에서는 PHP `symlink()` 가 `SeCreateSymbolicLink` 권한을 요구하므로 자동 복구가 silent skip 될 수 있어 매뉴얼 회복 절차가 여전히 필요합니다.\n\n#### spawn 자식 프로세스 실패 시 동작 모드\n\nbeta.5 부터 코어 업데이트의 자식 프로세스 spawn 이 실패하면 즉시 abort 가 기본 동작입니다 (`G7_UPDATE_SPAWN_FAILURE_MODE=abort`). 다음 4가지 상황에서 부모 프로세스 메모리의 stale 클래스로 인한 fatal 위험을 차단합니다:\n\n1. `proc_open` 비활성 (일부 공유 호스팅)\n2. `proc_open` 자원 생성 실패\n3. 자식 비정상 종료 (uncaught exception)\n4. 자식 exit=0 이지만 업그레이드 스텝 0건 실행 (silent skip)\n\nabort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute-upgrade-steps --from=... --to=...`) 을 안내합니다. `proc_open` 비활성 환경 등 호환성이 필요한 경우 `.env` 에 `G7_UPDATE_SPAWN_FAILURE_MODE=fallback` 을 설정하여 in-process fallback 으로 진행할 수 있으나, 부모 메모리 stale 로 인한 fatal 위험이 잔존합니다.\n\n### Added\n\n- 본인인증 관리자 권한을 조회/수정으로 분리 — \"프로바이더 설정 조회\" / \"정책 조회\" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)\n- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 \"정책 추가\" / \"수정\" / \"삭제\" / \"활성 토글\" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)\n- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단 (#28 @bigmsg 님께서 제보해주셨습니다.)\n- 업그레이드 스텝에 \"버전별 데이터 스냅샷\" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드) (#29 @bigmsg 님께서 건의해주셨습니다.)\n- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능\n\n### Changed\n\n#### Breaking\n\n- 본인인증 관리자 권한 키 정리 — 기존 `core.admin.identity.manage` / `core.admin.identity.policies.manage` 가 각각 `core.admin.identity.providers.update` / `core.admin.identity.policies.update` 로 변경. beta.4 환경에서 기존 권한을 부여받은 운영자 역할은 업그레이드 시 자동으로 새 권한 키로 마이그레이션 (부여 시점/부여자/스코프 메타데이터 모두 보존). 외부 확장 또는 운영 도구가 옛 키 문자열을 하드코딩한 경우 새 키로 갱신 필요\n\n### Fixed\n\n- 코어 업데이트 시 활성 모듈/플러그인/템플릿/언어팩 디렉토리가 삭제되던 문제 수정 — beta.4 → beta.5 업그레이드 시 번들 원본에서 활성 디렉토리를 자동 복원하고, 외부 설치 확장은 업데이트 로그로 재설치 안내\n- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화\n- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정\n- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)\n- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함) (#31 @glitter-gim 님께서 제보해주셨습니다.)\n- 본인인증 활동 로그의 \"액션\" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가\n- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경\n\n## [7.0.0-beta.4] - 2026-05-11\n\n### Changed\n\n#### Breaking\n\n- 레이아웃 `navigate` 액션의 기본 스크롤 동작이 페이지 이동 후 최상단 이동으로 변경됨 — 일반 하이퍼링크 이동 UX 와 일치. 이전처럼 스크롤 위치를 유지하려면 `scroll: \"preserve\"` 명시 필요. 검색 필터/페이지네이션 등 위치 유지가 필요한 화면에서 회귀가 의심되면 해당 옵션 추가 (engine-v1.45.0)\n\n#### 언어팩 매니페스트 정합화\n\n- 언어팩 매니페스트(`language-pack.json`) 를 모듈/플러그인/템플릿 매니페스트와 동일한 필드 구조로 정렬 — 외부 작성자가 다른 확장과 동일한 표준으로 언어팩을 만들 수 있도록 개선\n- 언어팩 매니페스트에 GitHub 저장소 필드 추가로 GitHub 기반 업데이트 경로 지원 (모듈/플러그인 매니페스트와 동일한 동작)\n- 관리자 환경설정의 언어팩 카드와 상세 모달이 모듈/플러그인/템플릿 카드와 동일한 형식으로 다국어 이름 · 설명 · GitHub 링크를 노출하도록 변경\n\n### Added\n\n#### Generator 메타 태그\n\n- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 \"GnuBoard7 {버전}\" 자동 적용, 운영자가 버전 노출을 원치 않으면 \"GnuBoard7\" 만 입력 가능 (#26 @Lastorder-DC 님께서 건의해주셨습니다.)\n\n#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치\n\n- 인스톨러 4단계 확장 선택 화면에 \"언어팩\" 카드 신설 — 번들 언어팩을 locale 별 서브헤딩으로 노출하고, 사용자가 모듈/플러그인/템플릿을 선택하면 그 확장에 종속된 번들 언어팩 카드가 즉시 활성화. 종속 확장을 해제하면 그 언어팩 카드는 자동으로 비활성화 + 선택 해제\n- 5단계 설치 진행 시 모든 확장의 install/activate 가 끝난 뒤 선택된 번들 언어팩을 일괄 설치 — 1건 실패는 best-effort 처리로 전체 설치를 중단하지 않음\n- 코어/확장당 다수 locale(일본어 + 중국어 등)이 동시에 번들된 시나리오 대응 — 각 locale 은 독립된 서브헤딩 + 카드로 노출\n\n#### 인스톨러 SSE 호환성 자동 감지\n\n- 인스톨러 시작 시 SSE 호환성을 사전 점검하여 환경에 맞는 모드(SSE 또는 폴링)로 단방향 진입 — 워커 동시 실행 race(테이블 / unique 키 중복 에러) 차단\n- SSE 비호환 환경 사용자에게 폴링 모드 진행 여부를 명시적 다이얼로그로 확인 후 시작\n\n#### ActivityLog 다국어 영역 분리\n\n- 활동 로그의 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되도록 변경 — 그동안 코어에 일괄 등록되어야 했던 모듈 origin 라벨(이커머스 주문·상품·마일리지, 게시판 게시물·댓글·신고, 페이지 등) 이 각 영역으로 이전되어 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명. 미정의 시 코어 라벨로 자동 fallback 하여 회귀 없음\n- 모듈/플러그인이 발화하는 활동 로그가 호출자/대상 모델의 영역을 자동으로 인식하여 자기 다국어 파일로 라우팅 — 새 활동 로그를 추가할 때 별도 분기 코드 없이 자기 lang 만 채우면 정합\n\n#### 다국어 시더 인프라\n\n- 확장 entity 시더가 활성 언어팩의 다국어 데이터를 자동 머지하도록 다국어 시더 인터페이스 도입 — 신규 시더 작성 시 회귀를 자동 차단\n- 번들 일본어 언어팩 빌드 스크립트가 시더 메타데이터를 일관된 경로로 조회하도록 단순화\n\n#### 다국어 라벨 helper + Provider/Registry 페이로드 정합화\n\n- 다국어 라벨 표시 시 활성 언어팩의 lang key fallback 을 자동으로 처리하는 다국어 라벨 보강 helper 추가 — Provider/Registry 등록 페이로드(알림 채널·결제 PG 등) 와 settings JSON 다국어 데이터를 단일 시그니처로 처리\n- 알림 채널(`config/notification.php` 의 `default_channels`) 의 라벨이 활성 언어팩(일본어 등) 으로 자동 보강되도록 변경 — 일본어 활성 시 한국어 fallback 으로 노출되던 회귀 차단\n- 토스페이먼츠 PG 프로바이더 등록 페이로드가 lang key 기반으로 라벨을 선언하도록 변경 — 활성 언어팩으로 자동 보강\n- Provider/Registry 등록 페이로드가 다국어 JSON(`['ko' => ..., 'en' => ...]`) 을 직접 보유하지 않도록 audit 룰 신설 (회귀 자동 차단)\n- settings JSON 다국어 entry 의 식별 키(code/id/key) 보유 검증 audit 룰 신설 (lang pack fallback 키 조립 가능성 보장)\n- 프론트엔드 `$localized()` 표현식이 두 번째 인수로 lang key fallback 을 받도록 확장 — 활성 로케일 라벨 부재 시 모듈/플러그인 언어팩의 키로 자동 보강\n- 이커머스 환경설정 카탈로그(배송 가능 국가 / 통화 / 결제수단) 표시 시 활성 언어팩의 라벨이 자동 적용되도록 변경 — 일본어 활성 시 카탈로그 라벨이 한국어로 노출되던 회귀 차단 (다음 일본어 언어팩 빌드 시 자동 적용)\n- audit 룰이 `getDefault*Channels/Providers/Methods` 등 registry 기본값 반환 메서드의 다국어 JSON 직접 보유도 감지하도록 강화\n\n#### Settings 카탈로그 다국어 자동 보강\n\n- 환경설정 카탈로그(결제수단·통화·배송 가능 국가 등) 의 다국어 라벨이 카탈로그 빌드 시점에 활성 언어팩으로 자동 보강되도록 변경 — 사용자 체크아웃 + 관리자 환경설정 양쪽 모두 일본어 등 활성 시 한국어 fallback 으로 노출되던 회귀 차단\n- 모듈/플러그인 개발자가 카탈로그를 추가할 때 누락하지 않도록 audit 룰이 helper 호출 누락 검출\n- [docs/backend/settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) 에 단순 helper 사용 패턴 문서화\n\n#### 확장 시스템\n\n- manifest `hidden: true` 플래그 추가 — 관리자 UI 목록에서 학습용/내부용 확장을 숨김 (CLI 는 정상 노출). 관리자 UI 에 슈퍼관리자 전용 \"숨김 포함\" 토글, artisan `module:list` / `plugin:list` / `template:list` 에 `--hidden` 플래그, 관리자 API `/api/admin/{modules,plugins,templates}` 에 `include_hidden` 쿼리 파라미터 지원\n- 학습용 최소 샘플 확장 4종 번들 추가\n - 모듈: `gnuboard7-hello_module` (Memo CRUD + 훅 발행 시연)\n - 플러그인: `gnuboard7-hello_plugin` (Action/Filter 훅 구독 시연)\n - Admin 템플릿: `gnuboard7-hello_admin_template` (Basic 컴포넌트 최소 셋)\n - User 템플릿: `gnuboard7-hello_user_template` (홈 + Memo 리스트 연동)\n- 모듈/플러그인/템플릿 정보 모달에 \"지원 언어\" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인\n- 모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화\n- 인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가\n- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리 (#18 @laelbe 님께서 제보해주셨습니다.)\n- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 \"다시 활성화\" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)\n- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)\n- 업데이트 모달에 코어 버전 호환성 안내 + \"위험을 이해하고 강제로 진행\" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능\n- 관리자 대시보드 \"시스템 알림\" 카드 노출 — 코어 호환성 자동 비활성화/재호환 알림이 분기 렌더되며 개별 dismiss 지원\n\n#### 코어 업데이트 가시성\n\n- 코어 업데이트 마무리 단계에서 sudo 환경 결함(파일시스템 ACL · immutable 비트 · NFS 권한 거부 등) 으로 일부 경로의 소유권/그룹 쓰기 권한을 정상화하지 못한 경우 즉시 콘솔에 실패 경로 + 운영자 수동 복구 명령(`sudo chown -R …` / `sudo chmod -R g+w …`) 안내 — 이전에는 silent fail 로 묻혀 운영자가 후속 권한 거부 발생 후에야 인지하던 문제 해소\n\n#### 알림 시스템\n\n- 권한 기반 수신자 타입 추가 — `permission` 타입으로 특정 권한을 가진 모든 사용자에게 알림 발송 가능 (예: 게시판 신고 알림은 신고 관리 권한자에게 자동 발송)\n- 모듈 환경설정에서 알림이 비활성화된 경우 발송 자체를 사전 차단하는 정책 게이트 도입 — 이전에는 수신자 해석 단계가 정책을 우회하여 발송되던 문제 해소\n- 게시판 환경설정 → 신고 정책 탭에 신고 알림 채널 선택 UI 추가 (이메일 / 사이트 알림 다중 선택)\n- 모듈/플러그인이 자기가 발송하는 알림 정의를 manifest 에서 직접 선언하도록 통일 — 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록되고, 제거 시 함께 정리됨. 권한·메뉴·본인인증과 동일한 declarative getter 패턴\n- 코어 기본 알림(회원가입 환영·비밀번호 재설정·비밀번호 변경) 의 다국어 제목/본문/변수/채널 정의를 `config/core.php` 로 통합 — 운영자가 코드 수정 없이도 향후 표면 변경을 추적할 수 있는 단일 소스 확보\n\n#### 본인인증\n\n- 본인인증(IdentityVerification) 인프라 도입 — 코어에 범용 IDV 프로바이더 계약을 마련하고, 기본 메일 프로바이더를 내장. 플러그인이 KCP·이니시스·SMS 등 다른 경로를 동일 계약으로 붙일 수 있도록 확장점 제공\n- 외부 본인인증 provider (KCP·PortOne·토스인증·Stripe Identity 등) 가 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입할 수 있는 슬롯 도입\n- 비동기 검증 흐름을 위한 백엔드 폴링/콜백 엔드포인트 추가 — `GET /api/identity/challenges/{id}` (상태 폴링), `POST /api/identity/callback/{providerId}` (외부 redirect 콜백 수신)\n- 본인인증 정책 시스템 신설 — 회원가입·비밀번호 재설정·민감 작업에 적용되는 모든 본인인증 시점을 라우트/훅 단위로 선언형 정책으로 통합 관리. 관리자 화면에서 정책 활성/유예 시간/프로바이더/실패 모드/단계(가입 제출 전 vs 가입 후 활성화 전)·적용 대상(self/admin/both)을 조정할 수 있으며, 운영자 수정값은 업데이트 재시딩 시 보존됨\n- 본인인증 정책의 enable 토글이 라우트 코드 수정 없이 즉시 적용 — 모든 API 라우트가 정책 DB 와 자동 매칭되어 운영자가 admin UI 에서 정책을 켜는 즉시 본인인증이 강제됨. 코어/모듈/플러그인 어떤 라우트의 응답 처리 패턴에서도 본인인증 흐름이 일관되게 모달까지 도달하도록 처리\n- 회원가입 단계 정책 2종 시드 — 가입 제출 전 동기 검증, 가입 후 활성화 전 비동기 challenge (기본값 비활성, 운영자 opt-in)\n- 본인인증 정책이 활성화된 모든 강제 지점(회원가입·비밀번호 재설정·민감 작업·게시판/이커머스 정책 등)에서 사용자/관리자 화면에 동일한 모달 UX 가 자동으로 표시되도록 코어 인터셉터와 공통 모달 인프라 도입\n- 전역 프론트엔드 인터셉트 — 서버가 HTTP 428 본인인증 요구 응답을 반환하면 자동으로 인증 모달을 띄우고 사용자가 인증에 성공하면 원 요청을 자동 재실행\n- 본인인증 가드가 모듈/플러그인이 선언한 훅에도 자동 적용되도록 동적 구독 도입 — 결제 직전·민감 액션 직전 등 도메인 특화 가드를 운영자 토글 한 번으로 활성화\n- 관리자 화면에 본인인증 정책 관리 페이지 추가 (정책 목록 DataGrid + 편집)\n- 관리자 화면에 본인인증 이력 페이지 추가 — 알림 발송 이력과 동일한 수준의 UI 제공: 인증 수단(Provider) 탭(활성화된 프로바이더에 따라 자동 갱신), 통합 검색(자동 감지/사용자 ID/대상 식별자/IP/정책 키), 상태·인증 목적·채널·발생 유형 다중선택 OR 필터, 날짜 범위 + 고급 검색(발생 유형·출처·정책 키) 토글 영역, 정렬·페이지 사이즈 선택, 행 펼침으로 인라인 상세 표시, 모바일 반응형, 사용자 타임존 기준 시각, 보관주기(180일) 일괄 파기, 인증 수단/발생 위치 다국어 라벨 표시\n- 본인인증 목적(purpose) 의 출처(source) 추적 — 코어/모듈/플러그인이 선언한 목적을 구분해 환경설정 화면에서 분리 표시하며, 코어 정책 화면 상단에 \"코어가 제공하는 본인인증 목적\" 칩 섹션(목적 코드/라벨 + 설명·허용 채널 툴팁) 추가\n- 모듈 환경설정의 본인인증 정책 탭을 코어 정책 관리 화면과 동일한 UI/UX 로 통일 — 검색·필터(강제 시점/출처)·페이지네이션·정책 추가/편집 모달 직접 호출(코어 화면으로 이동 X)·이 모듈이 등록한 본인인증 목적 칩 섹션(없으면 안내 메시지). 정책 목록의 \"인증 목적\" 컬럼이 코드명 대신 사람이 읽을 수 있는 라벨로 표시\n- 모듈/플러그인이 자기 컨텍스트의 본인인증 정책·목적·메시지 정의를 manifest 에서 직접 선언하도록 통일 — `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 선언만으로 코어 정책 관리에 자동 연동되며, 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록·정리됨. 권한·메뉴·알림과 동일한 declarative getter 패턴\n- 본인인증 정책/이력 목록 API 가 source 컨텍스트별 필터를 받아 모듈 환경설정 탭이 자기 정책/이력만 조회 가능하며, 운영자 정의 정책을 특정 모듈/플러그인 컨텍스트에 귀속시켜 추가할 수 있도록 source_identifier 입력 허용 (`admin` / `module:{id}` / `plugin:{id}`)\n- 본인인증 메일 메시지 템플릿 시스템 도입 — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의 가능하며, 메시지 발송 시 정책 → 목적 → 프로바이더 기본값 순서로 fallback 해석. 회원가입/계정 변경/중요 작업/비밀번호 재설정 + 프로바이더 기본값 등 5종 메일 템플릿이 한국어/영어로 시드되어 즉시 발송 가능\n- 본인인증 challenge 발급/검증/취소 라우트에 권한 미들웨어 + scope=self 가드 적용 — 게스트는 `core.identity.{request,verify,cancel}` 권한으로 비로그인 가입(Mode B) 흐름 진입, 로그인 사용자는 본인 challenge 만 다룰 수 있으며 관리자는 임의 challenge 도 처리 가능. 모달 취소 시 서버 cancel API 를 호출해 challenge 가 audit log 에 cancelled 상태로 즉시 기록되도록 정합화\n- 환경설정 → 본인인증 탭에 \"메시지 템플릿\" 서브탭 신설 — 알림 템플릿 관리와 동일한 UX (채널 서브탭·페이지당 항목 수 셀렉터·카드 펼침 본문 미리보기·활성/기본 배지·페이지네이션) 로 정의 목록·활성 토글·다국어 편집(변수 가이드 + 기본값 복원) 제공. 운영자가 추가한 정책에 전용 메일 메시지 정의를 화면에서 직접 등록·삭제 가능 (시드 기본값 보호 + 정책 키 매칭 검증). 외부 본인인증 프로바이더 플러그인이 자기 메시지 기본값을 코어 복원 로직에 기여할 수 있는 필터 훅 노출\n- 알림 발송 이력 / 본인인증 이력 샘플 시더를 코어/모듈별로 분리 — 코어 시더는 코어 정의·정책만, 각 모듈 시더는 자기 영역만 채우도록 영역 격리. 모듈은 `module:seed {id} --sample` 으로 자기 영역 이력만 독립 생성 가능. 샘플 데이터는 실제 등록된 사용자·정의·정책·프로바이더 기반으로 생성되어 운영 데이터와 동일한 스키마/분포 유지\n- 모듈/플러그인 제거 시 코어 공유 테이블에 적재된 해당 확장의 데이터(권한·관리자 메뉴·알림 정의·본인인증 정책·본인인증 메시지 정의·본인인증 목적) 가 함께 정리되며, 제거 모달의 \"삭제될 데이터\" 에도 항목별 건수가 표시되도록 개선\n- IDV 도메인 분류 데이터(목적·채널·트리거 출처·정책 범위·정책 실패 모드·정책 적용 대상·정책 출처·메시지 스코프) 8종을 PHP Backed Enum 으로 정의하여 정책 등록/수정 시 일관된 검증 적용\n\n#### 인스톨러\n\n- 인스톨러 설치 완료 화면에 설치된 코어 버전 표시 — 사용자가 어떤 버전이 설치되었는지 즉시 확인할 수 있도록 개선\n\n#### 언어팩 시스템\n\n- 새 언어(일본어/중국어 등)를 코어 수정 없이 추가할 수 있는 언어팩(Language Pack) 시스템 도입\n- ZIP 업로드 또는 GitHub URL로 언어팩 설치/제거/활성화 지원\n- 동일 언어 슬롯에 여러 벤더의 언어팩 공존 가능 — 라디오 전환으로 즉시 활성 변경\n- 코어/모듈/플러그인/템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 가능\n- 관리자 메뉴: 환경설정 > 언어팩 관리(통합) + 모듈/플러그인/템플릿별 진입점 추가\n- 사용자가 직접 수정한 다국어 키는 언어팩이 덮어쓰지 않도록 보존 정책 적용\n- 보안: 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단\n- 언어팩 관리 화면을 모듈 관리와 동일 수준의 운영 도구로 보강 — 검색(식별자/벤더/언어), 업데이트 확인, 캐시 갱신, 다중 선택 일괄 제거 지원\n- 활성/비활성 상태를 토글 스위치로 일원화 (보호된 팩은 비활성화된 토글로 표시)\n- 업데이트 가능 항목에 \"업데이트 가능\" 배지와 행 단위 업데이트 실행 버튼 노출\n- 정보 모달을 모듈 정보 모달과 동일한 4섹션 구조(기본정보 / 호환성 / 소스 정보 / 변경로그)로 제공하며, CHANGELOG.md 를 자동 파싱하여 버전별 카테고리로 표시\n- 설치 모달에서 ZIP 업로드 전에 manifest 와 검증 결과를 사전 확인할 수 있는 미리보기 제공\n- 모듈/플러그인/템플릿별 언어팩 페이지 진입 시 대상 확장 안내 배너와 환경설정 탭으로 회귀하는 링크 노출\n- 언어팩 업데이트 권한을 별도 권한 키로 분리하여 설치/관리 권한과 독립적으로 부여 가능\n- 언어팩 운영 풀 패리티 — 모듈/플러그인/템플릿 관리와 동일한 안전 장치/확장성 적용\n- 공식 일본어(ja) 번들 언어팩 12종 추가 — 코어, 주요 모듈(전자상거래/게시판/페이지), 주요 플러그인(CKEditor5/마케팅/토스페이먼츠), 기본 템플릿(admin/user)을 일본어로 즉시 사용 가능\n- 언어팩 관리 화면에서 번들 언어팩을 모듈/플러그인 관리와 동일하게 \"미설치\" 상태로 노출 — 행별 \"설치\" 버튼으로 즉시 설치 가능\n- 언어팩 목록 필터에 \"미설치 (번들)\" 상태 옵션 추가\n- 본인인증 메일 메시지 정의의 다국어 키를 언어팩으로 주입할 수 있도록 확장 — 코어 수정 없이 언어팩 ZIP 만으로 IDV 메일 본문/제목을 다국어화 가능\n- 모듈/플러그인이 선언한 알림/본인인증 메시지 정의의 다국어 키도 언어팩으로 주입되도록 정합 — 이전에는 hook 발화 누락으로 코어 정의에만 적용되던 동작 정상화\n - 업데이트 시 자동 백업 + 실패 시 직전 버전으로 자동 복구\n - 동시성 가드 — 진행 중인 작업 동안 활성/비활성/제거/재업데이트 진입 차단\n - 번들 디렉토리(`lang-packs/_bundled/{identifier}`) 에서 외부 다운로드 없이 (재)설치하는 경로 추가 — 코어 언어팩 복구/재배포 단순화\n - 라이프사이클 훅 명명을 모듈/플러그인/템플릿과 통일 — `core.language_packs.{installed|updated|uninstalled|activated|deactivated}` 발행 (확장 가능성 확대)\n - Artisan 커맨드 신규 — `language-pack:list`, `language-pack:install`, `language-pack:update`, `language-pack:uninstall`\n- 코어와 번들 확장(모듈/플러그인/템플릿)에 내장된 한국어/영어를 가상 보호 언어팩으로 자동 노출 — 별도 설치 없이 언어팩 관리자에서 항상 활성/보호 상태로 확인 가능, 수정/제거 차단\n- 호스트 확장 비활성화 시 그에 종속된 언어팩이 함께 비활성화되며, 재활성화 시 \"다음 언어팩도 활성화하시겠습니까\" 모달로 사용자 의사 확인 후 일괄 활성화\n- 여러 언어팩을 한 번에 활성화하는 `POST /api/admin/language-packs/bulk-activate` API 추가 (의존성/버전 호환성 자동 검사)\n- 언어팩 활성화 시 의존성 + 호스트 확장 버전 호환성 검사 자동 수행 — 호스트 확장이 비활성/미설치이거나 버전 미달이면 활성화 차단\n- 언어팩 업데이트 우선순위를 모듈/플러그인 패턴으로 정합화 — GitHub 1순위 + bundled 폴백, 강제 업데이트 시 bundled 우선\n- 언어팩 상세 모달에서 코어/번들 확장 내장 항목 클릭 시 \"별도 언어팩이 아닌 내장 번역\" 안내 배너 노출\n- 모듈/플러그인/템플릿 상세 모달의 닫기 버튼이 콘텐츠 길이와 무관하게 모달 하단에 항상 보이도록 sticky 처리\n\n#### SEO\n\n- 관리자 환경설정 > SEO 탭의 Sitemap 카드에서 sitemap 을 즉시 재생성하고 마지막 생성 시각을 확인할 수 있는 \"지금 생성\" 버튼 추가 — 큐 드라이버와 무관하게 동기 실행되며 결과를 즉시 표시\n\n#### SEO 봇 감지\n\n- 봇 감지 엔진을 `jaybizzle/crawler-detect` 라이브러리로 교체. 기본 약 1,000종의 봇(검색엔진·링크 미리보기·AI 검색 등)이 자동 감지됨 — 링크를 슬랙·페이스북·LinkedIn·트위터·디스코드·텔레그램 등에 붙여넣으면 제목·설명·이미지 미리보기가 즉시 동작\n- 라이브러리가 놓치는 봇 3종(`kakaotalk-scrap`·`Meta-ExternalAgent`·`ChatGPT-User`) 을 G7 보강 패턴으로 기본 포함\n- 봇 감지 확장 훅 `core.seo.resolve_is_bot` 신설 — 플러그인이 IP 범위 검증·역방향 DNS·Cloudflare 봇 점수 등을 주입할 수 있는 슬롯\n- \"봇 라이브러리 사용\" 관리자 토글 추가 (기본 on, 비활성 시 운영자 커스텀 목록만 사용하는 레거시 모드)\n\n#### SEO OG / Twitter 카드 / 도메인 ownership\n\n- 슬랙·페이스북 링크 미리보기에 이미지·카드가 표시되도록 OG 보강 태그를 자동 출력하도록 개선 — og:site_name, og:image:width, og:image:height, og:image:secure_url, og:image:type, og:image:alt, og:locale 추가\n- Twitter 카드 메타태그(twitter:card / twitter:site / twitter:title / twitter:image 등) 출력 신설 — 슬랙 unfurl 폴백 경로 정상화\n- 운영자 환경설정 SEO 탭에 \"OG / Twitter 카드 기본값\" 카드 추가 — 사이트 이름·이미지 기본 가로/세로·Twitter 카드 타입·Twitter 사이트 핸들 5개 입력\n- 모듈·플러그인이 자기 도메인의 OG/Twitter/JSON-LD 를 직접 선언하도록 SEO declaration API 신설 — 이커머스 상품(Product/Offer/AggregateRating)·게시판 게시글(Article) 등 도메인 스키마가 레이아웃에서 확장 코드로 owned 되어 데이터에 따라 정확한 부속 태그 생성\n- SEO 메타 확장 훅을 분기별·통합 모두 제공 — OG·Twitter·구조화 데이터 각각의 hook 슬롯과 통합 hook 모두 지원하여 확장이 원하는 단계에서 선택 변경 가능\n- 모듈 설정 타이틀 템플릿에서 변수가 비어있을 때 인접 구분자(- – · |) 가 자동 정리되도록 개선 — 옵셔널 그룹 `[ ... ]` 표기도 지원하여 페이지별 구성 명시 가능\n\n### Changed\n\n- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용 (#15 @laelbe 님께서 건의해주셨습니다.)\n- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환\n- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존\n- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 \"페이지 로딩 실패\" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 \"세션이 만료되었습니다. 다시 로그인해 주세요.\" 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공 (#19 @abc101 님께서 건의해주셨습니다.)\n- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경\n- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선\n- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환\n- 언어팩 활성/비활성 시점에 영향받는 모듈/플러그인의 entity 시더가 자동 재실행되어 신규 언어 라벨이 즉시 DB 에 반영되도록 라이프사이클 통합 — scope 별 라우팅 (코어 언어팩 → 모든 활성 확장, 모듈 언어팩 → 해당 모듈만, 플러그인 언어팩 → 해당 플러그인만)\n- 환경설정 → SEO → \"추가 봇 패턴\" 필드의 역할 변경 — 기존에는 유일한 봇 매칭 소스(기본 5종)였으나, 이제는 라이브러리가 놓치는 조직별 커스텀 봇만 추가하는 보강 레이어로 동작. 기존 설치의 운영자 커스텀 값은 모두 보존되며, 신규 설치 기본값은 jaybizzle 미커버 3종으로 변경\n\n### Security\n\n- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)\n\n### Fixed\n\n- 코어 업데이트 후 일부 환경에서 모듈/플러그인이 저장한 사용자 데이터(상품 이미지·첨부파일 등)에 PHP 가 접근하지 못해 \"찾을 수 없음\" 오류가 발생하던 문제 수정 — 업데이트 종료 시점 권한 복원 범위를 PHP 쓰기 영역(storage/logs·storage/framework·bootstrap/cache·storage/app/core_pending)으로 한정하고 항목별 정확 복원으로 정합화. 이전 버전에서 본 릴리즈로 업그레이드한 환경에서는 업그레이드 시점에 storage/app 디렉토리/파일 권한을 PHP 가 접근 가능한 형태로 자동 정상화함. 향후 업데이트에서는 사용자 데이터 디렉토리에 자동 생성되는 보존 마커로 권한이 영구 보호됨\n- PHP 8.5 환경에서 모든 페이지 응답이 손상되어 Firefox 에서는 \"Content Encoding Error\", Edge/Chromium 에서는 빈 화면으로 표시되던 문제 수정\n- `php artisan serve` 환경에서 인스톨러 진행 중 .env 파일이 변경되면 개발 서버가 워커를 재시작하면서 설치 단계가 중도에 끊기던 문제 수정 — 설치 진행 중에는 .env 를 건드리지 않고 완료 화면 노출 후 한 번에 반영하도록 변경\n- 관리자 환경설정 \"시스템 사양\" 카드의 메모리 항목이 서버 물리 메모리가 아닌 현재 PHP 프로세스가 사용 중인 메모리(수 MB 단위)로 표시되던 문제 수정 — 디스크 사용량과 동일한 형식(사용량/전체/백분율)으로 실제 서버 RAM 을 표시하도록 개선\n- 관리자 환경설정 \"시스템 사양\" 카드의 CPU 항목이 Windows 11 / Windows Server 2025 에서 \"operable program or batch file.\" 로 표시되던 문제 수정 — 해당 OS 에서 제거된 wmic 의존을 걷어내고 PowerShell 기반 조회로 전환, 구형 Windows 환경에서는 기존 방식으로 자동 폴백\n- 관리자 환경설정 \"정보\" 탭을 한 번 조회한 뒤 다른 탭으로 전환할 때마다 수 초 지연이 반복되던 문제 수정 — 시스템 정보 조회 결과를 1시간 캐싱하여 탭 전환 시 대기 시간 제거 (시스템 캐시 초기화 시 함께 무효화)\n- 큐 워커가 훅 페이로드의 enum 값(주문 상태 등)을 복원하지 못해 활동 로그·알림 등 일부 후속 처리가 실패하던 문제 수정\n- 항목 삭제 후 큐 워커가 처리하는 후속 훅에서 대상 데이터를 복원하지 못해 처리가 중단되던 문제 수정 — 소프트 삭제 페이지·첨부파일, 하드 삭제 주문 옵션 등 포함\n- 페이지 키워드 검색 결과의 전체 건수가 발행된 모든 페이지 수로 부풀려지던 문제 수정\n- 사용자가 댓글을 단 게시글 활동 조회 시 500 에러가 발생하던 문제 수정\n- 사용자 활동 통계에 삭제된 게시글의 댓글 수가 포함되던 문제 수정\n- 일부 주문에서 옵션 정보 직렬화 시 500 에러가 발생하던 문제 수정\n- 상품 옵션 삭제 검증 시 런타임 에러가 발생하던 문제 수정\n- 설치 직후 또는 활성 모듈 디렉토리 부재 시 이커머스 환경설정 기본값이 비어있던 문제 수정\n- 검색 가능 드롭다운(SearchableDropdown)에서 빠른 모달 전환 시 race condition 가능성 차단\n- 반응형 설정과 반복 렌더링이 결합된 레이아웃에서 무한 재귀가 발생할 수 있던 엔진 결함 수정 (engine-v1.43.1)\n- 슬롯 치환 시 텍스트 노드를 컴포넌트로 가정하여 발생할 수 있던 레이아웃 렌더링 오류 차단\n- 인스톨러 실행 시 보안 키 생성 단계에서 개발용 패키지 ServiceProvider 를 찾지 못해 설치가 중단되던 문제 수정 — 이전 환경에서 남은 컴파일 캐시를 vendor 교체 직후와 보안 키 생성 직전에 자동 정리하도록 개선\n- 폴링 모드 인스톨러가 큰 확장(레이아웃·테스트 수천 파일) 설치 도중 멈추던 문제 수정 — 명령 출력을 파일 기반으로 처리하여 OS 파이프 버퍼 한도와 무관하게 동작\n- PHP 8.5 + Apache + mod_fcgid 환경에서 폴링 모드 진행 상황이 실시간 반영되지 않고 설치 완료 시점에 일괄 표시되던 문제 수정 — 어느 모드로도 진행 상황이 즉시 표시되도록 보정. Apache 환경별 권장 설정은 INSTALL.md 와 시스템 요구사항 문서에 명시\n- 설치 완료 후 임시 파일이 자동 정리되지 않던 문제 수정\n- 알림 템플릿 편집 모달의 입력 필드에서 글자를 입력할 때마다 화면이 심하게 버벅이던 문제 수정\n- 슈퍼관리자가 다른 관리자 계정을 삭제할 수 없던 문제 수정 — 관리자 계정 삭제 가능 여부를 역할/권한/스코프 설정에 따라 판단하도록 개선 (슈퍼관리자 본인 보호는 유지)\n- `seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 \"큐에 디스패치\" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경\n- 관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선\n- 보안 환경설정의 \"최대 로그인 시도 횟수 / 차단 시간\" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현\n- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2) (#24 @minyho 님께서 제보해주셨습니다.)\n- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 \"해당 권한이 없습니다\" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨\n- PHP 8.5 환경에서 관리자 로그인 시 `PDO::MYSQL_ATTR_SSL_CA` 등 PDO 드라이버 상수가 deprecation 경고를 발생시키던 문제 수정 — `Pdo\\Mysql::ATTR_SSL_CA` 형태의 신규 상수로 전환하고, PHP 8.5 미만 환경에서는 기존 상수를 그대로 사용하도록 분기 처리 (#23 @yks118 님께서 제보해주셨습니다.)\n- 회원가입 시 일부 환경에서 동의 항목 메타데이터가 누락되어 활동 로그 처리에서 예외가 발생, 후속 훅 체인이 중단되며 신규 회원에게 `user` 역할이 자동 부여되지 않던 문제 수정 — 동의 메타데이터를 nullable 로 받아 누락 케이스에서도 후속 권한 부여가 정상 동작하도록 개선 (#25 @comtylove-netizen 님께서 제보해주셨습니다.)\n\n## [7.0.0-beta.3] - 2026-04-23\n\n### Fixed\n\n- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 \"제목은 필수입니다\" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선 (#17 @laelbe 님께서 제보해주셨습니다.)\n- 코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 \"Permission denied\" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수\n- 업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 \"Permission denied\" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)\n- 코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정\n\n### Notes\n\n- 7.0.0-beta.1 에서 7.0.0-beta.3 로 직접 업그레이드하는 경우, 환경(opcache CLI 활성 등)에 따라 권한 복구 스텝이 자동 실행되지 않고 건너뛰어질 수 있습니다. 업데이트 후 `storage/framework/cache` 등에서 Permission denied 가 발생하면 아래 명령을 수동 실행해 주세요 — 7.0.0-beta.2 에서 올라오는 경로에서는 해당 없음\n - `php artisan core:execute-upgrade-steps --from=7.0.0-beta.2 --to=7.0.0-beta.3 --force`\n- 시스템 레벨에서도 일관된 그룹 공유 권한을 원하면 php-fpm pool 설정에 `umask = 002` (또는 systemd unit 의 `UMask=0002`) 추가를 권장합니다. 코드 레벨 동조와 병행하면 외부 프로세스(cron, composer 등) 도 동일 권한으로 파일을 생성합니다\n\n## [7.0.0-beta.2] - 2026-04-20\n\n### Added\n\n#### 알림 시스템 재설계\n\n- 알림 시스템을 Definition(정의) × Template(채널별 템플릿) × Recipients(수신자) 3계층으로 재설계\n- 채널별 독립 수신자 설정 — trigger_user, related_user, role, specific_users 4종 타입 지원\n- 채널 Readiness 검증 — 미설정 채널(SMTP 미구성 등)의 발송을 사전 차단\n- 알림 발송 공통 디스패처 — 채널별 독립 발송, 발송 전후 훅 자동 실행, 모든 채널 자동 로깅\n- 알림 클릭 URL 지원 — database 채널 알림에 클릭 시 이동할 URL 패턴 설정 가능\n- 사용자/관리자 알림 전체 삭제 API 추가\n- 비밀번호 변경 시 `password_changed` 알림 자동 발송\n- 코어 권한에 사용자 알림 전용 카테고리 분리 (관리자/사용자 권한 의미 분리)\n- 알림 정의 일괄 초기화 기능 — 커스터마이징된 알림의 모든 채널 템플릿을 기본값으로 복원 (확인 모달 + 스피너 UX 포함)\n- 모듈/플러그인이 자신의 기본 알림 정의를 코어 리셋 로직에 제공할 수 있는 필터 훅 제공\n- 템플릿 편집 시 편집 상태가 자동 추적되어 리셋 버튼이 자동 노출\n- 알림 정의/템플릿 응답에 편집/삭제 권한 정보(abilities) 포함으로 UI가 권한에 따라 자동 조정\n\n#### Vendor 번들 시스템\n\n- Composer 실행 불가 환경(공유 호스팅 등)을 위한 vendor 번들 시스템 추가\n- vendor-bundle Artisan 커맨드 — 빌드, 검증, 일괄 처리\n- 모듈/플러그인 설치·업데이트에 `--vendor-mode=auto|composer|bundled` 옵션 추가\n- 인스톨러 Step 3에 Vendor 설치 방식 선택 UI 추가 (환경 자동 감지)\n\n#### GeoIP 및 타임존\n\n- MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가\n- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기 (#8 @abc101 님께서 건의해주셨습니다.)\n- Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능\n\n#### 인스톨러 개선\n\n- SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능\n- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결 (#10 @glitter-gim 님께서 건의해주셨습니다.)\n- 자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시\n- 다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)\n- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원) (#3 @laelbe 님께서 제보해주셨습니다.)\n- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행 (#5 @laelbe 님께서 건의해주셨습니다.)\n- 권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)\n- 소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시\n- Step 5에 \"설치 시작\" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작\n\n#### 공통 캐시 시스템\n\n- 코어/모듈/플러그인 격리 캐시 시스템 도입 (접두사 자동 부여)\n- 모델 변경 시 태그 기반 자동 캐시 무효화 트레이트 추가\n- 환경설정에서 캐시 TTL 중앙 관리 (7개 설정 키)\n\n#### 어드민 UI\n\n- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지 (#6 @laelbe 님께서 제보해주셨습니다.)\n- 목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)\n- DataGrid 컴포넌트에 `id` prop 추가\n\n#### 기타\n\n- HookManager 공통 큐 디스패치 — 환경설정 하나로 모든 훅 리스너 자동 비동기 실행, 큐 워커에서도 Auth/Request/Locale 컨텍스트 자동 복원\n- WebSocket 클라이언트/서버 endpoint 분리 설정 지원 — 리버스 프록시 환경 대응, SSL 검증 옵션 추가\n- SEO 변수 시스템 — 모듈/플러그인이 페이지별 SEO 변수를 제공할 수 있도록 개선\n- 코어 드라이버 확장 시스템 — 플러그인이 필터 훅으로 스토리지/캐시/세션/큐 등 드라이버를 등록 가능\n- `HasUserOverrides` 트레이트 — 시더 데이터와 사용자 수정을 자동으로 분리 추적\n- 템플릿 엔진 engine-v1.42.0 — `render: false` 선택적 리렌더 제어\n- 템플릿 엔진 engine-v1.41.0 — setLocal/dispatch debounce API + stale 값 오염 방지\n- 템플릿 엔진 engine-v1.40.0 — navigate fallback 옵션 (교차 템플릿 경로 대응)\n- 템플릿 엔진 engine-v1.30~38 — sortable wrapper 요소 지정, React.memo 자동 래핑, resolvedProps 참조 안정화, transition_overlay spinner 시스템, reloadExtensions 통합 핸들러\n\n#### 확장 업데이트\n\n- 코어 업데이트 완료 후 `_bundled` 에 새 버전이 있는 확장을 감지하고 일괄 업데이트 여부를 묻는 인터랙티브 프롬프트 제공\n- 일괄 업데이트 시 전역 레이아웃 전략(overwrite / keep) 1회 질의 후 확장별 예외 지정 가능\n- 모듈/플러그인 업데이트에도 `--layout-strategy=overwrite|keep` CLI 옵션 및 관리자 UI 모달 선택 지원 (기존 템플릿 전용 → 확장 전체 일관)\n- 관리자 모듈/플러그인 업데이트 모달에 사용자가 수정한 레이아웃 감지 및 목록 표시 기능 추가\n- 모듈/플러그인 업데이트 시 각 upgrade step 버전을 콘솔에 실시간 출력 (기존에는 파일 로그에만 기록)\n- 확장 업데이트 메서드 파라미터 순서를 3종(템플릿·모듈·플러그인) 공통 prefix(id, force, onProgress) 로 정렬해 일관성 확보\n- 모듈/플러그인이 런타임에 동적으로 생성한 권한·역할·메뉴(예: 게시판별 권한 세트)도 업데이트 시 보존되도록 개선\n- 모듈/플러그인/템플릿 업데이트 커맨드에 업데이트 소스 선택 옵션(`--source=auto|bundled|github`) 추가 — GitHub 원격 장애·태그 롤백 등 상황에서 번들 소스로 강제 설치 가능\n- GitHub 원격에 존재하지 않는 태그로 업데이트 시 \"아카이브 추출 실패\" 로 불명확한 오류가 나던 문제 개선 — 태그 존재 여부를 먼저 검증해 \"업데이트 소스 없음\" 안내로 명확화\n- 관리자 모듈/플러그인 업데이트 모달의 _global 상태키를 `hasModifiedTemplateLayouts`, `hasModifiedModuleLayouts`, `hasModifiedPluginLayouts` 로 통일해 네이밍 충돌 방지\n- `module:install` / `plugin:install` / `template:install` 에 `--force` 옵션 추가 — 이미 설치된 확장도 `_bundled`/`_pending` 원본으로 활성 디렉토리를 덮어써서 재설치 (불완전 설치 복구용)\n- 확장 활성 디렉토리 무결성 검사 추가 — `module.php`/`module.json` (모듈), `plugin.php`/`plugin.json` (플러그인), `template.json` (템플릿) 중 하나라도 누락된 활성 디렉토리를 로드 시 경고 로그 + `install --force` 복구 힌트 제공\n- 코어 업데이트 커맨드에 외부 ZIP 파일 직접 지정 옵션(`--zip=/path/to/g7.zip`) 추가 — GitHub 다운로드 대신 지정 ZIP 을 사용해 오프라인/수동 배포 가능\n- 모듈/플러그인/템플릿 업데이트 커맨드에 외부 ZIP 파일 직접 지정 옵션(`--zip=/path/to/ext.zip`) 추가 — manifest 식별자와 대상 확장이 일치하는지 검증 후 manifest 의 버전으로 업데이트\n- 외부 ZIP 은 GitHub 릴리스 zipball 의 래퍼 디렉토리와 평탄 루트 구조를 모두 자동 감지해 지원\n\n### Changed\n\n#### 알림 시스템\n\n- MailTemplate 시스템을 NotificationDefinition + NotificationTemplate 체계로 전면 전환\n- 코어 알림 3종의 직접 발송을 훅 기반 발송으로 전환\n- 알림 수신자를 definition 레벨에서 template 레벨로 이동 (채널별 독립 수신자)\n- 알림 broadcast 채널을 정수 User ID에서 UUID 기반으로 변경 (보안 강화)\n- 알림 시각 표시를 ISO 8601에서 사용자 타임존 기준으로 변경\n\n#### 캐시 시스템\n\n- 24개 서비스의 캐시 호출을 `Cache::` 파사드에서 `CacheInterface` DI로 전환\n- 13개 서비스의 TTL을 환경설정 중앙 관리로 통일 (하드코딩 제거)\n- 캐시 키 접두사를 `g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:` 체계로 통일\n- `CacheService` 클래스 삭제 — `CacheInterface`로 완전 대체\n\n#### 인스톨러\n\n- 권한 검증을 비트 값 비교에서 실제 읽기/쓰기 가능 여부 기반으로 단순화\n- `.env` 생성 안내를 소유자 일치 여부에 따라 1-step으로 통합\n\n#### 기타\n\n- 확장 정보 모달에 의존성 정보 노출 — 코어 요구 버전(`g7_version`), 의존 모듈/플러그인 목록과 각각의 요구 버전·설치 버전·충족 상태 뱃지를 모듈/플러그인/템플릿 정보 모달에서 확인 가능\n- `locale_names` 설정 추가 — 언어 표시명을 중앙에서 관리\n- 매 요청 `Schema::hasTable()` 호출 제거 — 설치 완료 환경에서 10~14회의 불필요한 스키마 쿼리 제거로 응답 시간 단축\n- SeoRenderer에 모듈/플러그인 SEO 변수 자동 해석 기능 추가\n- ResponseHelper — 디버그 모드에서 예외 상세 자동 포함, 프로덕션에서 내부 메시지 노출 차단\n- 환경설정 드라이버 변경 시 큐 워커 자동 재시작\n- `allow_url_fopen=Off` 환경 지원 — GitHub 연동 HTTP 호출을 Laravel Http 파사드로 전면 교체\n- 코어 업데이트 완료 후 안내 메시지를 \"수동 업데이트 명령 나열\"에서 \"일괄 업데이트 인터랙티브 프롬프트\"로 변경\n- 확장 삭제 시 권한·메뉴·역할은 \"데이터도 함께 삭제\" 옵션을 체크했을 때만 삭제되도록 변경 — 기본 삭제에서는 보존되어 재설치 시 사용자 역할 할당이 복원\n\n### Fixed\n\n- WebSocket 데이터소스가 progressive 목록에 포함되어 영구 블러되던 문제 수정\n- WebSocket 채널/이벤트 표현식 미평가로 인증 실패하던 문제 수정\n- WebSocket broadcast HTTP API가 클라이언트 host로 POST되어 실패하던 문제 수정\n- WebSocket 활성화 시 필수 필드 미검증 문제 수정\n- WebSocket 비활성화 시에도 Reverb 연결을 시도하던 문제 수정\n- `--force` 업데이트 시 번들 없는 외부 확장이 업데이트 소스를 찾지 못하던 문제 수정\n- 확장 의존성 데이터 구조 불일치 수정 — manifest JSON 기반으로 통일\n- 모듈/플러그인/템플릿 install/activate/deactivate/uninstall 직후 라우트 미반영 수정\n- 다국어 번역 캐시 경합으로 raw key 노출되던 문제 수정 (engine-v1.38.1)\n- 조건부 `$t:` 표현식에서 번역 실패하던 문제 수정 (engine-v1.38.2)\n- 테스트 환경과 개발 환경이 동일한 Redis 캐시를 공유하여 상호 오염되던 문제 수정\n- 대시보드 브로드캐스트 스케줄이 무한 락에 걸리던 문제 수정\n- 환경설정 저장 후 `config:cache` 상태에서 변경값 미반영 수정\n- Windows 환경에서 파일 잠금 감지 시 30초+ hang 수정 — 0.5초로 단축\n- 개발 의존성 없이 설치된 프로덕션 환경에서 샘플 시더와 팩토리가 실행되지 않던 문제 수정 — 한국어 샘플 데이터 생성기가 자동으로 대체 동작하도록 개선\n- `sudo php artisan core:update` 실행 시 vendor 디렉토리 전체가 root 소유로 오염되던 문제 수정\n- 코어 업데이트 후 신규 권한·메뉴가 DB에 반영되지 않아 관리자 환경설정 페이지 접근이 거부되던 문제 수정\n- beta.1에서 업그레이드 시 제거된 메일 템플릿 모델 참조로 Fatal 오류가 발생하던 문제 수정\n- 업그레이드 후 `bootstrap/cache` 등 일부 디렉토리가 root 소유로 남아 확장 설치·업데이트가 거부되던 문제 수정 — 소유권 복원 범위를 설치 안내 경로 전체로 확장\n- 코어 업데이트 진행률 표시와 upgrade step 실행 로그가 같은 줄에 뒤섞이던 문제 수정 — 각 step 안내가 별도 줄로 깔끔하게 출력됨\n- 템플릿/레이아웃 공개 API가 확장 활성화 전·설치 중에 받은 \"찾을 수 없음\" 에러 응답을 영구 캐시하여 복구 후에도 같은 오류를 반환하던 문제 수정 — 에러 응답은 캐시에서 제외\n- `_pending`/`_bundled` 에 업데이트 중 남은 임시 디렉토리(`{id}_YYYYMMDD_HHMMSS`, `{id}_updating_*` 등) 의 원본 manifest 때문에 `install` 이 존재하지 않는 표준 경로로 접근해 실패하던 문제 수정 — 디렉토리명과 identifier 불일치 시 스캔에서 제외\n- 업그레이드 후 일부 코어 권한이 DB에 반영되지 않아 사용자 역할의 알림 기능 권한이 비어있던 문제 수정 — 별도 프로세스 실행으로 최신 로직 기반 재동기화 보장\n- 코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리\n- 관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존\n- 알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제\n- 관리자 모듈/플러그인 목록의 \"작성자\" 표기가 manifest 의 `vendor` 필드 대신 식별자 prefix 를 사용하던 문제 수정 — `vendor` 가 정의된 확장은 정확한 작성자명으로 표시 (#9 @glitter-gim 님께서 제보해주셨습니다.)\n\n### Removed\n\n- MailTemplate 시스템 일괄 제거 — 모델, 컨트롤러, 서비스, 리스너, 시더, 팩토리, 다국어 파일 등. NotificationDefinition + NotificationTemplate 체계로 완전 대체\n\n> 참고: beta.1에서 업그레이드하는 운영 환경의 데이터 이관은 `Upgrade_7_0_0_beta_2`가 자동 처리합니다.\n\n## [7.0.0-beta.1] - 2026-04-01\n\n### Changed\n\n- 오픈 베타 릴리즈\n\n## [7.0.0-alpha.21] - 2026-03-30\n\n### Added\n\n- 인스톨러 PHP CLI/Composer 필수 검증 기능 — 기본 `php` 미감지 시 CLI 설정 필수 전환, Composer 실행 확인 및 미설치 시 설치 안내, 둘 다 검증 완료 전 다음 단계 진행 차단\n- 템플릿 레이아웃 수정 감지 시스템 — `original_content_hash`/`original_content_size` 컬럼 추가, SHA-256 해시 기반 수정 감지로 `updated_by` 방식 대체 (TemplateManager, LayoutRepository, 마이그레이션)\n- 관리자 로그인 화면 개선 — 비밀번호 찾기/재설정 플로우 추가, 테마 Segmented Control, 언어 셀렉트 확대 (PasswordResetController, PasswordResetNotification, ForgotPasswordRequest, ResetPasswordRequest)\n- `{{raw:expression}}` 바인딩 번역 면제 마커 시스템 — `$t:` 자동 번역 대상에서 특정 바인딩을 제외하는 마커 도입 (rawMarkers.ts)\n- 레이아웃 프리뷰 모드 — 관리자 레이아웃 편집기에서 실시간 미리보기 지원 (LayoutPreviewController, LayoutPreviewService, 마이그레이션)\n- 활동 로그 이력 조회 메뉴 신설 — ActivityLogController, ActivityLogResource, ActivityLogService, ActivityLogRepository, config/core.php 메뉴/권한 등록, 관리자 레이아웃 추가\n\n### Changed\n\n- `window.G7Config` 설정 노출 최소화 Phase 1 — 프론트엔드 미참조 설정의 브라우저 소스 노출 차단\n - SettingsService: 필드 레벨 `expose: false` 지원 추가 (formatCategorySettings)\n - FiltersFrontendSchema: `fields: {}` (빈 객체) 시 전체 차단 안전 기본값 적용\n - View Composers: TemplateComposer/UserTemplateComposer에 ModuleSettingsService 주입 — frontend_schema 기반 필터링 적용 (기존 config() 직접 참조 우회 수정)\n - 코어 defaults.json: general 4개 필드, security 전체, seo 전체, advanced 9개 필드(debug_mode 제외), upload 2개 필드, drivers 전체를 `expose: false` 처리\n- 캐시 무효화 로직 정비 — 016862cb 이전의 단순한 방식으로 복귀 (버전 증가 + TTL 자연 만료), `extension_cache_previous_versions` 추적 및 `getCacheVersionsToInvalidate()`/`getCacheVersionsForLayoutInvalidation()` 제거, Cache Tags 드라이버 분기 제거로 캐시 드라이버 비의존성 확보\n\n### Fixed\n\n- MariaDB를 `DB_CONNECTION=mysql` 드라이버로 연결 시 `WITH PARSER ngram` 에러로 인스톨러 설치 실패 수정 — `isMariaDb()` 메서드 추가하여 서버 버전 문자열 기반 실제 DBMS 감지 (DatabaseFulltextEngine)\n- 캐시 무효화 버전 키 누락 수정 — `warmTemplateCache()`가 버전 포함 키(`.v{version}`)로 캐시를 생성하나 `clearTemplateCache()`가 레거시 키만 삭제하던 문제, routes/language 캐시에 버전 포함 키 삭제 추가\n- 캐시 버전 0 무효화 누락 수정 — `array_filter()`가 버전 `0`을 falsy로 제거하여 `.v0` 캐시 키가 삭제 대상에서 누락되던 버그 (ClearsTemplateCaches, InvalidatesLayoutCache)\n- 테스트 활성 디렉토리 보호 강화 — `ProtectsExtensionDirectories`에 `moveDirectory` spy 추가, `copyToActive()` 원자적 교체가 활성 디렉토리를 rename으로 파괴하는 것 방지\n- 활동 로그 하위 호환 — `ActivityLogResource`에서 기존 DB 레코드(`type: 'text'`)의 enum 필드를 모델의 현재 `$activityLogFields` 기준으로 동적 번역 + 일괄 변경 `:count` 미치환 보정\n- 활동 로그 enum 미번역 전수 수정 — 14개 모델 필드의 `$activityLogFields` `type: 'enum'` 전환 + 14개 Enum 클래스에 `labelKey()` 메서드 추가 (User, Order, OrderOption, Product, Coupon, Post, Comment, Board)\n- 활동 로그 삭제/일괄삭제 `description_key` 및 샘플 시더 누락 추가 — 코어 시더 `activity_log_descriptions` 테이블에 delete/bulk_delete 키 보충 + 다국어 파일 동기화\n- 관리자 다크모드 품질 전수 조사 및 수정 — 레이아웃 JSON ~546건, TSX ~37건, CSS 5건의 누락된 `dark:` variant 추가 (이커머스 101파일, 게시판 19파일, 페이지 2파일, 마케팅 1파일, 템플릿 컴포넌트 ~20파일)\n- 레이아웃 편집 캐시 무효화 및 버전 히스토리 수정 — LayoutService의 저장/복원 시 PublicLayoutController 서빙 캐시 무효화 + `extension_cache_version` 증가 누락 수정\n- 사용자 관리에서 관리자/슈퍼관리자 삭제 시 토스트 메시지 및 어빌리티 처리 — CannotDeleteAdminException 추가, UserResource에 `can_delete` 어빌리티 반영\n- ActivityLog 핸들러 `$result` 타입 불일치 수정 — CoreActivityLogListener에서 `bool` → `array` 반환 타입 정합성 보정\n- 환경설정 사이트 로고 저장 시 Attachment 객체 정수 검증 오류 수정 — SaveSettingsRequest에서 Attachment JSON 객체를 정수로 검증하던 문제 수정\n- 관리자 SPA 네비게이션 로고 깜빡임 수정 — extends base 컴포넌트 불필요 remount 방지 (`_fromBase` 마킹 기반 stable key 패턴)\n- 인스톨러 다크모드 적색 배경 가독성 개선 — `alert-title`, `alert-message`, `permission-badge`, `test-result` 다크모드 색상 override 추가\n- 인스톨러 `BASE_PATH` 심볼릭 링크 미해석 — `realpath()` 적용 + 절대경로/상대경로 병기로 호스팅 환경 대응\n- 인스톨러 403 에러 페이지 다크모드 미지원 — `prefers-color-scheme: dark` 미디어쿼리 추가\n- 템플릿 업데이트 수정 감지 실패 — `LayoutRepository::update()`가 `updated_by`를 설정하지 않아 `hasModifiedLayouts()`가 항상 \"수정 없음\" 반환하던 문제 수정 (hash 비교 방식으로 전환)\n- 템플릿 업데이트 \"수정 유지\" 전략 미작동 — `layoutStrategy === 'keep'` 시 `refreshTemplateLayouts()` 미호출 → 양쪽 전략 모두 호출하되 `preserveModified` 플래그로 분기\n- 템플릿 업데이트 API 응답 키 불일치 — 백엔드 `has_modified` → 프론트엔드 기대 `has_modified_layouts`/`modified_count` 키 정렬\n- 템플릿/모듈/플러그인 설치 시 `incrementExtensionCacheVersion()` 누락 수정 — TemplateManager(install/activate/deactivate/uninstall) + ModuleManager(install) + PluginManager(install) 총 7곳 추가\n- 캐시 버전 변경 시 프론트엔드 다국어 미갱신 수정 — TemplateApp.ts에서 캐시 버전 변경 감지 시 TranslationEngine 재로드 추가\n- `warmTemplateCache()` 다국어 캐시 워밍 시 `$partial` 디렉티브 미해석 수정 — `json_decode`만 수행하던 코드를 `TemplateService::getLanguageDataWithModules()` 호출로 교체하여 fragment 해석 및 모듈/플러그인 다국어 병합 정상화\n- `ActivateTemplateCommand` 의존성 미충족 시 성공으로 보고하는 버그 수정 — `if ($result)` → `if ($result['success'])` 변경 및 의존성 경고 메시지 출력, `--force` 옵션 추가\n- `ActivateModuleCommand`/`ActivatePluginCommand` 의존성 미충족 시 경고 메시지 미출력 수정 — 의존성 경고 표시 및 `--force` 옵션 전달 추가\n\n## [7.0.0-alpha.20] - 2026-03-30\n\n### Added\n\n- Laravel Scout 통합 및 MySQL FULLTEXT(ngram) 검색엔진 드라이버 확장 시스템 도입 — 커스텀 `DatabaseFulltextEngine`으로 `MATCH...AGAINST IN BOOLEAN MODE` 지원, `core.search.engine_drivers` 필터 훅으로 Meilisearch/Elasticsearch 등 외부 엔진 플러그인 등록 가능\n- `DatabaseFulltextEngine` 다중 DBMS 호환 — FULLTEXT 미지원 DBMS(PostgreSQL, SQLite)에서 LIKE fallback 자동 전환, `whereFulltext()` 정적 헬퍼(관계 검색용), `addFulltextIndex()` 마이그레이션 헬퍼(DBMS별 조건부 DDL)\n- `config/scout.php` 설정 파일 추가 — 기본 드라이버 `mysql-fulltext`, `SCOUT_DRIVER` 환경변수로 전환 가능\n- `FulltextSearchable` 인터페이스 — FULLTEXT 검색 대상 컬럼 및 가중치 정의 계약\n- `AsUnicodeJson` 커스텀 캐스트 — JSON 컬럼에 한글을 `\\uXXXX` 이스케이프 없이 실제 UTF-8로 저장하여 FULLTEXT ngram 토크나이저 정상 동작 보장\n- 환경설정 > 드라이버 탭에 검색엔진 설정 카드 추가 — 기본 MySQL FULLTEXT(ngram) 드라이버 표시, 플러그인 설치 시 추가 드라이버 자동 표시\n- `SaveSettingsRequest`에 검색엔진 드라이버 동적 validation 추가 — `core.search.engine_drivers` 필터 훅 기반 허용 목록\n\n## [7.0.0-alpha.19] - 2026-03-29\n\n### Added\n\n- 검색/필터/정렬 성능 향상을 위한 누락 인덱스 일괄 추가 — activity_logs(description_key), users(created_at), mail_send_logs(status), template_layouts(template_id), schedules(created_at)\n\n## [7.0.0-alpha.18] - 2026-03-26\n\n### Added\n\n- SEO ExpressionEvaluator 산술 연산자 확장 — `*`, `/`, `%` 추가 (기존 `+`, `-`만 지원)\n- SEO PipeRegistry 파이프 함수 엔진 구현 — 프론트엔드 PipeRegistry.ts 빌트인 파이프 15종 PHP 미러링 (date, datetime, relativeTime, number, truncate, uppercase, lowercase, stripHtml, default, fallback, first, last, join, length, filterBy, keys, values, json, localized)\n- ActivityLog `description_params` ID→이름 변환 필터 훅 (`core.activity_log.filter_description_params`) — `ActivityLog::getLocalizedDescriptionAttribute()`에서 실행\n- 코어 모델 `$activityLogFields` 정의 — `User`, `Role`, `Menu`, `Schedule`, `MailTemplate` (5개 모델)\n- ActivityLog ChangeDetector 필드 라벨 다국어 키 추가 (`lang/ko/activity_log.php`, `lang/en/activity_log.php` — `fields` 섹션)\n- `module_helpers.php` — `getModuleSetting()` 헬퍼 함수 추가\n- CoreActivityLogListener: bulk_update per-User/per-Schedule 전환 + bulk_delete per-Schedule 전환 (건별 loggable_id 기록)\n- ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 fallback 지원\n- 메뉴 관리 크로스 depth 이동 지원 — `UpdateMenuOrderRequest`에 `moved_items` 검증 추가, `MenuRepository`에 크로스 depth reorder 로직 구현\n- `NotCircularParent` 검증 규칙 추가 — 메뉴 순환 참조 방지\n\n### Changed\n\n- ActivityLog 규정 문서 대폭 보강 (`docs/backend/activity-log.md`) — `description_params` 저장 정책, `ActivityLogDescriptionResolver` 패턴, Bulk Update ChangeDetector 패턴, 개발자 체크리스트 추가\n\n## [7.0.0-alpha.17] - 2026-03-26\n\n### Added\n\n- ActivityLog `description_params` ID→이름 변환 필터 훅 (`core.activity_log.filter_description_params`) — `ActivityLog::getLocalizedDescriptionAttribute()`에서 실행\n- 코어 모델 `$activityLogFields` 정의 — `User`, `Role`, `Menu`, `Schedule`, `MailTemplate` (5개 모델)\n- ActivityLog ChangeDetector 필드 라벨 다국어 키 추가 (`lang/ko/activity_log.php`, `lang/en/activity_log.php` — `fields` 섹션)\n- `module_helpers.php` — `getModuleSetting()` 헬퍼 함수 추가\n- CoreActivityLogListener: bulk_update per-User/per-Schedule 전환 + bulk_delete per-Schedule 전환 (건별 loggable_id 기록)\n- ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 fallback 지원\n\n### Changed\n\n- ActivityLog 규정 문서 대폭 보강 (`docs/backend/activity-log.md`) — `description_params` 저장 정책, `ActivityLogDescriptionResolver` 패턴, Bulk Update ChangeDetector 패턴, 개발자 체크리스트 추가\n\n## [7.0.0-alpha.17] - 2026-03-26\n\n### Added\n\n- Monolog 기반 ActivityLog 아키텍처: `Log::channel('activity')` → `ActivityLogHandler` → DB (3단계)\n- `ActivityLogChannel` (커스텀 Monolog 채널), `ActivityLogHandler`, `ActivityLogProcessor` 신규\n- ActivityLog i18n 지원: `description_key` + `description_params` 기반 실시간 다국어 번역\n- ActivityLog 구조화된 변경 이력: `changes` JSON 컬럼 (필드별 `label_key`, `old`/`new`, `type` 포함)\n- `ChangeDetector` 유틸리티 (모델 스냅샷 비교 → 구조화된 변경 이력 생성)\n- `CoreActivityLogListener` 전면 확장: 모든 코어 Service 훅 구독 (User/Role/Menu/Settings/Schedule/Auth/Module/Plugin/Template/Layout/MailTemplate/Attachment — 66개 훅)\n- 활동 로그 다국어 키 105개 정의 (`lang/ko/activity_log.php`, `lang/en/activity_log.php`)\n- `config/logging.php`에 `activity` 채널 추가\n- `config/activity_log.php` 전용 설정 파일 신규\n- `activity_logs` 테이블 복합 인덱스 추가 (`loggable_type`+`loggable_id`+`created_at`, `log_type`+`action`+`created_at`)\n\n### Fixed\n\n- `resolveLogType()` 사용자 역할 기반 → 요청 경로 기반으로 변경: 관리자가 사용자 화면에서 수행한 액션이 `admin`으로 기록되던 문제 수정 (ResolvesActivityLogType)\n\n### Changed\n\n- `ActivityLog` 모델: `description` 컬럼 삭제 → `description_key`/`description_params` 기반 다국어 전환\n- `ActivityLogService`: 기록 메서드 전면 제거 → 조회 전용으로 축소\n- `ActivityLogResource`: `description` → `localized_description` (실시간 번역)\n- 모든 Controller에서 `logAdminActivity()` 호출 전면 제거 → Listener 경로로 전환\n\n### Removed\n\n- `activity_logs.description` 컬럼 (DB 삭제)\n- `ActivityLogManager`, `ActivityLogDriverInterface`, `DatabaseActivityLogDriver`, `NullActivityLogDriver`\n- `ActivityLogListener` (이중 훅 계층 — Monolog Handler로 대체)\n- `ActivityLogService.log`/`logAdmin`/`logUser`/`logSystem` (Monolog 채널로 대체)\n- `AdminBaseController.logAdminActivity()`, `generateActivityDescription()`, `flattenDataForTranslation()`\n\n### Fixed\n\n- 마이페이지 프로필 저장 시 국가·언어 미선택 상태에서 오류가 발생하던 문제 수정 — 해당 필드를 선택 사항으로 변경\n- 인스톨러 Step 2 .env 복사 명령어 안내 수정 — `.env.example.production` → `.env.example` (functions.php 2곳, installer.js 4곳)\n\n## [7.0.0-alpha.16] - 2026-03-23\n\n### Fixed\n\n- 코어 업데이트 롤백 시 vendor 디렉토리 복원 불가 수정 — 백업 targets에 vendor 포함, excludes에서 vendor 제거\n- 코어 백업 복원 시 개별 target 실패가 전체 복원을 중단하는 문제 수정 — 개별 try-catch로 나머지 target 복원 계속 진행\n- 코어 업데이트 롤백 실패 시 수동 복구 안내 미출력 수정 — composer install 등 복구 단계 안내 추가\n- 코어 업데이트 완전 복원 성공 시 유지보수 모드 자동 해제 추가\n- 코어 업데이트 시 vendor 디렉토리 이중 처리로 인한 마이그레이션 실패 수정 — `backup_only` 설정 분리 (applyUpdate 제외, 백업/복원 전용)\n\n## [7.0.0-alpha.15] - 2026-03-23\n\n### Added\n\n- Users UUID 전환 — 외부 노출 ID를 UUID v7으로 전환, 정수 `id`는 API 응답에서 숨김\n- UniqueIdService 코어 서비스 추가 (UUID v7 + NanoID 생성)\n- 코어 업그레이드 스텝 추가 (Upgrade_7_0_0_beta_15)\n\n### Changed\n\n- User 모델: `getRouteKeyName()` → 'uuid', `$hidden`에 'id' 추가\n- 공개 프로필 API: Route Model Binding 전환 (`{userId}` → `{user}`)\n- 사용자 벌크 상태변경: 정수 ID → UUID 기반\n- API Resource: user.id → user.uuid 전환 (UserResource, UserCollection 외 7개)\n- Activity Log 메타데이터: user_id → uuid 전환\n- FormRequest: 정수 검증 → UUID 검증 전환\n\n### Fixed\n\n- `_global.currentUser?.id` → `?.uuid` 전환 (sirsoft-basic 템플릿 12개 파일)\n- 글쓰기 버튼 abilities 비활성화 누락 수정\n- 코어 업그레이드 스텝 raw SQL 테이블 프리픽스 미적용 수정 — `UpgradeContext::table()` 헬퍼 추가 (Upgrade_7_0_0_beta_15)\n\n## [7.0.0-alpha.14] - 2026-03-20\n\n### Fixed\n\n- 확장 업데이트 시 임시 디렉토리(`_updating_*`, `_old_*`) 오토로드 오염 방지 — 임시 디렉토리를 `_pending/` 하위에 생성하여 IDE 잠금 등으로 잔존 시에도 Fatal Error 방지 (ExtensionPendingHelper)\n\n### Added\n\n- Windows 파일잠금 감지/해제 기능 — 확장 업데이트 시 IDE 등이 파일 핸들을 보유한 경우 자동 감지 및 해제 시도 (FileHandleHelper, ExtensionPendingHelper)\n\n- SEO 렌더러 훅 시스템: `core.seo.filter_context`, `core.seo.filter_meta`, `core.seo.filter_view_data` — 확장이 SEO 렌더링 파이프라인에 런타임 데이터 변환으로 개입 가능\n- `seo.blade.php` 확장 슬롯: `extraHeadTags` (`` 직전), `extraBodyEnd` (`