diff --git a/AGENTS.md b/AGENTS.md
index a5f106e9..60af0255 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -144,6 +144,15 @@
| [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` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
diff --git a/README.md b/README.md
index afb587e2..0c1abe3e 100644
--- a/README.md
+++ b/README.md
@@ -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 레퍼런스 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
+| 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/ApiDocgenCommand.php b/app/Console/Commands/ApiDocgenCommand.php
index 977af410..a6596c00 100644
--- a/app/Console/Commands/ApiDocgenCommand.php
+++ b/app/Console/Commands/ApiDocgenCommand.php
@@ -228,12 +228,28 @@ class ApiDocgenCommand extends Command
// 대상별 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;
- $content = $scaffolder->readmeIndex($meta['label'], $meta['entries'], $existing);
+ // 코어 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')) {
@@ -816,6 +832,56 @@ class ApiDocgenCommand extends Command
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) 경로를 반환합니다.
*
diff --git a/app/Support/ApiDoc/ApiDocScaffolder.php b/app/Support/ApiDoc/ApiDocScaffolder.php
index 5dba71b0..d1200043 100644
--- a/app/Support/ApiDoc/ApiDocScaffolder.php
+++ b/app/Support/ApiDoc/ApiDocScaffolder.php
@@ -116,6 +116,16 @@ class ApiDocScaffolder
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';
+
/**
* 대상(코어/확장)의 API 문서 디렉토리 목차(README.md)를 생성합니다.
*
@@ -124,13 +134,23 @@ class ApiDocScaffolder
* 갱신되며, 블록 밖 사람 서술(개요·주의사항)은 보존한다. 확장 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): string
- {
+ 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'));
@@ -139,13 +159,21 @@ class ApiDocScaffolder
$rows[] = "| [{$e['file']}]({$e['file']}) | `{$e['domain']}` | {$e['count']} |";
}
- $genKey = 'api-readme-index';
+ $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[] = '';
@@ -156,15 +184,98 @@ class ApiDocScaffolder
$generated = implode("\n", $lines)."\n";
- // 블록 밖 사람 서술(개요 등)이 있으면 보존한다.
- $prose = $existing !== null ? $this->extractHumanProse($existing, $genKey) : null;
+ // 목차 블록 밖 사람 서술(주의사항 등)이 있으면 보존한다.
+ // 확장 목차(`## 확장 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[] = '## 확장 API 레퍼런스';
+ $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";
+ }
+
/**
* 인증/권한 라인을 구성합니다.
*
@@ -832,6 +943,99 @@ class ApiDocScaffolder
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## /', $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));
+ }
+
/**
* 기존 문서에서 특정 엔드포인트의 사람 서술(생성 블록 밖)을 추출합니다.
*
diff --git a/docs/README.md b/docs/README.md
index e65bf7a5..28674e30 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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 작성
diff --git a/docs/backend/README.md b/docs/backend/README.md
index 341c9790..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) 를 참고하세요.
+
+---
+
## 문서 목록
diff --git a/docs/backend/api-documentation.md b/docs/backend/api-documentation.md
index d8b24869..7e7427e7 100644
--- a/docs/backend/api-documentation.md
+++ b/docs/backend/api-documentation.md
@@ -54,20 +54,39 @@ G7 의 REST API 는 라우트 `->name()` 규약은 있으나 엔드포인트별
### 문서 목차와 발견성 (README.md 규약)
각 대상(코어·확장)의 API 문서 디렉토리에는 `README.md` 목차가 있어야 한다. `api:docgen` 이 도메인 파일
-목록·엔드포인트 수를 담아 자동 생성한다(`@generated` 블록, 재생성 멱등, 블록 밖 사람 개요는 보존).
+목록·엔드포인트 수를 담아 자동 생성한다(`@generated` 블록, 재생성 멱등, 블록 밖 사람 서술은 보존).
- 코어: `docs/backend/api/README.md`
- 확장: `{modules|plugins}/_bundled/{id}/docs/api/README.md`
-확장 API 문서의 발견성은 이 목차를 통해 확보한다. 코어 인덱스 생성기(`generate-docs-index.cjs`)는
-확장명을 하드코딩하지 않고 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 **패턴 스캔**해,
-CLAUDE.md·AGENTS.md·docs-index 의 "확장 API 레퍼런스" 표에 자동 편입한다(동적 로딩 원칙 — 코어는 규약과
-스캔 패턴만, 확장 이름은 파일 시스템에서 발견). 처음 진입하는 개발자/AI 의 도달 경로:
+코어 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
-CLAUDE.md "확장 API 레퍼런스" 표 (자동 스캔)
- → {확장}/docs/api/README.md (확장 소유 목차)
- → {도메인}.md (엔드포인트별 파라미터·응답·예시)
+README.md "API 레퍼런스" 또는 CLAUDE.md "API 레퍼런스 진입점" 표
+ → docs/backend/api/README.md (공통 규약 + 코어 목차 + 확장 목차)
+ → {도메인}.md 또는 {확장}/docs/api/README.md
+ → 엔드포인트별 파라미터·응답·예시
```
---
diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md
index 91339cb9..43188b71 100644
--- a/docs/backend/api/README.md
+++ b/docs/backend/api/README.md
@@ -3,6 +3,110 @@
> **소유**: 코어 · **생성**: `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
@@ -46,3 +150,23 @@
+## 확장 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/tests/Feature/Console/ApiDocgenCommandTest.php b/tests/Feature/Console/ApiDocgenCommandTest.php
index 4c10a8f4..dd8d565e 100644
--- a/tests/Feature/Console/ApiDocgenCommandTest.php
+++ b/tests/Feature/Console/ApiDocgenCommandTest.php
@@ -161,4 +161,132 @@ class ApiDocgenCommandTest extends TestCase
}
}
}
+
+ /**
+ * README 목차 항목/확장 목록 샘플을 반환합니다.
+ *
+ * @return array{0: array, 1: array}
+ */
+ private function readmeFixtures(): array
+ {
+ $entries = [
+ ['domain' => 'users', 'file' => 'users.md', 'count' => 12],
+ ['domain' => 'auth', 'file' => 'auth.md', 'count' => 15],
+ ];
+
+ $extensions = [
+ ['id' => 'sirsoft-page', 'type' => 'module', 'path' => '../../../modules/_bundled/sirsoft-page/docs/api/README.md', 'docs' => 2, 'endpoints' => 17],
+ ['id' => 'sirsoft-gdpr', 'type' => 'plugin', 'path' => '../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md', 'docs' => 4, 'endpoints' => 15],
+ ];
+
+ return [$entries, $extensions];
+ }
+
+ /**
+ * 코어 README 는 도메인 목차와 확장 API 목차를 함께 싣고, 재생성해도 멱등합니다.
+ *
+ * 최상위 README 의 "API 레퍼런스" 진입점이 이 문서이므로, 확장 API 문서의 발견 경로가
+ * 여기 없으면 개발자/AI 가 확장 엔드포인트에 도달하지 못한다.
+ */
+ #[Test]
+ public function 코어_readme는_확장_목차를_함께_싣고_멱등하게_재생성된다(): void
+ {
+ $scaffolder = new ApiDocScaffolder;
+ [$entries, $extensions] = $this->readmeFixtures();
+
+ $first = $scaffolder->readmeIndex('코어', $entries, null, $extensions);
+
+ $this->assertStringContainsString('## 확장 API 레퍼런스', $first);
+ $this->assertStringContainsString('`sirsoft-page`', $first);
+ $this->assertStringContainsString('`sirsoft-gdpr`', $first);
+ $this->assertStringContainsString('**확장 수**: 2 · **엔드포인트 수**: 32', $first);
+
+ $second = $scaffolder->readmeIndex('코어', $entries, $first, $extensions);
+ $this->assertSame($first, $second, '동일 입력 재생성은 멱등해야 한다.');
+ $this->assertSame(1, substr_count($second, '## 확장 API 레퍼런스'), '확장 목차가 중복 생성되면 안 된다.');
+ }
+
+ /**
+ * 코어 README 상단의 사람 개요(공통 규약)는 재생성해도 보존됩니다.
+ *
+ * 개요는 목차 표보다 먼저 읽혀야 하므로 @generated 블록 앞에 둔다. 생성기가 이 구간을
+ * 덮어쓰면 인증·응답 봉투·에러 규약 서술이 매 재생성마다 소실된다.
+ */
+ #[Test]
+ public function 코어_readme의_사람_개요는_생성_블록_앞에서_보존된다(): void
+ {
+ $scaffolder = new ApiDocScaffolder;
+ [$entries, $extensions] = $this->readmeFixtures();
+
+ $base = $scaffolder->readmeIndex('코어', $entries, null, $extensions);
+ $withOverview = str_replace(
+ '',
+ "## 공통 규약\n\nBearer 토큰 전용입니다. (사람 개요)\n\n",
+ $base
+ );
+
+ $regenerated = $scaffolder->readmeIndex('코어', $entries, $withOverview, $extensions);
+
+ $this->assertStringContainsString('Bearer 토큰 전용입니다. (사람 개요)', $regenerated);
+ $this->assertSame(1, substr_count($regenerated, '## 공통 규약'), '개요가 중복 삽입되면 안 된다.');
+ // 개요는 목차 표보다 앞에 있어야 한다.
+ $this->assertLessThan(
+ strpos($regenerated, ''),
+ strpos($regenerated, '## 공통 규약')
+ );
+
+ // 재생성 멱등 (개요 포함).
+ $this->assertSame($regenerated, $scaffolder->readmeIndex('코어', $entries, $regenerated, $extensions));
+ }
+
+ /**
+ * 확장 스코프 실행이라 확장 목록을 넘기지 않아도 기존 확장 목차는 소실되지 않습니다.
+ *
+ * `--scope=core` 축소 실행이 코어 README 의 확장 표를 지워버리면, 매 실행마다 진입점이
+ * 사라졌다 살아나는 drift 가 생긴다.
+ */
+ #[Test]
+ public function 확장_목록_미전달_시_기존_확장_목차를_보존한다(): void
+ {
+ $scaffolder = new ApiDocScaffolder;
+ [$entries, $extensions] = $this->readmeFixtures();
+
+ $withExtensions = $scaffolder->readmeIndex('코어', $entries, null, $extensions);
+ $regenerated = $scaffolder->readmeIndex('코어', $entries, $withExtensions, null);
+
+ $this->assertStringContainsString('## 확장 API 레퍼런스', $regenerated);
+ $this->assertStringContainsString('`sirsoft-page`', $regenerated);
+ $this->assertSame(1, substr_count($regenerated, '## 확장 API 레퍼런스'));
+ }
+
+ /**
+ * refreshExtensionIndex 는 코어 목차·개요를 건드리지 않고 확장 표만 갱신합니다.
+ *
+ * 확장 스코프 실행(`--scope=module:...`) 시 그 확장의 엔드포인트 수 변동을 코어 진입점에
+ * 반영하는 경로다. 변경이 없으면 null 을 반환해 불필요한 파일 쓰기를 피한다.
+ */
+ #[Test]
+ public function 확장_목차만_in_place_갱신하고_변경_없으면_null을_반환한다(): void
+ {
+ $scaffolder = new ApiDocScaffolder;
+ [$entries, $extensions] = $this->readmeFixtures();
+
+ $existing = $scaffolder->readmeIndex('코어', $entries, null, $extensions);
+
+ $this->assertNull(
+ $scaffolder->refreshExtensionIndex($existing, $extensions),
+ '동일 확장 목록이면 변경 없음(null)이어야 한다.'
+ );
+
+ // 엔드포인트 수가 늘어난 경우 표만 갱신된다.
+ $extensions[0]['endpoints'] = 20;
+ $updated = $scaffolder->refreshExtensionIndex($existing, $extensions);
+
+ $this->assertNotNull($updated);
+ $this->assertStringContainsString('2 / 20', $updated);
+ // 코어 목차와 도메인 항목은 그대로 남는다.
+ $this->assertStringContainsString('[users.md](users.md)', $updated);
+ $this->assertStringContainsString('', $updated);
+ $this->assertSame(1, substr_count($updated, '## 확장 API 레퍼런스'));
+ }
}