Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00ef91bda5 | ||
|
|
8fcb79d60f | ||
|
|
79b83626eb | ||
|
|
29c25376bc | ||
|
|
512869e407 | ||
|
|
ab4f05c1c0 | ||
|
|
ce8e55b390 | ||
|
|
4c73db2e91 | ||
|
|
47a2e815aa | ||
|
|
90f154ecc0 | ||
|
|
84ca40f0d8 | ||
|
|
c4beb116ad | ||
|
|
f848086fd6 | ||
|
|
cc705c2f21 | ||
|
|
1922003917 | ||
|
|
9be8faaea2 | ||
|
|
ae447c9cc4 | ||
|
|
57ac2b0b3b | ||
|
|
cd3f6ad2b7 | ||
|
|
a690fdd2c4 | ||
|
|
8cc40e0681 | ||
|
|
d85ff9971a | ||
|
|
793afb95dc | ||
|
|
68cbee0e83 | ||
|
|
bfaf551d41 | ||
|
|
8018c525b8 | ||
|
|
25c0ab46ec | ||
|
|
13e65131f4 | ||
|
|
a34a03eb40 | ||
|
|
b8ceb26cf3 | ||
|
|
feca0c7146 | ||
|
|
1ec9692cac | ||
|
|
b65c782447 | ||
|
|
2832db6ffd | ||
|
|
f432df9d9c | ||
|
|
edb4d13096 | ||
|
|
abe3fe85d4 | ||
|
|
809409f3cf | ||
|
|
12968b76ca | ||
|
|
051ed1e528 | ||
|
|
0f84657a5c | ||
|
|
0ff4b674ac | ||
|
|
e3c00addd8 | ||
|
|
0200f1d043 | ||
|
|
5f99b104c1 | ||
|
|
5f3b904aab | ||
|
|
45bf59472a | ||
|
|
2493bf424a | ||
|
|
c26b49a997 | ||
|
|
13aeb433cc | ||
|
|
168f439be3 | ||
|
|
56a78e1d55 | ||
|
|
70452745c4 | ||
|
|
79eb091351 | ||
|
|
848b408b60 | ||
|
|
49b21e788c | ||
|
|
dbbc343734 | ||
|
|
88116de788 | ||
|
|
c6cab1f668 | ||
|
|
f744b816b6 | ||
|
|
b9355f051c | ||
|
|
ff45a19b4b | ||
|
|
cebe352e46 | ||
|
|
48f877bdcd | ||
|
|
e7e2936ea0 | ||
|
|
8429d8ada5 | ||
|
|
d18aaab1fd | ||
|
|
646efeb91c | ||
|
|
dee5a12ded | ||
|
|
c59819645a | ||
|
|
1cdef4513a | ||
|
|
5a9ad97327 | ||
|
|
b428734a06 | ||
|
|
0e9bb39980 | ||
|
|
2a9f521487 | ||
|
|
76f7300e8f | ||
|
|
022d629144 | ||
|
|
f0d44989e1 | ||
|
|
9863ea9d3a | ||
|
|
479d537dfb | ||
|
|
657f546efc |
+1
-1
@@ -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.4
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -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.4
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 질문·사용법 문의 (sir.kr Q&A)
|
||||
url: https://sir.kr/questions/create?tag=%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7
|
||||
|
||||
@@ -136,3 +136,10 @@ playwright-report/
|
||||
test-results/
|
||||
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
|
||||
.playwright-mcp/
|
||||
|
||||
# ===== 소스맵 =====
|
||||
# 소스맵은 원본 코드 전문(sourcesContent)을 담고 있어 배포 대상이 아니다.
|
||||
# 로컬 개발 빌드에서는 생성되지만(디버깅용) 저장소에는 유입되지 않는다.
|
||||
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
|
||||
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
|
||||
*.map
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
|
||||
<!-- AUTO-GENERATED-START: docs-quick-reference -->
|
||||
|
||||
### 백엔드 [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 |
|
||||
|
||||
|
||||
<!-- AUTO-GENERATED-END: docs-quick-reference -->
|
||||
|
||||
---
|
||||
@@ -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 은 `<i>` 글리프라 박스 크기가 곧 `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) |
|
||||
|
||||
@@ -4,6 +4,34 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.4] - 2026-07-14
|
||||
|
||||
### Security
|
||||
|
||||
- 서버가 외부 주소를 대신 호출하는 기능들에서, 사내 서버나 클라우드 관리 주소처럼 외부에 공개되지 않은 내부 주소를 호출 대상으로 지정할 수 없도록 차단했습니다. 언어팩을 주소로 내려받을 때, 예약 작업으로 주소를 호출할 때, 쇼핑몰 배송비를 외부 계산 서버로 문의할 때가 해당합니다. 사내 서버를 주기적으로 호출하는 등 내부 주소가 꼭 필요한 운영 환경을 위해, 환경설정 > 보안에 "내부 네트워크 주소 호출 허용" 항목을 새로 추가했습니다. 이 항목은 기본적으로 꺼져 있습니다.
|
||||
- 주소를 검사할 때 앞부분만 맞으면 통과시키던 방식을 실제 주소가 정확히 일치하는지 확인하도록 바로잡았습니다. 이전에는 신뢰하는 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 본인인증 연동과 언어팩 업데이트 확인에 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546)
|
||||
- 배포본에 원본 소스코드가 그대로 담긴 파일(소스맵)이 함께 포함되어, 누구나 주소만 알면 내려받아 볼 수 있던 문제를 차단했습니다. 이제 배포용으로 빌드하면 이 파일이 아예 만들어지지 않으며, 기존에 포함되어 있던 파일도 모두 제거했습니다. 아울러 이 파일이 서버에 남아 있더라도 웹으로는 내려받을 수 없도록 막았습니다. 내 컴퓨터에서 개발용으로 빌드할 때는 디버깅에 필요하므로 이전과 동일하게 생성됩니다. (#72 @glitter-gim 님께서 건의해주셨습니다.)
|
||||
- 아직 공개하지 않은 예약 작업 기능에서, 서버 명령을 실행하는 두 유형(쉘 명령·Artisan 명령)에 대해 향후 정식 공개에 대비한 실행 제한 장치를 미리 마련했습니다. 쉘 명령 예약은 기본적으로 완전히 차단되며 서버 관리자가 실행 파일을 명시적으로 등록해야만 사용할 수 있고(관리자 화면에서는 넓힐 수 없습니다), Artisan 명령 예약도 데이터를 지우거나 임의 코드를 실행할 수 있는 위험한 명령은 거부됩니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1567)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 설치 과정에서 임시로 만들어지는 파일에 관리자 비밀번호가 그대로 남을 수 있던 문제를 수정했습니다. 설치가 정상적으로 끝나면 이 파일은 지워지지만, 설치를 중간에 그만두거나 마지막 정리가 이뤄지지 않은 경우에는 남아 있을 수 있었습니다. 이제 비밀번호는 이 파일에 아예 기록되지 않으며, 설치에 꼭 필요한 동안에만 접근이 제한된 별도 파일에 보관했다가 관리자 계정이 만들어지는 즉시 지워집니다. 이미 설치를 마친 사이트도 업데이트하면 남아 있던 파일이 자동으로 정리됩니다. (sir.kr 커뮤니티의 코ppp 님께서 제보해주셨습니다.)
|
||||
- 화면 편집기를 작은 화면에서 열면 상단 도구 모음의 버튼 글자가 세로로 쪼개지는 등 화면이 찌그러지던 문제를 수정했습니다. 화면 편집기는 왼쪽 화면 목록과 미리보기 화면을 나란히 놓고 쓰는 도구라 넓은 화면이 필요합니다. 이제 창이 좁아도 편집기를 억지로 우겨넣지 않고 원래 크기를 유지하며, 모자란 만큼은 가로 스크롤로 넘겨 보면 됩니다.
|
||||
- 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다.
|
||||
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못한 경우, 아무 안내 없이 빈 화면만 남거나 영문 오류 문구가 그대로 노출되던 문제를 수정했습니다. 이제 무슨 일이 있었는지 알려주는 안내와 새로고침 버튼이 표시됩니다. 관리자 화면에도 동일하게 적용됩니다.
|
||||
- 쇼핑몰 등 확장 기능을 불러오지 못한 상태에서 화면이 5초가량 빈 채로 멈춰 있다가 뜨던 문제를 수정했습니다. 이제 지체 없이 화면이 열리고, 불러오지 못한 확장 기능만 조용히 빠집니다.
|
||||
- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.)
|
||||
- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.)
|
||||
- 확장에 외부 라이브러리를 새로 추가한 뒤 번들을 만들면, 새로 추가한 라이브러리가 번들에 담기지 않은 채로 만들어지던 문제를 수정했습니다. 검증은 통과하지만 실제 파일이 빠져 있어 설치 후 실행 중에 오류가 나던 상태였습니다. 이제 새로 추가한 라이브러리가 번들에 정상적으로 포함됩니다. (확장을 직접 개발·수정하는 환경에 해당합니다.)
|
||||
|
||||
## [7.0.3] - 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
|
||||
- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
|
||||
- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
|
||||
|
||||
## [7.0.2] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.2 g7
|
||||
# (필요 시) mv g7-7.0.4 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.2-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.4-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
@@ -16,6 +16,11 @@
|
||||
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://g7.gnuboard.com"><strong>데모 사이트</strong></a> ·
|
||||
<a href="https://g7.gnuboard.com/admin"><strong>관리자 데모</strong></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
[소개](#그누보드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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\ApiDoc\ResourceFieldDescriber;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* 기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다.
|
||||
*
|
||||
* 파라미터 TODO 는 `api:docgen-backfill-params` 가 채우지만, 응답 필드 TODO 는 전용
|
||||
* 백필 커맨드가 없어 재생성(`api:docgen`)으로만 갱신되었습니다. 실측 서버 없이
|
||||
* 재생성하면 이미 실측된 응답 예시값이 퇴행하므로, 이 커맨드는 재생성 대신 응답 필드
|
||||
* 표의 `<!-- TODO: 설명 -->` 셀만 in-place 치환합니다.
|
||||
*
|
||||
* 채움 규칙 SSoT 는 ApiDocScaffolder::responseFieldTable 과 동일한 ResourceFieldDescriber
|
||||
* 입니다 — 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
|
||||
*
|
||||
* 도메인 특이 필드(ResourceFieldDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
|
||||
* 응답 필드 표만 대상으로 하며, 파라미터 표(`<!-- 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 = '<!-- TODO: 설명 -->';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @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<int, string> 파일 경로 목록
|
||||
*/
|
||||
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 를 채웁니다.
|
||||
*
|
||||
* 응답 필드 표 헤더 이후 표 행만 대상으로 하며, `<!-- 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` | <!-- TODO: 설명 --> |
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\ApiDoc\ParameterDescriber;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* 기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다.
|
||||
*
|
||||
* 실측 서버 없이 재생성하면 이미 실측된 응답표·사람 서술이 퇴행하므로, 이 커맨드는
|
||||
* 재생성 대신 파라미터 표의 `<!-- TODO: 용도 -->` 셀만 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 = '<!-- TODO: 용도 -->';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @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<int, string> 파일 경로 목록
|
||||
*/
|
||||
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 를 채웁니다.
|
||||
*
|
||||
* 파라미터 표 헤더 이후 표 행만 대상으로 하며, `<!-- 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 | 필수 | 허용값 | <!-- TODO: 용도 --> |
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Contracts\ApiDoc\ApiDocSampleSeeder;
|
||||
use App\Models\Module;
|
||||
use App\Models\Plugin;
|
||||
use App\Models\Template;
|
||||
use App\Models\User;
|
||||
use App\Support\ApiDoc\ApiDocSampleService;
|
||||
use App\Support\ApiDoc\ApiDocScaffolder;
|
||||
use App\Support\ApiDoc\ApiEndpointProbe;
|
||||
use App\Support\ApiDoc\ApiRouteInventory;
|
||||
use App\Support\ApiDoc\ColumnCommentResolver;
|
||||
use App\Support\ApiDoc\FormRequestIntrospector;
|
||||
use App\Support\ApiDoc\ResponseSchemaInferrer;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* API 레퍼런스 문서 생성 커맨드
|
||||
*
|
||||
* 등록된 API 라우트를 전수 수집하고, GET 엔드포인트는 실제 HTTP 호출로 응답을
|
||||
* 실측하여 요청 파라미터·응답 필드를 담은 마크다운 레퍼런스 문서를 생성합니다.
|
||||
*
|
||||
* @generated 블록만 갱신하며 사람이 작성한 서술은 보존합니다.
|
||||
*/
|
||||
class ApiDocgenCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string 커맨드 시그니처
|
||||
*/
|
||||
protected $signature = 'api:docgen
|
||||
{--scope=core : 범위 (core, module:vendor-id, plugin:vendor-id, all)}
|
||||
{--base-url= : 실측 기준 URL (미지정 시 .env APP_URL)}
|
||||
{--user= : 실측 토큰 발급 대상 사용자 ID}
|
||||
{--seed : 실측 전 완전 샘플 데이터를 시드 (개발 환경 전용)}
|
||||
{--check : 생성하지 않고 누락/미실측만 리포트}
|
||||
{--examples-only : 기존 문서에 요청/응답 예시 블록만 in-place 삽입 (표·서술 불가침, 재생성 금지)}
|
||||
{--dry-run : 생성 대상만 출력}';
|
||||
|
||||
/**
|
||||
* @var string 커맨드 설명
|
||||
*/
|
||||
protected $description = 'API 레퍼런스 문서를 실측 기반으로 생성/갱신합니다';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param ApiRouteInventory $inventory 라우트 인벤토리
|
||||
* @param FormRequestIntrospector $introspector FormRequest 분석기
|
||||
* @param ResponseSchemaInferrer $inferrer 응답 스키마 추론기
|
||||
* @param ApiDocScaffolder $scaffolder 스캐폴딩 생성기
|
||||
* @param ColumnCommentResolver $commentResolver 컬럼 주석 해석기
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(
|
||||
ApiRouteInventory $inventory,
|
||||
FormRequestIntrospector $introspector,
|
||||
ResponseSchemaInferrer $inferrer,
|
||||
ApiDocScaffolder $scaffolder,
|
||||
ColumnCommentResolver $commentResolver
|
||||
): int {
|
||||
$scope = (string) $this->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<int, array<string, mixed>> $routes 수집된 라우트 목록
|
||||
* @return array<string, ApiDocSampleSeeder> 확장 라벨(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<string, mixed> $route 라우트 메타데이터
|
||||
* @param bool $probed 실측 가능 여부
|
||||
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
|
||||
* @param array<string, mixed> $request FormRequest 분석 결과 (쓰기 바디 구성용)
|
||||
* @return array{0: array<string, mixed>|null, 1: array<string, mixed>} 스키마와 실측 메타
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $route 라우트 메타데이터
|
||||
* @param array<string, array{model: class-string, key: string, value: string}> $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<string, mixed> $request FormRequest 분석 결과
|
||||
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
|
||||
* @return array<string, mixed> 실측 요청 바디
|
||||
*/
|
||||
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<string, array{model: class-string, key: string, value: string}> $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<string, mixed> $route 라우트 메타데이터
|
||||
* @param array<string, array{model: class-string, key: string, value: string}> $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<string, array{model: class-string, key: string, value: string}> $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<string, mixed> $route 라우트 메타데이터
|
||||
* @param ColumnCommentResolver $resolver 컬럼 주석 해석기
|
||||
* @param array<string, array{model: class-string, key: string, value: string}> $sampleMap 도메인별 대표 샘플 맵
|
||||
* @return array<string, string> 컬럼명 => 주석
|
||||
*/
|
||||
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<string, array{model: class-string, key: string, value: string}> $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<string, array{model: class-string, key: string, value: string}> $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<string, mixed> $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<int, array{id: string, type: string, path: string, docs: int, endpoints: int}> 확장 목록
|
||||
*/
|
||||
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<string, mixed> $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 <<<MD
|
||||
# {$domain} API 레퍼런스
|
||||
|
||||
> **소유**: {$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;
|
||||
}
|
||||
}
|
||||
@@ -94,9 +94,12 @@ class BuildCoreCommand extends Command
|
||||
return $this->runWatchBundles($projectPath);
|
||||
}
|
||||
|
||||
// 3개 번들을 npm 스크립트로 순차 호출하므로 빌드 환경변수를 세 곳 모두에 주입한다.
|
||||
$buildEnv = $this->buildEnv($productionMode);
|
||||
|
||||
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($engineResult !== Command::SUCCESS) {
|
||||
return $engineResult;
|
||||
@@ -104,7 +107,7 @@ class BuildCoreCommand extends Command
|
||||
|
||||
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true);
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($editorResult !== Command::SUCCESS) {
|
||||
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
|
||||
@@ -114,7 +117,7 @@ class BuildCoreCommand extends Command
|
||||
|
||||
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true);
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true, $buildEnv);
|
||||
|
||||
if ($devtoolsResult !== Command::SUCCESS) {
|
||||
$this->error('❌ DevTools 번들 빌드 실패');
|
||||
@@ -197,7 +200,13 @@ class BuildCoreCommand extends Command
|
||||
$this->info('🔨 코어 빌드 시작 (전체)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
|
||||
// 감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$projectPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info('✅ 코어 빌드 완료 (전체)');
|
||||
@@ -311,15 +320,30 @@ class BuildCoreCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -328,6 +352,11 @@ class BuildCoreCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -189,8 +189,13 @@ class BuildModuleCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -338,15 +343,30 @@ class BuildModuleCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -355,6 +375,11 @@ class BuildModuleCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -189,8 +189,13 @@ class BuildPluginCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -338,15 +343,30 @@ class BuildPluginCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -355,6 +375,11 @@ class BuildPluginCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -181,8 +181,13 @@ class BuildTemplateCommand extends Command
|
||||
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
|
||||
}
|
||||
|
||||
// 빌드 실행
|
||||
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
|
||||
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
|
||||
$result = $this->runNpmCommand(
|
||||
$buildCommand,
|
||||
$buildPath,
|
||||
! $watchMode,
|
||||
$watchMode ? [] : $this->buildEnv($productionMode)
|
||||
);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info("✅ 빌드 완료: {$identifier}");
|
||||
@@ -285,15 +290,30 @@ class BuildTemplateCommand extends Command
|
||||
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
|
||||
*
|
||||
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
|
||||
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
|
||||
*
|
||||
* @param bool $productionMode 프로덕션 빌드 여부
|
||||
* @return array<string, string> Process 에 주입할 환경변수
|
||||
*/
|
||||
private function buildEnv(bool $productionMode): array
|
||||
{
|
||||
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* npm 명령 실행
|
||||
*
|
||||
* @param array $command 실행할 명령
|
||||
* @param string $cwd 작업 디렉토리
|
||||
* @param bool $waitForCompletion 완료 대기 여부
|
||||
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
|
||||
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
|
||||
{
|
||||
// Windows 환경에서는 cmd /c 사용
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@@ -302,6 +322,11 @@ class BuildTemplateCommand extends Command
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($cwd);
|
||||
|
||||
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
|
||||
if ($env !== []) {
|
||||
$process->setEnv($env);
|
||||
}
|
||||
$process->setTimeout(null); // 타임아웃 없음
|
||||
|
||||
if ($waitForCompletion) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\ApiDoc;
|
||||
|
||||
/**
|
||||
* API 문서 실측용 완전 샘플 시더 계약
|
||||
*
|
||||
* 확장(모듈/플러그인)이 자신의 API 도메인 대표 엔티티에 완전한 샘플 레코드를
|
||||
* 멱등하게 생성하고, docgen 이 상세 GET 실측(path 파라미터 치환)에 사용할
|
||||
* 도메인별 대표 route key 맵을 제공하기 위한 인터페이스입니다.
|
||||
*
|
||||
* 확장은 이 인터페이스를 구현한 클래스를 `{Namespace}\Support\ApiDoc\ApiDocSampleService`
|
||||
* 규약 위치에 두며, `api:docgen --scope=module:{id}` 실행 시 커맨드가 자동으로 발견합니다.
|
||||
* 코어의 `App\Support\ApiDoc\ApiDocSampleService` 도 동일 계약을 구현합니다.
|
||||
*/
|
||||
interface ApiDocSampleSeeder
|
||||
{
|
||||
/**
|
||||
* 도메인별 완전 샘플을 멱등 생성하고 대표 route key 맵을 반환합니다.
|
||||
*
|
||||
* 반환 맵의 키는 라우트 도메인 그룹명(`domain_group`, 예: pages)이며,
|
||||
* 값은 그 도메인의 상세 GET path 파라미터를 실측 가능한 실제 레코드로
|
||||
* 치환하기 위한 모델 FQCN·route key 이름·route key 값입니다.
|
||||
*
|
||||
* 선택 필드 `path_params`(param 명 => 실제 값 맵)를 두면, route-model binding
|
||||
* 도 도메인 폴백도 없는 문자열 path 파라미터(예: 게시판 slug 라우팅
|
||||
* `boards/{slug}/posts/{id}`)를 param 명 정확 일치로 치환해 상세 GET 을
|
||||
* 실측할 수 있습니다. 미제공 시 route key 기반 자동 치환만 적용됩니다.
|
||||
*
|
||||
* @return array<string, array{model: class-string, key: string, value: string, path_params?: array<string, string>}> 도메인 => 대표 레코드 정보
|
||||
*/
|
||||
public function seed(): array;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+11
-5
@@ -71,7 +71,7 @@ class EnvironmentDetector
|
||||
* → PATH 검색 → composer.phar
|
||||
*
|
||||
* @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색)
|
||||
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
|
||||
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
|
||||
*/
|
||||
public function findComposerBinary(?string $hint = null): ?string
|
||||
{
|
||||
@@ -120,7 +120,7 @@ class EnvironmentDetector
|
||||
* proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0.
|
||||
*
|
||||
* @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용)
|
||||
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
|
||||
* @return bool composer 가 현재 환경에서 실행 가능한지 여부
|
||||
*/
|
||||
public function canExecuteComposer(?string $hint = null): bool
|
||||
{
|
||||
@@ -178,7 +178,7 @@ class EnvironmentDetector
|
||||
* 지정 경로에 쓰기 가능한지 확인.
|
||||
*
|
||||
* @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사)
|
||||
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
|
||||
* @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부
|
||||
*/
|
||||
public function canWriteVendor(string $targetPath): bool
|
||||
{
|
||||
@@ -254,9 +254,15 @@ class EnvironmentDetector
|
||||
/**
|
||||
* composer 실행 명령 문자열 구성.
|
||||
*
|
||||
* @param array<int, string> $args
|
||||
* 반환된 문자열은 `proc_open(..., ['bypass_shell' => true])` 와 함께 사용해야 한다.
|
||||
* Windows 에서 `bypass_shell` 없이 실행하면 `cmd.exe` 가 따옴표로 시작하는 `.bat`
|
||||
* 경로의 앞뒤 따옴표를 벗겨 첫 인자(`install` 등)를 실행 파일로 오인한다.
|
||||
*
|
||||
* @param string $binary composer 바이너리 경로 (findComposerBinary 반환값)
|
||||
* @param array<int, string> $args composer 하위 명령과 옵션 (예: ['install', '--no-dev'])
|
||||
* @return string proc_open 에 넘길 실행 명령 문자열
|
||||
*/
|
||||
private function buildComposerCommand(string $binary, array $args): string
|
||||
public function buildComposerCommand(string $binary, array $args): string
|
||||
{
|
||||
$escaped = array_map('escapeshellarg', $args);
|
||||
|
||||
|
||||
+177
-57
@@ -82,7 +82,7 @@ class VendorBundler
|
||||
* 코어는 소스 = 출력 경로가 base_path() 로 동일합니다.
|
||||
*
|
||||
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
|
||||
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
|
||||
* @return VendorBundleResult 번들 생성 결과 (zip 경로, manifest 경로, 패키지 수 등)
|
||||
*/
|
||||
public function buildForCore(bool $force = false): VendorBundleResult
|
||||
{
|
||||
@@ -106,7 +106,7 @@ class VendorBundler
|
||||
* @param string $type 확장 타입 ('module' 또는 'plugin')
|
||||
* @param string $identifier 확장 식별자 (vendor-name 형식, 예: 'sirsoft-ecommerce')
|
||||
* @param bool $force manifest 해시 일치 여부와 무관하게 강제 재빌드
|
||||
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
|
||||
* @return VendorBundleResult 번들 생성 결과 (활성 디렉토리 부재 시 skipped=true)
|
||||
*/
|
||||
public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult
|
||||
{
|
||||
@@ -164,14 +164,17 @@ class VendorBundler
|
||||
*
|
||||
* @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리)
|
||||
* @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스)
|
||||
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
|
||||
* @return bool 번들이 stale 한지 여부 (해시 불일치 또는 manifest 부재). 외부 의존성이 없으면 항상 false
|
||||
*/
|
||||
public function isStale(string $sourcePath, ?string $outputPath = null): bool
|
||||
{
|
||||
$outputPath ??= $sourcePath;
|
||||
|
||||
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (file_exists($composerJsonPath) && ! $this->hasExternalDependencies($composerJsonPath)) {
|
||||
// 의존성 판정도 build() 와 같은 파일을 본다 (출력 우선, 없으면 소스 폴백).
|
||||
// 소스만 보면 _bundled 에 외부 패키지를 처음 추가한 상태를 "의존성 없음" 으로
|
||||
// 오판해 재빌드가 필요 없다고 답한다.
|
||||
$composerJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
|
||||
if ($composerJsonPath !== null && ! $this->hasExternalDependencies($composerJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -187,16 +190,19 @@ class VendorBundler
|
||||
return true;
|
||||
}
|
||||
|
||||
if (file_exists($composerJsonPath)) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($composerJsonPath);
|
||||
// 해시 비교 대상은 manifest 가 놓인 출력 디렉토리 기준 ({@see build()} 참조).
|
||||
// 소스만 검사하면 _bundled 의 composer.json 변경을 up-to-date 로 오보한다.
|
||||
$hashJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
|
||||
if ($hashJsonPath !== null) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($hashJsonPath);
|
||||
if (($manifest['composer_json_sha256'] ?? null) !== $currentHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$composerLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
if (file_exists($composerLockPath)) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($composerLockPath);
|
||||
$hashLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
|
||||
if ($hashLockPath !== null) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($hashLockPath);
|
||||
if (($manifest['composer_lock_sha256'] ?? null) !== $currentHash) {
|
||||
return true;
|
||||
}
|
||||
@@ -205,6 +211,35 @@ class VendorBundler
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 해시 비교 기준이 될 파일 경로를 결정합니다.
|
||||
*
|
||||
* manifest 와 zip 은 출력 디렉토리(_bundled)에 함께 저장되어 그대로 배포되며,
|
||||
* 런타임 검증({@see VendorIntegrityChecker::verify()})은 zip 이 놓인 디렉토리
|
||||
* (_bundled 또는 설치/업데이트 시 그 복사본인 _pending/staging)의 composer.json 을
|
||||
* 대조합니다. 따라서 해시도 그 디렉토리를 기준으로 계산해야 생성자와 검증자의
|
||||
* 기준이 일치합니다.
|
||||
*
|
||||
* 출력 디렉토리에 파일이 없으면 소스(활성 디렉토리)로 폴백합니다.
|
||||
* 코어는 소스와 출력이 동일하므로 어느 쪽을 골라도 같은 경로가 됩니다.
|
||||
*
|
||||
* @param string $sourcePath composer.json/lock 과 vendor/ 를 읽는 경로 (활성 디렉토리)
|
||||
* @param string $outputPath manifest 와 zip 을 쓰는 경로 (_bundled, 코어는 소스와 동일)
|
||||
* @param string $filename 'composer.json' 또는 'composer.lock'
|
||||
* @return string|null 해시 대상 파일의 절대 경로. 양쪽 모두 없으면 null
|
||||
*/
|
||||
private function resolveHashTarget(string $sourcePath, string $outputPath, string $filename): ?string
|
||||
{
|
||||
$outputFile = $outputPath.DIRECTORY_SEPARATOR.$filename;
|
||||
if (file_exists($outputFile)) {
|
||||
return $outputFile;
|
||||
}
|
||||
|
||||
$sourceFile = $sourcePath.DIRECTORY_SEPARATOR.$filename;
|
||||
|
||||
return file_exists($sourceFile) ? $sourceFile : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 실행.
|
||||
*
|
||||
@@ -215,12 +250,20 @@ class VendorBundler
|
||||
*/
|
||||
private function build(string $sourcePath, string $outputPath, string $target, bool $force): VendorBundleResult
|
||||
{
|
||||
// 전제 조건 검증
|
||||
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (! file_exists($composerJsonPath)) {
|
||||
// 빌드가 사용하는 composer.json/lock 은 **출력(_bundled) 우선, 없으면 소스 폴백** 이다
|
||||
// ({@see resolveHashTarget()}). 설치 입력·해시 기준·런타임 검증이 모두 같은 파일을
|
||||
// 보아야 한다.
|
||||
//
|
||||
// 개발자는 규정상 `_bundled` 에서만 작업하므로 새 패키지를 추가하면 `_bundled` 의
|
||||
// composer.json/lock 에만 반영되고 활성 디렉토리는 구버전으로 남는다. 설치 입력만
|
||||
// 활성에서 가져오면 구버전 lock 으로 install 한 zip 에 신버전 해시가 붙어, 무결성은
|
||||
// 통과하는데 새 패키지가 번들에서 누락된다 (런타임에 클래스 not found).
|
||||
// 코어는 source == output 이므로 항상 같은 파일이다.
|
||||
$composerJsonPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.json');
|
||||
if ($composerJsonPath === null) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'composer_json_not_found',
|
||||
context: ['path' => $composerJsonPath],
|
||||
context: ['path' => $sourcePath.DIRECTORY_SEPARATOR.'composer.json'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,26 +301,33 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 파일 제거
|
||||
if (file_exists($zipPath)) {
|
||||
@unlink($zipPath);
|
||||
}
|
||||
if (file_exists($manifestPath)) {
|
||||
@unlink($manifestPath);
|
||||
}
|
||||
|
||||
// === 스테이징 빌드 ===
|
||||
// 개발 머신의 vendor/ 를 직접 사용하지 않고, composer install --no-dev 를
|
||||
// 스테이징 디렉토리에서 새로 실행하여 dev 의존성과 완전히 분리된 번들을 생성한다.
|
||||
//
|
||||
// 새 zip/manifest 는 임시 경로(.tmp)에 먼저 쓰고, 빌드가 성공한 뒤에만 원본을
|
||||
// 교체한다. 기존 파일을 빌드 전에 삭제하면 composer install 실패 시 새 파일이
|
||||
// 생성되지 못한 채 원본만 유실되어 이후 module:update 가 번들 부재로 실패한다.
|
||||
$stagingDir = storage_path('app/vendor-bundle-staging/'.uniqid('build-', true));
|
||||
File::ensureDirectoryExists($stagingDir, 0755);
|
||||
|
||||
$tmpZipPath = $zipPath.'.tmp';
|
||||
$tmpManifestPath = $manifestPath.'.tmp';
|
||||
if (file_exists($tmpZipPath)) {
|
||||
@unlink($tmpZipPath);
|
||||
}
|
||||
if (file_exists($tmpManifestPath)) {
|
||||
@unlink($tmpManifestPath);
|
||||
}
|
||||
|
||||
// 설치 입력 = 해시 기준과 동일한 파일 (출력 우선, 없으면 소스 폴백).
|
||||
$composerLockPath = $this->resolveHashTarget($sourcePath, $outputPath, 'composer.lock');
|
||||
|
||||
try {
|
||||
// 1. composer.json + composer.lock 을 스테이징으로 복사
|
||||
File::copy($composerJsonPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.json');
|
||||
$sourceLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
if (file_exists($sourceLockPath)) {
|
||||
File::copy($sourceLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock');
|
||||
if ($composerLockPath !== null) {
|
||||
File::copy($composerLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock');
|
||||
}
|
||||
|
||||
// 2. 스테이징에서 composer install --no-dev 실행
|
||||
@@ -292,8 +342,8 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
// 4. zip 생성 (EXCLUDE_PATTERNS 만 적용 — 화이트리스트 불필요)
|
||||
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $zipPath);
|
||||
// 4. zip 생성 — 임시 경로에 먼저 쓴다 (EXCLUDE_PATTERNS 만 적용)
|
||||
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $tmpZipPath);
|
||||
|
||||
// 5. manifest 작성 — 스테이징의 composer.lock 을 기준으로 패키지 목록 추출
|
||||
$stagedLockPath = $stagingDir.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
@@ -301,19 +351,19 @@ class VendorBundler
|
||||
? $this->collectPackages($stagedLockPath)
|
||||
: [];
|
||||
|
||||
// manifest 의 해시는 설치 입력과 **동일한** composer.json/lock 기준 (출력 우선).
|
||||
// manifest·zip·composer.json 이 함께 배포되는 곳이 출력 디렉토리이며,
|
||||
// 런타임 검증도 그 디렉토리를 대조한다 ({@see resolveHashTarget()}).
|
||||
$manifest = [
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'generated_at' => date('c'),
|
||||
'generator' => 'g7 vendor-bundle:build',
|
||||
'target' => $target,
|
||||
// manifest 의 해시는 **소스** 의 composer.json/lock 기준 — 런타임에서
|
||||
// 소스 디렉토리(또는 번들과 함께 배포된) composer.json 과 비교하여
|
||||
// stale 여부를 감지한다.
|
||||
'composer_json_sha256' => $this->integrityChecker->computeFileHash($composerJsonPath),
|
||||
'composer_lock_sha256' => file_exists($sourceLockPath)
|
||||
? $this->integrityChecker->computeFileHash($sourceLockPath)
|
||||
'composer_lock_sha256' => $composerLockPath !== null
|
||||
? $this->integrityChecker->computeFileHash($composerLockPath)
|
||||
: null,
|
||||
'zip_sha256' => $this->integrityChecker->computeFileHash($zipPath),
|
||||
'zip_sha256' => $this->integrityChecker->computeFileHash($tmpZipPath),
|
||||
'zip_size' => $zipSize,
|
||||
'package_count' => count($packages),
|
||||
'php_requirement' => $this->extractPhpRequirement($composerJsonPath),
|
||||
@@ -321,7 +371,12 @@ class VendorBundler
|
||||
'packages' => $packages,
|
||||
];
|
||||
|
||||
File::put($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
File::put($tmpManifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
// 6. 원자적 교체 — 여기까지 성공했으므로 임시 파일을 최종 경로로 옮긴다.
|
||||
// 이 지점 이전에 실패하면 기존 번들이 그대로 보존된다.
|
||||
$this->promoteAtomic($tmpZipPath, $zipPath);
|
||||
$this->promoteAtomic($tmpManifestPath, $manifestPath);
|
||||
|
||||
Log::info('Vendor 번들 빌드 완료', [
|
||||
'target' => $target,
|
||||
@@ -340,10 +395,42 @@ class VendorBundler
|
||||
reason: 'built',
|
||||
);
|
||||
} finally {
|
||||
// 스테이징 정리 (실패/성공 무관)
|
||||
// 스테이징 및 미승격 임시 파일 정리 (실패/성공 무관)
|
||||
if (File::isDirectory($stagingDir)) {
|
||||
File::deleteDirectory($stagingDir);
|
||||
}
|
||||
if (file_exists($tmpZipPath)) {
|
||||
@unlink($tmpZipPath);
|
||||
}
|
||||
if (file_exists($tmpManifestPath)) {
|
||||
@unlink($tmpManifestPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 파일을 최종 경로로 원자적으로 교체합니다.
|
||||
*
|
||||
* Windows 에서 `rename()` 은 대상이 이미 존재하면 실패하므로, 기존 파일을 먼저
|
||||
* 제거한 뒤 이동한다. 이 메서드는 빌드가 성공적으로 완료된 뒤에만 호출되므로,
|
||||
* 여기서 기존 파일을 제거해도 (제거 후 이동 실패 확률이 극히 낮아) 안전하다.
|
||||
*
|
||||
* @param string $tmpPath 임시 파일 경로 (성공적으로 생성 완료된 상태)
|
||||
* @param string $finalPath 최종 경로
|
||||
*
|
||||
* @throws VendorInstallException 이동 실패 시
|
||||
*/
|
||||
private function promoteAtomic(string $tmpPath, string $finalPath): void
|
||||
{
|
||||
if (file_exists($finalPath)) {
|
||||
@unlink($finalPath);
|
||||
}
|
||||
|
||||
if (! @rename($tmpPath, $finalPath)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_promote_failed',
|
||||
context: ['from' => $tmpPath, 'to' => $finalPath],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,30 +464,37 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
$phpBinary = config('process.php_binary', 'php');
|
||||
|
||||
if (str_contains($binary, ' ')) {
|
||||
$composerCmd = $binary;
|
||||
} elseif (str_ends_with(strtolower($binary), '.phar')) {
|
||||
$composerCmd = escapeshellarg($phpBinary).' '.escapeshellarg($binary);
|
||||
} else {
|
||||
$composerCmd = escapeshellarg($binary);
|
||||
}
|
||||
|
||||
$command = $composerCmd.' install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress 2>&1';
|
||||
// 명령 조립과 실행 옵션은 canExecuteComposer 와 동일한 방식으로 통일한다.
|
||||
// buildComposerCommand + bypass_shell:true 를 함께 써야 Windows 에서 .bat
|
||||
// 경로가 cmd.exe 의 따옴표 처리로 깨지지 않는다.
|
||||
$command = $detector->buildComposerCommand($binary, [
|
||||
'install', '--no-dev', '--no-scripts', '--prefer-dist', '--no-interaction', '--no-progress',
|
||||
]);
|
||||
|
||||
Log::info('vendor-bundle 빌드: composer install 시작', [
|
||||
'target' => $target,
|
||||
'staging' => $stagingDir,
|
||||
]);
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
// stdout/stderr 는 파이프가 아닌 파일 descriptor 로 받는다.
|
||||
// (Windows 회귀) composer 는 autoload dump 단계에서 임시 추출물을
|
||||
// `rmdir /S /Q` async `cmd.exe` 손자 프로세스로 정리하는데, stdout/stderr 를
|
||||
// 파이프로 열면 이 손자들이 부모 파이프 핸들을 상속받아 붙잡은 채 종료하지 않아
|
||||
// 부모(php)의 read/proc_close 가 자식 완료 신호를 영영 받지 못하고 무한 대기한다.
|
||||
// 파일 descriptor 는 손자에게 상속될 열린 파이프 핸들이 없어 이 교착을 원천 차단한다
|
||||
// (실측: 파이프=행, 파일=140s 완주 EXIT 0 + autoload 정상 생성).
|
||||
$outPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stdout.log';
|
||||
$errPath = $stagingDir.DIRECTORY_SEPARATOR.'_composer_stderr.log';
|
||||
$descriptors = self::composerOutputDescriptors($outPath, $errPath);
|
||||
|
||||
$process = @proc_open($command, $descriptors, $pipes, $stagingDir, EnvironmentDetector::buildComposerEnv());
|
||||
$process = @proc_open(
|
||||
$command,
|
||||
$descriptors,
|
||||
$pipes,
|
||||
$stagingDir,
|
||||
EnvironmentDetector::buildComposerEnv(),
|
||||
['bypass_shell' => true],
|
||||
);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new VendorInstallException(
|
||||
@@ -409,12 +503,17 @@ class VendorBundler
|
||||
);
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
// stdin(파이프)만 즉시 닫고, stdout/stderr 는 파일이므로 배수 불필요 —
|
||||
// proc_close 가 자식 종료까지 블록 대기한다.
|
||||
if (isset($pipes[0]) && is_resource($pipes[0])) {
|
||||
fclose($pipes[0]);
|
||||
}
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
$stdout = is_file($outPath) ? (string) @file_get_contents($outPath) : '';
|
||||
$stderr = is_file($errPath) ? (string) @file_get_contents($errPath) : '';
|
||||
$output = trim($stdout.\PHP_EOL.$stderr);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_composer_failed',
|
||||
@@ -428,13 +527,34 @@ class VendorBundler
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* composer install 의 stdout/stderr 를 받을 proc_open descriptor 배열을 구성합니다.
|
||||
*
|
||||
* stdout/stderr 를 파이프가 아닌 파일로 여는 이유는 runComposerInstall 주석 참조 —
|
||||
* Windows 에서 composer autoload dump 의 async 손자 프로세스가 상속받는 파이프 핸들로
|
||||
* 인한 무한 대기(행)를 원천 차단한다. 이 메서드는 순수 함수로, descriptor 구조를
|
||||
* 회귀 테스트로 고정하기 위해 분리했다.
|
||||
*
|
||||
* @param string $outPath stdout 로그 파일 경로
|
||||
* @param string $errPath stderr 로그 파일 경로
|
||||
* @return array<int, array<int, string>> proc_open 2번째 인자용 descriptor 배열
|
||||
*/
|
||||
public static function composerOutputDescriptors(string $outPath, string $errPath): array
|
||||
{
|
||||
return [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['file', $outPath, 'w'],
|
||||
2 => ['file', $errPath, 'w'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* vendor/ 디렉토리를 재귀적으로 zip에 쓰기.
|
||||
*
|
||||
* 스테이징에서 composer install --no-dev 로 새로 생성된 vendor/ 를 대상으로 하므로
|
||||
* 화이트리스트 필터링은 불필요하며, EXCLUDE_PATTERNS (tests/, docs/ 등) 만 적용한다.
|
||||
*
|
||||
* @return array{0: int, 1: int} [zipSize, fileCount]
|
||||
* @return array{0: int, 1: int} [zipSize, fileCount]
|
||||
*/
|
||||
private function writeZip(string $vendorPath, string $zipPath): array
|
||||
{
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Requests\LanguagePack;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
@@ -26,11 +28,15 @@ class InstallFromUrlRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => ['required', 'url', 'max:500'],
|
||||
$rules = [
|
||||
// 원격 코드를 내려받는 지점이므로 https 고정 + 내부 주소 허용 설정과 무관하게 항상 차단
|
||||
'url' => ['required', 'url', 'max:500', new PublicOutboundUrl(schemes: ['https'], allowInternalOptIn: false)],
|
||||
'checksum' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
|
||||
'auto_activate' => ['nullable', 'boolean'],
|
||||
];
|
||||
|
||||
// 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공
|
||||
return HookManager::applyFilters('core.language_packs.install_from_url_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,13 +6,20 @@ use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\ScheduleFrequency;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Rules\AllowedArtisanCommand;
|
||||
use App\Rules\AllowedShellCommand;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CreateScheduleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool 권한 검사는 라우트의 permission 미들웨어가 담당하므로 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -22,7 +29,7 @@ class CreateScheduleRequest extends FormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -33,7 +40,25 @@ class CreateScheduleRequest extends FormRequest
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => "required|string|in:{$types}",
|
||||
'command' => 'required|string|max:2000',
|
||||
// command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로
|
||||
// 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다
|
||||
'command' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:2000',
|
||||
Rule::when(
|
||||
$this->input('type') === ScheduleType::Url->value,
|
||||
[new PublicOutboundUrl],
|
||||
),
|
||||
Rule::when(
|
||||
$this->input('type') === ScheduleType::Shell->value,
|
||||
[new AllowedShellCommand],
|
||||
),
|
||||
Rule::when(
|
||||
$this->input('type') === ScheduleType::Artisan->value,
|
||||
[new AllowedArtisanCommand],
|
||||
),
|
||||
],
|
||||
'expression' => 'required|string|max:100',
|
||||
'frequency' => "required|string|in:{$frequencies}",
|
||||
'without_overlapping' => 'boolean',
|
||||
@@ -86,7 +111,9 @@ class CreateScheduleRequest extends FormRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증된 데이터 반환
|
||||
* 검증된 데이터에 생성자(created_by)를 덧붙여 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed> 검증된 입력 + created_by
|
||||
*/
|
||||
public function validatedWithCreator(): array
|
||||
{
|
||||
|
||||
@@ -6,12 +6,20 @@ use App\Enums\ExtensionOwnerType;
|
||||
use App\Enums\ScheduleFrequency;
|
||||
use App\Enums\ScheduleType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Schedule;
|
||||
use App\Rules\AllowedArtisanCommand;
|
||||
use App\Rules\AllowedShellCommand;
|
||||
use App\Rules\PublicOutboundUrl;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateScheduleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool 권한 검사는 라우트의 permission 미들웨어가 담당하므로 항상 true
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -21,7 +29,7 @@ class UpdateScheduleRequest extends FormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -32,7 +40,17 @@ class UpdateScheduleRequest extends FormRequest
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => "sometimes|required|string|in:{$types}",
|
||||
'command' => 'sometimes|required|string|max:2000',
|
||||
// command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로
|
||||
// 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다
|
||||
'command' => [
|
||||
'sometimes',
|
||||
'required',
|
||||
'string',
|
||||
'max:2000',
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Url), [new PublicOutboundUrl]),
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Shell), [new AllowedShellCommand]),
|
||||
Rule::when($this->resolvesToScheduleType(ScheduleType::Artisan), [new AllowedArtisanCommand]),
|
||||
],
|
||||
'expression' => 'sometimes|required|string|max:100',
|
||||
'frequency' => "sometimes|required|string|in:{$frequencies}",
|
||||
'without_overlapping' => 'boolean',
|
||||
@@ -47,6 +65,26 @@ class UpdateScheduleRequest extends FormRequest
|
||||
return HookManager::applyFilters('core.schedule.update_validation_rules', $rules, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 수정 결과 스케줄이 주어진 타입이 되는지 판정합니다.
|
||||
*
|
||||
* PATCH 라 `type` 이 요청에 없을 수 있으므로, 없으면 저장된 스케줄의 타입을 기준으로
|
||||
* 판정한다 — 그렇지 않으면 기존 스케줄의 command 만 바꾸는 요청이 검증을 비껴간다.
|
||||
*
|
||||
* @param ScheduleType $type 판정할 스케줄 타입
|
||||
* @return bool 해당 타입의 스케줄이면 true
|
||||
*/
|
||||
protected function resolvesToScheduleType(ScheduleType $type): bool
|
||||
{
|
||||
if ($this->has('type')) {
|
||||
return $this->input('type') === $type->value;
|
||||
}
|
||||
|
||||
$schedule = $this->route('schedule');
|
||||
|
||||
return $schedule instanceof Schedule && $schedule->type === $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*
|
||||
|
||||
@@ -231,6 +231,8 @@ class SaveSettingsRequest extends FormRequest
|
||||
'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab),
|
||||
'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'],
|
||||
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지
|
||||
'security.allow_internal_outbound_urls' => ['nullable', 'boolean'],
|
||||
|
||||
// 캐시 설정 (advanced 탭)
|
||||
'advanced.cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
|
||||
@@ -614,6 +616,7 @@ class SaveSettingsRequest extends FormRequest
|
||||
'security.auth_token_lifetime.integer' => __('validation.settings.auth_token_lifetime_integer'),
|
||||
'security.auth_token_lifetime.min' => __('validation.settings.auth_token_lifetime_min'),
|
||||
'security.auth_token_lifetime.max' => __('validation.settings.auth_token_lifetime_max'),
|
||||
'security.allow_internal_outbound_urls.boolean' => __('validation.settings.allow_internal_outbound_urls_boolean'),
|
||||
'security.max_login_attempts.integer' => __('validation.settings.max_login_attempts_integer'),
|
||||
'security.max_login_attempts.min' => __('validation.settings.max_login_attempts_min'),
|
||||
'security.max_login_attempts.max' => __('validation.settings.max_login_attempts_max'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Artisan 타입 스케줄의 command 가 차단목록에 걸리지 않는지 검증하는 Custom Rule.
|
||||
*
|
||||
* `tinker` 처럼 임의 PHP 코드를 실행하거나 `db:wipe` 처럼 파괴적인 명령을 스케줄로 등록하는 것을
|
||||
* 저장 시점에 차단한다. 차단목록은 `config/schedule_security.php` 가 소유하며 관리자 환경설정에
|
||||
* 노출되지 않는다.
|
||||
*/
|
||||
class AllowedArtisanCommand implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 값이 실행 허용된 artisan 명령인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.schedule_command.artisan_denied'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! ScheduleCommandValidator::isArtisanCommandAllowed($value)) {
|
||||
$fail(__('validation.schedule_command.artisan_denied'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ class AllowedModuleFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source Maps
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
|
||||
|
||||
|
||||
@@ -20,9 +20,6 @@ class AllowedPluginFileType implements ValidationRule
|
||||
// Data
|
||||
'json',
|
||||
|
||||
// Source Maps
|
||||
'map',
|
||||
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'svg', 'webp', 'gif', 'ico',
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Shell 타입 스케줄의 command 가 실행 허용된 명령인지 검증하는 Custom Rule.
|
||||
*
|
||||
* 스케줄 생성 권한만 위임받은 계정이 임의 OS 명령을 서버에서 실행하는 것을 저장 시점에 차단한다.
|
||||
* 허용 여부는 `config/schedule_security.php` 의 opt-in 게이트와 실행 파일 화이트리스트가 결정하며,
|
||||
* 관리자 환경설정에 노출되지 않으므로 위임 관리자가 스스로 범위를 넓힐 수 없다.
|
||||
*/
|
||||
class AllowedShellCommand implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* 값이 실행 허용된 shell 명령인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.schedule_command.shell_not_allowed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! ScheduleCommandValidator::isShellCommandAllowed($value)) {
|
||||
$fail(__('validation.schedule_command.shell_not_allowed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* 서버가 대신 호출하게 될 URL 이 내부망을 가리키지 않는지 검증하는 Custom Rule.
|
||||
*
|
||||
* 외부 API 엔드포인트·스케줄 URL 처럼 저장된 값이 나중에 서버의 outbound 요청 목적지가
|
||||
* 되는 입력에 적용한다. 사설/루프백/링크로컬 IP 와 내부 도메인을 입력 시점에 차단해,
|
||||
* 서버가 내부망 정찰 도구로 쓰이는 것을 막는다(SSRF).
|
||||
*
|
||||
* 사내 서버를 호출하는 것이 정당한 용도(외부 API 연동, 스케줄 URL 호출)에 한해, 관리자
|
||||
* 환경설정의 `security.allow_internal_outbound_urls` 로 내부 주소를 허용할 수 있다.
|
||||
* 원격 코드(언어팩 ZIP 등)를 내려받는 지점은 `allowInternalOptIn: false` 로 두어 이
|
||||
* 설정과 무관하게 항상 차단한다.
|
||||
*/
|
||||
class PublicOutboundUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $schemes 허용 scheme (기본 http/https — 사내 API 는 http 인 경우가 있다)
|
||||
* @param bool $allowInternalOptIn 관리자 설정으로 내부 주소를 허용할 수 있는 지점인지 (기본 true)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $schemes = ['http', 'https'],
|
||||
private readonly bool $allowInternalOptIn = true,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 값이 공개 인터넷 URL 인지 검증합니다.
|
||||
*
|
||||
* @param string $attribute 검증 대상 필드명
|
||||
* @param mixed $value 검증 대상 값
|
||||
* @param Closure $fail 실패 콜백
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.outbound_url.invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$options = ['schemes' => $this->schemes];
|
||||
|
||||
// 내부 주소 허용이 켜져 있으면 구조 검증(scheme/userinfo)만 하고 내부망 차단은 건너뛴다
|
||||
if ($this->allowInternalOptIn && (bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
|
||||
if (! OutboundUrlValidator::isStructurallySafeUrl($value, $options)) {
|
||||
$fail(__('validation.outbound_url.invalid'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! OutboundUrlValidator::isPublicHttpUrl($value, $options)) {
|
||||
$fail(__('validation.outbound_url.internal_not_allowed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use Aws\S3\Exception\S3Exception;
|
||||
use Aws\S3\S3Client;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Predis\Client;
|
||||
|
||||
/**
|
||||
* 드라이버 연결 테스트 서비스
|
||||
@@ -89,7 +94,7 @@ class DriverConnectionTester
|
||||
}
|
||||
|
||||
// AWS SDK가 설치되어 있는지 확인
|
||||
if (! class_exists(\Aws\S3\S3Client::class)) {
|
||||
if (! class_exists(S3Client::class)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('settings.s3_sdk_missing'),
|
||||
@@ -98,7 +103,7 @@ class DriverConnectionTester
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$client = new \Aws\S3\S3Client([
|
||||
$client = new S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => $region,
|
||||
'credentials' => [
|
||||
@@ -121,7 +126,7 @@ class DriverConnectionTester
|
||||
'message' => __('settings.s3_test_success'),
|
||||
'latency' => $latency.'ms',
|
||||
];
|
||||
} catch (\Aws\S3\Exception\S3Exception $e) {
|
||||
} catch (S3Exception $e) {
|
||||
$errorMessage = match ($e->getAwsErrorCode()) {
|
||||
'NoSuchBucket' => __('settings.s3_bucket_not_found'),
|
||||
'AccessDenied' => __('settings.s3_access_denied'),
|
||||
@@ -165,7 +170,7 @@ class DriverConnectionTester
|
||||
// PHP Redis 확장 확인
|
||||
if (! extension_loaded('redis')) {
|
||||
// Predis 사용 시도
|
||||
if (! class_exists(\Predis\Client::class)) {
|
||||
if (! class_exists(Client::class)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('settings.redis_extension_missing'),
|
||||
@@ -177,7 +182,7 @@ class DriverConnectionTester
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$redis = new \Redis();
|
||||
$redis = new \Redis;
|
||||
$connected = @$redis->connect($host, $port, 3.0);
|
||||
|
||||
if (! $connected) {
|
||||
@@ -259,7 +264,7 @@ class DriverConnectionTester
|
||||
$options['password'] = $password;
|
||||
}
|
||||
|
||||
$client = new \Predis\Client($options);
|
||||
$client = new Client($options);
|
||||
$pong = $client->ping();
|
||||
|
||||
if ($pong->getPayload() !== 'PONG') {
|
||||
@@ -307,7 +312,7 @@ class DriverConnectionTester
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
$memcached = new \Memcached();
|
||||
$memcached = new \Memcached;
|
||||
$memcached->setOption(\Memcached::OPT_CONNECT_TIMEOUT, 3000);
|
||||
$memcached->addServer($host, $port);
|
||||
|
||||
@@ -376,6 +381,17 @@ class DriverConnectionTester
|
||||
// Reverb 서버 상태 확인 (기본 HTTP 엔드포인트)
|
||||
$url = sprintf('%s://%s:%d', $scheme, $host, $port);
|
||||
|
||||
// Reverb 는 통상 localhost·사내 IP 에서 동작하므로 사설 주소 자체는 정상 구성이다.
|
||||
// 다만 이 진단 요청을 임의 목적지 탐색에 전용하지 못하도록, 정상 설정에서는 나올 수
|
||||
// 없는 값(userinfo 위장, http/https 이외 scheme, 제어문자 주입)은 거부한다.
|
||||
if (! OutboundUrlValidator::isStructurallySafeUrl($url, ['schemes' => ['http', 'https']])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('settings.websocket_test_failed'),
|
||||
'error' => __('settings.websocket_invalid_host'),
|
||||
];
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// HTTP 요청으로 서버 응답 확인
|
||||
@@ -402,7 +418,7 @@ class DriverConnectionTester
|
||||
'message' => __('settings.websocket_test_failed'),
|
||||
'error' => "HTTP {$response->status()}",
|
||||
];
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
} catch (ConnectionException $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('settings.websocket_connection_refused'),
|
||||
|
||||
@@ -166,6 +166,7 @@ class ExtensionBundleService
|
||||
public function buildCssBundle(string $type): string
|
||||
{
|
||||
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
||||
$isProduction = app()->environment('production');
|
||||
$segments = [];
|
||||
|
||||
foreach ($ordered as $identifier => $paths) {
|
||||
@@ -197,7 +198,7 @@ class ExtensionBundleService
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = $content;
|
||||
$segments[] = $this->processCssSourceMap($content, $isProduction);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [
|
||||
'type' => $type,
|
||||
@@ -417,6 +418,29 @@ class ExtensionBundleService
|
||||
}, $content) ?? $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 소스맵 주석을 환경에 맞게 처리합니다.
|
||||
*
|
||||
* CSS 는 JS 와 주석 문법이 달라 소스맵 참조를 블록 주석으로 표기하므로
|
||||
* processJsSourceMap() 의 `//#` 패턴으로는 검출되지 않는다.
|
||||
*
|
||||
* prod: 주석 strip(맵 참조 제거). dev: 원본 유지.
|
||||
* 병합 번들에서는 개별 맵 URL 이 어차피 어긋나므로 dev rewrite 는 하지 않는다.
|
||||
*
|
||||
* @param string $content 원본 CSS 내용
|
||||
* @param bool $isProduction 프로덕션 여부
|
||||
* @return string 처리된 내용
|
||||
*/
|
||||
private function processCssSourceMap(string $content, bool $isProduction): string
|
||||
{
|
||||
if (! $isProduction) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// 구분자로 `~` 사용 — 패턴에 `/`, `#` 가 포함됨
|
||||
return preg_replace('~/\*#\s*sourceMappingURL=\S+?\s*\*/~', '', $content) ?? $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 내용에 상대경로 url() 참조가 있는지 확인합니다.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
|
||||
use App\Enums\LanguagePackScope;
|
||||
use App\Enums\LanguagePackStatus;
|
||||
use App\Exceptions\LanguagePackOperationException;
|
||||
use App\Exceptions\LanguagePackSlotConflictException;
|
||||
use App\Extension\Helpers\ExtensionBackupHelper;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
@@ -16,6 +17,7 @@ use App\Models\LanguagePack;
|
||||
use App\Services\LanguagePack\LanguagePackBaseLocales;
|
||||
use App\Services\LanguagePack\LanguagePackManifestValidator;
|
||||
use App\Services\LanguagePack\LanguagePackRegistry;
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
@@ -25,7 +27,6 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Exceptions\LanguagePackOperationException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
@@ -340,7 +341,6 @@ class LanguagePackService
|
||||
*
|
||||
* @param LanguagePack $pack 가상 행
|
||||
* @param array<string, mixed> $filters 필터
|
||||
* @return bool
|
||||
*/
|
||||
private function matchesBuiltInFilters(LanguagePack $pack, array $filters): bool
|
||||
{
|
||||
@@ -600,7 +600,7 @@ class LanguagePackService
|
||||
ZipInstallHelper::extractZip($zipPath, $extractPath);
|
||||
|
||||
return $this->finalizeInstall($extractPath, 'zip', $file->getClientOriginalName(), $autoActivate, $installedBy);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
throw $e;
|
||||
}
|
||||
@@ -632,7 +632,7 @@ class LanguagePackService
|
||||
ZipInstallHelper::extractZip($zipPath, $extractPath);
|
||||
|
||||
return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
File::deleteDirectory($tempPath);
|
||||
throw $e;
|
||||
@@ -653,6 +653,11 @@ class LanguagePackService
|
||||
*/
|
||||
public function installFromUrl(string $url, ?string $checksum, bool $autoActivate = false, ?int $installedBy = null, bool $force = false): LanguagePack
|
||||
{
|
||||
// 서버가 이 URL 을 대신 내려받으므로, 내부 네트워크 주소가 목적지가 되지 않도록 차단한다.
|
||||
if (! OutboundUrlValidator::isPublicHttpUrl($url)) {
|
||||
throw new LanguagePackOperationException('language_packs.errors.download_url_not_public');
|
||||
}
|
||||
|
||||
$this->assertInstallDirectoriesWritable();
|
||||
|
||||
$tempId = (string) Str::uuid();
|
||||
@@ -679,7 +684,7 @@ class LanguagePackService
|
||||
ZipInstallHelper::extractZip($zipPath, $extractPath);
|
||||
|
||||
return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
File::deleteDirectory($tempPath);
|
||||
throw $e;
|
||||
@@ -718,7 +723,7 @@ class LanguagePackService
|
||||
File::copyDirectory($bundledPath, $extractPath);
|
||||
|
||||
return $this->finalizeInstall($extractPath, 'bundled', $identifier, $autoActivate, $installedBy, $force);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
throw $e;
|
||||
}
|
||||
@@ -901,6 +906,7 @@ class LanguagePackService
|
||||
$pack = $this->repository->findById((int) $id);
|
||||
if (! $pack) {
|
||||
$failed[] = ['id' => $id, 'reason' => 'not_found'];
|
||||
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -955,7 +961,6 @@ class LanguagePackService
|
||||
*
|
||||
* @param LanguagePack $pack 대상 언어팩
|
||||
* @param bool $cascade 코어 제거 시 하위(module/plugin/template) 동일 locale 팩도 함께 제거
|
||||
* @return void
|
||||
*/
|
||||
public function uninstall(LanguagePack $pack, bool $cascade = false): void
|
||||
{
|
||||
@@ -1000,7 +1005,6 @@ class LanguagePackService
|
||||
* 슬롯 비활성화 후 다음 후보를 active 로 승격합니다.
|
||||
*
|
||||
* @param LanguagePack $pack 방금 비활성화된 언어팩
|
||||
* @return void
|
||||
*/
|
||||
private function promoteSlotSuccessor(LanguagePack $pack): void
|
||||
{
|
||||
@@ -1056,7 +1060,6 @@ class LanguagePackService
|
||||
*
|
||||
* @param string $packageRoot 패키지 루트 디렉토리
|
||||
* @param array<string, mixed> $manifest manifest 데이터
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 보안 위반 발견 시
|
||||
*/
|
||||
@@ -1103,7 +1106,6 @@ class LanguagePackService
|
||||
* 모듈/플러그인/템플릿 install 과 동일 수준의 가드 — 권한 부족 시 chmod 안내 메시지가
|
||||
* 포함된 `RuntimeException` 을 던집니다. 컨트롤러는 본 예외를 422 응답으로 변환합니다.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 디렉토리 미존재/쓰기 불가 시
|
||||
*/
|
||||
@@ -1126,7 +1128,6 @@ class LanguagePackService
|
||||
* 의존성 검증 — depends_on_core_locale 이 true 면 코어 언어팩 active 여부 확인.
|
||||
*
|
||||
* @param array<string, mixed> $manifest manifest 데이터
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 의존성 미충족 시
|
||||
*/
|
||||
@@ -1146,7 +1147,6 @@ class LanguagePackService
|
||||
* 모듈/플러그인 시스템과 동일한 강도로 — 비활성 확장에도 언어팩이 들러붙지 않게 차단합니다.
|
||||
*
|
||||
* @param array<string, mixed> $manifest manifest 데이터
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 대상 확장 미설치/비활성 시
|
||||
*/
|
||||
@@ -1256,7 +1256,6 @@ class LanguagePackService
|
||||
*
|
||||
* @param LanguagePack $existing 기존 언어팩
|
||||
* @param array<string, mixed> $manifest 새 manifest
|
||||
* @return void
|
||||
*
|
||||
* @throws RuntimeException 다운그레이드 시도 시
|
||||
*/
|
||||
@@ -1301,7 +1300,6 @@ class LanguagePackService
|
||||
* _pending 임시 디렉토리를 정리합니다.
|
||||
*
|
||||
* @param string $path 정리 대상 경로
|
||||
* @return void
|
||||
*/
|
||||
private function cleanupPending(string $path): void
|
||||
{
|
||||
@@ -1309,7 +1307,7 @@ class LanguagePackService
|
||||
if (File::isDirectory($path)) {
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('lang-pack pending cleanup failed', ['path' => $path, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
@@ -1421,19 +1419,33 @@ class LanguagePackService
|
||||
$manifest = $pack->manifest;
|
||||
if (is_array($manifest)) {
|
||||
$manifestUrl = $manifest['github_url'] ?? null;
|
||||
if (is_string($manifestUrl) && Str::startsWith($manifestUrl, 'https://github.com/')) {
|
||||
if (is_string($manifestUrl) && self::isGithubUrl($manifestUrl)) {
|
||||
return $manifestUrl;
|
||||
}
|
||||
}
|
||||
|
||||
$sourceUrl = (string) $pack->source_url;
|
||||
if (Str::startsWith($sourceUrl, 'https://github.com/')) {
|
||||
if (self::isGithubUrl($sourceUrl)) {
|
||||
return $sourceUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 이 실제 GitHub host 를 가리키는지 판정합니다.
|
||||
*
|
||||
* 접두사 매칭은 `https://github.com@evil.example/` 같은 userinfo 위장을 통과시키므로
|
||||
* host 를 완전 일치로 검증합니다.
|
||||
*
|
||||
* @param string $url 판정 대상 URL
|
||||
* @return bool GitHub URL 이면 true
|
||||
*/
|
||||
private static function isGithubUrl(string $url): bool
|
||||
{
|
||||
return OutboundUrlValidator::isHostAllowed($url, ['github.com', 'www.github.com']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 번들 manifest 로부터 최신 버전을 조회합니다 (요구사항 #4 폴백 경로).
|
||||
*
|
||||
|
||||
@@ -1043,7 +1043,6 @@ class ModuleService
|
||||
'mjs' => 'application/javascript',
|
||||
'css' => 'text/css',
|
||||
'json' => 'application/json',
|
||||
'map' => 'application/json',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
|
||||
@@ -708,7 +708,6 @@ class PluginService
|
||||
'mjs' => 'application/javascript',
|
||||
'css' => 'text/css',
|
||||
'json' => 'application/json',
|
||||
'map' => 'application/json',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
|
||||
@@ -10,8 +10,9 @@ use App\Enums\ScheduleType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduleHistory;
|
||||
use App\Support\OutboundUrlValidator;
|
||||
use App\Support\ScheduleCommandValidator;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -19,6 +20,7 @@ use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
|
||||
class ScheduleService
|
||||
{
|
||||
@@ -205,10 +207,13 @@ class ScheduleService
|
||||
'output' => $result['output'] ?? null,
|
||||
]);
|
||||
|
||||
// 스케줄 상태 업데이트
|
||||
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
|
||||
$schedule->last_result = ScheduleResultStatus::Success;
|
||||
$schedule->calculateNextRunAt();
|
||||
$schedule->save();
|
||||
$this->scheduleRepository->update($schedule, [
|
||||
'last_result' => ScheduleResultStatus::Success,
|
||||
'next_run_at' => $schedule->next_run_at,
|
||||
]);
|
||||
|
||||
// after_run 훅 실행
|
||||
HookManager::doAction('core.schedule.after_run', $schedule, $history);
|
||||
@@ -224,10 +229,13 @@ class ScheduleService
|
||||
'error_output' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// 스케줄 상태 업데이트
|
||||
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
|
||||
$schedule->last_result = ScheduleResultStatus::Failed;
|
||||
$schedule->calculateNextRunAt();
|
||||
$schedule->save();
|
||||
$this->scheduleRepository->update($schedule, [
|
||||
'last_result' => ScheduleResultStatus::Failed,
|
||||
'next_run_at' => $schedule->next_run_at,
|
||||
]);
|
||||
|
||||
Log::error('Schedule execution failed', [
|
||||
'schedule_id' => $schedule->id,
|
||||
@@ -245,12 +253,18 @@ class ScheduleService
|
||||
*
|
||||
* @param Schedule $schedule 스케줄
|
||||
* @return array 실행 결과
|
||||
*
|
||||
* @throws Exception 차단목록에 걸린 명령일 때
|
||||
*/
|
||||
private function executeArtisanCommand(Schedule $schedule): array
|
||||
{
|
||||
$output = '';
|
||||
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
||||
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
||||
if (! ScheduleCommandValidator::isArtisanCommandAllowed($schedule->command)) {
|
||||
throw new Exception(__('schedule.artisan_not_allowed'));
|
||||
}
|
||||
|
||||
Artisan::call($schedule->command, [], new \Symfony\Component\Console\Output\BufferedOutput);
|
||||
Artisan::call($schedule->command, [], new BufferedOutput);
|
||||
|
||||
return [
|
||||
'output' => Artisan::output(),
|
||||
@@ -268,9 +282,19 @@ class ScheduleService
|
||||
*/
|
||||
private function executeShellCommand(Schedule $schedule): array
|
||||
{
|
||||
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
||||
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
||||
$arguments = ScheduleCommandValidator::tokenizeShellCommand($schedule->command);
|
||||
|
||||
if ($arguments === null || ! ScheduleCommandValidator::isShellCommandAllowed($schedule->command)) {
|
||||
throw new Exception(__('schedule.shell_not_allowed'));
|
||||
}
|
||||
|
||||
$timeout = $schedule->timeout ?? 60;
|
||||
|
||||
$result = Process::timeout($timeout)->run($schedule->command);
|
||||
// 문자열이 아닌 인자 배열로 넘겨 `/bin/sh -c` 를 경유하지 않는다 —
|
||||
// 파이프·`;`·`$()` 같은 셸 메타문자가 명령으로 해석될 여지를 없앤다.
|
||||
$result = Process::timeout($timeout)->run($arguments);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw new Exception($result->errorOutput() ?: __('schedule.shell_command_failed'), $result->exitCode());
|
||||
@@ -292,6 +316,12 @@ class ScheduleService
|
||||
*/
|
||||
private function executeUrlCall(Schedule $schedule): array
|
||||
{
|
||||
// 저장된 URL 이 그대로 서버의 outbound 목적지가 되므로, 실행 직전에도 내부망 주소를 차단한다
|
||||
// (저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값 방어).
|
||||
if (! $this->isUrlCallAllowed($schedule->command)) {
|
||||
throw new Exception(__('schedule.url_not_public'));
|
||||
}
|
||||
|
||||
$timeout = $schedule->timeout ?? 30;
|
||||
|
||||
$response = Http::timeout($timeout)->get($schedule->command);
|
||||
@@ -306,6 +336,27 @@ class ScheduleService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄의 URL 호출이 허용되는 목적지인지 판정합니다.
|
||||
*
|
||||
* 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단하되, 사내 엔드포인트를 주기 호출하는
|
||||
* 정당한 운영을 위해 `security.allow_internal_outbound_urls` 로 허용할 수 있습니다.
|
||||
* 허용하더라도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다.
|
||||
*
|
||||
* @param string $url 스케줄에 저장된 호출 URL
|
||||
* @return bool 호출을 허용하면 true
|
||||
*/
|
||||
private function isUrlCallAllowed(string $url): bool
|
||||
{
|
||||
$options = ['schemes' => ['http', 'https']];
|
||||
|
||||
if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
|
||||
return OutboundUrlValidator::isStructurallySafeUrl($url, $options);
|
||||
}
|
||||
|
||||
return OutboundUrlValidator::isPublicHttpUrl($url, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄을 복제합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ApiDoc;
|
||||
|
||||
use App\Contracts\ApiDoc\ApiDocSampleSeeder;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\LanguagePack;
|
||||
use App\Models\Menu;
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationLog;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* API 문서 실측용 완전 샘플 데이터 서비스
|
||||
*
|
||||
* 각 API 도메인의 대표 엔티티에 모든 필드가 채워진 완전한 샘플 레코드를
|
||||
* 멱등하게 생성하고, docgen 이 상세 GET 실측에 사용할 도메인별 대표 route key
|
||||
* 맵을 제공합니다. 개발 환경 전용 — 생성된 샘플은 개발 DB 에 남습니다.
|
||||
*/
|
||||
class ApiDocSampleService implements ApiDocSampleSeeder
|
||||
{
|
||||
/**
|
||||
* @var string 샘플 레코드 식별용 마커 (이메일/식별자 prefix)
|
||||
*/
|
||||
private const MARKER = 'apidoc-sample';
|
||||
|
||||
/**
|
||||
* 도메인별 완전 샘플을 멱등 생성하고 대표 route key 맵을 반환합니다.
|
||||
*
|
||||
* @return array<string, array{model: class-string, key: string, value: string}> 도메인 => 대표 레코드 정보
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ApiDoc;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
/**
|
||||
* API 엔드포인트 실측 프로브
|
||||
*
|
||||
* 임시 Sanctum 토큰을 발급해 실제 HTTP 요청으로 엔드포인트를 호출하고,
|
||||
* 실제 응답 JSON 에서 필드 스키마(키·타입·샘플값)를 관측합니다.
|
||||
* 쓰기 메서드는 기본적으로 실호출하지 않으며 GET/HEAD 만 read-only 로 실측합니다.
|
||||
*/
|
||||
class ApiEndpointProbe
|
||||
{
|
||||
/**
|
||||
* @var string 실측 대상 기준 URL
|
||||
*/
|
||||
private string $baseUrl;
|
||||
|
||||
/**
|
||||
* @var string|null 발급된 임시 토큰 평문
|
||||
*/
|
||||
private ?string $token = null;
|
||||
|
||||
/**
|
||||
* @var string 임시 토큰 식별용 이름
|
||||
*/
|
||||
private string $tokenName = 'api-docgen-probe';
|
||||
|
||||
/**
|
||||
* @var User|null 실측 인증 사용자 (in-process 쓰기 실측 시 로그인 대상)
|
||||
*/
|
||||
private ?User $user = null;
|
||||
|
||||
/**
|
||||
* @param string|null $baseUrl 기준 URL (null 이면 .env 의 APP_URL 직접 사용)
|
||||
*/
|
||||
public function __construct(?string $baseUrl = null)
|
||||
{
|
||||
// config('app.url') 은 테스트 환경에서 override 될 수 있으므로(test.example.com 등),
|
||||
// 실측은 .env 의 APP_URL 을 우선 신뢰한다. 명시 인자가 있으면 그것을 최우선한다.
|
||||
$resolved = $baseUrl
|
||||
?: (string) env('APP_URL')
|
||||
?: (string) config('app.url');
|
||||
|
||||
$this->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<string, mixed> $body 쓰기 요청 바디 (쓰기 메서드에서만 사용)
|
||||
* @return array{ok: bool, status: int|null, body: array<string, mixed>|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<string, mixed>|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<string, mixed> $body 요청 바디
|
||||
* @return array{ok: bool, status: int|null, body: array<string, mixed>|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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ApiDoc;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* API 라우트 인벤토리
|
||||
*
|
||||
* 등록된 라우트를 전수 수집하여 API 문서 생성에 필요한 메타데이터
|
||||
* (메서드·URI·라우트명·소유 확장·권한·컨트롤러 액션)로 정규화합니다.
|
||||
*/
|
||||
class ApiRouteInventory
|
||||
{
|
||||
/**
|
||||
* API 라우트를 전수 수집하여 정규화된 배열로 반환합니다.
|
||||
*
|
||||
* @param string|null $scope 범위 필터 (null=전체, 'core', 'module:{id}', 'plugin:{id}')
|
||||
* @return array<int, array<string, mixed>> 정규화된 라우트 메타데이터 목록
|
||||
*/
|
||||
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<int, array<string, mixed>> 정규화된 엔트리 목록
|
||||
*/
|
||||
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<int, string> 미들웨어 목록
|
||||
*/
|
||||
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<int, array<string, mixed>> 정규화된 라우트 메타데이터 목록
|
||||
*/
|
||||
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<int, string> 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<int, string> $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<int, string> path 파라미터명 목록
|
||||
*/
|
||||
private function pathParams(string $uri): array
|
||||
{
|
||||
preg_match_all('/\{([^}?]+)\??\}/', $uri, $m);
|
||||
|
||||
return $m[1] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* path 파라미터명 → 바인딩된 Eloquent 모델 클래스 맵을 추출합니다.
|
||||
*
|
||||
* @param Route $route 라우트 인스턴스
|
||||
* @return array<string, string> 파라미터명 => 모델 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ApiDoc;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 컬럼 주석 해석기
|
||||
*
|
||||
* 모델 테이블의 컬럼 주석(한국어 comment) 을 조회해 응답 필드 설명의 기본값으로
|
||||
* 제공합니다. G7 은 마이그레이션 한국어 comment 를 필수화하므로, 이 주석이 필드
|
||||
* 설명의 SSoT 가 됩니다. 정적 스키마 조회이므로 실측 없이도 동작합니다.
|
||||
*/
|
||||
class ColumnCommentResolver
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string, string>> 테이블별 컬럼 주석 캐시
|
||||
*/
|
||||
private array $cache = [];
|
||||
|
||||
/**
|
||||
* 모델 테이블의 컬럼명 => 주석 맵을 반환합니다.
|
||||
*
|
||||
* @param class-string|null $modelClass 모델 FQCN
|
||||
* @return array<string, string> 컬럼명 => 주석
|
||||
*/
|
||||
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<string, string> 컬럼명 => 주석
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ApiDoc;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use ReflectionNamedType;
|
||||
|
||||
/**
|
||||
* FormRequest 정적 분석기
|
||||
*
|
||||
* 컨트롤러 메서드 시그니처에서 FormRequest 를 찾아 rules() 를 리플렉션으로
|
||||
* 읽고, 요청 파라미터의 타입·필수 여부·허용값을 문서용 메타데이터로 변환합니다.
|
||||
*/
|
||||
class FormRequestIntrospector
|
||||
{
|
||||
/**
|
||||
* 컨트롤러 메서드의 첫 FormRequest 파라미터에서 rules 메타데이터를 추출합니다.
|
||||
*
|
||||
* @param string|null $controller 컨트롤러 FQCN
|
||||
* @param string|null $method 메서드명
|
||||
* @return array{request_class: string|null, params: array<int, array<string, mixed>>, hook_filters: array<int, string>}
|
||||
*/
|
||||
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<FormRequest>|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<FormRequest> $requestClass FormRequest FQCN
|
||||
* @return array<string, mixed> 검증 규칙 배열
|
||||
*/
|
||||
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<string, mixed> $rules 검증 규칙 배열
|
||||
* @return array<int, array<string, mixed>> 파라미터 메타데이터 목록
|
||||
*/
|
||||
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<int, string> $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<int, string> $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<FormRequest> $requestClass FormRequest FQCN
|
||||
* @return array<int, string> 훅 필터 이름 목록
|
||||
*/
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ApiDoc;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 요청 파라미터 설명기
|
||||
*
|
||||
* G7 전역에서 의미가 표준화된 공통 요청 파라미터(페이지네이션·정렬·검색·필터·
|
||||
* 소프트삭제 토글 등)와, 일관된 명명 규칙(*_id / *Id / is_* / *_date / sort_* 등)의
|
||||
* 설명을 코드에서 확인된 계약 그대로 서술합니다.
|
||||
*
|
||||
* ResourceFieldDescriber(응답 필드)의 요청 파라미터 대응물입니다. 도메인 특이
|
||||
* 파라미터(예: refund_priority, temp_key)는 여기서 커버하지 않고 사람 서술(TODO)로
|
||||
* 남깁니다 — 자동 채움은 "도메인 무관하게 의미가 고정된 파라미터"에만 한정합니다.
|
||||
*/
|
||||
class ParameterDescriber
|
||||
{
|
||||
/**
|
||||
* @var array<string, string> 정확 이름 => 설명 (위치 무관 공통 파라미터)
|
||||
*/
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ApiDoc;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* 리소스 계약 필드 설명기
|
||||
*
|
||||
* 컬럼 주석이 없는 accessor/computed 필드(Resource 의 toArray() 가 파생하는 값)의
|
||||
* 설명을 제공합니다. G7 전역에서 의미가 표준화된 필드(BaseApiResource 의 is_owner /
|
||||
* abilities, Enum 파생 status_label / status_variant, User::isAdmin() 등)와, 일관된
|
||||
* 파생 규칙(_label / _variant / _flag / _name / is_* / *_at 접미·접두)을 코드에서
|
||||
* 확인된 의미 그대로 서술합니다.
|
||||
*
|
||||
* 우선순위: 이 계약 사전은 컬럼 주석보다 앞섭니다 — created_at 은 어느 테이블이든
|
||||
* "생성 일시" 이고, status_label 은 컬럼이 아니라 Enum label() 산물이기 때문입니다.
|
||||
*/
|
||||
class ResourceFieldDescriber
|
||||
{
|
||||
/**
|
||||
* @var array<string, string> 정확 필드명 => 설명 (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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\ApiDoc;
|
||||
|
||||
/**
|
||||
* 응답 스키마 추론기
|
||||
*
|
||||
* 실측한 응답 JSON(ResponseHelper envelope)에서 data 내부의 필드 스키마를
|
||||
* 추론합니다. 각 필드의 타입과 샘플값을 문서용 메타데이터로 변환합니다.
|
||||
*/
|
||||
class ResponseSchemaInferrer
|
||||
{
|
||||
/**
|
||||
* envelope 응답 body 에서 data 필드 스키마를 추론합니다.
|
||||
*
|
||||
* @param array<string, mixed> $body 실측 응답 body (envelope)
|
||||
* @return array{envelope: array<int, string>, shape: string, fields: array<int, array<string, mixed>>, 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<string, mixed> $row 응답 데이터 한 행
|
||||
* @return array<int, array<string, mixed>> 필드 메타데이터 목록
|
||||
*/
|
||||
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<int, array<string, mixed>> $rows 응답 데이터 행 목록
|
||||
* @return array<int, array<string, mixed>> 필드 메타데이터 목록
|
||||
*/
|
||||
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<mixed> $arr 배열
|
||||
* @return bool 연관 배열 여부
|
||||
*/
|
||||
private function isAssoc(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_keys($arr) !== range(0, count($arr) - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 부팅 시점 "DB 스키마 준비성" 판정에 쓰이는 인스톨러 컨텍스트 헬퍼.
|
||||
*
|
||||
* `config('app.installer_completed')` fast-path 는 프로덕션에서 `Schema::hasTable()`
|
||||
* 호출을 건너뛰어 매 요청 오버헤드를 제거한다. 그러나 `INSTALLER_COMPLETED=true` 가
|
||||
* 기록된 `.env` 를 빈 DB 새 서버에 복사한 뒤 `php artisan migrate` 로 테이블을
|
||||
* 만들기 전에 앱이 부팅되면, fast-path 가 테이블 존재를 잘못 전제하여 뒤따르는
|
||||
* 쿼리가 "table not found" 로 부팅을 깨뜨린다.
|
||||
*
|
||||
* 이 헬퍼는 스키마를 파괴/재생성하는 마이그레이션 계열 콘솔 명령 판정을 단일
|
||||
* SSoT 로 캡슐화하여, 해당 컨텍스트에서 fast-path 를 무력화하는 가드에 쓰인다.
|
||||
*/
|
||||
class InstallerContext
|
||||
{
|
||||
/**
|
||||
* 스키마를 파괴/재생성하는 콘솔 명령(마이그레이션 계열)이 실행 중인지 판정합니다.
|
||||
*
|
||||
* 이 컨텍스트에서는 `installer_completed=true` 라도 테이블이 아직 없을 수 있으므로
|
||||
* fast-path(hasTable 스킵)를 신뢰해서는 안 됩니다. 웹 요청 등 콘솔이 아닌
|
||||
* 실행 경로에서는 항상 false 를 반환합니다.
|
||||
*
|
||||
* @return bool 마이그레이션 계열 명령 실행 중 여부
|
||||
*/
|
||||
public static function isSchemaMutatingCommand(): bool
|
||||
{
|
||||
if (! app()->runningInConsole()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(
|
||||
$_SERVER['argv'][1] ?? null,
|
||||
['migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'],
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 서버가 외부로 나가는(outbound) HTTP 요청의 목적지 URL 을 검증하는 순수 유틸.
|
||||
*
|
||||
* 사용자·관리자·외부 콜백이 제어할 수 있는 값이 그대로 목적지가 되면 서버는
|
||||
* 내부망(사설 IP, 루프백, 클라우드 메타데이터 169.254.169.254)으로 요청을 대신
|
||||
* 보내는 도구가 된다(SSRF). 본 유틸은 두 계층의 판정을 제공한다:
|
||||
*
|
||||
* 1. `isHostAllowed()` — 신뢰 도메인 목록이 정해진 경우(결제/본인인증 게이트웨이 등).
|
||||
* host 를 **완전 일치**로 검증한다. 접두사 매칭(`str_starts_with`)은 금지 —
|
||||
* `https://trusted.example@127.0.0.1/`(userinfo)와
|
||||
* `https://trusted.example.attacker.com/`(접미사 확장)이 모두 통과하기 때문이다.
|
||||
*
|
||||
* 2. `isPublicHttpUrl()` — 신뢰 목록이 없고 "임의의 공개 URL 은 허용하되 내부망
|
||||
* 타격만 막는" 경우(외부 API 연동, 원격 다운로드 등).
|
||||
*
|
||||
* 두 메서드 모두 예외를 던지지 않고 bool 만 반환한다. 차단 시 어떤 예외/응답을
|
||||
* 낼지는 각 호출 지점이 자신의 반환 계약에 맞춰 결정한다.
|
||||
*
|
||||
* 방어 범위: **host 레벨**이다. DNS rebinding(검증 시점과 실제 연결 시점 사이에
|
||||
* 같은 호스트명이 내부 IP 로 재해석되는 공격)은 막지 못한다. 이를 막으려면 요청
|
||||
* 직전에 실제 해석된 IP 를 재검증해야 하며, 이는 본 유틸의 범위 밖이다.
|
||||
*/
|
||||
class OutboundUrlValidator
|
||||
{
|
||||
/** 공개 인터넷 호스트로 취급하지 않는 내부 도메인 접미사 */
|
||||
private const INTERNAL_HOST_SUFFIXES = [
|
||||
'.local',
|
||||
'.localhost',
|
||||
'.internal',
|
||||
'.intranet',
|
||||
'.lan',
|
||||
'.home.arpa',
|
||||
];
|
||||
|
||||
/** 그 자체로 내부를 가리키는 호스트명 */
|
||||
private const INTERNAL_HOST_NAMES = [
|
||||
'localhost',
|
||||
'ip6-localhost',
|
||||
'ip6-loopback',
|
||||
];
|
||||
|
||||
/**
|
||||
* URL 의 host 가 화이트리스트와 완전 일치하는지 검증한다.
|
||||
*
|
||||
* scheme 허용 목록·userinfo 부재·포트 표기까지 함께 강제하므로, 호출부는
|
||||
* 이 메서드 하나만 통과시키면 목적지 위조를 막을 수 있다.
|
||||
*
|
||||
* @param string $url 검증 대상 URL (외부 입력)
|
||||
* @param array<int, string> $allowedHosts 허용 host 목록 (예: ['kssa.inicis.com'])
|
||||
* @param array{schemes?: array<int, string>, allowPort?: bool} $options
|
||||
* schemes: 허용 scheme (기본 ['https'])
|
||||
* allowPort: 명시 포트 허용 여부 (기본 false)
|
||||
* @return bool 화이트리스트 host 와 완전 일치하면 true
|
||||
*/
|
||||
public static function isHostAllowed(string $url, array $allowedHosts, array $options = []): bool
|
||||
{
|
||||
$host = self::extractSafeHost($url, $options);
|
||||
|
||||
if ($host === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($allowedHosts as $allowed) {
|
||||
if ($host === strtolower(trim($allowed))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 의 host 가 공개 인터넷 주소인지(= 내부망이 아닌지) 검증한다.
|
||||
*
|
||||
* 사설 IP(10/172.16/192.168), 루프백(127.0.0.1, ::1), 링크로컬 및 클라우드
|
||||
* 메타데이터(169.254.169.254), `localhost`·`*.local` 등 내부 도메인을 차단한다.
|
||||
*
|
||||
* @param string $url 검증 대상 URL (외부 입력)
|
||||
* @param array{schemes?: array<int, string>, allowPort?: bool} $options
|
||||
* schemes: 허용 scheme (기본 ['https'])
|
||||
* allowPort: 명시 포트 허용 여부 (기본 true — 외부 API 는 비표준 포트를 쓸 수 있다)
|
||||
* @return bool 공개 인터넷 host 이면 true
|
||||
*/
|
||||
public static function isPublicHttpUrl(string $url, array $options = []): bool
|
||||
{
|
||||
$host = self::extractSafeHost($url, $options + ['allowPort' => true]);
|
||||
|
||||
if ($host === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::isPublicHost($host);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 이 구조적으로 안전한지만 판정한다 (내부망 여부는 보지 않는다).
|
||||
*
|
||||
* 내부 주소 호출을 의도적으로 허용한 환경에서도 userinfo(`@`) 위장·미허용 scheme·
|
||||
* 제어문자 주입은 여전히 막아야 하므로, 그 최소 방어선을 제공한다.
|
||||
*
|
||||
* @param string $url 검증 대상 URL
|
||||
* @param array{schemes?: array<int, string>, allowPort?: bool} $options 검증 옵션
|
||||
* @return bool 구조적으로 안전하면 true
|
||||
*/
|
||||
public static function isStructurallySafeUrl(string $url, array $options = []): bool
|
||||
{
|
||||
return self::extractSafeHost($url, $options + ['allowPort' => true]) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* host 문자열(URL 이 아닌 host 단독)이 공개 인터넷 주소인지 판정한다.
|
||||
*
|
||||
* @param string $host host 문자열 (예: 'example.com', '127.0.0.1', '[::1]')
|
||||
* @return bool 공개 인터넷 host 이면 true
|
||||
*/
|
||||
public static function isPublicHost(string $host): bool
|
||||
{
|
||||
$host = strtolower(trim($host));
|
||||
|
||||
// parse_url 이 IPv6 를 대괄호째 반환하므로 벗겨낸다
|
||||
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
|
||||
$host = substr($host, 1, -1);
|
||||
}
|
||||
|
||||
if ($host === '' || in_array($host, self::INTERNAL_HOST_NAMES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IP 리터럴 — 사설/예약 대역 차단 (루프백·링크로컬·메타데이터 포함)
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
||||
return filter_var(
|
||||
$host,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
||||
) !== false;
|
||||
}
|
||||
|
||||
// 10진수·8진수·16진수로 인코딩된 IP 우회(예: http://2130706433/ = 127.0.0.1) 차단.
|
||||
// 공개 도메인은 최상위 라벨이 숫자로만 이루어질 수 없다.
|
||||
$lastLabel = substr(strrchr('.'.$host, '.') ?: '', 1);
|
||||
if ($lastLabel === '' || ctype_digit($lastLabel) || str_starts_with($host, '0x')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::INTERNAL_HOST_SUFFIXES as $suffix) {
|
||||
if (str_ends_with($host, $suffix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 을 파싱해 구조적으로 안전한 host 를 소문자로 반환한다.
|
||||
*
|
||||
* scheme 미허용, userinfo(`@`) 포함, 미허용 포트, host 부재이면 null 을 반환해
|
||||
* 상위 판정을 즉시 실패시킨다.
|
||||
*
|
||||
* @param string $url 검증 대상 URL
|
||||
* @param array{schemes?: array<int, string>, allowPort?: bool} $options 검증 옵션
|
||||
* @return string|null 소문자 host, 구조적으로 안전하지 않으면 null
|
||||
*/
|
||||
private static function extractSafeHost(string $url, array $options): ?string
|
||||
{
|
||||
$schemes = $options['schemes'] ?? ['https'];
|
||||
$allowPort = $options['allowPort'] ?? false;
|
||||
|
||||
$url = trim($url);
|
||||
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 개행·제어문자가 섞인 URL 은 헤더/요청 분리 시도로 간주하고 즉시 거부
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $url) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
|
||||
if ($parts === false || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// userinfo(`user:pass@host`) 는 host 위조의 핵심 벡터 — 존재만으로 거부
|
||||
if (isset($parts['user']) || isset($parts['pass'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = strtolower($parts['scheme']);
|
||||
$normalizedSchemes = array_map(
|
||||
static fn (string $s): string => strtolower(trim($s)),
|
||||
$schemes,
|
||||
);
|
||||
|
||||
if (! in_array($scheme, $normalizedSchemes, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $allowPort && isset($parts['port'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = strtolower(trim($parts['host']));
|
||||
|
||||
return $host === '' ? null : $host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* 스케줄 Shell/Artisan command 가 실행 가능한 안전 명령인지 판정하는 순수 유틸.
|
||||
*
|
||||
* 스케줄 command 는 저장된 문자열이 그대로 서버에서 실행되는 값이므로, 스케줄 생성 권한을
|
||||
* 위임받은 계정이 임의 OS 명령·임의 PHP 코드를 실행하는 통로가 될 수 있다(권한 상승형 RCE).
|
||||
* 본 유틸은 두 축의 판정을 제공한다:
|
||||
*
|
||||
* 1. Shell — 기본 차단. `config/schedule_security.php` 의 opt-in 게이트가 켜져 있고,
|
||||
* 셸 메타문자가 없으며, 첫 토큰의 basename 이 허용 실행 파일 목록과 완전 일치할 때만 허용.
|
||||
* 허용되더라도 호출부는 `tokenizeShellCommand()` 로 얻은 인자 배열로 실행해야 한다
|
||||
* (`/bin/sh -c` 를 경유하지 않으므로 파이프·`;`·`$()` 가 무력화된다).
|
||||
*
|
||||
* 2. Artisan — 기본 허용, 차단목록만 거부. `tinker` 처럼 임의 PHP 코드를 실행하거나
|
||||
* `db:wipe` 처럼 파괴적인 명령을 스케줄로 실행하지 못하게 막는다.
|
||||
*
|
||||
* 예외를 던지지 않고 bool/배열만 반환한다 — 차단 시 어떤 응답/예외를 낼지는 호출부가 결정한다.
|
||||
*/
|
||||
class ScheduleCommandValidator
|
||||
{
|
||||
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
|
||||
private const SHELL_METACHARACTERS = [
|
||||
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
|
||||
'*', '?', '!', '\\', '"', "'", "\n", "\r", "\t",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shell 타입 command 를 실행해도 되는지 판정합니다.
|
||||
*
|
||||
* 게이트가 켜져 있고, 화이트리스트가 비어있지 않으며, 메타문자가 없고,
|
||||
* 첫 토큰의 basename 이 화이트리스트와 완전 일치할 때만 true.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 shell command 문자열
|
||||
* @return bool 실행을 허용하면 true
|
||||
*/
|
||||
public static function isShellCommandAllowed(string $command): bool
|
||||
{
|
||||
if (! (bool) config('schedule_security.shell.enabled', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = array_values(array_filter(array_map(
|
||||
static fn ($binary): string => trim((string) $binary),
|
||||
(array) config('schedule_security.shell.allowed_binaries', []),
|
||||
)));
|
||||
|
||||
if ($allowed === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = self::tokenize($command);
|
||||
|
||||
if ($tokens === null || $tokens === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(basename($tokens[0]), $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell command 를 `Process::run(array)` 에 넘길 안전한 인자 배열로 변환합니다.
|
||||
*
|
||||
* 메타문자가 섞여 있으면 셸 미경유 실행이 불가하므로 null 을 반환한다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 shell command 문자열
|
||||
* @return array<int, string>|null 인자 배열, 안전하게 토큰화할 수 없으면 null
|
||||
*/
|
||||
public static function tokenizeShellCommand(string $command): ?array
|
||||
{
|
||||
return self::tokenize($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 문자열에서 명령명(첫 토큰)을 추출합니다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return string|null 명령명, 빈 문자열이면 null
|
||||
*/
|
||||
public static function extractArtisanCommandName(string $command): ?string
|
||||
{
|
||||
$trimmed = trim($command);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $trimmed);
|
||||
|
||||
return ($tokens === false || $tokens === []) ? null : $tokens[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Artisan command 가 차단목록에 걸리지 않는지 판정합니다.
|
||||
*
|
||||
* @param string $command 스케줄에 저장된 artisan command 문자열
|
||||
* @return bool 실행을 허용하면 true
|
||||
*/
|
||||
public static function isArtisanCommandAllowed(string $command): bool
|
||||
{
|
||||
$name = self::extractArtisanCommandName($command);
|
||||
|
||||
if ($name === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$denylist = array_map(
|
||||
static fn ($denied): string => strtolower(trim((string) $denied)),
|
||||
(array) config('schedule_security.artisan.denylist', []),
|
||||
);
|
||||
|
||||
return ! in_array(strtolower($name), $denylist, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타문자 없는 공백 구분 토큰화. 메타문자가 있으면 null 을 반환합니다.
|
||||
*
|
||||
* 따옴표 escape 처리 버그를 원천 회피하기 위해, 따옴표를 포함한 복잡한 명령은
|
||||
* 지원하지 않고 거부하는 보수적 구현이다(복합 명령은 래퍼 스크립트로 유도).
|
||||
*
|
||||
* @param string $command command 문자열
|
||||
* @return array<int, string>|null 토큰 배열, 안전하지 않으면 null
|
||||
*/
|
||||
private static function tokenize(string $command): ?array
|
||||
{
|
||||
$command = trim($command);
|
||||
|
||||
if ($command === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 제어문자(개행 포함)가 섞인 명령은 즉시 거부
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $command) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (self::SHELL_METACHARACTERS as $meta) {
|
||||
if (str_contains($command, $meta)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $command);
|
||||
|
||||
return ($tokens === false || $tokens === []) ? null : $tokens;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.2'),
|
||||
'version' => env('APP_VERSION', '7.0.4'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
// 스케줄러 Shell/Artisan 실행 보안 정책.
|
||||
// 관리자 환경설정에 노출하지 않는 코드 소유 정책 — command 실행 능력은 신뢰 경계 안에서만 넓힌다.
|
||||
// (스케줄 권한만 위임받은 관리자가 화이트리스트를 넓혀 임의 명령 실행을 복구하는 것을 막는다.)
|
||||
return [
|
||||
'shell' => [
|
||||
// Shell 타입 스케줄 실행 자체를 켜는 opt-in 게이트 (기본 차단).
|
||||
'enabled' => env('SCHEDULE_SHELL_ENABLED', false),
|
||||
|
||||
// 허용 실행 파일 basename 목록. 빈 배열이면 모든 shell 명령을 거부한다.
|
||||
'allowed_binaries' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('SCHEDULE_SHELL_ALLOWED_BINARIES', '')),
|
||||
))),
|
||||
],
|
||||
|
||||
'artisan' => [
|
||||
// 파괴적·코드 실행형 artisan 명령 차단목록 (첫 토큰 기준).
|
||||
'denylist' => [
|
||||
'tinker',
|
||||
'db:wipe',
|
||||
'migrate:fresh',
|
||||
'migrate:reset',
|
||||
'migrate:rollback',
|
||||
'schedule:run',
|
||||
'schedule:work',
|
||||
'env:decrypt',
|
||||
'key:generate',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -24,7 +24,8 @@
|
||||
"login_lockout_time": 5,
|
||||
"two_factor_auth": false,
|
||||
"password_min_length": 8,
|
||||
"require_password_special_char": false
|
||||
"require_password_special_char": false,
|
||||
"allow_internal_outbound_urls": false
|
||||
},
|
||||
"mail": {
|
||||
"mailer": "smtp",
|
||||
|
||||
@@ -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<User>
|
||||
*/
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -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 @@
|
||||
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
|
||||
## 카테고리별 전체 문서 목록
|
||||
|
||||
### 백엔드 (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 (실시간 이벤트) |
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
|
||||
---
|
||||
|
||||
## API 레퍼런스
|
||||
|
||||
엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시는 [api/README.md](api/README.md) 에 있습니다.
|
||||
공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428 에러)도 그 문서 상단에 정리되어 있습니다.
|
||||
확장(모듈·플러그인)이 소유한 API 문서 목록도 같은 문서에서 찾을 수 있습니다.
|
||||
|
||||
문서 작성·갱신 규정은 [api-documentation.md](api-documentation.md) 를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
<!-- AUTO-GENERATED-START: backend-readme-docs -->
|
||||
## 문서 목록
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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. 추출 불가분(훅 주입 파라미터·동적 응답)은 <!-- TODO --> 마커 남기고 사람이 채움
|
||||
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 레퍼런스"
|
||||
표와 AGENTS.md·docs-index 에 자동 편입한다(동적 로딩 원칙 — 코어는 규약과 스캔 패턴만, 확장
|
||||
이름은 파일 시스템에서 발견). 문서 수·엔드포인트 수는 각 README 의 집계 라인
|
||||
(`**문서 수**: N · **엔드포인트 수**: M`)에서 읽으므로, 이 라인 형식을 바꾸면 두 스캐너를 함께 갱신한다.
|
||||
|
||||
처음 진입하는 개발자/AI 의 도달 경로:
|
||||
|
||||
```text
|
||||
README.md "API 레퍼런스" 또는 AGENTS.md "API 레퍼런스 진입점" 표
|
||||
→ docs/backend/api/README.md (공통 규약 + 코어 목차 + 확장 목차)
|
||||
→ {도메인}.md 또는 {확장}/docs/api/README.md
|
||||
→ 엔드포인트별 파라미터·응답·예시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 표준 문서 포맷
|
||||
|
||||
엔드포인트 1개당 아래 6개 구성(헤더 · 요청 파라미터 · 요청 예시 · 응답 필드 · 응답 예시 · 에러 응답)을
|
||||
따른다. `<!-- @generated:start -->` ~ `<!-- @generated:end -->` 사이는 `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
|
||||
<!-- @generated:start:api.admin.users.index -->
|
||||
- **라우트명**: `api.admin.users.index`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\UserController@index`
|
||||
- **인증/권한**: `auth:sanctum` + `admin` + `permission:admin,core.users.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
|------|------|------|------|--------|------|
|
||||
| keyword | query | string | 아니오 | — | <!-- TODO: 용도 --> |
|
||||
| status | query | string | 아니오 | `active`, `dormant`, `withdrawn` | <!-- TODO: 용도 --> |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있음 (`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` | <!-- TODO: 설명 --> |
|
||||
| uuid | string | `a231747f-...` | <!-- TODO: 설명 --> |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```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` 에 필드별 메시지) |
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** <!-- 사람이 작성: 이 엔드포인트의 용도, 주의사항, 예시 시나리오 -->
|
||||
```
|
||||
|
||||
### 응답 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-*`)
|
||||
|
||||
파라미터/응답 필드 표의 `<!-- TODO: 설명 -->` 셀 중 **도메인 무관 공통 필드**(페이지네이션·정렬·검색·
|
||||
식별자·기간·토글·공통 파생 필드 등)는 전체 재생성 없이 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 → 파라미터 허용값 유추 근거
|
||||
@@ -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 레퍼런스
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 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 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
## 확장 API 레퍼런스
|
||||
|
||||
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
|
||||
|
||||
<!-- @generated:start:api-readme-extensions -->
|
||||
- **확장 수**: 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 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
@@ -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
|
||||
<!-- @generated:start:api.admin.activity-logs.index -->
|
||||
- **라우트명**: `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` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 시스템 활동 로그를 페이지네이션 목록으로 조회합니다. `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
|
||||
<!-- @generated:start:api.admin.activity-logs.bulk-destroy -->
|
||||
- **라우트명**: `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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.activities.delete`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 지정한 활동 로그들을 일괄 삭제합니다. `ids` 배열에 삭제할 로그 ID를 담아 요청하며, 서비스가 각 항목을 삭제하고 실제 삭제된 건수(`deleted_count`)를 반환합니다. `core.activities.delete` 권한이 필요합니다. 로그 목록에서 여러 항목을 선택해 한 번에 정리하는 시나리오에 사용합니다.
|
||||
|
||||
|
||||
### DELETE /api/admin/activity-logs/{activityLog}
|
||||
<!-- @generated:start:api.admin.activity-logs.destroy -->
|
||||
- **라우트명**: `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 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 단일 활동 로그를 삭제합니다. 경로의 `{activityLog}`는 라우트 모델 바인딩으로 로그 ID를 받아 해당 레코드를 삭제합니다. `core.activities.delete` 권한이 필요하며, 삭제 실패 시 오류가 로그로 기록되고 500이 반환됩니다.
|
||||
|
||||
|
||||
@@ -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}
|
||||
<!-- @generated:start:api.attachment.download -->
|
||||
- **라우트명**: `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` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 해시(12자)로 식별되는 첨부파일을 다운로드합니다. 이미지 파일은 캐싱 헤더와 함께 인라인으로 표시하고 그 외 파일은 다운로드 방식으로 제공합니다. 인증이 필요 없는 공개 라우트이지만 접근 권한은 AttachmentService가 로그인/비로그인 사용자 모두를 대상으로 하이브리드 방식으로 검사하며, 파일이 없으면 404, 권한이 없으면 403을 반환합니다. 게시글 첨부·상품 이미지 등 공개 리소스를 URL로 직접 내려받는 시나리오에 사용합니다.
|
||||
|
||||
|
||||
@@ -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
|
||||
<!-- @generated:start:api.admin.attachments.upload -->
|
||||
- **라우트명**: `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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 단일 파일을 업로드해 첨부파일(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
|
||||
<!-- @generated:start:api.admin.attachments.upload_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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.attachments.create`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 여러 파일을 한 번의 요청으로 일괄 업로드합니다. `files` 배열의 각 파일이 개별 첨부파일 레코드로 등록되며, `attachmentable_type`/`attachmentable_id`/`collection` 등의 옵션은 배치 전체에 공통 적용됩니다. `core.attachments.create` 권한이 필요하고, 성공 시 201과 함께 생성된 첨부파일 리소스 컬렉션을 반환합니다. 갤러리·다중 이미지 첨부처럼 한 대상에 여러 파일을 붙이는 시나리오에 사용합니다.
|
||||
|
||||
|
||||
### PATCH /api/admin/attachments/reorder
|
||||
<!-- @generated:start: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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.attachments.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 첨부파일의 표시 순서를 재정렬합니다. `order` 배열에 담긴 순서대로 각 첨부파일의 정렬 값이 갱신됩니다. `core.attachments.update` 권한이 필요합니다. 갤러리에서 드래그 앤 드롭으로 이미지 순서를 바꾸는 등 이미 등록된 첨부파일의 나열 순서만 변경할 때 사용합니다.
|
||||
|
||||
|
||||
### DELETE /api/admin/attachments/{attachment}
|
||||
<!-- @generated:start:api.admin.attachments.destroy -->
|
||||
- **라우트명**: `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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.attachments.delete`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 지정한 첨부파일을 삭제합니다. 경로의 `{attachment}`는 라우트 모델 바인딩으로 첨부파일 ID를 받으며, 서비스가 DB 레코드와 실제 저장 파일을 함께 제거합니다. `core.attachments.delete` 권한이 필요합니다. 존재하지 않는 ID면 404가 반환됩니다.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
<!-- @generated:start:api.me.avatar.delete -->
|
||||
- **라우트명**: `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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 현재 인증 사용자의 아바타를 삭제합니다. 사용자에 연결된 아바타 첨부파일(Attachment) 레코드와 실제 파일을 함께 제거하며, 삭제 활동이 로그로 기록됩니다. 아바타가 없으면 404(`user.avatar_not_found`)를 반환합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 사용자가 자신의 프로필 사진을 기본값으로 되돌리는 시나리오에 사용합니다.
|
||||
|
||||
|
||||
### POST /api/me/avatar
|
||||
<!-- @generated:start:api.me.avatar.upload -->
|
||||
- **라우트명**: `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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 현재 인증 사용자의 아바타 이미지를 업로드합니다. 기존 아바타가 있으면 먼저 삭제한 뒤 새 이미지를 `avatar` 컬렉션의 다형성 첨부파일로 등록하고, 업로드 활동을 로그로 기록합니다. `auth:sanctum` 인증만 필요하고 별도 권한은 없으며, 이미지는 최대 2048KB로 제한됩니다. 확장은 `core.user.upload_avatar_rules` 훅으로 검증 규칙을 추가할 수 있습니다.
|
||||
|
||||
|
||||
@@ -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
|
||||
<!-- @generated:start: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` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-403 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** WebSocket 프라이빗/프레즌스 채널 구독 시 Laravel Broadcast 채널 인증을 수행하는 엔드포인트입니다. `auth:sanctum` 토큰으로 인증하며, 웹소켓 사용이 OFF(`broadcasting.default === 'null'`)이면 채널 인증을 거부해 403을 반환합니다(reverb.key 무력화를 우회한 직접 연결 시도까지 차단). 컨트롤러 없이 라우트 클로저가 토글 가드를 적용한 뒤 `Broadcast::auth`에 위임하며, 실시간 이벤트 구독을 위해 클라이언트 브로드캐스팅 라이브러리가 자동 호출합니다.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
||||
# Dashboard API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Dashboard 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/dashboard/activities
|
||||
<!-- @generated:start:api.admin.dashboard.activities -->
|
||||
- **라우트명**: `api.admin.dashboard.activities`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@activities`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.dashboard.activities`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/dashboard/activities HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| type | string | `user` | 활동 분류 (log_type Enum 값 — admin: 관리자, user: 사용자, system: 시스템) |
|
||||
| icon | string | `circle-info` | 아이콘 식별자 (아이콘 클래스/이름) |
|
||||
| icon_color | string | `green` | 분류별 색상 (log_type Enum variant() 파생 — admin: blue, user: green, system: gray) |
|
||||
| title | string | `첨부파일 다운로드 (게시물: 237)` | 제목 |
|
||||
| description | string | `API 문서 샘플 사용자` | 설명 (다국어 필드는 로케일별 값 객체) |
|
||||
| time | string | `4시간 전` | 상대 시각 표시 (예: "24초 전" — diffForHumans() 산물) |
|
||||
| timestamp | string | `2026-07-07T10:00:47+09:00` | 활동 발생 절대 시각 (created_at 을 사용자 타임존으로 변환한 ISO 8601) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "최근 활동을 성공적으로 조회했습니다.",
|
||||
"data": [
|
||||
{
|
||||
"type": "admin",
|
||||
"icon": "circle-info",
|
||||
"icon_color": "blue",
|
||||
"title": "사용자 목록 조회",
|
||||
"description": "API 문서 샘플 사용자",
|
||||
"time": "2분 전",
|
||||
"timestamp": "2026-07-08T12:12:12+09:00"
|
||||
},
|
||||
{
|
||||
"type": "admin",
|
||||
"icon": "circle-info",
|
||||
"icon_color": "blue",
|
||||
"title": "사용자 목록 조회",
|
||||
"description": "API 문서 샘플 사용자",
|
||||
"time": "6분 전",
|
||||
"timestamp": "2026-07-08T12:08:26+09:00"
|
||||
},
|
||||
{
|
||||
"type": "admin",
|
||||
"icon": "circle-info",
|
||||
"icon_color": "blue",
|
||||
"title": "사용자 목록 조회",
|
||||
"description": "API 문서 샘플 사용자",
|
||||
"time": "9분 전",
|
||||
"timestamp": "2026-07-08T12:04:38+09:00"
|
||||
},
|
||||
{
|
||||
"type": "admin",
|
||||
"icon": "circle-info",
|
||||
"icon_color": "blue",
|
||||
"title": "사용자 목록 조회",
|
||||
"description": "API 문서 샘플 사용자",
|
||||
"time": "43분 전",
|
||||
"timestamp": "2026-07-08T11:31:03+09:00"
|
||||
},
|
||||
{
|
||||
"type": "admin",
|
||||
"icon": "circle-info",
|
||||
"icon_color": "blue",
|
||||
"title": "사용자 목록 조회",
|
||||
"description": "API 문서 샘플 사용자",
|
||||
"time": "45분 전",
|
||||
"timestamp": "2026-07-08T11:29:30+09:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.dashboard.activities`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 관리자 대시보드에 표시할 최근 활동 내역(사용자 등록, 모듈 활성화 등)을 조회합니다. 인증(`auth:sanctum`)과 `core.dashboard.activities` 권한이 필요합니다. 각 항목은 유형·아이콘·제목·설명과 상대 시간(`time`)·절대 시각(`timestamp`)을 포함하며, 대시보드 최근 활동 카드를 렌더링할 때 사용합니다.
|
||||
|
||||
|
||||
### GET /api/admin/dashboard/alerts
|
||||
<!-- @generated:start:api.admin.dashboard.alerts -->
|
||||
- **라우트명**: `api.admin.dashboard.alerts`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@alerts`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.dashboard.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/dashboard/alerts 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": []
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 시스템 업데이트·경고 등 관리자에게 알릴 시스템 알림 목록을 조회합니다. 인증(`auth:sanctum`)과 `core.dashboard.read` 권한이 필요합니다. 알릴 항목이 없으면 빈 목록을 반환하며(위 실측이 빈 상태였던 이유), 대시보드 상단 시스템 알림 영역을 렌더링할 때 사용합니다.
|
||||
|
||||
|
||||
### GET /api/admin/dashboard/recent-notifications
|
||||
<!-- @generated:start:api.admin.dashboard.recent-notifications -->
|
||||
- **라우트명**: `api.admin.dashboard.recent-notifications`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@recentNotifications`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notification-logs.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/dashboard/recent-notifications HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `677` | 기본 키 (내부 식별자) |
|
||||
| type | string | `apidoc.sample.event` | 알림 유형 식별자 (notification_type — 발송을 유발한 알림 정의 키) |
|
||||
| channel | string | `mail` | 발송 채널 (mail: 이메일, database: 인앱, sms 등 알림이 전달된 매체) |
|
||||
| recipient | string | `API 문서 샘플 사용자` | 수신자 표시명 (recipientUser 관계의 name → recipient_name → recipient_identifier 순 폴백) |
|
||||
| subject | string | `API 문서 샘플 알림` | 알림 제목 (subject 를 50자로 절삭한 값) |
|
||||
| status | string | `sent` | 발송 상태 (status Enum 값 — sent: 발송 성공, failed: 발송 실패, skipped: 발송 건너뜀) |
|
||||
| time | string | `19시간 전` | 상대 시각 표시 (예: "24초 전" — diffForHumans() 산물) |
|
||||
| timestamp | string | `2026-07-06T18:20:23+09:00` | 발송 절대 시각 (sent_at, 없으면 created_at 을 사용자 타임존으로 변환한 ISO 8601) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "최근 알림을 성공적으로 조회했습니다.",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "apidoc.sample.event",
|
||||
"channel": "mail",
|
||||
"recipient": "API 문서 샘플 사용자",
|
||||
"subject": "API 문서 샘플 알림",
|
||||
"status": "sent",
|
||||
"time": "2시간 전",
|
||||
"timestamp": "2026-07-08T09:41:24+09:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 대시보드 "최근 알림" 카드에 표시할 최근 알림 발송 이력을 조회합니다. 인증(`auth:sanctum`)과 `core.notification-logs.read` 권한이 필요합니다. 각 항목은 알림 타입·채널·수신자·제목·상태와 상대 시간(`time`)·절대 시각(`timestamp`)을 포함하며, 전체 이력 목록(notification-logs)의 요약 뷰를 대시보드에 노출할 때 사용합니다.
|
||||
|
||||
|
||||
### GET /api/admin/dashboard/resources
|
||||
<!-- @generated:start:api.admin.dashboard.resources -->
|
||||
- **라우트명**: `api.admin.dashboard.resources`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@resources`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.dashboard.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/dashboard/resources HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| cpu | object | `{"percentage":6,"color":"green"}` | CPU 사용률 정보 (percentage: 0~100 사용률, color: 임계 색상 — green<50, blue 50~69, yellow 70~89, red≥90) |
|
||||
| memory | object | `{"percentage":96,"used":"30.1 GB","total":"31.5 GB","colo…` | 메모리 사용량 정보 (percentage 사용률, used/total: 사용량·총량 형식화 문자열, color: 임계 색상). 수집 불가 시 percentage 0·"알 수 없음"·color gray 폴백 |
|
||||
| disk | object | `{"percentage":76,"used":"360.2 GB","total":"474.7 GB","co…` | 디스크 사용량 정보 (percentage 사용률, used/total: 사용량·총량 형식화 문자열, color: 임계 색상). 수집 불가 시 percentage 0·"알 수 없음"·color gray 폴백 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "시스템 리소스 정보를 성공적으로 조회했습니다.",
|
||||
"data": {
|
||||
"cpu": {
|
||||
"percentage": 52,
|
||||
"color": "blue"
|
||||
},
|
||||
"memory": {
|
||||
"percentage": 86,
|
||||
"used": "27 GB",
|
||||
"total": "31.5 GB",
|
||||
"color": "yellow"
|
||||
},
|
||||
"disk": {
|
||||
"percentage": 76,
|
||||
"used": "362.6 GB",
|
||||
"total": "474.7 GB",
|
||||
"color": "yellow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 서버의 CPU·메모리·디스크 사용량을 조회합니다. 인증(`auth:sanctum`)과 `core.dashboard.read` 권한이 필요합니다. 각 항목은 사용률(`percentage`)과 상태 색상(`color`), 메모리·디스크의 경우 사용량/총량 문자열을 포함하며, 대시보드 시스템 리소스 게이지를 렌더링할 때 사용합니다.
|
||||
|
||||
|
||||
### GET /api/admin/dashboard/stats
|
||||
<!-- @generated:start:api.admin.dashboard.stats -->
|
||||
- **라우트명**: `api.admin.dashboard.stats`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\DashboardController@stats`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.dashboard.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/dashboard/stats HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| total_users | object | `{"count":156,"change_percent":15500,"change_display":"+15…` | 전체 사용자 수 (통계 객체는 count/추이 포함) |
|
||||
| installed_modules | object | `{"total":3,"active":3}` | 설치된 모듈 집계 객체 (total/active) |
|
||||
| active_plugins | object | `{"total":9,"active":9}` | 활성 플러그인 집계 객체 (total/active) |
|
||||
| installed_templates | object | `{"total":2,"active":2}` | 설치된 템플릿 집계 객체 (total/active) |
|
||||
| language_packs | object | `{"total":20,"active":16}` | 언어팩 집계 객체 (active: 현재 활성 언어팩 수, total: 활성 + 미설치 번들 팩 수) |
|
||||
| system_status | object | `{"status":"normal","label":"정상","all_services_running":true}` | 시스템 상태 객체 (status: normal 정상 / warning 경고, label: 상태 다국어 라벨, all_services_running: 전체 서비스 정상 동작 여부) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "대시보드 통계를 성공적으로 조회했습니다.",
|
||||
"data": {
|
||||
"total_users": {
|
||||
"count": 2,
|
||||
"change_percent": 0,
|
||||
"change_display": "+2",
|
||||
"trend": "up"
|
||||
},
|
||||
"installed_modules": {
|
||||
"total": 3,
|
||||
"active": 3
|
||||
},
|
||||
"active_plugins": {
|
||||
"total": 9,
|
||||
"active": 0
|
||||
},
|
||||
"installed_templates": {
|
||||
"total": 2,
|
||||
"active": 0
|
||||
},
|
||||
"language_packs": {
|
||||
"total": 17,
|
||||
"active": 1
|
||||
},
|
||||
"system_status": {
|
||||
"status": "normal",
|
||||
"label": "정상",
|
||||
"all_services_running": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.dashboard.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 대시보드 상단 통계 카드에 표시할 집계 데이터를 조회합니다. 인증(`auth:sanctum`)과 `core.dashboard.read` 권한이 필요합니다. 총 사용자 수(증감률 포함), 설치/활성 모듈·플러그인·템플릿·언어팩 수, 시스템 상태를 객체 형태로 반환하며, 대시보드 진입 시 요약 지표를 렌더링할 때 사용합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Extensions API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Extensions 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/extensions/auto-deactivated
|
||||
<!-- @generated:start:api.admin.extensions.auto-deactivated -->
|
||||
- **라우트명**: `api.admin.extensions.auto-deactivated`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\ExtensionRecoveryController@autoDeactivated`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/extensions/auto-deactivated HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| items | object | `{"plugins":[],"modules":[],"templates":[]}` | 코어 비호환으로 자동 비활성화된 확장을 타입별(`plugins`/`modules`/`templates`)로 묶은 목록. 각 원소는 식별자(`identifier`), 비호환 요구 버전(`incompatible_required_version`), 비활성화 시각(`deactivated_at`)을 가지며, 사용자가 dismiss했거나 hidden(학습용 샘플) 확장은 제외됨 |
|
||||
| current_core_version | string | `7.0.1` | 현재 설치된 코어 버전 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "자동 비활성화된 확장 목록입니다.",
|
||||
"data": {
|
||||
"items": {
|
||||
"plugins": [],
|
||||
"modules": [],
|
||||
"templates": []
|
||||
},
|
||||
"current_core_version": "7.0.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 코어 버전 비호환으로 자동 비활성화된 확장 목록을 타입별(`plugins`/`modules`/`templates`)로 반환합니다. 각 항목에는 식별자, 비호환 요구 버전, 비활성화 시각과 함께 현재 코어 버전이 담깁니다. 사용자가 dismiss한 알림과 hidden(학습용 샘플) 확장은 결과에서 제외됩니다. `core.plugins.activate` 권한이 필요하며, 상단 배너·대시보드 카드의 데이터 소스로 사용됩니다.
|
||||
|
||||
|
||||
### POST /api/admin/extensions/{type}/{identifier}/dismiss
|
||||
<!-- @generated:start:api.admin.extensions.dismiss -->
|
||||
- **라우트명**: `api.admin.extensions.dismiss`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\ExtensionRecoveryController@dismiss`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| type | path | string | 예 | module, plugin, template | 대상 확장의 타입 (module: 모듈, plugin: 플러그인, template: 템플릿). 타입에 맞는 Repository/Manager를 해석하는 데 사용되며 그 외 값은 422 |
|
||||
| identifier | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/extensions/{type}/{identifier}/dismiss HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 특정 확장의 호환성 알림을 현재 사용자 기준으로 dismiss(닫기) 처리합니다. 경로의 `{type}`(module|plugin|template)과 `{identifier}`로 대상을 지정하며, 해당 확장의 자동 비활성화 알림과 재호환 알림을 함께 dismiss합니다. `core.plugins.activate` 권한이 필요합니다. dismiss는 사용자별로 저장되므로, 캐시 만료나 감지 갱신 시 재호환 상태가 바뀌면 다시 노출될 수 있습니다.
|
||||
|
||||
|
||||
### POST /api/admin/extensions/{type}/{identifier}/recover
|
||||
<!-- @generated:start:api.admin.extensions.recover -->
|
||||
- **라우트명**: `api.admin.extensions.recover`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\ExtensionRecoveryController@recover`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.plugins.activate`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| type | path | string | 예 | module, plugin, template | 대상 확장의 타입 (module: 모듈, plugin: 플러그인, template: 템플릿). 타입에 맞는 Repository/Manager를 해석하는 데 사용되며 그 외 값은 422 |
|
||||
| identifier | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/extensions/{type}/{identifier}/recover HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.plugins.activate`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 코어와 재호환된 확장을 원클릭으로 복구(재활성화)합니다. 경로의 `{type}`/`{identifier}`로 대상을 지정하며, 대상이 `IncompatibleCore` 사유로 자동 비활성화된 상태인지 검증한 뒤 코어 버전 재검증을 거쳐 활성화합니다. 잘못된 타입은 422, 미존재 확장은 404, hidden 확장이나 자동 비활성화가 아닌 경우는 error_code와 함께 422를 반환하고, 재검증 실패 시 글로벌 핸들러가 core_version_mismatch로 변환합니다. `core.plugins.activate` 권한이 필요합니다.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,979 @@
|
||||
# Language Packs API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Language Packs 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/language-packs
|
||||
<!-- @generated:start:api.admin.language-packs.index -->
|
||||
- **라우트명**: `api.admin.language-packs.index`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@index`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| scope | query | string | 아니오 | — | 조회 범위 한정 키 |
|
||||
| target_identifier | query | string | 아니오 | max 150 | 대상 확장 식별자 |
|
||||
| locale | query | string | 아니오 | max 20 | 로케일 코드 (표시 언어/지역) |
|
||||
| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) |
|
||||
| vendor | query | string | 아니오 | max 100 | 벤더명 (확장 제작자 식별자) |
|
||||
| search | query | string | 아니오 | max 150 | 검색어 (지정한 검색 대상 필드에서 부분 일치) |
|
||||
| exclude_protected | query | boolean | 아니오 | — | 보호 항목 제외 여부 |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/language-packs?scope=%EC%98%88%EC%8B%9C%EA%B0%92&target_identifier=example-key&locale=ko&status=%EC%98%88%EC%8B%9C%EA%B0%92&vendor=%EC%98%88%EC%8B%9C%EA%B0%92&search=%EC%98%88%EC%8B%9C%EA%B0%92&exclude_protected=1&per_page=1&page=1 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_목록 응답: `data.data[]` 배열 항목의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `16` | 기본 키 (내부 식별자) |
|
||||
| identifier | string | `g7-core-en` | 언어팩 고유 식별자 ({vendor}-{scope}-{target?}-{locale}) |
|
||||
| vendor | string | `g7` | 언어팩 제작자 식별자 |
|
||||
| scope | string | `core` | 적용 대상 분류 |
|
||||
| target_identifier | string | `gnuboard7-hello_module` | 대상 확장 식별자 (scope=core일 때 null) |
|
||||
| locale | string | `en` | IETF BCP-47 locale 태그 |
|
||||
| locale_name | string | `EN` | 영문 언어명 |
|
||||
| locale_native_name | string | `English` | 원어 언어명 |
|
||||
| text_direction | string | `ltr` | 텍스트 방향 |
|
||||
| version | string | `7.0.1` | 언어팩 버전 |
|
||||
| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 |
|
||||
| target_version_constraint | null | `null` | 대상 확장 버전 제약 (semver) |
|
||||
| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 |
|
||||
| name | string | `API 문서 샘플 언어팩` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) |
|
||||
| license | string | `MIT` | 라이선스 |
|
||||
| description | string | `문서 실측용 언어팩` | 언어팩 설명 (다국어) |
|
||||
| status | string | `active` | 언어팩 상태 |
|
||||
| is_protected | boolean | `true` | protected 여부 |
|
||||
| source_type | string | `built_in` | 설치 소스 유형 (zip/github/url/bundled/bundled_with_extension) |
|
||||
| origin | string | `built_in` | 출처 (설치/등록 원천 구분 값) |
|
||||
| source_url | string | `lang/en` | 설치 소스 URL 또는 경로 |
|
||||
| github_url | null | `null` | GitHub 저장소 URL (manifest 파생) |
|
||||
| github_changelog_url | null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) |
|
||||
| bundled_identifier | string | `g7-module-gnuboard7-hello_module-ja` | 대응하는 번들 확장 식별자 (번들 원본 매칭용) |
|
||||
| install_blocked_reason | string | `target_not_installed` | 설치가 차단된 사유 (차단 없으면 null) |
|
||||
| target_name | string | `게시판` | 대상 확장의 표시 이름 (scope+target_identifier 로 해석) |
|
||||
| installed_at | string | `2026-07-03 19:20:23` | installed 일시 |
|
||||
| activated_at | string | `2026-07-03 19:20:23` | activated 일시 |
|
||||
| created_at | string | `2026-07-06 19:20:23` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-06 19:20:23` | 최종 수정 일시 |
|
||||
| has_update | boolean | `false` | update 여부 |
|
||||
| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "언어팩 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"id": null,
|
||||
"identifier": "g7-core-en",
|
||||
"vendor": "g7",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
"locale": "en",
|
||||
"locale_name": "EN",
|
||||
"locale_native_name": "English",
|
||||
"text_direction": "ltr",
|
||||
"version": "7.0.1",
|
||||
"latest_version": null,
|
||||
"target_version_constraint": null,
|
||||
"target_version_mismatch": false,
|
||||
"name": null,
|
||||
"license": null,
|
||||
"description": null,
|
||||
"status": "active",
|
||||
"is_protected": true,
|
||||
"source_type": "built_in",
|
||||
"origin": "built_in",
|
||||
"source_url": "lang/en",
|
||||
"github_url": null,
|
||||
"github_changelog_url": null,
|
||||
"bundled_identifier": null,
|
||||
"install_blocked_reason": null,
|
||||
"target_name": null,
|
||||
"installed_at": null,
|
||||
"activated_at": null,
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"has_update": false,
|
||||
"abilities": {
|
||||
"can_activate": true,
|
||||
"can_deactivate": true,
|
||||
"can_uninstall": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "apidoc-sample-lang",
|
||||
"vendor": "apidoc",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
"locale": "fr",
|
||||
"locale_name": "French",
|
||||
"locale_native_name": "Français",
|
||||
"text_direction": "ltr",
|
||||
"version": "1.0.0",
|
||||
"latest_version": "1.0.0",
|
||||
"target_version_constraint": null,
|
||||
"target_version_mismatch": false,
|
||||
"name": "API 문서 샘플 언어팩",
|
||||
"license": "MIT",
|
||||
"description": "문서 실측용 언어팩",
|
||||
"status": "active",
|
||||
"is_protected": false,
|
||||
"source_type": "bundled",
|
||||
"origin": "bundled",
|
||||
"source_url": null,
|
||||
"github_url": null,
|
||||
"github_changelog_url": null,
|
||||
"bundled_identifier": null,
|
||||
"install_blocked_reason": null,
|
||||
"target_name": null,
|
||||
"installed_at": "2026-07-05 10:41:24",
|
||||
"activated_at": "2026-07-05 10:41:24",
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 10:41:24",
|
||||
"has_update": false,
|
||||
"abilities": {
|
||||
"can_activate": true,
|
||||
"can_deactivate": true,
|
||||
"can_uninstall": true
|
||||
}
|
||||
},
|
||||
"... (총 25건 중 2건 표시)"
|
||||
],
|
||||
"meta": {
|
||||
"total": 43,
|
||||
"active": 15,
|
||||
"installed": 0,
|
||||
"inactive": 0,
|
||||
"error": 0,
|
||||
"uninstalled": 10,
|
||||
"current_page": 1,
|
||||
"last_page": 2,
|
||||
"per_page": 25
|
||||
},
|
||||
"abilities": {
|
||||
"can_install": true,
|
||||
"can_activate": true,
|
||||
"can_deactivate": true,
|
||||
"can_uninstall": true,
|
||||
"can_refresh_cache": true,
|
||||
"can_check_updates": true,
|
||||
"can_update": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 설치된 언어팩과 번들 소스로부터 노출되는 언어팩 목록을 페이지네이션으로 조회합니다. `core.language_packs.read` 권한이 필요합니다. `scope`/`locale`/`status`/`vendor`/`search` 등으로 필터링하고 `exclude_protected` 로 보호(protected) 팩을 제외할 수 있습니다. 관리자 언어팩 관리 화면의 목록/필터 표시에 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/bulk-activate
|
||||
<!-- @generated:start:api.admin.language-packs.bulk-activate -->
|
||||
- **라우트명**: `api.admin.language-packs.bulk-activate`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@bulkActivate`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.language_packs.bulk_activate_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/bulk-activate HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ids": [
|
||||
"예시값"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 전달된 `ids` 배열의 언어팩을 일괄 활성화합니다. `core.language_packs.manage` 권한이 필요합니다. 성공/실패를 분리한 결과를 반환하므로 일부만 실패해도 전체가 롤백되지 않습니다. 비활성 팩을 한 번에 재활성화하는 reactivate 모달의 "활성화" 동작에 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/check-updates
|
||||
<!-- @generated:start:api.admin.language-packs.check-updates -->
|
||||
- **라우트명**: `api.admin.language-packs.check-updates`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@checkUpdates`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/check-updates HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.update`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** GitHub 소스로 설치된 언어팩들의 원격 최신 버전을 조회해 업데이트 가능 여부를 확인합니다. `core.language_packs.update` 권한이 필요합니다. 실제 업데이트를 수행하지 않고 검사 결과(checked, updates, details)만 반환하며, 외부 GitHub 호출을 동반합니다. 언어팩 목록의 업데이트 배지 표시에 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/install-from-bundled
|
||||
<!-- @generated:start:api.admin.language-packs.install-from-bundled -->
|
||||
- **라우트명**: `api.admin.language-packs.install-from-bundled`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@installFromBundled`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| identifier | body | string | 예 | max 200 | 대상 확장/리소스의 식별자 |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/install-from-bundled HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "example-key",
|
||||
"auto_activate": true
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** `lang-packs/_bundled/{identifier}` 디렉토리의 번들 소스에서 언어팩을 설치(또는 재설치)합니다. `core.language_packs.install` 권한이 필요합니다. `auto_activate` 가 true면 설치 후 곧바로 활성화합니다. 코어/확장에 선탑재된 번들 언어팩을 DB에 등록할 때 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/install-from-file
|
||||
<!-- @generated:start:api.admin.language-packs.install-from-file -->
|
||||
- **라우트명**: `api.admin.language-packs.install-from-file`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@installFromFile`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| file | body | file | 예 | max 10240 | 업로드 파일 |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/install-from-file 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="auto_activate"
|
||||
|
||||
1
|
||||
------G7ExampleBoundary--
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 업로드된 ZIP 파일에서 언어팩을 설치합니다. `core.language_packs.install` 권한이 필요합니다. manifest 검증에 실패하면 422로 응답하며, `auto_activate` 가 true면 설치 후 즉시 활성화합니다. 관리자가 로컬 ZIP 파일을 직접 업로드해 언어팩을 추가하는 화면에 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/install-from-github
|
||||
<!-- @generated:start:api.admin.language-packs.install-from-github -->
|
||||
- **라우트명**: `api.admin.language-packs.install-from-github`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@installFromGithub`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| github_url | body | string | 예 | — | GitHub 저장소 URL |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/install-from-github HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"github_url": "https://example.com",
|
||||
"auto_activate": true
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** GitHub 저장소 URL에서 언어팩을 다운로드해 설치합니다. `core.language_packs.install` 권한이 필요합니다. 외부 GitHub 호출을 동반하며 manifest 검증 실패 시 422로 응답합니다. `auto_activate` 가 true면 설치 후 즉시 활성화합니다. GitHub로 배포되는 언어팩을 URL 만으로 설치할 때 사용하며, 이후 check-updates/update 로 갱신을 추적할 수 있습니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/install-from-url
|
||||
<!-- @generated:start:api.admin.language-packs.install-from-url -->
|
||||
- **라우트명**: `api.admin.language-packs.install-from-url`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@installFromUrl`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| url | body | string | 예 | max 500 | 언어팩 ZIP 다운로드 URL. `https` 만 허용하며, 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)와 userinfo(`https://a@b/`) 위장 주소는 422 로 거부됩니다 — 서버가 대신 내려받는 요청이므로 내부망 접근을 막기 위함(SSRF). 원격 코드를 가져오는 경로라 `security.allow_internal_outbound_urls` 설정을 켜도 내부 주소는 계속 차단됩니다 |
|
||||
| checksum | body | string | 아니오 | — | 무결성 검증 체크섬 (SHA-256) |
|
||||
| auto_activate | body | boolean | 아니오 | — | 설치 후 자동 활성화 여부 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/install-from-url HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"checksum": "예시값",
|
||||
"auto_activate": true
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 임의의 URL에서 언어팩 ZIP을 내려받아 설치합니다. `core.language_packs.install` 권한이 필요합니다. `checksum` 을 함께 전달하면 다운로드 무결성을 검증하며, manifest 검증 실패 시 422로 응답합니다. `auto_activate` 가 true면 설치 후 즉시 활성화합니다. GitHub 외 임의 호스팅에 배포된 언어팩을 설치할 때 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/manifest-preview
|
||||
<!-- @generated:start:api.admin.language-packs.manifest-preview -->
|
||||
- **라우트명**: `api.admin.language-packs.manifest-preview`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@manifestPreview`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.install`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| file | body | file | 예 | max 5120 | 업로드 파일 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/manifest-preview 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--
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.install`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 업로드된 ZIP을 실제로 설치하지 않고 manifest 와 검증 결과만 미리 조회합니다. `core.language_packs.install` 권한이 필요합니다. 부수 효과 없이 읽기만 수행하며 검증 실패 시 422로 응답합니다. 설치 확인 모달에서 대상 언어팩의 메타데이터와 유효성을 미리 보여줄 때 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/refresh-cache
|
||||
<!-- @generated:start:api.admin.language-packs.refresh-cache -->
|
||||
- **라우트명**: `api.admin.language-packs.refresh-cache`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@refreshCache`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/refresh-cache HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 번역/레지스트리/템플릿 언어 캐시를 무효화합니다. `core.language_packs.manage` 권한이 필요합니다. 언어팩 파일을 직접 수정했거나 활성 상태가 프론트에 반영되지 않을 때 캐시를 강제로 갱신하는 용도로 사용합니다.
|
||||
|
||||
|
||||
### DELETE /api/admin/language-packs/{id}
|
||||
<!-- @generated:start:api.admin.language-packs.uninstall -->
|
||||
- **라우트명**: `api.admin.language-packs.uninstall`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@uninstall`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
| cascade | query | boolean | 아니오 | — | 연쇄 처리 여부 (의존 항목 함께 처리) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/admin/language-packs/1?cascade=1 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 언어팩을 제거하고 설치된 파일을 삭제합니다. `core.language_packs.manage` 권한이 필요합니다. `cascade` 가 true면 연관 자원까지 함께 제거합니다. 대상이 존재하지 않으면 404로 응답합니다. 관리자 언어팩 관리 화면의 삭제(제거) 동작에 사용합니다.
|
||||
|
||||
|
||||
### GET /api/admin/language-packs/{id}
|
||||
<!-- @generated:start:api.admin.language-packs.show -->
|
||||
- **라우트명**: `api.admin.language-packs.show`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@show`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/language-packs/1 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `1` | 기본 키 (내부 식별자) |
|
||||
| identifier | string | `apidoc-sample-lang` | 언어팩 고유 식별자 ({vendor}-{scope}-{target?}-{locale}) |
|
||||
| vendor | string | `apidoc` | 언어팩 제작자 식별자 |
|
||||
| scope | string | `core` | 적용 대상 분류 |
|
||||
| target_identifier | null | `null` | 대상 확장 식별자 (scope=core일 때 null) |
|
||||
| locale | string | `fr` | IETF BCP-47 locale 태그 |
|
||||
| locale_name | string | `French` | 영문 언어명 |
|
||||
| locale_native_name | string | `Français` | 원어 언어명 |
|
||||
| text_direction | string | `ltr` | 텍스트 방향 |
|
||||
| version | string | `1.0.0` | 언어팩 버전 |
|
||||
| latest_version | string | `1.0.0` | 감지된 최신 배포 버전 |
|
||||
| target_version_constraint | null | `null` | 대상 확장 버전 제약 (semver) |
|
||||
| target_version_mismatch | boolean | `false` | 대상 버전 불일치 경고 플래그 |
|
||||
| name | string | `API 문서 샘플 언어팩` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) |
|
||||
| license | string | `MIT` | 라이선스 |
|
||||
| description | string | `문서 실측용 언어팩` | 설명 (다국어 필드는 로케일별 값 객체) |
|
||||
| status | string | `active` | 상태 값 (도메인별 상태 집합 — 사람이 읽는 라벨은 status_label, UI 변형은 status_variant 참조) |
|
||||
| is_protected | boolean | `false` | protected 여부 |
|
||||
| source_type | string | `bundled` | 설치 소스 유형 (zip/github/url/bundled/bundled_with_extension) |
|
||||
| origin | string | `bundled` | 출처 (설치/등록 원천 구분 값) |
|
||||
| source_url | null | `null` | source URL |
|
||||
| github_url | null | `null` | GitHub 저장소 URL (manifest 파생) |
|
||||
| github_changelog_url | null | `null` | GitHub 변경 이력(CHANGELOG) URL (manifest 파생) |
|
||||
| bundled_identifier | null | `null` | 대응하는 번들 확장 식별자 (번들 원본 매칭용) |
|
||||
| install_blocked_reason | null | `null` | 설치가 차단된 사유 (차단 없으면 null) |
|
||||
| target_name | null | `null` | 대상 확장의 표시 이름 (scope+target_identifier 로 해석) |
|
||||
| installed_at | string | `2026-07-05 10:41:24` | installed 일시 |
|
||||
| activated_at | string | `2026-07-05 10:41:24` | activated 일시 |
|
||||
| created_at | string | `2026-07-08 10:41:24` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-08 10:41:24` | 최종 수정 일시 |
|
||||
| has_update | boolean | `false` | update 여부 |
|
||||
| abilities | object | `{"can_activate":true,"can_deactivate":true,"can_uninstall…` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
| manifest | object | `{"name":{"en":"API Doc Sample Pack","ko":"API 문서 샘플 언어팩"}…` | language-pack.json 전체 스냅샷 |
|
||||
| validation_summary | object | `{"target_version_mismatch":false,"depends_on_core_locale"…` | 검증 결과 요약 객체 (통과/실패 건수 등) |
|
||||
| source_meta | object | `{"type":"bundled","url":null,"installed_by":6,"latest_ver…` | 원천 메타데이터 객체 (출처·경로·해석 정보) |
|
||||
| changelog_entries | array | `[]` | 변경 이력 항목 목록 (버전별 변경 내용) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "언어팩 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"identifier": "apidoc-sample-lang",
|
||||
"vendor": "apidoc",
|
||||
"scope": "core",
|
||||
"target_identifier": null,
|
||||
"locale": "fr",
|
||||
"locale_name": "French",
|
||||
"locale_native_name": "Français",
|
||||
"text_direction": "ltr",
|
||||
"version": "1.0.0",
|
||||
"latest_version": "1.0.0",
|
||||
"target_version_constraint": null,
|
||||
"target_version_mismatch": false,
|
||||
"name": "API 문서 샘플 언어팩",
|
||||
"license": "MIT",
|
||||
"description": "문서 실측용 언어팩",
|
||||
"status": "active",
|
||||
"is_protected": false,
|
||||
"source_type": "bundled",
|
||||
"origin": "bundled",
|
||||
"source_url": null,
|
||||
"github_url": null,
|
||||
"github_changelog_url": null,
|
||||
"bundled_identifier": null,
|
||||
"install_blocked_reason": null,
|
||||
"target_name": null,
|
||||
"installed_at": "2026-07-05 10:41:24",
|
||||
"activated_at": "2026-07-05 10:41:24",
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 10:41:24",
|
||||
"has_update": false,
|
||||
"abilities": {
|
||||
"can_activate": true,
|
||||
"can_deactivate": true,
|
||||
"can_uninstall": true
|
||||
},
|
||||
"manifest": {
|
||||
"name": {
|
||||
"en": "API Doc Sample Pack",
|
||||
"ko": "API 문서 샘플 언어팩"
|
||||
},
|
||||
"locale": "fr",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"validation_summary": {
|
||||
"target_version_mismatch": false,
|
||||
"depends_on_core_locale": null
|
||||
},
|
||||
"source_meta": {
|
||||
"type": "bundled",
|
||||
"url": null,
|
||||
"installed_by": 6,
|
||||
"latest_version": "1.0.0",
|
||||
"directory_path": "lang-packs/apidoc-sample-lang"
|
||||
},
|
||||
"changelog_entries": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 단일 언어팩의 상세 정보를 조회합니다. `core.language_packs.read` 권한이 필요합니다. `{id}` 가 정수면 DB 레코드를, 문자열(번들 식별자)이면 `lang-packs/_bundled/{id}` manifest 로 합성된 가상 행을 반환합니다. 미설치 번들 언어팩까지 상세 모달로 열람할 수 있도록 하며, 없으면 404로 응답합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/{id}/activate
|
||||
<!-- @generated:start:api.admin.language-packs.activate -->
|
||||
- **라우트명**: `api.admin.language-packs.activate`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@activate`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/1/activate HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 언어팩을 활성화합니다(슬롯 스위칭). `core.language_packs.manage` 권한이 필요합니다. 동일 슬롯(scope·target·locale)에 이미 다른 활성 팩이 있으면 409(slot_conflict)로 현재/대상 팩을 함께 반환하며, 프론트는 확인 모달을 띄운 뒤 `force=true` 로 재호출해 교체합니다. 대상이 없으면 404로 응답합니다.
|
||||
|
||||
|
||||
### GET /api/admin/language-packs/{id}/changelog
|
||||
<!-- @generated:start:api.admin.language-packs.changelog -->
|
||||
- **라우트명**: `api.admin.language-packs.changelog`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@changelog`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/language-packs/1/changelog HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| identifier | string | `apidoc-sample-lang` | 언어팩 고유 식별자 ({vendor}-{scope}-{target?}-{locale}) |
|
||||
| entries | array | `[]` | 항목 목록 (각 원소는 대상 도메인 레코드) |
|
||||
| changelog | string | `` | 변경 이력 텍스트 (원격/파일 CHANGELOG 본문) |
|
||||
| has_changelog | boolean | `false` | changelog 여부 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "언어팩 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"identifier": "apidoc-sample-lang",
|
||||
"entries": [],
|
||||
"changelog": "",
|
||||
"has_changelog": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.read`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 언어팩의 CHANGELOG.md 내용을 반환합니다. `core.language_packs.read` 권한이 필요합니다. `{id}` 가 정수면 DB 레코드를, 문자열이면 번들 가상 행을 사용합니다. 파싱된 항목(entries), 원문(changelog), 존재 여부(has_changelog)를 함께 반환하며, CHANGELOG 파일이 없어도 빈 값으로 정상 응답합니다. 상세 모달의 변경 이력 탭에 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/{id}/deactivate
|
||||
<!-- @generated:start:api.admin.language-packs.deactivate -->
|
||||
- **라우트명**: `api.admin.language-packs.deactivate`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@deactivate`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.manage`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/1/deactivate HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.manage`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 활성 언어팩을 비활성화합니다. `core.language_packs.manage` 권한이 필요합니다. 해당 슬롯의 번역 적용이 해제되며, 대상이 없으면 404로 응답합니다. 관리자 언어팩 관리 화면에서 활성 팩을 끄는 동작에 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/language-packs/{id}/update
|
||||
<!-- @generated:start:api.admin.language-packs.update -->
|
||||
- **라우트명**: `api.admin.language-packs.update`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LanguagePackController@performUpdate`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.language_packs.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| id | path | string | 예 | — | 대상 리소스의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/language-packs/1/update HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.language_packs.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** GitHub 소스 언어팩을 최신 버전으로 다시 내려받아 적용합니다. `core.language_packs.update` 권한이 필요합니다. 외부 GitHub 재다운로드와 파일 교체를 동반하며, 갱신된 언어팩 정보를 반환합니다. 대상이 없으면 404로 응답합니다. check-updates 로 업데이트가 감지된 팩을 실제로 갱신할 때 사용합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Layouts API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Layouts 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/layouts/preview/{token}.json
|
||||
<!-- @generated:start:api.public.layouts.preview.serve -->
|
||||
- **라우트명**: `api.public.layouts.preview.serve`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Public\LayoutPreviewController@serve`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| token | path | string | 예 | — | 인증/검증 토큰 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/layouts/preview/{token}.json HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 편집 중인 레이아웃을 미리보기 토큰(UUID)으로 조회해 JSON으로 서빙합니다. 토큰으로 대상 레이아웃을 찾은 뒤 상속 병합과 확장(extension) 적용까지 마친 결과를 반환하며, 토큰이 유효하지 않으면 404를 반환합니다. 인증 미들웨어가 적용되지만 실질적 보안 메커니즘은 토큰 자체이며, 레이아웃 편집기에서 저장 전 변경분을 실제 렌더링으로 확인하는 용도입니다.
|
||||
|
||||
|
||||
### GET /api/layouts/{templateIdentifier}/{layoutName}.json
|
||||
<!-- @generated:start:api.public.layouts.serve -->
|
||||
- **라우트명**: `api.public.layouts.serve`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicLayoutController@serve`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| templateIdentifier | path | string | 예 | — | 대상 template의 식별자 |
|
||||
| layoutName | path | string | 예 | — | 대상 layout의 이름 (식별자) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/layouts/{templateIdentifier}/{layoutName}.json HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 활성 템플릿의 병합된 레이아웃 JSON을 프론트엔드에 서빙합니다. 템플릿이 존재하고 활성 상태여야 하며, 상속 병합·확장 적용을 마친 결과를 ETag·Cache-Control 헤더와 함께 반환하고 미변경 시 304로 응답합니다. 레이아웃의 `permissions`에 따라 접근을 제한하고(비회원 401, 권한 부족 403), 컴포넌트 단위 권한 필터링을 사용자별로 적용합니다. 쿼리 `v`(정수 캐시 버전)로 캐시를 구분하며, `with_source_meta=1`은 `core.templates.layouts.edit` 권한이 있어야 노드별 출처 메타(편집기 전용)를 포함해 반환합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# License API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 License 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/license
|
||||
<!-- @generated:start:api.admin.license -->
|
||||
- **라우트명**: `api.admin.license`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\LicenseController@core`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/license HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| content | string | `프로그램 명칭 : 그누보드7 (Gnuboard7) 저작자 : (주…` | 본문 내용 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "성공적으로 처리되었습니다.",
|
||||
"data": {
|
||||
"content": "프로그램 명칭 : 그누보드7 (Gnuboard7)\n\n저작자 : (주)에스아이알소프트\n\n라이선스 (License)\n\n번역문 아래에 원문이 있습니다.\n\n주의)\n1. 번역문과 원문의 내용상 차이가 있는 경우 원문의 내용을 우선으로 따릅니다.\n2. 이 라이선스 파일 및 내용은 저작자를 제외한 어느 누구도 추가, 수정, 삭제할 수 없습니다.\n\n----- MIT 라이선스 (한국어 번역) --------------------------------------------------------\n\nMIT 라이선스\n\nCopyright (c) 2026 (주)에스아이알소프트\n\n이 소프트웨어와 관련 문서 파일(이하 \"소프트웨어\")의 복사본을 취득하는 모든 사람에게\n소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는\n판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음\n조건을 따라야 합니다:\n\n위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야\n합니다.\n\n소프트웨어는 \"있는 그대로\" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지\n않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에\n국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의\n사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타\n책임에 대해 책임을 지지 않습니다.\n\n----- MIT License (English Original) --------------------------------------------------------\n\nThe MIT License (MIT)\n\nCopyright (c) 2026 SIRSOFT\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 코어 라이선스 파일의 원문 텍스트를 `content`로 반환합니다. `auth:sanctum` 인증이 필요하며, 라이선스 파일이 없으면 404(`common.not_found`)를 반환합니다. 관리자 화면에서 코어 저작권·라이선스 고지를 표시하는 용도로 사용합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Locales API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Locales 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/locales/active
|
||||
<!-- @generated:start:api.public.locales.active -->
|
||||
- **라우트명**: `api.public.locales.active`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Public\LocaleController@active`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/locales/active HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| locales | array | `["ko","en","fr","ja"]` | 활성 로케일 코드 배열 |
|
||||
| locale_names | object | `{"ko":"한국어","en":"English","ja":"日本語","fr":"Français"}` | 로케일 코드별 표시명 맵 (config app.locale_names) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "locales.fetched",
|
||||
"data": {
|
||||
"locales": [
|
||||
"ko",
|
||||
"en",
|
||||
"fr"
|
||||
],
|
||||
"locale_names": {
|
||||
"ko": "한국어",
|
||||
"en": "English",
|
||||
"fr": "Français"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
_대표 에러 없음 (공개 조회). 인증·권한 미요구 엔드포인트로 도메인 특이 에러를 반환하지 않습니다._
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 현재 사이트가 즉시 노출 가능한 활성 로케일 목록과 로케일별 표시명(`locale_names`) 매핑을 반환합니다. 활성 코어 언어팩을 기준으로 산출되며 인증이 필요 없는 공개 엔드포인트입니다. 언어팩 설치·활성화 직후 사용자 언어 셀렉터를 새로고침 없이 갱신하는 시나리오에 사용합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# Me API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Me 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### DELETE /api/me
|
||||
<!-- @generated:start:api.me.destroy -->
|
||||
- **라우트명**: `api.me.destroy`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@destroy`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/me HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-500 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
현재 로그인한 사용자가 자신의 계정을 탈퇴한다. 프론트의 회원 탈퇴 모달(`_modal_withdraw.json`)이 호출한다. 별도 요청 파라미터 없이 인증 토큰의 사용자를 대상으로 하며, `UserService::withdrawUser()` 가 아바타·토큰 삭제와 개인정보 익명화를 수행한다. 되돌릴 수 없는 작업이므로 호출 전 사용자 확인 절차를 두는 것을 권장한다.
|
||||
|
||||
|
||||
### GET /api/me
|
||||
<!-- @generated:start:api.me.show -->
|
||||
- **라우트명**: `api.me.show`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@show`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/me 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: 영어) |
|
||||
| timezone | string | `Asia/Seoul` | 사용자 시간대 (예: Asia/Seoul, UTC) |
|
||||
| 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 배지용) |
|
||||
| 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…` | 자기소개 |
|
||||
| is_super | boolean | `false` | super 여부 |
|
||||
| is_admin | boolean | `true` | 관리자 역할 보유 여부 (User::isAdmin() — 역할 관계 기반 파생) |
|
||||
| withdrawn_at | null | `null` | withdrawn 일시 |
|
||||
| last_login_at | string | `2026-07-05 19:15:16` | last login 일시 |
|
||||
| last_login_human | string | `1일 전` | 마지막 로그인 시각의 상대 표현 (diffForHumans() 산물, 사용자 시간대 기준) |
|
||||
| created_at | string | `2026-07-06 19:15:16` | 생성 일시 |
|
||||
| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) |
|
||||
| notify_post_complete | boolean | `false` | 게시글 작성 완료 알림 수신 설정 (게시판 모듈 주입) |
|
||||
| notify_post_reply | boolean | `false` | 내 게시글에 대한 답글 알림 수신 설정 (게시판 모듈 주입) |
|
||||
| notify_comment | boolean | `false` | 내 게시글에 대한 댓글 알림 수신 설정 (게시판 모듈 주입) |
|
||||
| notify_reply_comment | boolean | `false` | 내 댓글에 대한 대댓글 알림 수신 설정 (게시판 모듈 주입) |
|
||||
| email_subscription | boolean | `false` | 광고성 이메일 수신 동의 여부 (마케팅 플러그인 주입, 채널) |
|
||||
| email_subscription_at | null | `null` | email subscription 일시 |
|
||||
| marketing_consent | boolean | `false` | 마케팅 정보 수신 전체 동의 마스터 키 (마케팅 플러그인 주입) |
|
||||
| marketing_consent_at | null | `null` | marketing consent 일시 |
|
||||
| third_party_consent | boolean | `false` | 제3자 정보 제공 동의 여부 (법적 항목, 마케팅 플러그인 주입) |
|
||||
| third_party_consent_at | null | `null` | third party consent 일시 |
|
||||
| info_disclosure | boolean | `false` | 개인정보 이용 안내 동의 여부 (법적 항목, 마케팅 플러그인 주입) |
|
||||
| info_disclosure_at | null | `null` | info disclosure 일시 |
|
||||
| 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, 마케팅 플러그인 주입) |
|
||||
| consent_histories | array | `[]` | 동의 변경 이력 (원소 channel_key/action/source/created_at, 마케팅 플러그인 주입) |
|
||||
| ecommerce_mileage | object | `{"enabled":false}` | 마일리지 정보 (enabled: 기능 활성 여부, 잔액, 이커머스 모듈 주입) |
|
||||
| ecommerce_preferred_currency | null | `null` | 선호 결제 통화 (이커머스 모듈 주입, 미설정 시 null) |
|
||||
| ecommerce_preferred_shipping_country | null | `null` | 선호 배송 국가 코드 (이커머스 모듈 주입, 미설정 시 null) |
|
||||
| ecommerce_preferred_shipping_country_name | null | `null` | 선호 배송 국가 이름 (코드 파생, 이커머스 모듈 주입, 미설정 시 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",
|
||||
"timezone": "Asia/Seoul",
|
||||
"country": "KR",
|
||||
"status": "active",
|
||||
"status_label": "활성",
|
||||
"status_variant": "success",
|
||||
"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.",
|
||||
"is_super": false,
|
||||
"is_admin": true,
|
||||
"withdrawn_at": null,
|
||||
"last_login_at": "2026-07-07 10:41:24",
|
||||
"last_login_human": "1일 전",
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"is_owner": 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 토큰이 없거나 만료된 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
현재 로그인한 사용자의 프로필 정보를 조회한다. 프론트의 마이페이지(`mypage/profile.json`)와 프로필 수정 화면(`mypage/profile-edit.json`)이 소비한다. 응답은 `UserResource::toProfileArray()` 산물로, 비밀번호 등 민감 필드는 제외된다. 표의 `notify_*`(게시판 모듈)·`marketing_consent*`/`email_subscription*`/`third_party_consent*`/`info_disclosure*`/`channels`/`consent_histories`(마케팅 플러그인)·`ecommerce_*`(이커머스 모듈) 필드는 코어가 아니라 각 확장이 `core.user.filter_resource_data` 훅으로 병합하는 확장 소유 필드이며, 해당 확장이 비활성인 환경에서는 응답에 나타나지 않는다.
|
||||
|
||||
|
||||
### PUT /api/me
|
||||
<!-- @generated:start:api.me.update -->
|
||||
- **라우트명**: `api.me.update`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@update`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| name | body | string | 예 | max 255 | 대상의 이름/명칭 |
|
||||
| nickname | body | string | 아니오 | max 50 | 닉네임 |
|
||||
| email | body | email | 예 | max 255 | 이메일 주소 |
|
||||
| password | body | string | 아니오 | — | 비밀번호 |
|
||||
| current_password | body | string | 아니오 | — | 현재 비밀번호 (변경 전 확인용) |
|
||||
| language | body | string | 아니오 | — | 언어 코드 |
|
||||
| country | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) |
|
||||
| timezone | body | string | 아니오 | — | 타임존 식별자 |
|
||||
| homepage | body | string | 아니오 | max 255 | 홈페이지 URL |
|
||||
| mobile | body | string | 아니오 | max 20 | 휴대전화 번호 |
|
||||
| phone | body | string | 아니오 | max 20 | 전화번호 |
|
||||
| zipcode | body | string | 아니오 | max 10 | 우편번호 |
|
||||
| address | body | string | 아니오 | max 255 | 기본 주소 |
|
||||
| address_detail | body | string | 아니오 | max 255 | 상세 주소 |
|
||||
| signature | body | string | 아니오 | max 1000 | 서명 |
|
||||
| bio | body | string | 아니오 | max 5000 | 자기소개 |
|
||||
| notify_post_complete | body | boolean | 아니오 | — | 게시글 작성 완료 알림 수신 설정 (게시판 모듈 추가) |
|
||||
| notify_post_reply | body | boolean | 아니오 | — | 내 게시글에 대한 답글 알림 수신 설정 (게시판 모듈 추가) |
|
||||
| notify_comment | body | boolean | 아니오 | — | 내 게시글에 대한 댓글 알림 수신 설정 (게시판 모듈 추가) |
|
||||
| notify_reply_comment | body | boolean | 아니오 | — | 내 댓글에 대한 대댓글 알림 수신 설정 (게시판 모듈 추가) |
|
||||
| email_subscription | body | boolean | 아니오 | — | 광고성 이메일 수신 동의 (마케팅 플러그인 추가, 채널) |
|
||||
| marketing_consent | body | boolean | 아니오 | — | 마케팅 정보 수신 전체 동의 마스터 키 (마케팅 플러그인 추가) |
|
||||
| third_party_consent | body | boolean | 아니오 | — | 제3자 정보 제공 동의 (법적 항목, 마케팅 플러그인 추가) |
|
||||
| info_disclosure | body | boolean | 아니오 | — | 개인정보 이용 안내 동의 (법적 항목, 마케팅 플러그인 추가) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.update_profile_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PUT /api/me HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "예시 이름",
|
||||
"nickname": "예시 이름",
|
||||
"email": "user@example.com",
|
||||
"password": "Password123!",
|
||||
"current_password": "Password123!",
|
||||
"language": "ko",
|
||||
"country": "KR",
|
||||
"timezone": "Asia/Seoul",
|
||||
"homepage": "https://example.com",
|
||||
"mobile": "010-1234-5678",
|
||||
"phone": "010-1234-5678",
|
||||
"zipcode": "06234",
|
||||
"address": "서울특별시 강남구 테헤란로 1",
|
||||
"address_detail": "서울특별시 강남구 테헤란로 1",
|
||||
"signature": "예시값",
|
||||
"bio": "예시 내용입니다.",
|
||||
"notify_post_complete": true,
|
||||
"notify_post_reply": true,
|
||||
"notify_comment": true,
|
||||
"notify_reply_comment": true
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
현재 로그인한 사용자가 자신의 프로필을 수정한다. 프론트의 프로필 수정 화면(`partials/mypage/profile/_edit.json`)이 사용한다. `name`·`email` 은 필수이며 `email` 은 본인을 제외한 중복이 허용되지 않는다. 비밀번호를 함께 변경하려면 `password`(+ `password_confirmation`)와 현재 비밀번호(`current_password`)를 함께 보내야 하며, `password` 가 빈 문자열이면 비밀번호 미변경으로 처리된다. `notify_*`·`marketing_consent`·`email_subscription` 등 확장 소유 파라미터는 게시판 모듈·마케팅 플러그인이 `core.user.update_profile_validation_rules` 훅으로 추가하며, 해당 확장이 비활성인 환경에서는 수용되지 않는다. 성공 시 갱신된 프로필이 `UserResource` 형태로 반환된다.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
# Notification Channels API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Notification Channels 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/notification-channels
|
||||
<!-- @generated:start:api.admin.notification-channels.index -->
|
||||
- **라우트명**: `api.admin.notification-channels.index`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationChannelController@index`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/notification-channels HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| channels | array | `[{"id":"mail","name_key":"notification.channels.mail.name…` | 사용 가능한 알림 채널 메타데이터 목록. 각 원소는 `id`(채널 식별자: mail, database 등), `name`/`name_key`·`description`/`description_key`(활성 locale 기준 해석된 라벨/설명과 원본 다국어 키), `icon`(Font Awesome 클래스), `source`(제공 주체: core/module/plugin)·`source_label`(출처 표시 라벨), `allow_guest`(비회원 발송 허용 여부), `readiness`(컨트롤러가 `ChannelReadinessCheckerInterface`로 붙인 채널 설정 완료 여부 정보)로 구성됩니다. config 기본 채널(mail, database)에 `core.notification.filter_available_channels` 훅으로 추가된 확장 채널이 병합됩니다. |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 채널 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"channels": [
|
||||
{
|
||||
"id": "mail",
|
||||
"name_key": "notification.channels.mail.name",
|
||||
"icon": "fas fa-envelope",
|
||||
"description_key": "notification.channels.mail.description",
|
||||
"source": "core",
|
||||
"source_label_key": "notification.channels.core_default",
|
||||
"allow_guest": true,
|
||||
"name": "메일",
|
||||
"description": "이메일로 알림 발송",
|
||||
"source_label": "코어 기본 채널",
|
||||
"readiness": {
|
||||
"ready": false,
|
||||
"reason": "notification.readiness.mail_smtp_host_empty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "database",
|
||||
"name_key": "notification.channels.database.name",
|
||||
"icon": "fas fa-bell",
|
||||
"description_key": "notification.channels.database.description",
|
||||
"source": "core",
|
||||
"source_label_key": "notification.channels.core_default",
|
||||
"allow_guest": false,
|
||||
"name": "사이트내 알림",
|
||||
"description": "사이트내 알림 센터에 표시",
|
||||
"source_label": "코어 기본 채널",
|
||||
"readiness": {
|
||||
"ready": true,
|
||||
"reason": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 시스템에서 사용 가능한 알림 채널 목록을 반환하며, 각 채널에 설정 완료 여부(`readiness`) 정보를 붙여 제공합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요합니다. 플러그인이 Filter 훅으로 채널을 확장할 수 있으므로 목록은 설치된 확장에 따라 달라집니다. 알림 정의·템플릿 편집 화면에서 채널 선택 옵션을 채우고 미설정 채널을 안내할 때 사용합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
# Notification Definitions API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Notification Definitions 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/notification-definitions
|
||||
<!-- @generated:start:api.admin.notification-definitions.index -->
|
||||
- **라우트명**: `api.admin.notification-definitions.index`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@index`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) |
|
||||
| extension_type | query | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) |
|
||||
| extension_identifier | query | string | 아니오 | max 100 | 확장 식별자 |
|
||||
| channel | query | string | 아니오 | max 50 | 채널 필터 — 활성 채널(`channels`) 배열에 이 채널을 포함하는 정의만 조회 (mail, database 등) |
|
||||
| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| sort_by | query | string | 아니오 | `id`, `type`, `extension_type`, `is_active`, `created_at`, `updated_at` | 정렬 기준 필드명 |
|
||||
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_definition.filter_index_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/notification-definitions?search=%EC%98%88%EC%8B%9C%EA%B0%92&extension_type=core&extension_identifier=example-key&channel=%EC%98%88%EC%8B%9C%EA%B0%92&is_active=1&per_page=1&sort_by=id&sort_order=asc HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| number | integer | `1` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) |
|
||||
| id | integer | `1` | 기본 키 (내부 식별자) |
|
||||
| type | string | `welcome` | 알림 타입 (welcome, order_confirmed 등) |
|
||||
| hook_prefix | string | `core.auth` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) |
|
||||
| extension_type | string | `core` | 확장 타입: core, module, plugin |
|
||||
| extension_identifier | string | `core` | 확장 식별자: core, sirsoft-board 등 |
|
||||
| name | object | `{"ko":"회원가입 환영","en":"Welcome","ja":"会員登録 ウェルカム"}` | 다국어 이름 ({"ko": "회원가입 환영", "en": "Welcome"}) |
|
||||
| description | object | `{"ko":"회원가입 완료 시 발송되는 환영 알림","en":"Welcome notification s…` | 다국어 설명 |
|
||||
| variables | array | `[{"key":"name","description":"수신자 이름"},{"key":"app_name",…` | 사용 가능 변수 메타데이터 ([{key, description}]) |
|
||||
| channels | array | `["mail","database"]` | 활성 채널 (["mail", "database"]) |
|
||||
| hooks | array | `["core.auth.after_register"]` | 트리거 훅 목록 (["core.auth.after_register"]) |
|
||||
| is_active | boolean | `true` | active 여부 |
|
||||
| is_default | boolean | `true` | default 여부 |
|
||||
| templates | array | `[{"id":1,"definition_id":1,"channel":"mail","subject":{"k…` | 채널별 알림 템플릿 목록 (templates 관계 로드 시 NotificationTemplateResource 배열, 미로드 시 null) |
|
||||
| created_at | string | `2026-05-27 15:20:18` | 생성 일시 |
|
||||
| updated_at | string | `2026-06-30 13:33:16` | 최종 수정 일시 |
|
||||
| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 정의 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"number": 1,
|
||||
"id": 1,
|
||||
"type": "apidoc-sample.event",
|
||||
"hook_prefix": "core",
|
||||
"extension_type": "core",
|
||||
"extension_identifier": "",
|
||||
"name": {
|
||||
"ko": "API 문서 샘플 알림",
|
||||
"en": "API Doc Sample Notification"
|
||||
},
|
||||
"description": {
|
||||
"ko": "문서 실측용 알림 정의",
|
||||
"en": "Sample notification"
|
||||
},
|
||||
"variables": [],
|
||||
"channels": [
|
||||
"database",
|
||||
"mail"
|
||||
],
|
||||
"hooks": [],
|
||||
"is_active": true,
|
||||
"is_default": false,
|
||||
"templates": [
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 1,
|
||||
"channel": "mail",
|
||||
"subject": "API 문서 샘플 템플릿 제목",
|
||||
"body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.",
|
||||
"click_url": "/admin/apidoc-sample",
|
||||
"recipients": [
|
||||
{
|
||||
"type": "role",
|
||||
"value": "admin",
|
||||
"display_name": "관리자"
|
||||
}
|
||||
],
|
||||
"is_active": true,
|
||||
"is_default": false,
|
||||
"user_overrides": null,
|
||||
"updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d",
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 10:41:24",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 10:41:24",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"id": 2,
|
||||
"type": "order_confirmed",
|
||||
"hook_prefix": "sirsoft-ecommerce",
|
||||
"extension_type": "module",
|
||||
"extension_identifier": "sirsoft-ecommerce",
|
||||
"name": {
|
||||
"ko": "결제 완료",
|
||||
"en": "Payment Completed"
|
||||
},
|
||||
"description": {
|
||||
"ko": "결제 완료 시 고객에게 발송",
|
||||
"en": "Sent to customer when payment is completed"
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"key": "name",
|
||||
"description": "수신자 이름"
|
||||
},
|
||||
{
|
||||
"key": "app_name",
|
||||
"description": "사이트 이름"
|
||||
},
|
||||
{
|
||||
"key": "order_number",
|
||||
"description": "주문번호"
|
||||
},
|
||||
{
|
||||
"key": "total_amount",
|
||||
"description": "결제 금액"
|
||||
},
|
||||
{
|
||||
"key": "order_url",
|
||||
"description": "주문 상세 URL"
|
||||
},
|
||||
{
|
||||
"key": "site_url",
|
||||
"description": "사이트 URL"
|
||||
},
|
||||
{
|
||||
"key": "shipping_recipient_name",
|
||||
"description": "수취인 이름"
|
||||
},
|
||||
{
|
||||
"key": "shipping_country_name",
|
||||
"description": "배송국가명"
|
||||
},
|
||||
{
|
||||
"key": "shipping_address",
|
||||
"description": "배송지 주소"
|
||||
}
|
||||
],
|
||||
"channels": [
|
||||
"mail",
|
||||
"database"
|
||||
],
|
||||
"hooks": [
|
||||
"sirsoft-ecommerce.order.after_confirm"
|
||||
],
|
||||
"is_active": true,
|
||||
"is_default": true,
|
||||
"templates": [
|
||||
{
|
||||
"id": 2,
|
||||
"definition_id": 2,
|
||||
"channel": "mail",
|
||||
"subject": {
|
||||
"ko": "[{app_name}] 결제가 완료되었습니다 (주문번호: {order_number})",
|
||||
"en": "[{app_name}] Your payment has been completed (Order #{order_number})"
|
||||
},
|
||||
"body": {
|
||||
"ko": "<div style=\"font-family:'Malgun Gothic',sans-serif;max-width:600px;margin:0 auto;padding:20px\"><h2 style=\"color:#333;border-bottom:2px solid #4F46E5;padding-bottom:10px\">결제 완료</h2><p style=\"color:#555;line-height:1.6\">{name}님, 결제가 완료되었습니다. 주문해 주셔서 감사합니다.</p><div style=\"background:#f8f9fa;padding:15px;border-radius:8px;margin:15px 0\"><p style=\"margin:5px 0\"><strong>주문번호:</strong> {order_number}</p><p style=\"margin:5px 0\"><strong>결제금액:</strong> {total_amount}</p><p style=\"margin:5px 0\"><strong>받는분:</strong> {shipping_recipient_name}</p><p style=\"margin:5px 0\"><strong>배송국가:</strong> {shipping_country_name}</p><p style=\"margin:5px 0\"><strong>배송지:</strong> {shipping_address}</p></div><p style=\"color:#555;line-height:1.6\">주문 상세 내용은 아래 버튼을 클릭하여 확인하실 수 있습니다.</p><div style=\"text-align:center;margin:25px 0\"><a href=\"{order_url}\" style=\"display:inline-block;padding:12px 30px;background-color:#4F46E5;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:bold\">주문 상세 보기</a></div><hr style=\"border:none;border-top:1px solid #eee;margin:20px 0\"><p style=\"color:#999;font-size:12px\">본 메일은 {app_name}에서 발송되었습니다.</p></div>",
|
||||
"en": "<div style=\"font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px\"><h2 style=\"color:#333;border-bottom:2px solid #4F46E5;padding-bottom:10px\">Payment Completed</h2><p style=\"color:#555;line-height:1.6\">Dear {name}, your payment has been completed. Thank you for your order.</p><div style=\"background:#f8f9fa;padding:15px;border-radius:8px;margin:15px 0\"><p style=\"margin:5px 0\"><strong>Order Number:</strong> {order_number}</p><p style=\"margin:5px 0\"><strong>Total Amount:</strong> {total_amount}</p><p style=\"margin:5px 0\"><strong>Recipient:</strong> {shipping_recipient_name}</p><p style=\"margin:5px 0\"><strong>Shipping Country:</strong> {shipping_country_name}</p><p style=\"margin:5px 0\"><strong>Shipping Address:</strong> {shipping_address}</p></div><p style=\"color:#555;line-height:1.6\">Click the button below to view your order details.</p><div style=\"text-align:center;margin:25px 0\"><a href=\"{order_url}\" style=\"display:inline-block;padding:12px 30px;background-color:#4F46E5;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:bold\">View Order</a></div><hr style=\"border:none;border-top:1px solid #eee;margin:20px 0\"><p style=\"color:#999;font-size:12px\">This email was sent from {app_name}.</p></div>"
|
||||
},
|
||||
"click_url": null,
|
||||
"recipients": [
|
||||
{
|
||||
"type": "trigger_user"
|
||||
}
|
||||
],
|
||||
"is_active": true,
|
||||
"is_default": true,
|
||||
"user_overrides": null,
|
||||
"updated_by": null,
|
||||
"created_at": "2026-07-08 10:43:32",
|
||||
"updated_at": "2026-07-08 10:43:32",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"definition_id": 2,
|
||||
"channel": "database",
|
||||
"subject": {
|
||||
"ko": "결제가 완료되었습니다",
|
||||
"en": "Your payment has been completed"
|
||||
},
|
||||
"body": {
|
||||
"ko": "{name}님, 주문번호 {order_number}의 결제가 완료되었습니다.",
|
||||
"en": "{name}, payment for your order {order_number} has been completed."
|
||||
},
|
||||
"click_url": "{order_url}",
|
||||
"recipients": [
|
||||
{
|
||||
"type": "trigger_user"
|
||||
}
|
||||
],
|
||||
"is_active": true,
|
||||
"is_default": true,
|
||||
"user_overrides": null,
|
||||
"updated_by": null,
|
||||
"created_at": "2026-07-08 10:43:32",
|
||||
"updated_at": "2026-07-08 10:43:32",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"created_at": "2026-07-08 10:43:32",
|
||||
"updated_at": "2026-07-08 10:43:32",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
},
|
||||
"... (총 18건 중 2건 표시)"
|
||||
],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"last_page": 1,
|
||||
"per_page": 25,
|
||||
"total": 18,
|
||||
"from": 1,
|
||||
"to": 18,
|
||||
"has_more_pages": false
|
||||
},
|
||||
"abilities": {
|
||||
"can_update": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 등록된 알림 정의 목록을 페이지네이션으로 조회합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요합니다. `search`, `extension_type`, `extension_identifier`, `channel`, `is_active` 로 필터링하고 `sort_by`/`sort_order` 로 정렬하며, 확장이 `core.notification_definition.filter_index_rules` 훅으로 필터를 추가할 수 있습니다. 관리자 알림 정의 관리 목록 화면을 렌더링할 때 사용합니다.
|
||||
|
||||
|
||||
### GET /api/admin/notification-definitions/{definition}
|
||||
<!-- @generated:start:api.admin.notification-definitions.show -->
|
||||
- **라우트명**: `api.admin.notification-definitions.show`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@show`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| definition | path | string | 예 | — | 대상 definition의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/notification-definitions/1 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `21` | 기본 키 (내부 식별자) |
|
||||
| type | string | `apidoc-sample.event` | 알림 타입 (welcome, order_confirmed 등) |
|
||||
| hook_prefix | string | `core` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) |
|
||||
| extension_type | string | `core` | 확장 타입: core, module, plugin |
|
||||
| extension_identifier | string | `` | 확장 식별자: core, sirsoft-board 등 |
|
||||
| name | object | `{"ko":"API 문서 샘플 알림","en":"API Doc Sample Notification"}` | 다국어 이름 ({"ko": "회원가입 환영", "en": "Welcome"}) |
|
||||
| description | object | `{"ko":"문서 실측용 알림 정의","en":"Sample notification"}` | 다국어 설명 |
|
||||
| variables | array | `[]` | 사용 가능 변수 메타데이터 ([{key, description}]) |
|
||||
| channels | array | `["database","mail"]` | 활성 채널 (["mail", "database"]) |
|
||||
| hooks | array | `[]` | 트리거 훅 목록 (["core.auth.after_register"]) |
|
||||
| is_active | boolean | `true` | active 여부 |
|
||||
| is_default | boolean | `false` | default 여부 |
|
||||
| templates | array | `[{"id":41,"definition_id":21,"channel":"mail","subject":"…` | 채널별 알림 템플릿 목록 (templates 관계 로드 시 NotificationTemplateResource 배열, 미로드 시 null) |
|
||||
| created_at | string | `2026-07-06 19:15:16` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-06 19:15:16` | 최종 수정 일시 |
|
||||
| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 정의를 조회했습니다.",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"type": "apidoc-sample.event",
|
||||
"hook_prefix": "core",
|
||||
"extension_type": "core",
|
||||
"extension_identifier": "",
|
||||
"name": {
|
||||
"ko": "API 문서 샘플 알림",
|
||||
"en": "API Doc Sample Notification"
|
||||
},
|
||||
"description": {
|
||||
"ko": "문서 실측용 알림 정의",
|
||||
"en": "Sample notification"
|
||||
},
|
||||
"variables": [],
|
||||
"channels": [
|
||||
"database",
|
||||
"mail"
|
||||
],
|
||||
"hooks": [],
|
||||
"is_active": true,
|
||||
"is_default": false,
|
||||
"templates": [
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 1,
|
||||
"channel": "mail",
|
||||
"subject": "API 문서 샘플 템플릿 제목",
|
||||
"body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.",
|
||||
"click_url": "/admin/apidoc-sample",
|
||||
"recipients": [
|
||||
{
|
||||
"type": "role",
|
||||
"value": "admin",
|
||||
"display_name": "관리자"
|
||||
}
|
||||
],
|
||||
"is_active": true,
|
||||
"is_default": false,
|
||||
"user_overrides": null,
|
||||
"updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d",
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 10:41:24",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 10:41:24",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 단일 알림 정의의 상세 정보를 조회하며, 응답에 소속 템플릿(`templates`)을 함께 로드합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요합니다. `definition` 경로 파라미터로 대상을 지정하며, 정의 편집 화면 진입 시 채널별 템플릿을 포함한 전체 구성을 불러올 때 사용합니다.
|
||||
|
||||
|
||||
### PUT /api/admin/notification-definitions/{definition}
|
||||
<!-- @generated:start:api.admin.notification-definitions.update -->
|
||||
- **라우트명**: `api.admin.notification-definitions.update`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@update`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| definition | path | string | 예 | — | 대상 definition의 식별자 |
|
||||
| channels | body | array | 아니오 | min 1 | 활성 채널 목록 — 이 정의가 발송에 사용할 채널 배열 (각 원소 최대 50자, mail·database 등). 지정 시 최소 1개 필요 |
|
||||
| hooks | body | array | 아니오 | — | 트리거 훅 목록 — 이 알림을 발송시키는 훅 이름 배열 (각 원소 최대 255자, core.auth.after_register 등) |
|
||||
| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_definition.filter_update_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PUT /api/admin/notification-definitions/1 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"channels": [
|
||||
"예시값"
|
||||
],
|
||||
"hooks": [
|
||||
"예시값"
|
||||
],
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 알림 정의의 활성 채널(`channels`), 트리거 훅(`hooks`), 활성 상태(`is_active`)를 수정합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. Service 계층에서 수정 후 템플릿을 다시 로드해 반환하며, 확장이 `core.notification_definition.filter_update_rules` 훅으로 추가 파라미터를 검증에 넣을 수 있습니다. 발송 채널 구성이나 훅 연결을 변경할 때 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/notification-definitions/{definition}/reset
|
||||
<!-- @generated:start:api.admin.notification-definitions.reset -->
|
||||
- **라우트명**: `api.admin.notification-definitions.reset`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@reset`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| definition | path | string | 예 | — | 대상 definition의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/notification-definitions/1/reset HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `1` | 기본 키 (내부 식별자) |
|
||||
| type | string | `apidoc-sample.event` | 알림 타입 (welcome, order_confirmed 등) |
|
||||
| hook_prefix | string | `core` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) |
|
||||
| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) |
|
||||
| extension_identifier | string | `` | 이 리소스를 소유한 확장의 식별자 |
|
||||
| name | object | `{"ko":"API 문서 샘플 알림","en":"API Doc Sample Notification"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) |
|
||||
| description | object | `{"ko":"문서 실측용 알림 정의","en":"Sample notification"}` | 설명 (다국어 필드는 로케일별 값 객체) |
|
||||
| variables | array | `[]` | 사용 가능 변수 메타데이터 ([{key, description}]) |
|
||||
| channels | array | `["database","mail"]` | 활성 채널 (["mail", "database"]) |
|
||||
| hooks | array | `[]` | 트리거 훅 목록 (["core.auth.after_register"]) |
|
||||
| is_active | boolean | `true` | active 여부 |
|
||||
| is_default | boolean | `true` | default 여부 |
|
||||
| templates | array | `[{"id":1,"definition_id":1,"channel":"mail","subject":"AP…` | 템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생) |
|
||||
| created_at | string | `2026-07-08 10:41:24` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-08 12:14:43` | 최종 수정 일시 |
|
||||
| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 정의의 모든 템플릿이 기본값으로 복원되었습니다.",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"type": "apidoc-sample.event",
|
||||
"hook_prefix": "core",
|
||||
"extension_type": "core",
|
||||
"extension_identifier": "",
|
||||
"name": {
|
||||
"ko": "API 문서 샘플 알림",
|
||||
"en": "API Doc Sample Notification"
|
||||
},
|
||||
"description": {
|
||||
"ko": "문서 실측용 알림 정의",
|
||||
"en": "Sample notification"
|
||||
},
|
||||
"variables": [],
|
||||
"channels": [
|
||||
"database",
|
||||
"mail"
|
||||
],
|
||||
"hooks": [],
|
||||
"is_active": true,
|
||||
"is_default": true,
|
||||
"templates": [
|
||||
{
|
||||
"id": 1,
|
||||
"definition_id": 1,
|
||||
"channel": "mail",
|
||||
"subject": "API 문서 샘플 템플릿 제목",
|
||||
"body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.",
|
||||
"click_url": "/admin/apidoc-sample",
|
||||
"recipients": [
|
||||
{
|
||||
"type": "role",
|
||||
"value": "admin",
|
||||
"display_name": "관리자"
|
||||
}
|
||||
],
|
||||
"is_active": true,
|
||||
"is_default": false,
|
||||
"user_overrides": null,
|
||||
"updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d",
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 10:41:24",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 12:14:43",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 알림 정의에 속한 모든 채널 템플릿을 기본값(default) 데이터로 일괄 복원하고, 정의 자체를 default 상태로 표시합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. 각 템플릿의 제목·본문을 기본값으로 덮어쓰는 파괴적 작업이므로 사용자 편집분이 사라집니다. 관리자가 커스터마이징한 알림 문구를 초기 상태로 되돌릴 때 사용합니다.
|
||||
|
||||
|
||||
### PATCH /api/admin/notification-definitions/{definition}/toggle-active
|
||||
<!-- @generated:start:api.admin.notification-definitions.toggle-active -->
|
||||
- **라우트명**: `api.admin.notification-definitions.toggle-active`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationDefinitionController@toggleActive`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| definition | path | string | 예 | — | 대상 definition의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PATCH /api/admin/notification-definitions/1/toggle-active HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `1` | 기본 키 (내부 식별자) |
|
||||
| type | string | `apidoc-sample.event` | 알림 타입 (welcome, order_confirmed 등) |
|
||||
| hook_prefix | string | `core` | 훅 접두사 (core.auth, sirsoft-ecommerce 등) |
|
||||
| extension_type | string | `core` | 이 리소스를 소유한 확장의 타입 (core/module/plugin/template) |
|
||||
| extension_identifier | string | `` | 이 리소스를 소유한 확장의 식별자 |
|
||||
| name | object | `{"ko":"API 문서 샘플 알림","en":"API Doc Sample Notification"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) |
|
||||
| description | object | `{"ko":"문서 실측용 알림 정의","en":"Sample notification"}` | 설명 (다국어 필드는 로케일별 값 객체) |
|
||||
| variables | array | `[]` | 사용 가능 변수 메타데이터 ([{key, description}]) |
|
||||
| channels | array | `["database","mail"]` | 활성 채널 (["mail", "database"]) |
|
||||
| hooks | array | `[]` | 트리거 훅 목록 (["core.auth.after_register"]) |
|
||||
| is_active | boolean | `false` | active 여부 |
|
||||
| is_default | boolean | `false` | default 여부 |
|
||||
| templates | null | `null` | 템플릿 목록 (각 원소 identifier/name 등 — 템플릿 관계 파생) |
|
||||
| created_at | string | `2026-07-08 10:41:24` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-08 12:14:43` | 최종 수정 일시 |
|
||||
| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 정의 활성 상태가 변경되었습니다.",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"type": "apidoc-sample.event",
|
||||
"hook_prefix": "core",
|
||||
"extension_type": "core",
|
||||
"extension_identifier": "",
|
||||
"name": {
|
||||
"ko": "API 문서 샘플 알림",
|
||||
"en": "API Doc Sample Notification"
|
||||
},
|
||||
"description": {
|
||||
"ko": "문서 실측용 알림 정의",
|
||||
"en": "Sample notification"
|
||||
},
|
||||
"variables": [],
|
||||
"channels": [
|
||||
"database",
|
||||
"mail"
|
||||
],
|
||||
"hooks": [],
|
||||
"is_active": false,
|
||||
"is_default": false,
|
||||
"templates": null,
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 12:14:43",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 알림 정의의 활성 상태(`is_active`)를 현재 값의 반대로 토글합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. 비활성 정의는 해당 알림 발송이 중단되므로, 관리자가 목록에서 특정 알림을 켜거나 끄는 스위치 조작에 사용합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# Notification Logs API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Notification Logs 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/notification-logs
|
||||
<!-- @generated:start:api.admin.notification-logs.index -->
|
||||
- **라우트명**: `api.admin.notification-logs.index`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationLogController@index`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notification-logs.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| sender_user_id | query | integer | 아니오 | — | sender user 식별자 |
|
||||
| recipient_user_id | query | integer | 아니오 | — | recipient user 식별자 |
|
||||
| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) |
|
||||
| channel | query | string | 아니오 | max 50 | 발송 채널 필터 (해당 채널로 발송된 이력만 조회 — mail, database, fcm 등) |
|
||||
| notification_type | query | string | 아니오 | max 100 | 알림 타입 필터 (해당 타입의 이력만 조회 — welcome, order_confirmed 등) |
|
||||
| extension_type | query | string | 아니오 | `core`, `module`, `plugin` | 확장 유형 (core/module/plugin/template) |
|
||||
| status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| sort_by | query | string | 아니오 | `id`, `channel`, `notification_type`, `status`, `sent_at`, `created_at` | 정렬 기준 필드명 |
|
||||
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_log.filter_index_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/notification-logs?sender_user_id=1&recipient_user_id=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&channel=%EC%98%88%EC%8B%9C%EA%B0%92¬ification_type=%EC%98%88%EC%8B%9C%EA%B0%92&extension_type=core&status=%EC%98%88%EC%8B%9C%EA%B0%92&per_page=1&sort_by=id&sort_order=asc HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| number | integer | `677` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) |
|
||||
| id | integer | `677` | 기본 키 (내부 식별자) |
|
||||
| channel | string | `mail` | 채널: mail, database, fcm 등 |
|
||||
| notification_type | string | `apidoc.sample.event` | 알림 타입: welcome, order_confirmed 등 |
|
||||
| extension_type | string | `core` | 확장 타입: core, module, plugin |
|
||||
| extension_identifier | string | `` | 확장 식별자 |
|
||||
| recipient_user_id | integer | `166` | recipient user 식별자 (연관 리소스 참조) |
|
||||
| recipient_identifier | string | `apidoc-sample-user@example.com` | 수신자 식별자 (채널별: 이메일, 디바이스토큰, user_id 등) |
|
||||
| recipient_name | string | `API 문서 샘플 사용자` | 수신자 표시명 (발송 시점 스냅샷) |
|
||||
| sender_user_id | integer | `166` | sender user 식별자 (연관 리소스 참조) |
|
||||
| sender | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 발신자 사용자 객체 (uuid/name/email — senderUser 관계 파생) |
|
||||
| recipient | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 수신자 사용자 객체 (uuid/name/email — recipientUser 관계 파생) |
|
||||
| subject | string | `API 문서 샘플 알림` | 렌더링된 제목 |
|
||||
| body | string | `문서 실측용 알림 본문입니다.` | 렌더링된 본문 |
|
||||
| status | string | `sent` | 상태: sent, failed, skipped |
|
||||
| error_message | string | `해당 채널은 비회원 발송을 허용하지 않아 발송을 건너뛰었습니다.` | 에러 메시지 |
|
||||
| source | string | `apidoc` | 발송 출처: notification, test_mail 등 |
|
||||
| sent_at | string | `2026-07-06 18:20:23` | sent 일시 |
|
||||
| created_at | string | `2026-07-06 19:20:23` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-06 19:20:23` | 최종 수정 일시 |
|
||||
| abilities | object | `{"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 발송 이력을 조회했습니다.",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"number": 1,
|
||||
"id": 1,
|
||||
"channel": "mail",
|
||||
"notification_type": "apidoc.sample.event",
|
||||
"extension_type": "core",
|
||||
"extension_identifier": "",
|
||||
"recipient_user_id": 6,
|
||||
"recipient_identifier": "apidoc-sample-user@example.com",
|
||||
"recipient_name": "API 문서 샘플 사용자",
|
||||
"sender_user_id": 6,
|
||||
"sender": {
|
||||
"uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
|
||||
"name": "API 문서 샘플 사용자",
|
||||
"email": "apidoc-sample-user@example.com"
|
||||
},
|
||||
"recipient": {
|
||||
"uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d",
|
||||
"name": "API 문서 샘플 사용자",
|
||||
"email": "apidoc-sample-user@example.com"
|
||||
},
|
||||
"subject": "API 문서 샘플 알림",
|
||||
"body": "문서 실측용 알림 본문입니다.",
|
||||
"status": "sent",
|
||||
"error_message": null,
|
||||
"source": "apidoc",
|
||||
"sent_at": "2026-07-08 09:41:24",
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 10:41:24",
|
||||
"abilities": {
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"last_page": 1,
|
||||
"per_page": 25,
|
||||
"total": 1,
|
||||
"from": 1,
|
||||
"to": 1,
|
||||
"has_more_pages": false
|
||||
},
|
||||
"abilities": {
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notification-logs.read`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 알림 발송 이력을 페이지네이션으로 조회합니다. 인증(`auth:sanctum`)과 `core.notification-logs.read` 권한이 필요합니다. 발송자/수신자 ID, `search`, `channel`, `notification_type`, `extension_type`, `status` 로 필터링하고 `sort_by`/`sort_order` 로 정렬하며, 확장이 `core.notification_log.filter_index_rules` 훅으로 필터를 추가할 수 있습니다. 요청 사용자(`request->user()`)를 Service 에 전달해 열람 범위를 결정하며, 관리자 알림 발송 이력 화면을 렌더링할 때 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/notification-logs/bulk-delete
|
||||
<!-- @generated:start:api.admin.notification-logs.bulk-destroy -->
|
||||
- **라우트명**: `api.admin.notification-logs.bulk-destroy`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationLogController@bulkDestroy`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notification-logs.delete`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/notification-logs/bulk-delete HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ids": [
|
||||
"예시값"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notification-logs.delete`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 알림 발송 이력을 ID 배열(`ids`)로 다건 삭제하고 삭제 건수(`deleted_count`)를 반환합니다. 인증(`auth:sanctum`)과 `core.notification-logs.delete` 권한이 필요합니다. 복구 불가능한 삭제이므로 주의가 필요하며, 관리자가 목록에서 여러 이력을 선택해 일괄 정리할 때 사용합니다.
|
||||
|
||||
|
||||
### DELETE /api/admin/notification-logs/{notificationLog}
|
||||
<!-- @generated:start:api.admin.notification-logs.destroy -->
|
||||
- **라우트명**: `api.admin.notification-logs.destroy`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationLogController@destroy`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notification-logs.delete`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| notificationLog | path | string | 예 | — | 대상 notification log의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/admin/notification-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.notification-logs.delete`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 단일 알림 발송 이력을 삭제합니다. 인증(`auth:sanctum`)과 `core.notification-logs.delete` 권한이 필요합니다. `notificationLog` 경로 파라미터로 대상을 지정하며, 복구 불가능한 삭제입니다. 관리자가 개별 발송 이력 한 건을 제거할 때 사용합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# Notification Templates API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Notification Templates 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### POST /api/admin/notification-templates/preview
|
||||
<!-- @generated:start:api.admin.notification-templates.preview -->
|
||||
- **라우트명**: `api.admin.notification-templates.preview`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationTemplateController@preview`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| definition_id | body | integer | 예 | — | definition 식별자 |
|
||||
| subject | body | array | 예 | — | 제목 |
|
||||
| body | body | array | 예 | — | 본문 |
|
||||
| locale | body | string | 아니오 | max 10 | 로케일 코드 (표시 언어/지역) |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/notification-templates/preview HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"definition_id": 1,
|
||||
"subject": [
|
||||
"예시값"
|
||||
],
|
||||
"body": [
|
||||
"예시값"
|
||||
],
|
||||
"locale": "ko"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 저장 전 알림 템플릿의 렌더링 결과를 미리 확인합니다. `definition_id` 와 다국어 `subject`/`body`, 선택적 `locale` 을 받아 샘플 변수로 치환된 제목·본문을 반환합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요하며, 실제 발송이나 저장은 일어나지 않습니다. 템플릿 편집 화면에서 변수 치환 결과를 실시간으로 확인할 때 사용합니다.
|
||||
|
||||
|
||||
### PUT /api/admin/notification-templates/{template}
|
||||
<!-- @generated:start:api.admin.notification-templates.update -->
|
||||
- **라우트명**: `api.admin.notification-templates.update`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationTemplateController@update`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| template | path | string | 예 | — | 대상 template의 식별자 |
|
||||
| subject | body | array | 예 | — | 제목 |
|
||||
| body | body | array | 예 | — | 본문 |
|
||||
| click_url | body | string | 아니오 | max 500 | 알림 클릭 시 이동할 대상 URL (미설정 시 이동 없음) |
|
||||
| recipients | body | array | 아니오 | — | 수신자 규칙 목록. 각 원소는 type(trigger_user: 이벤트 유발 사용자, related_user: 연관 사용자, role: 역할 대상, specific_users: 지정 사용자), value(대상 식별값), relation(연관 사용자 관계명), exclude_trigger_user(유발 사용자 제외 여부)로 구성 |
|
||||
| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_template.filter_update_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PUT /api/admin/notification-templates/1 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"subject": [
|
||||
"예시값"
|
||||
],
|
||||
"body": [
|
||||
"예시값"
|
||||
],
|
||||
"click_url": "https://example.com",
|
||||
"recipients": [
|
||||
"예시값"
|
||||
],
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 단일 채널 알림 템플릿의 다국어 제목(`subject`)·본문(`body`)과 클릭 URL, 수신자(`recipients`), 활성 상태를 수정합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. `template` 경로 파라미터로 대상을 지정하며, 확장이 `core.notification_template.filter_update_rules` 훅으로 추가 파라미터를 검증에 넣을 수 있습니다. 관리자가 특정 채널의 알림 문구를 편집해 저장할 때 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/notification-templates/{template}/reset
|
||||
<!-- @generated:start:api.admin.notification-templates.reset -->
|
||||
- **라우트명**: `api.admin.notification-templates.reset`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationTemplateController@reset`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| template | path | string | 예 | — | 대상 template의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/notification-templates/1/reset HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-404 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 단일 채널 템플릿을 소속 정의의 기본값 데이터로 복원합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. 소속 정의가 없으면 404, 해당 채널의 기본 데이터가 없으면 404 를 반환합니다. 편집한 문구를 버리고 기본값 하나만 되돌릴 때 사용하며, 정의 전체를 복원하는 정의 reset 과 달리 대상 템플릿에만 적용됩니다.
|
||||
|
||||
|
||||
### PATCH /api/admin/notification-templates/{template}/toggle-active
|
||||
<!-- @generated:start:api.admin.notification-templates.toggle-active -->
|
||||
- **라우트명**: `api.admin.notification-templates.toggle-active`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationTemplateController@toggleActive`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| template | path | string | 예 | — | 대상 template의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PATCH /api/admin/notification-templates/1/toggle-active HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| id | integer | `1` | 기본 키 (내부 식별자) |
|
||||
| definition_id | integer | `1` | definition 식별자 (연관 리소스 참조) |
|
||||
| channel | string | `mail` | 채널: mail, database, fcm |
|
||||
| subject | string | `API 문서 샘플 템플릿 제목` | 다국어 제목 ({"ko": "...", "en": "..."}) |
|
||||
| body | string | `안녕하세요 {{name}} 님, 문서 실측용 본문입니다.` | 다국어 본문 ({"ko": "...", "en": "..."}) |
|
||||
| click_url | string | `/admin/apidoc-sample` | click URL |
|
||||
| recipients | array | `[{"type":"role","value":"admin","display_name":"관리자"}]` | 수신자 규칙 JSON ([{type, value, relation, exclude_trigger_user}]) |
|
||||
| is_active | boolean | `false` | active 여부 |
|
||||
| is_default | boolean | `false` | default 여부 |
|
||||
| user_overrides | array | `["is_active"]` | 사용자가 수정한 필드명 목록 |
|
||||
| updated_by | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null) |
|
||||
| created_at | string | `2026-07-08 10:41:24` | 생성 일시 |
|
||||
| updated_at | string | `2026-07-08 12:14:43` | 최종 수정 일시 |
|
||||
| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 템플릿 활성 상태가 변경되었습니다.",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"definition_id": 1,
|
||||
"channel": "mail",
|
||||
"subject": "API 문서 샘플 템플릿 제목",
|
||||
"body": "안녕하세요 {{name}} 님, 문서 실측용 본문입니다.",
|
||||
"click_url": "/admin/apidoc-sample",
|
||||
"recipients": [
|
||||
{
|
||||
"type": "role",
|
||||
"value": "admin",
|
||||
"display_name": "관리자"
|
||||
}
|
||||
],
|
||||
"is_active": false,
|
||||
"is_default": false,
|
||||
"user_overrides": [
|
||||
"is_active"
|
||||
],
|
||||
"updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d",
|
||||
"created_at": "2026-07-08 10:41:24",
|
||||
"updated_at": "2026-07-08 12:14:43",
|
||||
"abilities": {
|
||||
"can_update": true,
|
||||
"can_delete": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** 단일 채널 알림 템플릿의 활성 상태(`is_active`)를 현재 값의 반대로 토글합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. 비활성 템플릿은 해당 채널로의 발송이 중단되므로, 정의는 유지한 채 특정 채널만 켜거나 끌 때 사용합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
# Notifications API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Notifications 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/notifications
|
||||
<!-- @generated:start:api.admin.notifications.index -->
|
||||
- **라우트명**: `api.admin.notifications.index`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@index`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notifications.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| read | query | string | 아니오 | `unread`, `read`, `all` | 읽음 상태 필터 (unread: 미읽음(`read_at` null)만, read: 읽음(`read_at` not null)만, all: 전체). 미지정 시 전체 |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
||||
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_index_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/notifications?read=unread&per_page=1&page=1&sort_order=asc HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
|
||||
<!-- 실측 응답에 필드 없음(빈 목록 등) — 데이터가 있는 상태로 재실측하거나 사람이 작성. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"last_page": 1,
|
||||
"per_page": 25,
|
||||
"total": 0,
|
||||
"from": null,
|
||||
"to": null,
|
||||
"has_more_pages": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증된 관리자 본인의 사이트내 알림 목록을 최신순(`created_at desc`)으로 페이지네이션해 반환합니다. `read` 파라미터로 미읽음(`unread`)·읽음(`read`)·전체(`all`, 기본)를 필터링하고, `per_page` 미지정 시 15건 단위로 반환합니다. 조회 대상은 항상 요청 사용자 본인의 알림으로 한정되며, 다른 사용자의 알림은 조회되지 않습니다. `_admin_base.json` 헤더의 알림 벨이 이 엔드포인트를 auto_fetch 로 소비하며 WebSocket 알림 수신 시 갱신됩니다. 항목 필드는 `id`, `type`, `type_label`, `subject`, `body`, `url`, `read_at`, `created_at` 로 구성됩니다.
|
||||
|
||||
|
||||
### DELETE /api/admin/notifications/all
|
||||
<!-- @generated:start:api.admin.notifications.destroy-all -->
|
||||
- **라우트명**: `api.admin.notifications.destroy-all`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@destroyAll`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notifications.delete`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/admin/notifications/all HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| deleted_count | integer | `0` | deleted 개수 (집계) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "모든 알림이 삭제되었습니다.",
|
||||
"data": {
|
||||
"deleted_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notifications.delete`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증된 관리자 본인의 모든 사이트내 알림(읽음·미읽음 무관)을 삭제합니다. 삭제 대상은 요청 사용자 본인의 알림으로만 한정되며, 응답 `data.deleted_count` 에 삭제된 건수를 반환합니다. 되돌릴 수 없는 작업이므로 UI 에서 확인 절차를 거친 뒤 호출합니다.
|
||||
|
||||
|
||||
### POST /api/admin/notifications/read-all
|
||||
<!-- @generated:start:api.admin.notifications.read-all -->
|
||||
- **라우트명**: `api.admin.notifications.read-all`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@markAllAsRead`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notifications.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/notifications/read-all HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| marked_count | integer | `0` | marked 개수 (집계) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "모든 알림을 읽음 처리했습니다.",
|
||||
"data": {
|
||||
"marked_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증된 관리자 본인의 미읽음 알림을 모두 읽음 처리합니다. 처리 대상 `read_at` 을 현재 시각으로 갱신하며, 응답 `data.marked_count` 에 읽음 처리된 건수를 반환합니다. 이미 읽음 상태인 알림은 대상에서 제외됩니다.
|
||||
|
||||
|
||||
### POST /api/admin/notifications/read-batch
|
||||
<!-- @generated:start:api.admin.notifications.read-batch -->
|
||||
- **라우트명**: `api.admin.notifications.read-batch`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@markBatchAsRead`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notifications.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ids | body | array | 예 | min 1, max 100 | 대상 리소스 식별자 배열 (대량 작업 대상) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_batch_read_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/notifications/read-batch HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ids": [
|
||||
"예시값"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
`ids` 배열로 지정한 알림들을 일괄 읽음 처리합니다. 처리 대상은 요청 사용자 본인의 미읽음 알림 중 지정된 ID 에 해당하는 것으로 한정되며, 이미 읽음 상태이거나 본인 소유가 아닌 ID 는 무시됩니다. 응답 `data.marked_count` 에 실제 읽음 처리된 건수를 반환합니다. `ids` 는 최소 1개, 최대 100개까지 전달할 수 있습니다.
|
||||
|
||||
|
||||
### GET /api/admin/notifications/unread-count
|
||||
<!-- @generated:start:api.admin.notifications.unread-count -->
|
||||
- **라우트명**: `api.admin.notifications.unread-count`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@unreadCount`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notifications.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/notifications/unread-count HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| unread_count | integer | `0` | unread 개수 (집계) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "미읽음 알림 수를 조회했습니다.",
|
||||
"data": {
|
||||
"unread_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notifications.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증된 관리자 본인의 미읽음 알림 개수를 집계해 `data.unread_count` 로 반환합니다. `_admin_base.json` 헤더 알림 벨의 미읽음 배지에 사용되며, WebSocket 으로 새 알림이 수신되면 이 값을 재조회해 갱신합니다.
|
||||
|
||||
|
||||
### DELETE /api/admin/notifications/{notification}
|
||||
<!-- @generated:start:api.admin.notifications.destroy -->
|
||||
- **라우트명**: `api.admin.notifications.destroy`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@destroy`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notifications.delete`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| notification | path | string | 예 | — | 대상 notification의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/admin/notifications/{notification} HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notifications.delete`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
경로의 `{notification}` ID 에 해당하는 알림 1건을 삭제합니다. 대상은 요청 사용자 본인의 알림으로 한정되며, 해당 ID 가 본인 소유로 존재하지 않으면 404(`notification.user.not_found`)를 반환합니다.
|
||||
|
||||
|
||||
### PATCH /api/admin/notifications/{notification}/read
|
||||
<!-- @generated:start:api.admin.notifications.read -->
|
||||
- **라우트명**: `api.admin.notifications.read`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\NotificationController@markAsRead`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.notifications.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| notification | path | string | 예 | — | 대상 notification의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PATCH /api/admin/notifications/{notification}/read HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.notifications.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
경로의 `{notification}` ID 에 해당하는 알림 1건을 읽음 처리하고, 갱신된 알림 리소스를 `data` 로 반환합니다. 대상은 요청 사용자(관리자) 본인의 알림으로 한정되며, 본인 소유로 존재하지 않으면 404(`notification.user.not_found`)를 반환합니다. 반환 리소스에는 `id`, `type`, `type_label`, `subject`, `body`, `url`, `read_at`, `created_at` 가 포함됩니다.
|
||||
|
||||
|
||||
### GET /api/user/notifications
|
||||
<!-- @generated:start:api.user.notifications.index -->
|
||||
- **라우트명**: `api.user.notifications.index`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@index`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| read | query | string | 아니오 | `unread`, `read`, `all` | 읽음 상태 필터 (unread: 미읽음(`read_at` null)만, read: 읽음(`read_at` not null)만, all: 전체). 미지정 시 전체 |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
||||
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_index_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/user/notifications?read=unread&per_page=1&page=1&sort_order=asc HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
|
||||
|
||||
<!-- 실측 응답에 필드 없음(빈 목록 등) — 데이터가 있는 상태로 재실측하거나 사람이 작성. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "알림 목록을 조회했습니다.",
|
||||
"data": {
|
||||
"data": [],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"last_page": 1,
|
||||
"per_page": 25,
|
||||
"total": 0,
|
||||
"from": null,
|
||||
"to": null,
|
||||
"has_more_pages": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.user-notifications.read`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증된 사용자 본인의 사이트내 알림 목록을 최신순(`created_at desc`)으로 페이지네이션해 반환합니다. `read` 파라미터로 미읽음(`unread`)·읽음(`read`)·전체(`all`, 기본)를 필터링하고, `per_page` 미지정 시 20건 단위로 반환합니다. 조회 대상은 항상 요청 사용자 본인의 알림으로 한정됩니다. `_user_base.json` 이 이 엔드포인트를 소비하며 WebSocket 알림 수신 시 갱신됩니다. 관리자 스코프(`/api/admin/notifications`)와 동일한 서비스·리소스를 사용하되 권한(`core.user-notifications.*`)과 기본 페이지 크기(20건)가 다릅니다.
|
||||
|
||||
|
||||
### DELETE /api/user/notifications/all
|
||||
<!-- @generated:start:api.user.notifications.destroy-all -->
|
||||
- **라우트명**: `api.user.notifications.destroy-all`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@destroyAll`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.delete`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/user/notifications/all HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| deleted_count | integer | `0` | deleted 개수 (집계) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "모든 알림이 삭제되었습니다.",
|
||||
"data": {
|
||||
"deleted_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.user-notifications.delete`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증된 사용자 본인의 모든 사이트내 알림(읽음·미읽음 무관)을 삭제합니다. 삭제 대상은 요청 사용자 본인의 알림으로만 한정되며, 응답 `data.deleted_count` 에 삭제된 건수를 반환합니다. 되돌릴 수 없는 작업입니다.
|
||||
|
||||
|
||||
### POST /api/user/notifications/read-all
|
||||
<!-- @generated:start:api.user.notifications.read-all -->
|
||||
- **라우트명**: `api.user.notifications.read-all`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@markAllAsRead`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/user/notifications/read-all HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| marked_count | integer | `0` | marked 개수 (집계) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "모든 알림을 읽음 처리했습니다.",
|
||||
"data": {
|
||||
"marked_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증된 사용자 본인의 미읽음 알림을 모두 읽음 처리합니다. 처리 대상 `read_at` 을 현재 시각으로 갱신하며, 응답 `data.marked_count` 에 읽음 처리된 건수를 반환합니다. 이미 읽음 상태인 알림은 대상에서 제외됩니다.
|
||||
|
||||
|
||||
### POST /api/user/notifications/read-batch
|
||||
<!-- @generated:start:api.user.notifications.read-batch -->
|
||||
- **라우트명**: `api.user.notifications.read-batch`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@markBatchAsRead`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ids | body | array | 예 | min 1, max 100 | 대상 리소스 식별자 배열 (대량 작업 대상) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_batch_read_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/user/notifications/read-batch HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ids": [
|
||||
"예시값"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
`ids` 배열로 지정한 알림들을 일괄 읽음 처리합니다. 처리 대상은 요청 사용자 본인의 미읽음 알림 중 지정된 ID 에 해당하는 것으로 한정되며, 이미 읽음 상태이거나 본인 소유가 아닌 ID 는 무시됩니다. 응답 `data.marked_count` 에 실제 읽음 처리된 건수를 반환합니다. `ids` 는 최소 1개, 최대 100개까지 전달할 수 있습니다.
|
||||
|
||||
|
||||
### GET /api/user/notifications/unread-count
|
||||
<!-- @generated:start:api.user.notifications.unread-count -->
|
||||
- **라우트명**: `api.user.notifications.unread-count`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@unreadCount`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/user/notifications/unread-count HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| unread_count | integer | `0` | unread 개수 (집계) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "미읽음 알림 수를 조회했습니다.",
|
||||
"data": {
|
||||
"unread_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.user-notifications.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
인증된 사용자 본인의 미읽음 알림 개수를 집계해 `data.unread_count` 로 반환합니다. `_user_base.json` 의 알림 미읽음 배지에 사용되며, WebSocket 으로 새 알림이 수신되면 이 값을 재조회해 갱신합니다.
|
||||
|
||||
|
||||
### DELETE /api/user/notifications/{notification}
|
||||
<!-- @generated:start:api.user.notifications.destroy -->
|
||||
- **라우트명**: `api.user.notifications.destroy`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@destroy`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.delete`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| notification | path | string | 예 | — | 대상 notification의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
DELETE /api/user/notifications/{notification} HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.user-notifications.delete`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
경로의 `{notification}` ID 에 해당하는 알림 1건을 삭제합니다. 대상은 요청 사용자 본인의 알림으로 한정되며, 해당 ID 가 본인 소유로 존재하지 않으면 404(`notification.user.not_found`)를 반환합니다.
|
||||
|
||||
|
||||
### PATCH /api/user/notifications/{notification}/read
|
||||
<!-- @generated:start:api.user.notifications.read -->
|
||||
- **라우트명**: `api.user.notifications.read`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\NotificationController@markAsRead`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.user-notifications.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| notification | path | string | 예 | — | 대상 notification의 식별자 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PATCH /api/user/notifications/{notification}/read HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.user-notifications.update`)이 없는 경우 |
|
||||
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
경로의 `{notification}` ID 에 해당하는 알림 1건을 읽음 처리하고, 갱신된 알림 리소스를 `data` 로 반환합니다. 대상은 요청 사용자 본인의 알림으로 한정되며, 본인 소유로 존재하지 않으면 404(`notification.user.not_found`)를 반환합니다. 반환 리소스에는 `id`, `type`, `type_label`, `subject`, `body`, `url`, `read_at`, `created_at` 가 포함됩니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Password API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Password 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### PUT /api/me/password
|
||||
<!-- @generated:start:api.me.password -->
|
||||
- **라우트명**: `api.me.password`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@changePassword`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| current_password | body | string | 예 | — | 현재 비밀번호 (변경 전 확인용) |
|
||||
| password | body | string | 예 | — | 비밀번호 |
|
||||
| password_confirmation | body | string | 예 | — | 비밀번호 확인 (password 와 일치해야 함) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.change_password_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PUT /api/me/password HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"current_password": "Password123!",
|
||||
"password": "Password123!",
|
||||
"password_confirmation": "Password123!"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
현재 로그인한 사용자가 자신의 비밀번호를 변경한다. 프론트의 비밀번호 변경 화면(`mypage/change-password.json`)이 사용한다. `current_password` 는 `current_password:sanctum` 규칙으로 검증되어 현재 비밀번호가 틀리면 실패하며, 새 비밀번호는 8자 이상이면서 `password_confirmation` 과 일치해야 한다. 해싱은 `UserService::updateUser()` 가 담당한다. 비밀번호 변경이 본인인증(IDV) 대상으로 설정된 경우 확장이 428 을 유발할 수 있으며, 이는 글로벌 예외 핸들러가 처리한다.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
# Profile API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Profile 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/user/profile
|
||||
<!-- @generated:start:api.user.profile.show -->
|
||||
- **라우트명**: `api.user.profile.show`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@show`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.profile.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/user/profile HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-403 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-403 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.profile.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
`GET /api/me` 와 동일하게 `ProfileController@show` 를 호출해 현재 사용자의 프로필을 조회하지만, `permission:core.profile.read` 권한 미들웨어가 추가된 경로다. 응답 형태는 `UserResource::toProfileArray()` 산물로 `GET /api/me` 와 같으며, 필드별 소유(확장 병합) 규칙도 동일하다. 실측 예시가 비어 있는 것은 문서 생성 시 샘플 사용자가 해당 권한을 갖지 못해 403 이 반환되었기 때문이며, 응답 필드는 `GET /api/me` 문서를 참조한다.
|
||||
|
||||
|
||||
### PUT /api/user/profile
|
||||
<!-- @generated:start:api.user.profile.update -->
|
||||
- **라우트명**: `api.user.profile.update`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@update`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.profile.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| name | body | string | 예 | max 255 | 대상의 이름/명칭 |
|
||||
| nickname | body | string | 아니오 | max 50 | 닉네임 |
|
||||
| email | body | email | 예 | max 255 | 이메일 주소 |
|
||||
| password | body | string | 아니오 | — | 비밀번호 |
|
||||
| current_password | body | string | 아니오 | — | 현재 비밀번호 (변경 전 확인용) |
|
||||
| language | body | string | 아니오 | — | 언어 코드 |
|
||||
| country | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) |
|
||||
| timezone | body | string | 아니오 | — | 타임존 식별자 |
|
||||
| homepage | body | string | 아니오 | max 255 | 홈페이지 URL |
|
||||
| mobile | body | string | 아니오 | max 20 | 휴대전화 번호 |
|
||||
| phone | body | string | 아니오 | max 20 | 전화번호 |
|
||||
| zipcode | body | string | 아니오 | max 10 | 우편번호 |
|
||||
| address | body | string | 아니오 | max 255 | 기본 주소 |
|
||||
| address_detail | body | string | 아니오 | max 255 | 상세 주소 |
|
||||
| signature | body | string | 아니오 | max 1000 | 서명 |
|
||||
| bio | body | string | 아니오 | max 5000 | 자기소개 |
|
||||
| notify_post_complete | body | boolean | 아니오 | — | 내 글에 답변/처리 완료 시 알림 수신 여부 (게시판 모듈 알림 설정) |
|
||||
| notify_post_reply | body | boolean | 아니오 | — | 내 글에 답글이 달렸을 때 알림 수신 여부 (게시판 모듈 알림 설정) |
|
||||
| notify_comment | body | boolean | 아니오 | — | 내 글에 댓글이 달렸을 때 알림 수신 여부 (게시판 모듈 알림 설정) |
|
||||
| notify_reply_comment | body | boolean | 아니오 | — | 내 댓글에 대댓글이 달렸을 때 알림 수신 여부 (게시판 모듈 알림 설정) |
|
||||
| email_subscription | body | boolean | 아니오 | — | 광고성 이메일 수신 동의 여부 (sirsoft-marketing 채널, 훅 주입 파라미터) |
|
||||
| marketing_consent | body | boolean | 아니오 | — | 마케팅 정보 수신 전체 동의 마스터 키 — 마케팅 채널 전체 동의/철회 제어 (sirsoft-marketing 훅 주입 파라미터) |
|
||||
| third_party_consent | body | boolean | 아니오 | — | 제3자 정보 제공 동의 여부 (법적 필수 항목, sirsoft-marketing 훅 주입 파라미터) |
|
||||
| info_disclosure | body | boolean | 아니오 | — | 개인정보 이용 안내 동의 여부 (법적 필수 항목, sirsoft-marketing 훅 주입 파라미터) |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.user.update_profile_validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
PUT /api/user/profile HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "예시 이름",
|
||||
"nickname": "예시 이름",
|
||||
"email": "user@example.com",
|
||||
"password": "Password123!",
|
||||
"current_password": "Password123!",
|
||||
"language": "ko",
|
||||
"country": "KR",
|
||||
"timezone": "Asia/Seoul",
|
||||
"homepage": "https://example.com",
|
||||
"mobile": "010-1234-5678",
|
||||
"phone": "010-1234-5678",
|
||||
"zipcode": "06234",
|
||||
"address": "서울특별시 강남구 테헤란로 1",
|
||||
"address_detail": "서울특별시 강남구 테헤란로 1",
|
||||
"signature": "예시값",
|
||||
"bio": "예시 내용입니다.",
|
||||
"notify_post_complete": true,
|
||||
"notify_post_reply": true,
|
||||
"notify_comment": true,
|
||||
"notify_reply_comment": true
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-403 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.profile.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
`PUT /api/me` 와 동일하게 `ProfileController@update` 를 호출해 프로필을 수정하되, `permission:core.profile.update` 권한 미들웨어가 추가된 경로다. 요청 파라미터와 검증 규칙(`UpdateProfileRequest`), 확장 소유 파라미터 병합(`core.user.update_profile_validation_rules`)은 `PUT /api/me` 와 동일하다. 성공 시 갱신된 프로필이 `UserResource` 형태로 반환된다.
|
||||
|
||||
|
||||
### GET /api/user/profile/activity-log
|
||||
<!-- @generated:start:api.user.profile.activity-log -->
|
||||
- **라우트명**: `api.user.profile.activity-log`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@activityLog`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:core.profile.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/user/profile/activity-log HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-403 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-403 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 403 | Forbidden | 요구 권한(`core.profile.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
현재 사용자의 최근 활동 로그를 조회한다(`permission:core.profile.read` 필요). `data.activities` 에 최대 50건의 로그가 최신순으로 담기며, 각 항목은 `id`·`action`·`action_label`·`description`(로케일 반영)·`ip_address`·`created_at`(ISO 8601) 필드를 가진다. 실측 예시가 비어 있는 것은 문서 생성 시 샘플 사용자가 권한을 갖지 못해 403 이 반환되었기 때문이다.
|
||||
|
||||
|
||||
### POST /api/user/profile/update-language
|
||||
<!-- @generated:start:api.user.profile.update-language -->
|
||||
- **라우트명**: `api.user.profile.update-language`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@updateLanguage`
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/user/profile/update-language HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능)
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 400 | Bad Request | `language` 값이 `config('app.supported_locales')`(기본 `['ko','en']`)에 포함되지 않는 미지원 로케일인 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
현재 사용자의 언어 설정만 변경한다. 요청 본문의 `language` 값을 받아 `config('app.supported_locales')`(기본 `['ko','en']`)에 포함되는지 검사하며, 허용되지 않는 값이면 400 을 반환한다. 프로필 전체 수정 없이 언어만 즉시 전환할 때 사용하며, 성공 시 갱신된 사용자 정보가 `UserResource` 형태로 반환된다. `language` 는 FormRequest 가 아닌 컨트롤러에서 직접 읽어 검증하므로 문서 상단 파라미터 표에는 자동 수집되지 않는다.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
# Search API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Search 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/search
|
||||
<!-- @generated:start:api.search -->
|
||||
- **라우트명**: `api.search`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicSearchController@search`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| q | query | string | 아니오 | min 2, max 200 | 검색어 (부분 일치) |
|
||||
| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) |
|
||||
| sort | query | string | 아니오 | `relevance`, `latest`, `oldest`, `views`, `popular`, `price_asc`, `price_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) |
|
||||
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
|
||||
| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 |
|
||||
| board_slug | query | string | 아니오 | max 100 | 검색 범위를 특정 게시판으로 한정 (게시판 모듈이 `core.search.validation_rules` 훅으로 추가하는 파라미터, 해당 slug의 게시판 글만 검색) |
|
||||
| category_id | query | integer | 아니오 | — | category 식별자 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.search.validation_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/search?q=%EC%98%88%EC%8B%9C%EA%B0%92&type=%EC%98%88%EC%8B%9C%EA%B0%92&sort=relevance&page=1&per_page=1&board_slug=example-key&category_id=1 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| q | string | `` | 실제 검색에 사용된 검색어 (요청 `q` 를 trim 하여 에코, 검색어가 비어 있으면 빈 문자열) |
|
||||
| total | integer | `0` | 전체 개수 (집계) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "검색어를 입력해주세요.",
|
||||
"data": {
|
||||
"q": "",
|
||||
"total": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
프론트엔드 통합 검색(`search/index.json`)이 호출하는 공개 엔드포인트입니다. 인증이 필요 없으며 게스트도 사용할 수 있습니다. 코어 컨트롤러는 검색 결과를 직접 생성하지 않고, 검증된 파라미터로 검색 컨텍스트(q/type/sort/page/per_page 및 요청 객체)를 구성한 뒤 `core.search.results` Filter 훅을 실행합니다. 게시판·상품 등 각 검색 대상 모듈이 이 훅에 리스너를 등록해 자신의 카테고리 결과를 추가하고, `core.search.build_response` 훅으로 응답 구조를 완성합니다. 따라서 활성 검색 모듈이 없으면 항상 빈 결과(`total: 0`)가 반환됩니다. 검색 엔진 자체는 Scout + `DatabaseFulltextEngine`(MySQL FULLTEXT) 기반이며, 상세는 `docs/backend/search-system.md`를 참고하세요.
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Seo API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Seo 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /api/admin/seo/cached-urls
|
||||
<!-- @generated:start:api.admin.seo.cached-urls -->
|
||||
- **라우트명**: `api.admin.seo.cached-urls`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@cachedUrls`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/seo/cached-urls HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| urls | array | `[]` | 현재 사전 렌더 캐시에 남아 있는 봇 대상 페이지 URL 목록 (SeoCacheManager 인덱스에서 조회). |
|
||||
| count | integer | `0` | 캐시된 URL 개수 (`urls` 배열 길이). |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "messages.success",
|
||||
"data": {
|
||||
"urls": [],
|
||||
"count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
SeoCacheManager 인덱스에서 현재 캐시된 SEO 페이지 URL 목록과 개수를 조회합니다. 어떤 봇 대상 페이지가 사전 렌더 캐시로 남아 있는지 확인하는 진단 용도로 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/seo/clear-cache
|
||||
<!-- @generated:start:api.admin.seo.clear-cache -->
|
||||
- **라우트명**: `api.admin.seo.clear-cache`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@clearCache`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| layout | body | string | 아니오 | — | 무효화 대상 레이아웃명. 지정 시 해당 레이아웃의 SEO 캐시만 삭제하고 무효화된 항목 수를 반환하며, 미지정 시 전체 SEO 캐시를 삭제한다. |
|
||||
| module | body | string | 아니오 | — | 모듈 식별자 필터. 검증 규칙에는 정의되어 있으나 현재 컨트롤러 로직에서는 사용되지 않는다(향후 모듈 단위 캐시 무효화 확장 예약 필드). |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/seo/clear-cache HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"layout": "예시값",
|
||||
"module": "예시값"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
SEO 사전 렌더 캐시를 삭제합니다. `layout` 을 지정하면 해당 레이아웃 캐시만 무효화하고, 지정하지 않으면 전체 SEO 캐시를 삭제합니다. 응답의 `data.cleared` 는 `layout` 지정 시 무효화된 항목 수(정수), 미지정 시 문자열 `"all"` 입니다. 설정이나 콘텐츠 변경 후 오래된 봇 응답이 캐시로 남는 것을 방지할 때 사용합니다.
|
||||
|
||||
|
||||
### POST /api/admin/seo/sitemap/regenerate
|
||||
<!-- @generated:start:api.admin.seo.sitemap.regenerate -->
|
||||
- **라우트명**: `api.admin.seo.sitemap.regenerate`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@regenerateSitemap`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/seo/sitemap/regenerate HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| last_updated_at | string | `2026-07-08T03:14:49+00:00` | last updated 일시 |
|
||||
| size_bytes | integer | `25489` | 크기 (바이트) |
|
||||
| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Sitemap 재생성이 완료되었습니다.",
|
||||
"data": {
|
||||
"last_updated_at": "2026-07-08T03:14:49+00:00",
|
||||
"size_bytes": 25489,
|
||||
"ttl": 86400
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
sitemap.xml 을 즉시 재생성합니다. SitemapManager 에 위임하며 큐 드라이버와 무관하게 동기(즉시) 실행되고, 완료 후 마지막 생성 시각을 갱신합니다. SEO 설정에서 sitemap 기능이 비활성인 경우 400(`seo.sitemap_disabled`), 생성 실패 시 500 을 반환합니다. 관리자가 콘텐츠 변경 후 검색엔진에 노출할 sitemap 을 스케줄 대기 없이 즉시 갱신할 때 사용합니다.
|
||||
|
||||
|
||||
### GET /api/admin/seo/stats
|
||||
<!-- @generated:start:api.admin.seo.stats -->
|
||||
- **라우트명**: `api.admin.seo.stats`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@stats`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/admin/seo/stats HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| overall | object | `{"total_entries":0,"hits":0,"misses":0,"hit_rate":0,"avg_…` | 최근 7일 전체 캐시 통계 집계. `total_entries`(기록 총 건수), `hits`(적중 건수), `misses`(미적중 건수), `hit_rate`(적중률 %, hits/total×100), `avg_response_time_ms`(미적중 시 평균 렌더링 소요 시간 ms, 데이터 없으면 null). |
|
||||
| by_layout | array | `[]` | 레이아웃별 통계 목록 (`layout_name` 으로 그룹핑). 각 원소는 `layout_name`·`total`·`hits`·`misses`·`hit_rate`·`avg_response_time_ms` 를 가지며, 레이아웃 단위로 캐시 효율을 비교하는 용도. |
|
||||
| by_module | array | `[]` | 모듈별 통계 목록 (`module_identifier` 로 그룹핑). 각 원소는 `module_identifier`·`total`·`hits`·`misses`·`hit_rate`·`avg_response_time_ms` 를 가지며, 어느 확장 모듈의 SEO 페이지가 캐시로 재사용되는지 파악하는 용도. |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "messages.success",
|
||||
"data": {
|
||||
"overall": {
|
||||
"total_entries": 0,
|
||||
"hits": 0,
|
||||
"misses": 0,
|
||||
"hit_rate": 0,
|
||||
"avg_response_time_ms": null
|
||||
},
|
||||
"by_layout": [],
|
||||
"by_module": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
SEO 캐시 적중 현황을 최근 7일 기준으로 전체·레이아웃별·모듈별로 조회합니다. 봇 대상 사전 렌더 캐시가 얼마나 효과적으로 재사용되는지(적중률, 렌더 비용 절감)를 모니터링하는 관리자 대시보드용 통계입니다.
|
||||
|
||||
|
||||
### POST /api/admin/seo/warmup
|
||||
<!-- @generated:start:api.admin.seo.warmup -->
|
||||
- **라우트명**: `api.admin.seo.warmup`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@warmup`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:core.settings.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/admin/seo/warmup HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: side-effectful-write — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
SEO 캐시 워밍업(모든 SEO 레이아웃 사전 렌더)을 위한 엔드포인트입니다. 현재 컨트롤러는 실제 워밍업 로직 없이 `status: dispatched` 와 안내 메시지만 반환합니다(실 렌더링은 후속 구현 예정). 응답 성공은 요청 접수만을 의미하며 이 시점에 캐시가 채워지지는 않습니다.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
# Verify Password API 레퍼런스
|
||||
|
||||
> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Verify Password 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표
|
||||
3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### POST /api/me/verify-password
|
||||
<!-- @generated:start:api.me.verify-password -->
|
||||
- **라우트명**: `api.me.verify-password`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Auth\ProfileController@verifyPassword`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| password | body | string | 예 | — | 비밀번호 |
|
||||
|
||||
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.auth.verify_password_rules`).
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/me/verify-password HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"password": "Password123!"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: write-method — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-401 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
현재 로그인한 사용자의 비밀번호를 재확인한다. 민감한 작업 직전 본인 확인 게이트로 사용되며, 프론트의 `_password_verify_section.json` 이 호출한다. 요청의 `password` 를 `Hash::check` 로 저장된 해시와 대조해, 일치하면 성공 응답(`user.password_verified`)을, 틀리면 401(`user.password_incorrect`)을 반환한다. 비밀번호를 변경하지 않고 신원만 확인하므로 사용자 데이터는 바뀌지 않는다.
|
||||
|
||||
|
||||
+23
-16
@@ -68,7 +68,8 @@
|
||||
| 코어 | `/admin/[기능명]` | `/admin/users` |
|
||||
| 모듈 | `/admin/[vendor-module]/[기능명]` | `/admin/sirsoft-ecommerce/products` |
|
||||
| 플러그인 | `/admin/[vendor-plugin]/[기능명]` | `/admin/sirsoft-payment/settings` |
|
||||
| 공개 API | `/api/[vendor-module]/[기능명]` | `/api/sirsoft-ecommerce/products` |
|
||||
| 모듈 공개 API | `/api/modules/[vendor-module]/[기능명]` | `/api/modules/sirsoft-ecommerce/products` |
|
||||
| 플러그인 공개 API | `/api/plugins/[vendor-plugin]/[기능명]` | `/api/plugins/sirsoft-gdpr/consent` |
|
||||
|
||||
### 리소스 URL 규칙
|
||||
|
||||
@@ -149,32 +150,35 @@ sirsoft-ecommerce.products.delete
|
||||
### 모듈 라우트 파일
|
||||
|
||||
```php
|
||||
// modules/sirsoft-ecommerce/src/routes/api.php
|
||||
// modules/_bundled/sirsoft-ecommerce/src/routes/api.php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Sirsoft\Ecommerce\Controllers\Api\Admin\ProductController;
|
||||
|
||||
Route::prefix('admin/sirsoft-ecommerce')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
// 상품 관리 (권한 체크 포함)
|
||||
// ModuleRouteServiceProvider 가 URL prefix('api/modules/sirsoft-ecommerce')와
|
||||
// name prefix('api.modules.sirsoft-ecommerce.')를 자동 적용한다.
|
||||
// 라우트 파일 내부 group 에는 관리자 세그먼트('admin')만 두고, 접두는 중복 입력하지 않는다.
|
||||
Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
// 상품 관리 (권한 체크 포함) → 최종 URL: /api/modules/sirsoft-ecommerce/admin/products
|
||||
Route::get('/products', [ProductController::class, 'index'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.view')
|
||||
->name('api.sirsoft-ecommerce.products.index');
|
||||
->name('products.index'); // 최종 name: api.modules.sirsoft-ecommerce.products.index
|
||||
|
||||
Route::post('/products', [ProductController::class, 'store'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.create')
|
||||
->name('api.sirsoft-ecommerce.products.store');
|
||||
->name('products.store');
|
||||
|
||||
Route::get('/products/{id}', [ProductController::class, 'show'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.view')
|
||||
->name('api.sirsoft-ecommerce.products.show');
|
||||
->name('products.show');
|
||||
|
||||
Route::put('/products/{id}', [ProductController::class, 'update'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.edit')
|
||||
->name('api.sirsoft-ecommerce.products.update');
|
||||
->name('products.update');
|
||||
|
||||
Route::delete('/products/{id}', [ProductController::class, 'destroy'])
|
||||
->middleware('permission:sirsoft-ecommerce.products.delete')
|
||||
->name('api.sirsoft-ecommerce.products.destroy');
|
||||
->name('products.destroy');
|
||||
});
|
||||
```
|
||||
|
||||
@@ -253,17 +257,20 @@ Route::middleware('auth:sanctum')->prefix('user')->group(function () {
|
||||
### 공개 API 라우트
|
||||
|
||||
```php
|
||||
// modules/sirsoft-ecommerce/src/routes/api.php
|
||||
// modules/_bundled/sirsoft-ecommerce/src/routes/api.php
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Controllers\Api\Public\ProductController;
|
||||
|
||||
Route::prefix('api/sirsoft-ecommerce')->group(function () {
|
||||
// 공개 상품 API (인증 불필요)
|
||||
Route::get('/products', [ProductController::class, 'index'])
|
||||
->name('api.sirsoft-ecommerce.public.products.index');
|
||||
// ModuleRouteServiceProvider 가 URL prefix('api/modules/sirsoft-ecommerce')와
|
||||
// name prefix('api.modules.sirsoft-ecommerce.')를 자동 적용한다.
|
||||
// 라우트 파일에서 prefix/name 접두를 중복 입력하지 않는다.
|
||||
Route::prefix('products')->group(function () {
|
||||
// 공개 상품 API (인증 불필요) → 최종 URL: /api/modules/sirsoft-ecommerce/products
|
||||
Route::get('/', [ProductController::class, 'index'])
|
||||
->name('public.products.index'); // 최종 name: api.modules.sirsoft-ecommerce.public.products.index
|
||||
|
||||
Route::get('/products/{id}', [ProductController::class, 'show'])
|
||||
->name('api.sirsoft-ecommerce.public.products.show');
|
||||
Route::get('/{id}', [ProductController::class, 'show'])
|
||||
->name('public.products.show');
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -334,11 +334,14 @@ protected function loadModuleRoutes(): void
|
||||
|
||||
### 동작 표
|
||||
|
||||
| 환경 | `installer_completed` | 동작 |
|
||||
|------|----------------------|------|
|
||||
| 프로덕션 (설치 완료) | `true` | 가드 통과 → `hasTable` 스킵 (쿼리 0건) |
|
||||
| 인스톨러 실행 중 / 마이그레이션 전 | `false` (기본값) | 기존 `hasTable` 폴백 경로 (원본 동작 보존) |
|
||||
| 테스트 (`.env.testing` 에 플래그 없음) | `false` | 기존 `hasTable` 경로 |
|
||||
| 환경 | `installer_completed` | 마이그레이션 명령 | 동작 |
|
||||
|------|----------------------|------------------|------|
|
||||
| 프로덕션 (설치 완료, 웹 요청) | `true` | 아님 | 가드 통과 → `hasTable` 스킵 (쿼리 0건) |
|
||||
| 설치 완료 `.env` 복사 + 빈 DB (마이그레이션 전) | `true` | **실행 중** | fast-path 무력화 → `hasTable` 폴백 (table not found 부팅 실패 방지) |
|
||||
| 인스톨러 실행 중 / 마이그레이션 전 | `false` (기본값) | — | 기존 `hasTable` 폴백 경로 (원본 동작 보존) |
|
||||
| 테스트 (`.env.testing` 에 플래그 없음) | `false` | — | 기존 `hasTable` 경로 |
|
||||
|
||||
두 번째 행이 이 가드의 핵심 케이스다. `INSTALLER_COMPLETED=true` 가 적힌 `.env` 를 빈 DB 새 서버에 복사한 뒤 `php artisan migrate` 로 테이블을 만들기 **전** 에 앱이 부팅되면, fast-path 가 테이블 존재를 잘못 전제하여 뒤따르는 쿼리가 "table not found" 로 부팅을 깨뜨린다. 이 컨텍스트에서는 fast-path 를 신뢰하지 않고 실제 `hasTable` 검증 경로로 폴백해야 한다 (아래 "마이그레이션 컨텍스트 가드" 참조).
|
||||
|
||||
### 적용 가이드
|
||||
|
||||
@@ -346,6 +349,66 @@ protected function loadModuleRoutes(): void
|
||||
- **`hasTable` 폴백 경로는 반드시 유지** — `installer_completed=false` 환경에서도 안전하게 부팅되어야 함
|
||||
- **try/catch 도 그대로 유지** — DB 연결 실패 시 안전한 스킵 계약 준수
|
||||
- 가드는 hasTable 체크 **전체 블록을 `if (! config('app.installer_completed'))` 로 래핑** 하는 형태
|
||||
- **fast-path 이후 쿼리가 try/catch 로 보호되지 않는 지점은 마이그레이션 컨텍스트 가드를 함께 적용** (아래 참조). 예외로 보호되는 지점은 빈 DB 에서도 안전하므로 불필요
|
||||
|
||||
### 마이그레이션 컨텍스트 가드
|
||||
|
||||
`installer_completed` fast-path 는 테이블 존재를 전제한다. 그러나 `INSTALLER_COMPLETED=true` 가 적힌 `.env` 를 빈 DB 새 서버에 복사한 뒤 `php artisan migrate` 로 테이블을 만들기 **전** 에 앱이 부팅되면, fast-path 가 `true` 를 반환하고 곧이어 실제 쿼리(`Model::where()->pluck()` 등)가 실행되어 "table not found" 로 부팅이 실패한다. 마이그레이션을 실행하려는 부팅에서 그 명령이 오히려 실패하는 닭-달걀 상태다.
|
||||
|
||||
이를 막기 위해, 스키마를 파괴/재생성하는 마이그레이션 계열 콘솔 명령 판정을 `App\Support\InstallerContext::isSchemaMutatingCommand()` 로 단일 SSoT 화하고, fast-path 지점에 가드로 결합한다. 명령 리스트: `migrate`, `migrate:fresh`, `migrate:refresh`, `migrate:rollback`, `migrate:reset`, `db:wipe`.
|
||||
|
||||
```php
|
||||
// App\Support\InstallerContext
|
||||
public static function isSchemaMutatingCommand(): bool
|
||||
{
|
||||
if (! app()->runningInConsole()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(
|
||||
$_SERVER['argv'][1] ?? null,
|
||||
['migrate', 'migrate:fresh', 'migrate:refresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'],
|
||||
true
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
가드 결합 방식은 지점의 **논리 극성** 에 따라 다르다:
|
||||
|
||||
- **Trait (fast-path 통과 조건)** — `&&` 로 결합. 마이그레이션 중이면 fast-path 를 건너뛰고 hasTable 폴백으로 진입.
|
||||
|
||||
```php
|
||||
if (! InstallerContext::isSchemaMutatingCommand() && config('app.installer_completed')) {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
- **RouteServiceProvider (hasTable 검증 블록 진입 조건)** — `||` 로 결합. 마이그레이션 중이면 hasTable 검증 경로로 진입해 빈 DB 시 early return → 무방비 `pluck` 도달 차단.
|
||||
|
||||
```php
|
||||
if (! config('app.installer_completed') || InstallerContext::isSchemaMutatingCommand()) {
|
||||
try {
|
||||
if (! Schema::hasTable('modules')) {
|
||||
return;
|
||||
}
|
||||
// ...
|
||||
} catch (\Exception) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`isRegistryReady()` 처럼 fast-path 앞에서 조기 반환하는 지점은 `if (InstallerContext::isSchemaMutatingCommand()) { return false; }` 로 단순 게이트한다.
|
||||
|
||||
#### 부류 A (가드 필수) vs 부류 B (불필요)
|
||||
|
||||
| 부류 | 조건 | 예 | 조치 |
|
||||
|------|------|-----|------|
|
||||
| A | fast-path 이후 쿼리가 try/catch 로 **미보호** → 빈 DB 에서 부팅 실패 | `Caches{Module,Plugin,Template}Status`, `Module/PluginRouteServiceProvider` | 마이그레이션 컨텍스트 가드 적용 |
|
||||
| B | fast-path 이후 쿼리가 try/catch(\Throwable) 로 **보호** → 예외를 삼켜 안전한 기본값 반환 | `NotificationHookListener`, `IdentityPolicyRepository::{listHookTargets,activeExtensionIdentifiers}` | 가드 불필요 (예외 흡수로 안전) |
|
||||
|
||||
### config 캐시 재생성 주의
|
||||
|
||||
- `config:cache` 는 config 소스를 변경하는 라이프사이클에서 `App\Support\ConfigCacheHelper::rebuild()` 로 자동 재생성된다 (아래 "config 캐시 자동 재생성" 참조). `.env` 를 직접 편집한 경우처럼 헬퍼를 거치지 않는 변경은 여전히 `php artisan config:clear && php artisan config:cache` 를 수동 실행한다
|
||||
|
||||
### 확장 Trait 패턴
|
||||
|
||||
@@ -253,6 +253,22 @@ php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치)
|
||||
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
|
||||
```
|
||||
|
||||
### API 문서 Artisan 커맨드
|
||||
|
||||
```bash
|
||||
# API 레퍼런스 문서 생성/갱신 (실측 기반 스캐폴딩). 상세: docs/backend/api-documentation.md
|
||||
php artisan api:docgen --scope=core # 코어 문서 생성 (범위: core|module:{id}|plugin:{id}|all)
|
||||
php artisan api:docgen --scope=core --seed # 실측용 완전 샘플 시드 후 생성 (개발 환경 전용)
|
||||
php artisan api:docgen --scope=core --base-url=https://example.com # 실측 기준 URL 지정
|
||||
php artisan api:docgen --scope=core --examples-only # 표·서술 불가침, 요청/응답 예시 블록만 in-place 삽입
|
||||
php artisan api:docgen --scope=core --check # 생성 없이 누락/drift 만 리포트 (하네스 소비)
|
||||
php artisan api:docgen --scope=core --dry-run # 생성 대상 파일/엔드포인트 목록만 출력
|
||||
|
||||
# 파라미터/응답 필드 설명 TODO 셀 in-place 백필 (재생성 없이 공통 필드 자동 서술, 멱등)
|
||||
php artisan api:docgen-backfill-params # 요청 파라미터 표 TODO 셀 백필
|
||||
php artisan api:docgen-backfill-fields # 응답 필드 표 TODO 셀 백필
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 확장 업데이트 (CLI + API)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. URL prefix 자동: /api/admin/[vendor-module]/...
|
||||
2. name prefix 자동: api.[vendor-module]....
|
||||
1. URL prefix 자동: /api/modules/[vendor-module]/...
|
||||
2. name prefix 자동: api.modules.[vendor-module]....
|
||||
3. 활성화된 모듈만 라우트 등록됨
|
||||
4. 프론트엔드: routes/admin.json, routes/user.json으로 admin/user 분기
|
||||
5. 레거시 routes.json은 admin으로 폴백 (경고 로그 출력)
|
||||
|
||||
@@ -30,6 +30,34 @@ G7 코어와 모듈/플러그인은 PHP 의존성 관리에 Composer를 사용
|
||||
|
||||
이 네 파일(`composer.json` / `composer.lock` / `vendor-bundle.json` / `vendor-bundle.zip`)은 코어 루트 및 각 `_bundled` 확장 루트에 함께 존재하며 모두 Git 으로 추적한다. 추출 결과인 `vendor/` 디렉토리만 `.gitignore` 대상이다. `composer.lock` 이 누락되면 `VendorIntegrityChecker` 가 `composer_lock_sha256` 을 검증할 수 없으므로, `composer update` 직후 생성된 `composer.lock` 과 재빌드된 번들 두 파일을 같은 커밋에 함께 포함한다.
|
||||
|
||||
### 1.1 빌드가 보는 composer.json / composer.lock
|
||||
|
||||
확장의 번들 빌드는 산출물을 `_bundled` 에 쓰지만(활성 디렉토리 오염 방지), **입력으로 삼는 `composer.json` / `composer.lock` 도 `_bundled` 를 기준으로 한다**. 출력 디렉토리에 해당 파일이 없을 때만 활성 디렉토리로 폴백하며, 코어는 소스와 출력이 같은 경로(`base_path()`)이므로 언제나 동일한 파일을 본다. 이 규칙은 `VendorBundler::resolveHashTarget()` 한 곳에 있고, 빌드의 네 가지 판단이 모두 그것을 쓴다.
|
||||
|
||||
| 판단 | 사용처 |
|
||||
|------|------|
|
||||
| 외부 의존성 유무 (번들링 대상인가) | `build()`, `isStale()` |
|
||||
| `composer install` 입력 (스테이징에 복사할 파일) | `build()` |
|
||||
| manifest 해시 (`composer_{json,lock}_sha256`) | `build()` |
|
||||
| stale 판정 (재빌드 필요한가) | `isStale()` |
|
||||
|
||||
빌드는 활성 디렉토리의 `vendor/` 를 읽지 않는다. 스테이징 디렉토리에서 `composer install --no-dev` 를 새로 실행해 dev 의존성이 섞이지 않은 `vendor/` 를 만들고 그것을 압축한다.
|
||||
|
||||
네 판단이 같은 파일을 보아야 하는 이유는 두 가지다.
|
||||
|
||||
**첫째, 검증자의 대조 위치.** `VendorIntegrityChecker::verify($sourceDir)` 의 `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리를 뜻하고, 실제 호출 경로는 모두 `_bundled` 계열을 넘긴다.
|
||||
|
||||
| 경로 | verify 에 넘기는 디렉토리 |
|
||||
|------|--------------------------|
|
||||
| 신규 설치 | `_pending/{id}` (`_bundled` 복사본) |
|
||||
| 업데이트 | `_pending/{id}_updating_*` (`_bundled` 복사본) |
|
||||
| 코어 업데이트 | pending 경로 (소스 = 출력) |
|
||||
| `vendor-bundle:verify-all` | `_bundled/{id}` |
|
||||
|
||||
해시를 활성에서 계산하면 생성 기준과 검증 기준이 어긋난다. 두 `composer.json` 의 내용이 같을 때만 우연히 통과하며, `_bundled` 의 `composer.json` 만 변경된 상태(버전 bump 직후 등)에서는 manifest 가 영구히 불일치하여 `module:update` 가 `composer_json_sha_mismatch` 로 차단된다. 그 오류를 해소할 유일한 명령이 `module:update` 이므로 순환이 발생한다. `isStale()` 이 활성만 보면 `--check` 가 stale 을 `up-to-date` 로 오보하는 것도 같은 뿌리다.
|
||||
|
||||
**둘째, 개발자가 `_bundled` 에서만 작업한다는 규정.** 새 패키지를 추가하면 `_bundled` 의 `composer.json` / `composer.lock` 에만 반영되고 활성 디렉토리는 다음 `{module|plugin}:update` 전까지 구버전으로 남는다. 이때 `composer install` 의 입력만 활성에서 가져오면, **구버전 lock 으로 설치한 zip 에 신버전 해시를 붙인 manifest** 가 만들어진다. 해시는 정합하므로 무결성 검증은 통과하는데 정작 새 패키지가 번들에서 빠져, 설치 후 런타임에 클래스 not found 로 터진다. 설치 입력과 해시 기준이 같은 파일을 보면 이 어긋남 자체가 성립하지 않는다.
|
||||
|
||||
`vendor-bundle.json` 스키마 (v1.0):
|
||||
|
||||
```json
|
||||
@@ -212,11 +240,11 @@ Content-Type: application/json
|
||||
|
||||
### 7.1 SHA256 검증
|
||||
|
||||
`VendorIntegrityChecker::verify()` 가 다음을 검증합니다:
|
||||
`VendorIntegrityChecker::verify($sourceDir)` 가 다음을 검증합니다. `$sourceDir` 은 zip 과 manifest 가 놓인 디렉토리이며, 확장의 경우 `_bundled` 또는 그 복사본(`_pending`)입니다 (§1.1).
|
||||
|
||||
- vendor-bundle.zip 파일의 실제 SHA256 vs manifest 기록 값
|
||||
- composer.json SHA256 (소스에 존재 시)
|
||||
- composer.lock SHA256 (소스에 존재 시)
|
||||
- composer.json SHA256 (해당 디렉토리에 존재 시)
|
||||
- composer.lock SHA256 (해당 디렉토리에 존재 시)
|
||||
|
||||
검증 실패 시 `VendorInstallException` 이 발생하며 설치가 중단됩니다.
|
||||
|
||||
@@ -230,6 +258,9 @@ Content-Type: application/json
|
||||
## 8. 자동 트리거
|
||||
|
||||
`composer.json` 또는 `composer.lock` 파일이 변경되면 번들이 stale 상태가 됩니다.
|
||||
확장의 경우 판정 기준은 `_bundled` 디렉토리의 두 파일이다 (§1.1). 확장 버전을 올려
|
||||
`_bundled/composer.json` 의 `version` 만 바뀐 경우에도 stale 이 되므로 재빌드가 필요하다.
|
||||
|
||||
다음 명령으로 재빌드해야 합니다:
|
||||
|
||||
```bash
|
||||
@@ -269,6 +300,8 @@ CI 통합 예시:
|
||||
| `composer_not_available` | composer 모드 강제했으나 실행 불가 | --vendor-mode=auto 또는 bundled로 변경 |
|
||||
| `no_vendor_strategy_available` | composer 불가 + 번들 없음 | vendor-bundle.zip 업로드 또는 호스팅 변경 |
|
||||
| `bundle_contains_unsafe_path` | 외부 zip 파일이 zip slip 시도 | 신뢰할 수 있는 소스에서만 다운로드 |
|
||||
| `bundle_build_promote_failed` | 빌드 산출물을 최종 경로로 원자적 교체 실패 | 디스크 공간/권한 확인 후 재빌드 (원본 번들은 promoteAtomic 으로 보존됨) |
|
||||
| 빌드가 `Generating optimized autoload files` 직후 무한 대기 (Windows) | composer autoload dump 의 async 손자 프로세스가 부모 파이프 핸들을 상속·점유 | 코어 반영됨 — stdout/stderr 를 파이프가 아닌 파일 descriptor 로 실행 (`VendorBundler::composerOutputDescriptors`). 최신 코어에서는 재발하지 않음 |
|
||||
|
||||
## 11. 참고
|
||||
|
||||
|
||||
@@ -185,6 +185,40 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
|
||||
- 상세 설명: [`docs/extension/plugin-development.md`](../extension/plugin-development.md) "폼 상태 정합성" 섹션
|
||||
- 구현 참고: [`DynamicRenderer.tsx`](../../resources/js/core/template-engine/DynamicRenderer.tsx) `performStateUpdate` 상단 주석 (~50줄)
|
||||
|
||||
### `_localInit` 은 단일 슬롯이 아니다 (engine-v1.52.2+)
|
||||
|
||||
데이터소스의 `initLocal` 이 만든 초기화 payload 는 `dataContext._localInit` 한 키로 전달된다.
|
||||
생산부는 `updateTemplateData`(3개 write site), 소비부는 `DynamicRenderer` 의 `_localInit` useEffect 다.
|
||||
|
||||
소비는 React commit **이후**에 일어난다. progressive 데이터소스는 응답이 오는 대로 각자 독립적으로
|
||||
`updateTemplateData({ _localInit })` 를 호출하므로, `initLocal` 을 가진 progressive 소스가 둘 이상이면
|
||||
두 호출이 같은 commit 사이에 들어올 수 있다. 이때 슬롯을 교체하면 먼저 도착한 payload 가
|
||||
한 번도 관측되지 않고 사라진다.
|
||||
|
||||
`_localInit` 슬롯 병합과 `__g7LocalInitTracking` 레지스트리의 단일 소유자는
|
||||
[`localInitSlot.ts`](../../resources/js/core/template-engine/localInitSlot.ts) 다.
|
||||
|
||||
```text
|
||||
❌ updateTemplateData 에서 _localInit 을 얕은 스프레드(...data)로 교체
|
||||
❌ 소비 여부와 무관하게 _localInit 을 무조건 누적 병합
|
||||
(소비가 끝난 payload 가 refetchDataSource 시 재적용되어 사용자 폼 편집을 되돌린다)
|
||||
❌ 소비 여부 판정을 위해 해시 계산식을 생산부에 복제
|
||||
(생산·소비 양쪽의 판정 기준이 어긋난다 — 슬롯 참조 비교를 쓴다)
|
||||
❌ __g7LocalInitTracking 을 레이아웃 전환 시 리셋하지 않음
|
||||
(_local 은 비웠는데 추적 해시가 남아 새 레이아웃의 동일 payload 가 "이미 적용됨" 으로 건너뛰어진다)
|
||||
```
|
||||
|
||||
```text
|
||||
✅ 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합 — mergeLocalInitSlot()
|
||||
✅ 관측 시 슬롯 참조 기록 — markLocalInitConsumed(). 적용/건너뜀 여부와 무관하게 호출
|
||||
✅ 레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 초기화 — resetLocalInitTracking()
|
||||
✅ _forceLocalInit 은 한쪽에만 있으면 보존, 양쪽에 있으면 최신 타임스탬프 (refetchOnMount 강제 초기화 유실 방지)
|
||||
✅ 소비부는 A(setLocalDynamicState)/B(_globalSetState) 양쪽을 갱신 — 이 대칭을 깨지 않는다
|
||||
```
|
||||
|
||||
> 이 결함은 정상 CPU 에서 간헐적이라 놓치기 쉽다. CPU 스로틀링(6배 이상)으로 commit 을 지연시켜야
|
||||
> 경합 창이 결정적으로 열린다.
|
||||
|
||||
### 라이프사이클 (SPA 네비게이션)
|
||||
|
||||
SPA navigate 시 `_local` 상태는 **레이아웃 이름 기준으로 선택적 초기화**됩니다:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user