Merge branch 'develop'

This commit is contained in:
HeuJung
2026-08-10 21:05:00 +09:00
2663 changed files with 414271 additions and 58478 deletions
+4 -2
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.5
APP_VERSION=7.0.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -22,7 +22,9 @@ BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# 설치 직후 기본값은 운영 기준(error). 관리자 환경설정 > 고급에서 조정하며,
# 그 값이 이 항목보다 우선합니다. debug 로 두면 정상 동작 기록까지 매 요청 쌓입니다.
LOG_LEVEL=error
DB_CONNECTION=mysql
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.5
APP_VERSION=7.0.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+2
View File
@@ -48,6 +48,8 @@ node_modules/
# Settings JSON files (환경별 설정)
/storage/app/settings/
# 성능 계측 리포트 (g7:bench --report) — 측정값이 실행 머신 사양에 종속되므로 저장소에 축적하지 않는다
/storage/app/benchmarks/
# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성)
/storage/app/ext-bundles/
+232 -9
View File
@@ -6,7 +6,7 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (32개)
### 백엔드 [backend/](docs/backend/) (34개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -16,6 +16,7 @@
| [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 토큰만 사용) |
| [benchmark.md](docs/backend/benchmark.md) | 성능 계측 시스템 (Benchmark) | `g7:bench` 가 4축(list/screen/write/batch)을 잰다 — 계측 대상은 커맨드... |
| [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
| [console-confirm.md](docs/backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel... |
| [controllers.md](docs/backend/controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / PublicBaseCont... |
@@ -32,6 +33,7 @@
| [language-pack-service.md](docs/backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactivate/uninsta... |
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
| [pagination.md](docs/backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [routing.md](docs/backend/routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](docs/backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ... |
@@ -114,7 +116,7 @@
| [module-assets.md](docs/extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 | module.json에 에셋 매니페스트 정의 (js, css, loading strategy) |
| [module-basics.md](docs/extension/module-basics.md) | 모듈 개발 기초 | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
| [module-commands.md](docs/extension/module-commands.md) | 모듈 Artisan 커맨드 | 목록: php artisan module:list |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /src/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/modules/[vendor-module]/... |
@@ -150,24 +152,28 @@
| 대상 | 진입점 | 문서/엔드포인트 |
|------|--------|----------------|
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 |
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
### 확장 API 레퍼런스 (9개 확장, 자동 스캔)
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
> 각 확장이 소유하는 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 |
| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 |
| `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-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 |
| `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 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
| `sirsoft-tosspayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-tosspayments/docs/api/README.md) | 2 / 4 |
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 2 / 3 |
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
@@ -188,7 +194,7 @@
### ① 코어 → 확장 동기화 (`requires.g7_version`)
- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정
- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Repositories/Concerns/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정
- 조치: 영향 받는 번들 확장의 `g7_version` 상향 + 각 확장 CHANGELOG 에 변경 기재
### ② 확장 → 확장 동기화 (`dependencies.{modules|plugins}`)
@@ -219,6 +225,8 @@
| `handler: "nav"` | `handler: "navigate"` |
| `handler: "setLocalState"` | `handler: "setState"` + `target: "local"` |
| `navigate` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
| `navigate` `params.path: "back"` (동작 키워드로 착각) | `handler: "navigateBack"` — path 는 주소로 해석되어 조용히 `/back` 으로 이동한다 |
| `navigate` `params.url` / `href` / `to` 로 목적지 전달 | `params.path` (또는 액션 `target`) — 엔진은 이 둘만 읽는다. 다른 이름은 무시되어 목적지가 `undefined` 가 되고, 예외도 404 도 없이 버튼만 동작하지 않는다 |
| apiCall `params.target` (params 내부) | `target` 은 액션 top-level. params 내부 위치 시 URL 미해석 |
| apiCall `params.onSuccess` / `params.onError` (params 내부) | 액션 top-level. params 내부면 무시됨 |
| `refetchDataSource` `params.id` | `params.dataSourceId` 사용 |
@@ -261,6 +269,8 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
| `w-3.5 h-3.5` | 14 | `text-sm` |
| `w-4 h-4` | 16 | `text-base` |
| `w-5 h-5` | 20 | `text-xl` |
| `w-6 h-6` | 24 | `text-2xl` |
| `w-12 h-12` | 48 | `text-5xl` |
`size` prop 은 Font Awesome `fa-*` 클래스로 매핑되며 등가가 아니다 — `size="sm"` → `fa-sm` → `font-size: 0.875em`(상대값) + `line-height` 붕괴로 16px 이 12.25×0.88px 이 된다. 새 아이콘에는 써도 되지만, 기존 `w-N h-N` 의 치환용으로는 쓰지 않는다.
@@ -297,6 +307,201 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
| `AuthManager.updateConfig({ loginPath: '//evil.com/...' })` (protocol-relative) | `//` 시작 금지 (open redirect 방지) |
| 401 에러 페이지(`errors/401.json`)에서 직접 로그인 리다이렉트 구현 | 코어 `TemplateApp.showRouteError` 가드에 위임 (자동 처리) |
### 정적 확장자 라우트 / 자산 URL 생성
| 금지 | 올바른 사용 |
|------|------------|
| `Route::get('{id}/routes.json', ...)` (`.js`/`.css`/`.json`/`.map` 단일 등록) | `Route::dualSuffix('{id}/routes', 'json', ...)` — 확장자 형태 + 확장자 없는 형태 동시 등록 |
| `Route::get('bundle.js', ...)` (접미사가 종류를 구분해 제거 불가) | `Route::dualSuffixSegment('bundle', 'js', ...)` (`bundle.js` + `bundle/js`) |
| `Route::get('assets/{id}/{path}', ...)` (와일드카드 자산) | `Route::dualAsset('assets/{id}', ...)` (`.../{path}` + `?file=` 쿼리) |
| 서버에서 `'/api/templates/assets/'.$id.'/'.$path` 문자열 조립 | `App\Support\AssetUrl::templateAsset($id, $path)` |
| 프론트에서 `` `/api/templates/${id}/routes.json` `` 템플릿 리터럴 조립 | `resources/js/core/support/assetUrl.ts` 의 `suffixed()` / `templateAsset()` 등 |
정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서는 확장자 붙은 동적 응답이 `try_files ... /index.php` 폴백 기회 없이 404 가 된다. 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts` 는 동일 규칙을 공유하므로 한쪽만 바꾸면 그 자산만 404 가 된다. 상세: [routing.md](docs/backend/routing.md) "정적 확장자로 끝나는 동적 엔드포인트", [api/README.md](docs/backend/api/README.md) "자산 URL 이중 모드".
### 라우트 캐시 안전성
`route:cache` 가 걸리면 `RouteServiceProvider::boot()` 이 캐시 로드로 분기해 라우트 파일 자체가 실행되지 않는다. 클로저는 직렬화 형태로 복원되므로 문제가 아니다 (`routes/web.php` SPA catch-all 이 증거). 깨지는 것은 오토로드되지 않는 심볼 참조뿐이다.
| 금지 | 올바른 사용 |
|------|------------|
| 라우트 파일에 전역 함수 선언 + 핸들러가 호출 | 로직을 클래스(`app/Support/…`)로 옮기고 핸들러는 위임만 |
| 파일 스코프 변수를 핸들러가 `use` 없이 참조 | 클래스 상수 또는 `use ($var)` 로 클로저에 캡처 |
| 벤더/프로바이더가 `boot()` 에서 조건부 등록하는 라우트에 의존 | 그 URI 를 G7 라우트 파일이 직접 소유 |
전역 함수 위반은 `Call to undefined function` 500 인데 예외의 `file` 이 `laravel-serializable-closure://` 라 원인 파일이 스택에 드러나지 않는다. 프로바이더 등록분이 사라지는 이유는 별개다 — `Router::setCompiledRoutes()` 가 `booted` 콜백에서 라우트 컬렉션을 통째로 교체하므로 그보다 앞선 등록은 조건 충족 여부와 무관하게 폐기된다(프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()` 가드를 갖지만 모든 패키지가 그렇지는 않다). 정적 검사가 라우트 파일의 전역 함수 선언을 차단한다. 상세: [routing.md](docs/backend/routing.md) "캐시 안전한 라우트 작성".
### 목록 컨텍스트 왕복 (list context round-trip)
페이지네이션 목록 화면과 그에 딸린 상세·형제 상세·작성/수정 폼·확인 모달은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다.
| 금지 | 올바른 사용 |
|------|------------|
| 클러스터 내 navigate 에 `mergeQuery` 누락 | `"params": { "path": "…", "mergeQuery": true, "query": {} }` |
| 이전글/다음글 등 형제 상세 이동만 규약에서 누락 | 목록 진입 / 목록 복귀 / 형제 이동 / 폼 취소 / 삭제 후 복귀 전 leg 동일 적용 |
| 현재 값을 그대로 다시 넘기는 키 열거 (`{"del": "{{query.del ?? ''}}"}`) | `mergeQuery` 가 이미 전부 나른다 — 열거는 중복이자 누락 위험 |
| 덮어쓸 키만 남기지 않고 필터 키 전부 재열거 | 값을 바꿔야 하는 키만 남긴다 (페이지 되돌림은 `{"page": ""}`) |
| 새로고침 버튼에 `mergeQuery: false` | 새로고침은 보던 목록을 다시 부르는 것 — 병합 유지 |
| `mergeQuery` 를 표현식으로 분기 (`"{{cond}}"`) | boolean 리터럴 고정 — 분기마다 보존 여부가 갈리면 한쪽이 조용히 상태를 떨군다 |
| `"path": "/board/{slug}/write?parent_id={{id}}"` (인라인 쿼리스트링) | 인라인 쿼리는 병합 시 버려진다 → `query` 객체로 옮긴다 |
| `mergeQuery: true` + `query` 키 생략 | 의도를 드러내도록 `"query": {}` 를 함께 둔다 |
| `"query": []` (배열 리터럴) | `"query": {}` — 동작은 같아 조용히 통과하지만, 나중에 덮어쓸 키를 넣으면 그 값이 버려진다 |
| 목적지가 표현식이라 판정 불가한 이동을 무표시로 둠 (`"{{_global.shopBase}}/products"`) | 클러스터 내 이동이면 `mergeQuery: true`, 밖으로 나가는 이동이면 예외 주석으로 의도를 명시 |
| 의도적 리셋(검색·필터 초기화 / 탭 전환 / 프리셋 적용)에 `mergeQuery: true` | 리셋은 병합하지 않는다 — 병합하면 초기화 버튼이 아무 일도 하지 않는다 |
| 탭 전환(`onTabChange`)이나 겹치지 않는 다른 목록으로의 이동에 `mergeQuery: true` | 목록 정체성이 다르면 승계하지 않는다 — 남의 검색어·페이지가 얹혀 빈 화면이 열린다 |
| 면제 주석은 "병합하지 않는다" 인데 코드는 `mergeQuery: true` | 주석과 코드를 일치시킨다 (주석은 사실이 아니라 선언일 뿐) |
| 검색 실행·페이지 이동 액션에서 `query` 키를 비움 | 값을 바꾸는 액션은 그 값을 직접 넘긴다 (`{"page": "{{$args[0]}}"}`) — 병합만으로는 새 값이 전달되지 않는다 |
| `path` 없이 `query` 만 바꾸는 액션에 `mergeQuery` 누락 (탭 전환 `{"tab": …}`, 항목 선택 `{"id": …, "mode": "view"}`) | `path` 생략은 "현재 주소에 작용" 이라 목록 화면 자신이 대상 — `mergeQuery: true` 없으면 지금 걸린 목록 상태가 통째로 날아간다 |
의도적 리셋(검색 초기화 / 필터 초기화 / 탭 전환 / 프리셋 적용 / 다른 목록으로의 이동)은 예외다. 그 경우 액션 노드 `comment` 에 `audit:allow layout-list-context-navigate-merge-query <사유>` 를 남겨 의도를 코드에 기록한다. 상세: [actions-handlers-navigation.md "목록 컨텍스트 왕복 규약"](docs/frontend/actions-handlers-navigation.md)
### 중첩 리소스 스코프 / 계층 무결성
| 금지 | 올바른 사용 |
|------|------------|
| 중첩 라우트의 상위 리소스 ID 를 받아만 두고 조회에 미반영 | Repository where 절에 상위 스코프 반영(SSoT) + Service 가 상위 ID 전달 → 교차 접근 시 404 |
| `$request->except(...)` / `->all()` 결과를 Service 쓰기 메서드로 전달 | `$request->validated()` 기준 (FormRequest 미정의 필드가 `$fillable` 로 새는 것 차단) |
| 요청 배열 항목의 `Rule::exists` 에 상위 스코프 미부착 | `Rule::exists(Model::class,'id')->where('order_id', $order->id)` → 422 |
| 수정/순서변경 FormRequest 의 `parent_id` 에 `Rule::exists` 만 부착 | 자손 전체를 검사하는 순환 방지 Rule 부착 (자기참조만 막는 Rule 은 `A→B→A` 통과) |
| 같은 리소스의 두 엔드포인트가 서로 다른 검증 강도 | 부모 변경 경로 전부 동일 강도 — 약한 쪽이 우회로가 된다 |
| 설정값이 정하는 한계를 Service 에서 리터럴로 재클램프 | Service 는 계산만, 상한 검증은 Rule 단일 책임 (이중 클램프 시 깊이 제한이 통째로 무력화) |
| 계층 재귀(path/depth 재계산)에 방문 ID 가드 없음 | 방문 집합으로 유한 종료 — 검증 우회 경로/오염 데이터에서도 무한 루프 금지 |
> 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md)
### 목록 응답의 하위 컬렉션
목록은 화면이 그 행에서 **실제로 그리는 것**만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다 (공개 #76 — 상품 100건 × 옵션 20건).
| 금지 | 올바른 사용 |
|------|------------|
| `relationLoaded('x') ? $this->x : $this->whenLoaded('y')` (가짜 가드) | `whenLoaded('x', fn () => ...)` 하나만 — 로드 여부는 **Repository 가** 결정한다 |
| Resource 는 `whenLoaded` 로 방어하는데 Repository 가 목록에서 무조건 eager load | 목록의 `relations:` 는 목록이 **실제로 직렬화하는** 관계만 |
| 개수/합계를 PHP 컬렉션 연산으로 (`$this->options->where(...)->sum(...)`) | `withCount:` / `withSum`(`outerUsing:`) DB 집계 |
| `toListArray()` 를 정의해 두고 컬렉션이 `toArray()` 를 호출 | 컨트롤러/컬렉션이 목록 표현을 **명시 호출** |
| 목록 Resource 안에서 관계 재쿼리 (`$this->images()->first()`) | `relationLoaded` 분기로 로드된 컬렉션에서 고른다 |
| 집계 별칭 존재 여부를 `!== null` 로 판정 | `array_key_exists($alias, $model->getAttributes())` — SUM 은 0건에서 NULL 이라 값 검사로는 "집계 안 함" 과 구분되지 않는다 |
| 목록에서 뺀 값을 대체 경로 없이 제거 | 지연 로드 경로(배치 조회)를 먼저 만들고, 하위호환은 opt-in 파라미터(`?with_options=1`)로 |
착수 전 **소비처를 실측**한다. 화면이 그 값을 실제로 순회·렌더하면 제거는 기능 축소다 — 계획서에 "안 쓴다" 고 적혀 있어도 레이아웃 JSON 을 열어 확인한다.
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
### 저장값 + 확장 카탈로그 병합 설정의 공개 응답
설정 항목이 "운영자 저장값 + 확장이 훅으로 등록한 카탈로그" 의 병합으로 만들어지면, 저장값은 남아 있는데 카탈로그에서 항목이 사라지는 상태가 생긴다 — 그 확장을 삭제·비활성화했거나, 확장이 자기 기능 토글을 껐을 때다. 병합부는 이를 고아 항목으로 표시하지만 저장값의 `is_active` 는 참 그대로 남는다.
| 금지 | 올바른 사용 |
|------|------------|
| 공개 응답이 저장값 플래그(`is_active`)만 보고 항목을 내보냄 | 카탈로그 소속(`_orphaned`)을 함께 판정 — 공급 확장이 더 이상 제공하지 않는 항목은 공개 응답에서 제거 |
| 고아 항목을 관리자 응답에서도 제거 | 관리자 응답은 유지 — 운영자가 확인하고 지워야 할 대상이다 |
| 소비 화면(레이아웃 JSON)마다 필터를 넣어 차단 | 공개 API 단일 지점에서 차단 — 화면마다 복제하면 템플릿 하나만 빠져도 같은 결함이 남는다 |
| 같은 데이터를 내보내는 공개 엔드포인트가 서로 다른 게터를 사용 | 전 엔드포인트가 같은 공개 게터를 경유 — 한쪽이 raw `getSettings()` 를 쓰면 그 경로만 조용히 뚫린다 |
| 항목 제거 후 배열 인덱스를 그대로 둠 | `array_values()` 로 재정렬 — 비연속 키는 JSON 객체로 직렬화되어 화면 반복이 깨진다 |
이 결함은 예외도 경고도 로그도 남기지 않는다. 이미 제공 불가한 항목이 사용자 화면에서 선택 가능한 상태로 남아 있는 것이 유일한 증상이고, 관리자 화면은 고아 표시로 정상 차단하고 있어 양쪽을 나란히 보지 않으면 드러나지 않는다.
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
### 목록 조회 컬럼 프루닝과 지연 조인
| 금지 | 올바른 사용 |
|------|------------|
| `->paginate($perPage)` / `->paginate($perPage, ['*'])` (컬럼 목록 미지정) | 목록이 실제로 쓰는 컬럼만 명시. 깊은 OFFSET 이 가능한 목록은 `PaginatesWithDeferredJoin` |
| 요청 값에서 온 정렬 컬럼을 그대로 `orderBy` 에 전달 | `ResolvesSortSpec` 으로 닫힌 집합 해석 (방향만 검사하는 `in_array` 는 보호가 아니다) |
| 지연 조인의 `$query` 에 미리 `orderBy`/`with`/`select` 적용 | 필터/where 만 적용해 넘기고, 정렬·관계·컬럼은 trait 인자로 전달 |
| 쿼리에 `with()` 만 하고 `relations:` 인자 생략 | 관계는 `relations:` 로 전달 — trait 이 inner 뿐 아니라 **outer 에서도** eager load 를 지우므로 관계가 조용히 사라진다 (예외·쿼리 오류 없이 응답에서 필드만 없어져 관계를 단언하지 않는 테스트는 전부 통과) |
| 목록 SELECT 에 `SUBSTRING(content, 1, N)` 을 두고 프루닝했다고 간주 | 오버플로 페이지 읽기가 그대로 발생 — 잘라내기는 outer(`$columns`)에서만 |
| 그룹 쿼리(`groupBy`)의 총 건수를 `count()` 로 계산 | `getCountForPagination()` (서브쿼리로 감싸 그룹 수를 센다) |
| raw SQL 안에 테이블명·별칭을 문자열로 조립 | 테이블명은 `(new Model)->getTable()`, 프리픽스는 `DB::getTablePrefix()`, 별칭은 빌더(`join($table.' as uc', …)`)가 만들게 |
| `whereRaw('1 = 0')` / `where($c, DB::raw("({$sub->toSql()})"))` + `mergeBindings` | `whereIn($key, [])` / `where($c, '=', $sub)` (빌더가 바인딩까지 처리) |
| 화면 정렬 셀렉트에 게이트가 모르는 컬럼을 넣기 | `화면 옵션 ⊆ FormRequest 게이트 ⊆ Repository 화이트리스트` — 어긋나면 422 후 직전 목록이 남아 정렬된 것처럼 보인다 |
| 분류값 필터의 허용 어휘를 화면·게이트·기록 지점에 각각 리터럴로 적기 | 어휘는 Enum 단일 출처에서 파생 — `화면 필터 옵션 = 라벨 키 = 실제 기록 어휘`. 부분집합이 되면 빠진 값으로 기록된 행이 어떤 필터 조합으로도 도달 불가하고, 라벨 키가 없는 값은 목록 셀에 원시 키 문자열로 노출된다 |
| 목록 응답이 `last_page > 1` 인데 화면에 페이저·총건수가 없음 | 페이지 이동 컨트롤과 총건수를 함께 노출 — 없으면 1페이지 밖 행이 조용히 잘리고, 잘렸다는 사실조차 화면에 나타나지 않는다 |
| 쿼리 파라미터 불리언에 `boolean` 규칙만 부착 | `prepareForValidation()` 으로 `"true"`/`"false"` 정규화 (쿼리는 문자열로 도착 — 화면이 그 형태로 보내면 목록 전체가 422). 단, 해석 불가한 값은 건드리지 말 것 |
| 같은 화면의 개수 배지와 그 배지가 여는 목록이 서로 다른 엔드포인트 | 배지 계산과 목록 조회는 같은 스코프의 같은 데이터소스에서 |
| 관계 테이블 컬럼 정렬을 `join`+`groupBy` 로 구현 | `SortsByRelatedColumn` 의 상관 서브쿼리 (1:N 조인은 원 행을 부풀려 총 건수·페이지 경계를 깨고, INNER 는 자식 없는 행을 지운다) |
| 관계 정렬을 넣고 인덱스는 그대로 | `(외래키, 정렬컬럼)` 복합 인덱스 마이그레이션 동반 (서브쿼리가 행마다 실행된다) |
| 페이지네이션 정렬을 비고유 컬럼(`created_at` 등)으로만 끝내기 | 정렬 마지막에 기본키를 덧붙여 전순서 보장 (동률 구간에서 인접 페이지가 같은 행을 중복 노출하고 다른 행을 누락한다) |
> 상세: [service-repository.md "목록 조회 컬럼 프루닝과 지연 조인" / "정렬 컬럼 화이트리스트" / "화면 정렬 옵션은 게이트의 부분집합이어야 한다" / "관계 테이블 컬럼 기준 정렬" / "허용되는 Raw 쿼리"](docs/backend/service-repository.md)
### 대용량 목록의 총 건수와 페이지 이동
총 건수 상한과 페이지 이동 범위는 **별개 결정**이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고, "다음" 이동은 `per_page + 1` 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 같은 술어를 `count()` 한 번, `get()` 한 번 실행 | `BoundedPaginator::paginate()` 한 번 (총 건수 + 페이지를 한 번에) |
| `paginate(PHP_INT_MAX)` 후 PHP `array_slice` | 실제 `page`/`per_page` 를 저장소까지 하달 |
| `forPage($page, $perPage + 1)` | offset 은 `per_page` 기준으로 따로 계산 (안 그러면 페이지가 깊어질수록 경계가 밀린다) |
| Scout `->keys()->all()` + 무제한 `whereIn` | 키워드 술어를 페이지 쿼리에 직접 밀어넣기 (`DatabaseFulltextEngine::whereFulltext`) |
| FULLTEXT 원문 키워드를 raw 로 바인딩 | 코어 sanitizer 경유 — `+` `-` `*` `"` 입력이 파싱 오류로 500 이 된다 |
| `whereDate` / `whereYear`+`whereMonth` | `FiltersByDateRange` 의 범위 조건 (컬럼에 함수를 씌우면 인덱스를 못 쓴다) |
| 총 건수를 모르는데 `last_page` 를 1 로 채움 | `null` 로 내보내 화면이 마지막 페이지 점프만 감추게 한다 |
| 상한값을 저장소·화면에 리터럴로 재기입 | `PaginationLimits` 단일 해석 + 확장은 `core.pagination.filter_*` 필터 훅으로만 조정 |
| 결과 크기가 데이터 증가에 비례하는데 상한 없는 `->get()` / `->pluck()` | 목록은 페이지네이션, 순회는 `chunkById`/`lazyById`, 몇 건이면 `limit` — 운영자 등록 수에 묶인 설정성 테이블만 예외이며 그 근거를 코드에 남긴다 |
| 배지·요약 건수를 `int` 하나로 돌려주기 | `BoundedPaginator::count()` 의 `BoundedCount` — 잘린 값과 정확한 값이 구분되지 않으면 잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나간다 |
| 여러 카테고리 건수를 합치며 정확도는 버리기 | 하나라도 부정확하면 합계도 부정확. 단, 특정 탭만 볼 때는 그 카테고리의 정확도만 본다 |
| 정렬 마지막이 비고유 컬럼 | 기본키를 덧붙여 전순서 보장 (동률 구간에서 행이 겹치거나 샌다) |
| 관련도순(`_ft_score`)에 커서 적용 | 계산값은 WHERE 절 경계로 쓸 수 없다 — offset 유지 (`KeysetPaginator::supports` 가 판정) |
> 상세: [pagination.md](docs/backend/pagination.md)
### 검색 인덱스 재생성(리인덱싱)
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 재생성을 자동 트리거(마이그레이션 종료·확장 업데이트 완료 등)에 연결 | 운영자가 **명시적으로 선택**했을 때만 수행. 인덱스 잠금·전체 재색인 비용이 운영 중 사이트를 멈춘다 |
| 재생성 체크 상태를 전역에 남겨 다음 모달 진입에 이월 | 모달 진입 시드와 제출 후 초기화 **양쪽**에서 해제. 이월되면 운영자가 아무것도 누르지 않았는데 재생성이 수행되고, 서버 옵인 가드는 정상이라 HTTP 테스트로는 드러나지 않는다 |
| 모듈·플러그인 모달이 같은 전역 키 공유 | 면마다 별도 키 (한쪽 체크가 다른 쪽으로 전이 금지) |
| 응답 헬퍼가 `JsonResource::resolve()` 만 호출해 `additional()` 유실 | 부가 데이터를 응답 최상위에 병합 — 색인 누락은 오류 없이 "검색 0건" 으로만 나타나므로 응답 페이로드가 유일한 통로다 |
| 재생성 수행을 곧 복구로 간주 | `remaining` 은 **재생성 후 재점검** 결과 — "재생성했다" 와 "복구됐다" 를 구분해 보고 |
| 점검 커맨드의 비-0 종료를 "실행 실패" 로 표시 | 이상 발견 신호다. 종료 코드와 출력을 그대로 노출 |
| 특정 엔진(FULLTEXT) 전용으로 점검·재생성 구현 | `SearchIndexMaintainer` 계약 + `core.search.index_maintainers` 훅 |
| "점검 대상 0" 과 "점검 불가" 를 같은 문구로 보고 | 구분 보고 — 뭉뚱그리면 "인덱스가 다 정상" 으로 읽힌다 |
> 상세: [search-system.md](docs/backend/search-system.md)
### 검색 질의는 활성 엔진이 만든다
검색 엔진은 `core.search.engine_drivers` 훅으로 교체 가능하다. 그런데 그 교체가 실제로 먹는 것은 **활성 엔진을 거치는 경로뿐**이다. 저장소가 구체 엔진 클래스를 지목하면 등록된 엔진은 호출될 기회 자체를 잃고, 오류도 경고도 없이 그 사이트의 검색만 조용히 다른 방식으로 동작한다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| `DatabaseFulltextEngine::whereFulltext(...)` 등 구체 엔진 정적 호출 | `KeywordSearch::apply()` / `::applyAny()` (해석기가 활성 엔진에 위임) |
| `DB::getDriverName() === 'pgsql'` 처럼 드라이버명을 코드에 비교 | 선언형 `config('core.search.*')` + `core.search.like_operators` 필터 훅 |
| 매칭 ID 전량을 PHP 로 적재 후 `whereIn` (`search()->keys()->all()`) | 술어를 페이지 쿼리에 직접 부착 — ID 왕복도 목록 폭발도 없다 |
| 엔진에게 페이지 번호를 넘겨 한 페이지만 받기 | 페이지네이션은 DB 담당. 엔진은 **키 집합 상한**만 책임진다 (`KeywordSearchContext`) |
| 키 집합 상한을 총 건수 상한과 다른 값으로 두기 | 둘 다 `PaginationLimits::resultCap()` — 갈라지면 엔진이 돌려준 건수와 화면 총 건수의 근거가 달라진다 |
| 부분일치 폴백을 "전문검색 없을 때의 임시방편" 으로 취급 | 전문검색 미제공 DBMS 에서는 **그것이 정상 경로** — 와일드카드 escape + 대소문자 규칙을 갖춘다 |
| 확장이 `Model::search()` 를 쓰는 것을 금지로 오해 | Scout 경로는 그대로 유효하다. 새 계약은 대체가 아니라 **추가 통로** |
정적 검사는 이 저장소 안만 볼 수 있다 — 외부 엔진이 상한을 지키는지는 강제할 수 없으므로, 코어는 **값을 손에 쥐어 주는 것**까지 하고 그 값이 도달하는지를 계약 테스트가 고정한다.
> 상세: [search-system.md "키워드 술어는 활성 엔진이 만든다"](docs/backend/search-system.md)
### 통화 단위는 설정이 정한다
G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통화**(구매자가 고른 통화), **결제 통화**(PG 청구 통화)를 각각 따로 설정한다. 셋은 같을 수도, 모두 다를 수도 있다. 금액을 다루는 지점이 특정 통화를 전제하면 값은 맞고 **단위만 틀린** 금액이 나가며, 예외도 경고도 없다.
| 금지 | 올바른 사용 |
|--------|---------------|
| 다국어 문구에 `:amount원` / `:amount円` | 문구는 `:amount` 로 중립, 호출부가 `ecommerce_format_price($amount, $currency)` 로 포맷해 전달 |
| 레이아웃에서 `{{금액.toLocaleString()}}원` 조립 | 서버가 준 `*_formatted` / `multi_currency_*[통화].formatted` 를 그대로 출력 |
| `formatCurrencyPrice($price, 'KRW')` (통화 코드 리터럴) | `formatBaseCurrency()` / `formatOrderCurrency()` (설정·주문 스냅샷이 통화를 정한다) |
| `number_format($amount).'원'` | 같은 도메인의 통화 인지 헬퍼(`formatOrderChargeAmount()` 등) 경유 |
| `_global.preferredCurrency ?? 'KRW'` | `_global.preferredCurrency ?? _global.defaultCurrency` (둘 다 없으면 `*_formatted` 로 내려간다) |
| 통화표를 코드에 고정(기호·자릿수 5종 표 + 특정 통화 폴백) | 설정의 `symbol` / `decimal_places` 를 읽고, 미설정 시에만 폴백표 |
| `code === 'KRW' ? 0자리 : 2자리` 식 코드 분기 | `decimal_places` 로 판정 (운영자가 추가한 0자리 통화도 포함) |
| 통화 선택 입력의 기본값을 `"KRW"` 로 시드 | 설정의 `default_currency` — 마일리지처럼 **통화별 원장**을 쓰는 도메인은 표시가 아니라 **기록이 틀어진다** |
언어는 통화가 아니다. 한국어 문구에 `원`, 일본어에 `円` 을 박으면 기본 통화가 다른 상점에서 UI 언어가 통화를 결정하게 된다 — 영어 문구가 `:amount` 로 중립인 것이 정답이다.
주문·결제·환불 금액은 **거래 시점 통화로 동결**한다(`currency_snapshot.base_currency`). 운영자가 이후 기본 통화를 바꿔도 과거 주문의 표기는 불변이어야 한다.
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
### Listener 데이터 접근
| 금지 | 올바른 사용 |
@@ -704,9 +909,15 @@ Controller → Request → Service → RepositoryInterface → Repository → Mo
절대 금지: DB CASCADE에 의존한 삭제 → Service에서 명시적 삭제 (훅/파일/로깅 보장)
절대 금지: 로케일 하드코딩 → config('app.supported_locales') 사용
필수: 마이그레이션 한국어 comment 필수, down() 구현 필수
필수: FK 컬럼의 ->comment() 는 ->constrained()/->references()/->on() 앞에 둔다 (뒤에 두면 comment 가 컬럼이 아닌 FK 정의에 부착되어 조용히 사라진다)
필수: 소스 교정만으로는 기설치본이 낫지 않는다 — 마이그레이션은 재실행되지 않으므로 업그레이드 스텝 백필을 함께 작성
필수: 필터가 걸린 쿼리를 순회하며 그 행을 update/delete 하면 chunkById() (키셋 순회)
절대 금지: 그 경우 chunk()/each()/lazy() 사용 — OFFSET 기반이라 처리된 행이 결과에서 이탈한 만큼 커서가 밀려 미처리 행을 조용히 건너뛴다 (250건/청크 100 → 100건 누락, 예외·로그 없음)
주의: ResponseHelper::success($messageKey, $data) — 메시지가 첫 번째 인수
```
갱신값이 항상 필터 소속을 유지해 안전한 경우(예: `whereNotNull` + 갱신값이 항상 non-null)만 예외이며, 그 근거를 코드 주석에 남긴다. 정적 검사가 이 패턴을 검출한다.
> 상세 규칙 (API 리소스, ServiceProvider, validation, 인증, 활동 로그 등): [docs/backend/](docs/backend/) 각 문서 참조
### 컨트롤러 계층
@@ -750,7 +961,9 @@ BaseApiController (최상위)
필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가)
필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가)
필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지)
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
필수: 모든 확장 작업은 Artisan 커맨드로 수행
```
@@ -807,14 +1020,18 @@ public function createProduct(array $data): Product
| `*.json` (레이아웃만) | `{type}:update {id} --force` 실행 |
| `*.tsx`, `*.ts` + `*.json` | `{type}:build` + `{type}:update {id} --force` |
| `*.tsx`, `*.ts`만 | `{type}:build` + `{type}:update {id} --force` |
| `lang-packs/_bundled/**` (번들 언어팩 콘텐츠/버전) | `language-pack:update {id} --force` (빌드 불필요) |
```bash
# 확장 업데이트 (_bundled → 활성 반영)
php artisan template:update sirsoft-admin_basic --force
php artisan module:update sirsoft-ecommerce --force
php artisan plugin:update sirsoft-payment --force
php artisan language-pack:update g7-core-ja --force
```
번들 언어팩도 `_bundled` 는 배포 원본일 뿐이다. 설치본(`lang-packs/{id}/`)을 갱신하지 않으면 새로 추가한 번역 키가 런타임에 존재하지 않아 해당 로케일이 조용히 기준 로케일로 폴백한다.
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
@@ -971,6 +1188,7 @@ php artisan migrate:rollback
| `lang/**` | [database-guide.md](docs/database-guide.md) (다국어 섹션) |
| `routes/**` | [routing.md](docs/backend/routing.md) |
| `app/Seo/**` | [seo-system.md](docs/backend/seo-system.md) |
| `app/Benchmark/**`, `config/benchmark.php` | [benchmark.md](docs/backend/benchmark.md) |
---
@@ -1003,3 +1221,8 @@ php artisan migrate:rollback
- **ResolvesActivityLogType**: `app/ActivityLog/Traits/ResolvesActivityLogType.php`
- **ChangeDetector**: `app/ActivityLog/ChangeDetector.php`
- **CoreActivityLogListener**: `app/Listeners/CoreActivityLogListener.php`
- **BenchmarkProfileRegistry**: `app/Benchmark/BenchmarkProfileRegistry.php`
- **성능 계측 DTO**: `app/Benchmark/DTO/{BenchmarkProfile,BenchmarkRunOptions,BenchmarkResult}.php`
- **BenchmarkAxisRunner**: `app/Benchmark/Contracts/BenchmarkAxisRunner.php`
- **성능 계측 축 실행기**: `app/Benchmark/Axes/{List,Screen,Write,Batch}AxisRunner.php`
- **BenchmarkAxis**: `app/Enums/BenchmarkAxis.php`
+219 -2
View File
@@ -4,6 +4,224 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.6] - 2026-08-10
### Security
- 언어팩 안의 PHP 파일에 번역 배열 외의 코드가 들어 있으면 설치 단계에서 거부하도록 바꿨습니다. 언어팩의 PHP 파일은 활성화되면 사이트가 그대로 실행하므로, 파일을 만들거나 외부 명령을 실행하는 코드가 섞여 있으면 서버에서 임의 코드가 실행될 수 있었습니다. 이전에는 위험한 함수 몇 가지를 이름으로 골라 막았기 때문에 목록에 없는 함수나 함수 이름을 쓰지 않는 방식으로 우회할 수 있었습니다. 이제는 번역 배열만 통과시키므로 어떤 형태의 코드든 들어갈 수 없으며, 거부될 때는 어느 파일 몇 번째 줄이 문제인지 알려 드립니다. 함께 언어팩에 담을 수 있는 파일 형식을 `.json`·`.php`·`.md` 로 한정하고, PHP 파일은 정해진 언어 폴더 안에만 둘 수 있게 했습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1678)
- 언어팩을 설치하면서 곧바로 활성화하려면 언어팩 관리 권한이 필요하도록 바꿨습니다. 설치 권한과 활성화 권한은 원래 별도인데, 설치 요청에 '바로 켜기'를 함께 보내면 설치 권한만으로 활성화까지 되어 권한 경계가 무너져 있었습니다. 권한이 없으면 그 항목만 거부되고 설치 자체는 그대로 진행됩니다. 파일·URL·GitHub·번들 네 가지 설치 경로 모두 같은 기준을 적용하므로, 어느 경로로 설치하느냐에 따라 권한 경계가 달라지지 않습니다. 저장소에 동봉된 번들 언어팩의 재설치도 종전처럼 할 수 있으며, 다만 활성화 권한이 없으면 재설치한 언어팩이 꺼진 상태가 되어 다시 켜려면 관리 권한이 필요합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1678)
- 설치 마법사의 PHP·Composer 실행 경로 입력에서 실행 파일이 아닌 값을 넣지 못하도록 형식 검사를 강화했습니다. 이전에는 실행 파일 뒤에 다른 파일이나 옵션을 덧붙이는 형태가 통과해, 설치가 끝나지 않은 상태에서 서버의 임의 코드가 실행될 수 있었습니다. 정상적인 입력(멀티 PHP 환경의 "PHP 경로 Composer 경로" 형태, 시놀로지·cPanel·Plesk 등의 다양한 PHP 실행 파일 이름, Windows 경로)은 그대로 사용할 수 있으며, 파일 존재 여부를 미리 확인하지 않으므로 접근이 제한된 서버에서도 정상 경로가 거부되지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1677)
- 아직 공개하지 않은 예약 작업 기능에서, Artisan 명령 예약의 실행 제한을 허용 목록 방식으로 바꿔 정식 공개에 대비했습니다. 종전에는 위험한 명령 몇 가지만 거부하고 나머지를 모두 허용했기 때문에, 예약 작업 권한을 위임받은 계정이 파일을 만들거나 데이터베이스 구조를 바꾸는 명령을 등록할 수 있었습니다. 이제 캐시 정리·큐 처리·만료 데이터 정리 같은 유지보수 명령만 등록할 수 있고, 각 명령에 붙일 수 있는 옵션도 정해진 것만 허용합니다. 설치된 모듈·플러그인이 제공하는 명령은 자동으로 허용됩니다. 등록이 거부되면 왜 거부됐는지(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)를 구분해 안내합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1679)
- 같은 기능에서, 저장한 예약 작업 명령이 실행 직전에 다시 해석되면서 저장할 때 확인한 것과 다른 명령이 실행될 수 있던 경로도 함께 막았습니다. 명령을 따옴표나 역슬래시로 감싸면 검사 단계와 실행 단계가 서로 다른 명령으로 읽었습니다. 이제 명령 형식을 `명령명 --옵션[=값]` 으로 한정하고 실행할 때도 해석된 결과를 그대로 사용하므로, 확인한 명령과 실행되는 명령이 달라질 수 없습니다. (KISA 측에서 제보해주신 내용을 확인하는 과정에서 함께 발견했습니다 — KVE-2026-1679)
- 보안 환경설정의 계정 잠금 시간을 `0`(무한대)으로 설정하면 실제로 무기한 잠기도록 수정했습니다. 화면 안내는 "0을 입력하면 무한대"였지만 실제로는 1분 뒤 자동 해제되어, 무차별 대입 시도를 영구 차단할 수 없었습니다. 무기한 잠긴 계정에는 남은 시간 대신 관리자 문의 안내가 표시됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 보안에 비밀번호 최소 길이와 특수문자 필수 여부를 추가했습니다. 두 항목은 설정 화면에만 있고 실제로는 어디에도 적용되지 않던 값으로, 이제 비밀번호를 새로 정하는 모든 화면(회원가입·비밀번호 변경·재설정·프로필 수정·관리자의 회원 등록과 수정)에 적용됩니다. 로그인에는 적용하지 않습니다 — 정책을 올렸을 때 기존 비밀번호를 쓰는 회원이 자기 계정에 로그인조차 못 하게 되는 것을 막기 위함입니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 본인인증 메일의 인증번호 자리수 설정이 4~10 범위를 벗어나면 조용히 잘려 저장 값과 다르게 동작하던 문제를 수정했습니다. 이제 허용 범위가 설정 화면에 표시되고, 범위를 벗어난 값은 기본값(6자리)으로 처리하며 기록을 남깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 보안의 로그인 시도 제한 항목에 IP 단위 요청 제한이 어떻게 계산되는지(설정값의 6배, 분당 최소 30회 보장) 안내를 추가했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 업로드의 최대 파일 크기와 허용 파일 형식이 실제 업로드에 적용되도록 수정했습니다. 이전에는 설정을 바꿔도 어느 경로에서도 반영되지 않아, 허용 형식 목록에 없는 확장자(예: `.php`)도 업로드할 수 있었습니다. 허용 형식 목록을 비워 두면 종전처럼 형식을 제한하지 않습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 허용 파일 형식을 저장해도 업로드 제한에 반영되지 않던 문제를 수정했습니다. 입력 칸은 `jpg,png,pdf` 처럼 쉼표로 구분한 한 줄이지만 적용 단계가 그 형태를 받아들이지 못해, 업로드 탭을 한 번이라도 저장하면 형식 제한이 저장 이전 목록으로 남았습니다. 저장은 성공하고 화면에도 값이 보여 어긋난 것을 알 방법이 없었습니다. 대문자나 앞의 점(`.JPG`, `.png`)은 자동으로 정리되며, 이미 저장되어 있던 값도 그대로 적용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 업로드된 파일의 저장 확장자를 파일명이 아니라 파일 내용에서 판별하도록 바꿨습니다. 확장자만 바꿔 올린 파일이 원래 형식과 다르게 저장되던 문제가 해소됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 본인인증의 '가입 후 본인확인' 정책을 켜면 회원가입 자체가 막혀 아무도 가입할 수 없던 문제를 수정했습니다. 이 정책은 가입은 받아들이고 계정을 '인증 대기' 상태로 둔 뒤 본인확인 안내를 보내는 설정인데, 안내를 보낸 직후 가입이 거절되어 '가입 전 본인확인' 정책과 구분이 없었습니다. 이제 설정한 대로 가입이 완료되고, 본인확인을 마치면 계정이 활성화됩니다. (안내 발송이 실패해도 가입은 유지되며 실패 사실은 기록에 남습니다.)
- 보안 환경설정의 '2단계 인증'이 실제로 동작합니다. 이전에는 항목만 있고 켜도 아무 일이 일어나지 않아, 관리자는 2단계 인증이 걸린 줄 알지만 비밀번호 하나로 로그인됐습니다. 이제 켜 두면 비밀번호 확인 뒤 인증번호를 메일로 보내고, 그 번호를 확인해야 로그인이 완료됩니다. 번호를 확인하기 전에는 로그인 상태가 만들어지지 않습니다. 안내 메일 문구는 환경설정 > 본인인증에서 '로그인 2단계 인증' 목적을 골라 따로 지정할 수 있습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 업로드의 이미지 최대 가로/세로 크기와 품질이 실제 업로드에 적용됩니다. 이전에는 값을 저장해도 이미지를 줄이는 동작이 없어 원본이 그대로 저장됐습니다. 크기를 지정하지 않으면 종전처럼 줄이지 않으며, 지정한 크기 이내인 이미지는 다시 변환하지 않아 불필요한 화질 손실이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 회원 상세 화면에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 무기한 잠긴 계정은 로그인 성공으로 풀 수 없으므로 관리자가 직접 해제할 수 있어야 합니다. 해제는 활동 로그에 남습니다. (사용자 수정 권한이 필요합니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.)
### Added
- 회원가입 시 휴대폰번호와 전화번호를 선택 항목으로 입력할 수 있습니다. 두 항목 모두 필수가 아니며, 입력하면 내 정보에서 확인·수정할 수 있습니다.
#### 사이트맵
- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.)
- 사이트맵을 검색엔진 규격(파일당 5만 URL / 50MB)에 맞춰 여러 파일로 자동 분할하고, 이를 묶는 색인 파일을 함께 만듭니다. 한 파일에 담을 URL 수는 SEO 설정에서 조정할 수 있습니다.
- 게시글·상품·페이지를 공개/비공개로 바꾸거나 삭제하면 사이트맵에서 해당 항목만 자동으로 더하거나 빼도록 바꿨습니다. 전체를 다시 만들지 않고 바뀐 부분만 반영하므로 항상 최신 상태가 빠르게 유지됩니다.
- 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다.
- 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다.
- 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다.
- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다.
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
#### 설치·서버 환경
- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다.
- 일부 서버 설정에서 모든 페이지가 백지로 나오던 문제에 대응했습니다. 서버에 `.js`·`.css`·`.json` 주소를 가로채는 정적 파일 최적화 설정(aaPanel·CyberPanel·Plesk 기본 템플릿 등)이 있으면 G7 의 일부 주소가 PHP 에 전달되지 못해 화면이 뜨지 않았습니다. 이제 G7 이 확장자 없는 주소도 함께 제공하며, 화면이 뜨지 않으면 브라우저가 이를 감지해 자동으로 전환하므로 서버 설정을 바꾸지 않아도 사이트가 표시됩니다. (sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.)
- 자산 파일 주소 방식을 설치 마법사가 자동으로 감지해 반영하고, 설치 후에도 관리자 > 환경설정 > 일반에서 바꿀 수 있습니다. 설치 2단계 서버 요구사항 화면에서 감지 결과를 미리 확인할 수 있고, 3단계에서 직접 고를 수도 있습니다. 화면이 아예 뜨지 않는 상황을 위해 `php artisan g7:asset-url-mode` 명령으로도 확인·전환할 수 있습니다. 검색엔진 봇은 자바스크립트를 실행하지 않으므로, 이 설정을 확정해 두면 봇에게도 올바른 주소가 전달됩니다.
- 서버에 OPcache가 켜져 있는지를 설치 화면과 관리자 환경설정 > 정보에서 확인할 수 있습니다. OPcache는 PHP 코드 해석 결과를 재사용해 사이트 응답 속도를 크게 높여 주는 기능으로, 꺼져 있으면 성능이 떨어진다는 안내와 함께 설정 방법을 알려 드립니다. 꺼져 있어도 설치는 그대로 진행되므로 설치가 막히지 않습니다.
#### 언어팩
- 사이트에서 쓰기로 한 언어의 언어팩을 한 번에 채워 넣는 기능을 추가했습니다. 한국어·영어 외 언어(일본어 등)는 설치되어 있어야 화면에 표시되는데, 설치가 빠져 있으면 오류 없이 한국어로 대체되어 운영자가 눈치채기 어려웠습니다. 이제 언어팩 관리 목록과 명령으로 "설치가 필요한 언어"를 한 번에 채울 수 있고, 여러 번 실행해도 안전합니다.
- 언어팩 관리 목록이 "설치되어 있다고 표시되지만 실제 파일이 없어 다른 언어로 대체되는" 상태를 눈에 띄게 보여주고, 그 자리에서 다시 설치할 수 있는 버튼을 제공합니다. 예전에는 목록에 정상으로 보이는데도 실제로는 번역이 적용되지 않는 경우를 발견하기 어려웠습니다. 파일이 사라진 언어팩은 화면의 다시 설치 버튼으로도, 언어팩을 채우는 명령으로도 복구할 수 있습니다.
#### 목록 성능
- 검색 인덱스가 실제로 내용을 담고 있는지 점검하고, 비어 있으면 다시 만들 수 있는 기능을 추가했습니다. 인덱스가 비면 검색은 오류 없이 "결과 없음" 만 돌려주기 때문에 그동안은 알아차릴 방법이 없었습니다. 관리자 > 개발 도구에서 「검색 인덱스 점검」 으로 확인할 수 있고, `php artisan search:index` 로도 실행됩니다. 점검·재생성 방식은 검색 엔진마다 다르므로, 다른 검색 엔진을 쓰는 확장도 자기 방식의 점검을 등록해 같은 화면에서 함께 다룰 수 있습니다.
- 다시 만들기는 **선택할 때만** 실행됩니다. 인덱스를 다시 만드는 동안에는 해당 인덱스가 잠기거나 전체 재색인이 일어나 운영 중인 사이트에 영향을 주기 때문입니다. 확장·코어 업데이트 화면과 명령어에 「업데이트 후 검색 인덱스 재생성」 선택 항목을 두었고, 선택하지 않아도 색인이 비어 있으면 그 사실은 알려 드립니다. 사이트 규모가 크다면 선택하지 말고 접속이 적은 시간에 따로 실행하시길 권합니다.
- 업데이트 화면의 「검색 인덱스 재생성」 선택이 그 창에서만 유효하도록 바로잡았습니다. 예전에는 한 번 선택하면 창을 닫거나 업데이트를 마친 뒤에도 선택이 남아 있어, 다음 확장을 업데이트할 때 아무것도 고르지 않았는데 인덱스 재생성이 다시 실행됐습니다.
- 확장을 업데이트하면 색인이 비어 있는 검색 인덱스가 있는지 결과가 함께 전달되도록 바로잡았습니다. 예전에는 점검은 이루어졌지만 그 결과가 화면까지 오지 않아 운영자가 알 수 없었습니다.
- 모듈·플러그인 업데이트 완료 안내에 확장 이름과 버전이 표시되지 않던 문제를 수정했습니다.
- 모듈·플러그인 목록의 총 개수와 페이지 이동이 동작하지 않던 문제를 수정했습니다. 설치된 확장이 12개를 넘으면 2페이지 이후 항목이 화면에 나타나지 않았습니다.
- `php artisan module:check-updates` 등 업데이트 확인 명령이 업데이트가 있는 확장을 「최신」 으로 표시하고 요약도 "업데이트 가능 0개" 로 알리던 문제를 수정했습니다.
- 통신이 끊겨 요청이 전송되지 못했을 때 내부 식별 문구가 그대로 표시되던 것을 네트워크 오류 안내로 바꿨습니다.
- 개발 도구에서 점검 명령이 이상을 발견해 알려 준 경우를 「실행 실패」 로 표시하던 것을 종료 코드 안내로 바꿨습니다. 점검은 정상 수행된 것이며, 발견된 내용은 출력에 그대로 표시됩니다.
- 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. 주문 목록의 「발송일」처럼 정렬 기준이 다른 표에 있는 값일 때도 총 건수와 페이지 경계가 어긋나지 않게 조회하는 공통 처리를 함께 넣었습니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
#### 대용량 목록
- 검색 결과나 목록이 아주 많을 때 화면이 열리지 않거나 서버가 멈추던 문제를 개선했습니다. 이제 총 건수는 일정 규모까지만 정확히 세고 그보다 많으면 "10,000건 이상" 처럼 표시하며, 다음 페이지로 넘기는 것은 끝까지 그대로 됩니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다 — 그 위치를 계산하려면 전체를 세야 하기 때문입니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 총 건수를 세는 범위와 주소로 직접 요청할 수 있는 최대 페이지 번호를 관리자 > 환경설정 > 고급에서 조정할 수 있습니다. 두 값 모두 0 으로 두면 제한하지 않습니다.
- 검색 결과가 많아 총 건수를 정확히 세지 못한 경우, 검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있다는 안내를 함께 표시합니다.
- 활동 로그·알림 발송 기록·본인인증 기록·스케줄 실행 이력·회원 목록에도 같은 방식을 적용했습니다. 기록이 수십만 건 쌓여도 목록 첫 화면이 총 건수를 세는 비용에 끌려가지 않습니다.
- 알림함도 같은 방식으로 조회합니다. 알림이 오래 쌓인 계정에서도 목록이 빠르게 열립니다.
- 알림 발송 기록을 최신순으로 볼 때 뒤쪽 페이지를 이동하는 방식이 활동 로그와 동일해졌습니다. 몇 페이지를 넘겨도 속도가 일정하게 유지됩니다.
- 검색 결과 화면에서 다른 분류의 건수 배지가 필요 없을 때는 그 숫자를 세는 작업을 건너뛸 수 있습니다.
- 검색 결과의 탭 배지(게시글 N · 상품 N · 페이지 N)도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. 탭을 열어 본 목록과 배지의 숫자가 서로 다른 기준을 말하던 문제가 사라집니다.
- 활동 로그 목록을 페이지 번호 대신 이어보기 방식으로도 조회할 수 있습니다. 기록이 많이 쌓인 사이트에서 뒤쪽으로 갈수록 느려지지 않습니다. 화면 동작은 그대로이며, 필요할 때만 쓰는 선택지입니다.
- 통합 검색도 최신순·조회순·가격순처럼 목록 자체의 순서로 볼 때는 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 검색 결과가 아주 많아도 몇 페이지를 넘기든 속도가 일정합니다. 관련도순은 계산된 점수로 정렬하므로 종전의 페이지 번호 방식을 유지합니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
#### 성능 점검
- 확장이 자기 성능 측정 대상을 직접 등록할 수 있게 했습니다. 모듈·플러그인이 자신의 목록 화면, 관리자 화면, 저장 동작, 정기 작업 중 속도를 재고 싶은 것을 선언해 두면, 사이트 관리자가 성능 점검을 실행할 때 코어 항목과 함께 측정됩니다. 확장을 설치하면 그 확장의 측정 대상이 자동으로 목록에 나타나고, 제거하면 함께 사라집니다. 화면 측정은 응답 시간과 함께 그 화면이 실행한 데이터베이스 조회 횟수를 보여주므로, 목록 자체는 빠른데 화면이 느린 원인을 찾을 수 있습니다.
### Changed
- 레이아웃 편집기의 파일 목록·버전 이력·확장 목록이 목록에 필요한 정보만 받아오도록 바꿨습니다. 예전에는 목록 한 번에 그 템플릿의 모든 레이아웃 본문과 버전마다의 본문 사본까지 함께 실려, 레이아웃이 많거나 저장을 자주 한 템플릿일수록 편집기 진입이 느려졌습니다. 편집 본문은 파일을 선택할 때 받아오므로 화면에 보이는 내용은 이전과 동일합니다. 버전 이력은 최근 100건까지 표시하며, 더 필요하면 조회 건수를 지정할 수 있습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 역할 관리 목록과 메뉴 관리 목록이 화면에서 쓰지 않는 정보를 함께 내려주던 것을 정리했습니다. 역할 목록은 권한 전체 목록을 세 가지 형태로 중복해서, 메뉴 목록은 하위 메뉴마다 역할 정보를 함께 싣고 있었습니다. 역할의 권한은 역할 수정 화면에서 종전대로 확인·편집할 수 있습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 레이아웃 버전 목록 조회에 건수 제한을 두었습니다. 허용 범위를 벗어난 값을 지정하면 안내 문구가 표시됩니다. 버전 이력의 '더 보기'는 조회 상한에 이르면 더 넓히지 않습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 알림 정의 목록이 지금 보고 있는 채널의 템플릿만 받아오도록 바꿨습니다. 예전에는 정의마다 모든 채널의 제목과 본문이 함께 실려, 채널을 여러 개 쓰는 사이트일수록 목록이 무거웠습니다. 채널을 지정하지 않고 목록을 조회하면 템플릿 본문은 함께 오지 않으며, 전체 채널의 내용이 필요하면 정의 단건 조회를 이용하면 됩니다. 화면에 보이는 내용과 '되돌리기' 버튼 표시 조건은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 본인인증 메시지 정의 목록이 대표 템플릿 한 건만 받아오도록 바꿨습니다. 전체 템플릿은 정의를 열었을 때 종전대로 받아옵니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 역할 목록에 할당된 권한 개수를 함께 표시할 수 있도록 집계 값을 제공합니다. 권한 목록 자체를 싣지 않고도 규모를 확인할 수 있습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 페이지를 한 번 열 때마다 서버가 되풀이하던 준비 작업을 정리했습니다. 내부 동작 기록을 매 요청 400줄씩 남기던 부분, 환경설정 파일을 한 요청에서 스무 번 넘게 다시 읽던 부분, 이미 저장해 둔 목록을 매번 데이터베이스에서 다시 가져오던 부분을 없앴습니다. 화면에 보이는 내용은 그대로이며 응답이 빨라집니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 권한 확인이 화면 요소마다 데이터베이스를 다시 조회하던 것을 요청당 한 번으로 줄였습니다. 메뉴·버튼이 많은 화면일수록 체감 차이가 큽니다. 권한을 바꾸면 종전처럼 다음 요청부터 즉시 반영됩니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시글·상품·쿠폰 검색이 조건에 맞는 항목 전체를 먼저 메모리에 올린 뒤 다시 조회하던 방식을 없앴습니다. 검색 결과가 아주 많아도 메모리 사용량이 늘지 않습니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 플러그인으로 다른 검색엔진을 연결했을 때 게시판·상품·페이지 검색이 그 엔진을 쓰지 않고 기본 방식으로 동작하던 문제를 해소했습니다. 이제 연결한 검색엔진이 모든 검색 화면에 적용됩니다. 전문검색을 제공하지 않는 데이터베이스에서도 검색어의 `%` `_` 같은 기호를 입력한 글자 그대로 찾도록 개선했습니다.
- 기간으로 걸러 보는 목록(주문·회원·쿠폰·스케줄 등)의 날짜 조건을 색인을 활용하는 방식으로 바꿨습니다. 기록이 많은 사이트에서 기간 검색이 빨라집니다.
- 설치 직후 기록 수준(`LOG_LEVEL`) 기본값을 운영 기준(`error`)으로 바꿨습니다. 이전 기본값은 정상 동작까지 모두 기록해 로그 파일이 빠르게 커졌습니다. 관리자 > 환경설정 > 고급에서 조정할 수 있으며, 그 값이 이 항목보다 우선합니다.
- 권장 서버 사양(CPU·메모리)을 시스템 요구사항 문서에 추가했습니다.
- 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
- 활동 로그·알림 발송 이력·회원 목록의 정렬 색인을 정비했습니다. 같은 시각에 쌓인 기록이 많은 구간에서 순서를 정하느라 생기던 추가 작업이 사라져, 기록이 많은 사이트일수록 목록이 빨라집니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 기록이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 해당 기록의 저장이 잠시 대기할 수 있습니다.
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
- 같은 규칙에서 벗어나 있던 필터 이름 6개(플러그인 설정 저장, 비밀번호 재설정 토큰 확인, 비밀번호 확인, 변경사항 조회, 통합 검색, 프로필 사진 업로드)를 추가로 통일했습니다. 이번에는 예전 이름도 함께 계속 발행하므로 기존 확장은 수정 없이 그대로 동작하며, 예전 이름을 사용 중인 확장이 있으면 서버 기록에 한 번 안내가 남습니다. 새로 만드는 확장은 새 이름을 사용하세요.
### Fixed
#### 화면 동작
- 화면에서 선택·클릭 같은 동작에 반응하도록 지정한 항목이 아무 반응도 하지 않던 문제를 수정했습니다. 오류나 경고 없이 조용히 동작하지 않아 원인을 찾기 어려웠습니다. 게시판 글쓰기의 분류 선택이 이에 해당해, 분류를 골라도 글에 반영되지 않았습니다. 함께, 반응은 하되 선택한 값만 비어 저장되던 경우도 수정했습니다.
- 화면에서 고른 값이 모듈·플러그인이 제공하는 동작에는 전달되지 않던 문제를 수정했습니다. 값을 저장하는 방식이 두 가지인데 그중 한 가지로 저장하면 화면에는 선택한 것으로 표시되지만 확장 기능은 그 값을 읽지 못했고, 오류나 경고가 남지 않아 화면만 봐서는 알 수 없었습니다. 이제 어느 방식으로 저장하든 같은 값이 전달됩니다.
#### 레이아웃 코드 편집
- 레이아웃 코드 편집 화면이 파일 목록을 불러올 때 편집하지 않는 레이아웃의 본문까지 전부 내려받던 문제를 수정했습니다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB가 브라우저에 쌓여 화면이 크게 느려지거나, 디버그 모드에서는 탭이 메모리 부족으로 종료됐습니다. 이제 목록에는 이름·설명·크기·수정일만 실리고 본문은 선택한 파일만 불러옵니다.
#### 화면 편집기
- 화면 편집기가 주소 일부를 설정으로 바꿀 수 있는 화면도 알아보도록 화면 인식 방식을 넓혔습니다. 예를 들어 쇼핑몰은 주소를 바꾸거나 주소 없이 운영할 수 있는데, 편집기는 주소가 한 토막 있는 경우만 인식해 그 외의 사이트에서는 해당 화면의 상태 미리보기 전환이 표시되지 않았습니다. 이제 확장이 "이 토막은 있을 수도 없을 수도 있다"고 알려 줄 수 있으며, 함께 배포되는 쇼핑몰에 이미 적용되어 있습니다.
#### 설정 화면 입력
- 설정 화면에서 여러 입력칸을 고친 뒤 다른 탭에 갔다 돌아오면, 먼저 고친 칸에 입력했던 값이 그대로 남던 문제를 수정했습니다. 저장된 값은 원래 값이라 화면에 보이는 값과 실제 값이 어긋났고, 새로고침해야 드러났습니다. 이제 탭을 오갈 때 모든 입력칸이 저장된 값으로 함께 돌아옵니다.
#### 확장 업데이트
- 속도 최적화를 적용한 사이트에서, 확장을 설치·활성화·업데이트해도 그 확장이 새로 추가한 주소가 동작하지 않던 문제를 수정했습니다. 예전에는 최적화 시점에 저장해 둔 주소 목록만 사용해 새 주소가 목록에 없었고, 화면에는 오류 대신 "페이지를 찾을 수 없음"만 나와 원인을 알기 어려웠습니다. 이제 확장을 설치·활성화·비활성화·삭제·업데이트할 때와 코어 업데이트 후에 주소 목록이 자동으로 다시 만들어집니다.
- 코어를 업데이트하면 주소 목록 최적화가 꺼진 채로 남던 문제를 함께 수정했습니다. 업데이트 도중 목록을 비우기만 하고 다시 만들지 않아, 관리자가 직접 '시스템 최적화'를 실행할 때까지 사이트가 최적화 없이 동작했습니다.
- 모듈·플러그인 업데이트에서 '수정 유지'를 선택해도 직접 고친 화면이 새 버전으로 덮어써지던 문제를 수정했습니다. 업데이트 과정이 보존 여부를 판단하기 전에 모든 화면을 파일 기준으로 먼저 되돌려, 비교할 수정본이 남지 않아 '수정 유지'가 항상 무효가 됐습니다. 이제 선택한 대로 수정한 화면이 그대로 유지됩니다.
- 업데이트 전 표시되는 "수정된 레이아웃" 안내가 확인에 실패했을 때도 "수정된 레이아웃이 없습니다"라고 단언하던 문제를 수정했습니다. 네트워크가 끊긴 상태에서 그대로 '모두 교체'를 진행하면 직접 고친 화면이 사라질 수 있었습니다. 이제 확인하지 못했다는 사실과 함께 '수정 유지' 권장 안내가 표시됩니다.
- 템플릿 업데이트 안내가 그 템플릿에 등록된 모듈·플러그인 화면의 수정까지 자기 것으로 세던 문제를 수정했습니다. 실제로 교체·보존되는 대상은 템플릿 자신의 화면뿐이라 표시 건수와 실제 동작이 어긋났습니다.
- '업데이트 확인' 버튼을 눌러 업데이트가 발견돼도 항상 "모두 최신 상태입니다"라고 안내되던 문제를 수정했습니다. 모듈·플러그인·템플릿 관리 화면 모두에 해당합니다.
- 업데이트 완료 안내에 확장 이름과 버전이 표시되지 않고 자리표시자 글자가 그대로 노출되던 문제를 수정했습니다.
- 모듈·플러그인·템플릿 관리 화면의 목록 요약이 항상 "총 0개 중 1-0개 표시"로 나오고, 목록이 한 화면을 넘어도 다음 쪽으로 넘어갈 수 없던 문제를 수정했습니다.
- 존재하지 않는 확장 이름으로 수정 화면 확인을 요청하면 "수정된 화면 없음"으로 응답하던 것을 "찾을 수 없음"으로 구분해 응답하도록 바꿨습니다.
#### 관리자 화면
- 템플릿 레이아웃 편집 화면의 파일 목록에서 이름·설명이 빈칸으로 보이던 문제를 수정했습니다. 번역 제외 표기를 쓴 글자가 화면 구성 요소의 본문 자리에서만 해석되지 않아 아무것도 출력되지 않았습니다.
- 번역 제외 표기를 쓴 글자가 검색엔진 봇 화면에서 해석되지 않던 문제를 함께 수정했습니다. 일반 화면에는 값이 나오는데 봇 화면에서만 비어 보일 수 있었습니다.
#### 검색엔진 봇 화면
- 검색엔진 봇에게만 게시글·답글의 원글·댓글·페이지·상품 설명의 본문이 통째로 빈 채 전달되던 문제를 수정했습니다. 위지윅 편집기 플러그인이 켜져 있으면 본문 영역이 플러그인 화면으로 교체되는데, 봇용 화면을 만드는 쪽이 그 교체 규칙을 몰라 내용 없는 껍데기만 만들었습니다. 미리보기용 요약(메타 태그)에는 본문이 들어가 있어 겉으로는 정상으로 보였습니다. 이제 일반 화면과 봇용 화면이 같은 본문을 출력합니다. 업그레이드 시 이미 만들어져 있던 봇용 화면은 한 번 비워져 새로 만들어집니다. (#86 @koojunho 님께서 제보해주셨습니다.)
- 봇용 화면이 자료를 불러오는 주소에 검색어 같은 값을 끼워 넣어 둔 경우, 그 값이 해석되지 않아 목록이 통째로 비어 있던 문제를 수정했습니다. 예전에는 주소 안의 글 번호만 해석했고 검색어·화면 초기값은 그대로 남아 조회에 실패했습니다.
- 봇용 화면이 일반 화면과 다르게 해석하던 문법들을 일반 화면과 동일하게 맞췄습니다. 여러 조건을 묶은 표시 조건, 반복 목록의 다른 표기 방식, 글자만 넣은 조각, 데스크톱 전용으로 지정한 하위 구성·반복 목록, 지연 표기한 번역, 화면 시작 시 정해지는 초기값이 해당됩니다. 조건이 거짓인데도 내용이 노출되거나, 화면에는 보이는 내용이 봇에게만 사라지던 차이가 없어집니다.
- 봇용 화면에서 값 대신 `true`/`false` 가 출력되던 문제를 수정했습니다. 두 값 중 채워진 쪽을 쓰는 표기(`||`)가 값이 아니라 참·거짓으로 계산되어, 검색 화면 주소에 검색어 대신 `true` 가 들어가는 등의 증상이 있었습니다. 화면에 넣은 값이 참·거짓이거나 비어 있을 때도 봇용 화면에만 `false` 같은 글자가 그대로 노출되던 문제를 함께 수정했습니다.
- 검색엔진 봇용 화면에 넣는 사용자 작성 본문을 일반 화면과 같은 기준으로 정리하도록 수정했습니다. 예전에는 봇용 화면에만 본문이 아무 검사 없이 들어가, 글에 넣어 둔 스크립트나 외부 프레임이 봇용 주소로 접속했을 때 실제로 실행될 수 있었습니다. 또 서식 없이 글자로만 저장한 글의 태그가 봇용 화면에서만 서식으로 해석되어 두 화면의 본문이 달라 보였습니다. 이제 서식 없는 글은 글자 그대로, 서식 있는 글은 위험한 요소를 걷어낸 뒤 표시되며, 일반 화면과 결과가 같습니다. 게시글·답글·페이지·상품 설명 모두에 적용됩니다.
- 봇용 화면의 선택 상자(정렬·분류 등)에 항목 이름 대신 내부 식별자가 글자로 노출되던 문제를 수정했습니다. 이제 일반 화면과 같이 선택 항목의 이름이 표시됩니다.
- 봇용 화면에만 일반 화면에 없는 내용이 나타나거나, 반대로 있어야 할 내용이 사라지던 경우를 정리했습니다. 값이 비어 있을 때 대신 다른 내용을 채워 넣던 동작, 화면 폭 조건이 여러 개 겹칠 때 적용 순서가 달라지던 동작, 알아볼 수 없는 형태의 표시 조건을 봇용 화면에서만 숨기던 동작이 해당됩니다.
#### 화면 표시·목록 상태
- 건수가 아주 많아 총 건수를 끝까지 세지 못하는 목록에서 항목 번호가 0 이나 음수로 표시되던 문제를 수정했습니다. 번호를 지어내지 않고 「-」로 표시하며, 총 건수를 정확히 센 목록의 번호는 종전과 동일합니다.
- 활동 로그 목록의 총 건수가 끝까지 세지 못한 값인데도 정확한 숫자처럼 표시되던 문제를 수정했습니다. 이제 그 경우 「이상」 표시가 함께 나옵니다.
- 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.)
- 위 문제와 같은 뿌리에서 비롯된 화면 표시 오류를 함께 정리했습니다. 같은 방식으로 작성한 값이 어디에 놓이느냐(목록 칸·본문·표시 조건·버튼 동작)에 따라 다르게 해석되던 것을 한 가지 기준으로 통일했으며, 아래 항목이 그 결과입니다. 대부분 오류 메시지 없이 값이 비거나 잘못 보이던 증상이라 눈치채기 어려웠습니다.
- 행마다 달라지는 조건(예: "활성 상태인 항목만 표시")을 목록에 걸면 해당하는 행만 걸러지지 않고 **목록 전체가 사라지던** 문제를 수정했습니다. 이제 행마다 조건을 따져 표시하며, 검색엔진 봇이 보는 화면에도 같은 규칙이 적용됩니다.
- 관리자 목록의 여러 개 고를 수 있는 상태·분류 필터처럼 항목 이름에 대괄호가 들어가는 값이 화면에 따라 비어 보이던 문제를 수정했습니다. 선택한 조건이 화면에 표시되지 않거나 조건 표시가 사라지던 증상이 해소됩니다.
- 값 하나를 계산하다 실패하면 그 값이 속한 영역 전체가 그려지지 않던 문제를 수정했습니다. 이제 문제가 된 자리만 비고 나머지는 정상 표시됩니다.
- 계산식에 날짜·숫자 서식을 함께 걸면 원본 값이 바뀌어도 처음 계산된 값이 계속 표시되던 문제를 수정했습니다.
- 목록 칸이나 합계 행 안에서 번역 문구가 문장 중간에 들어간 경우 번역되지 않고 원본 키가 그대로 보이던 문제, 그리고 일부 표시 값에 `{{ }}` 기호가 섞여 나오던 문제를 수정했습니다.
- 목록 칸이나 합계 행에서 '번역하지 않고 원문 그대로 표시'로 지정한 값에 보이지 않는 특수문자가 함께 출력되던 문제를 수정했습니다. 화면에서는 빈칸이나 깨진 글자로 보였고, 값을 복사해 붙여넣을 때 따라붙었습니다.
- 목록 표·카드의 셀에서 `true`/`false` 같은 값을 직접 쓴 자리가 비어 보이던 문제를 수정했습니다. 같은 값을 표시 조건에 쓰면 정상 동작해서, 같은 작성이 놓인 자리에 따라 갈렸습니다.
- 레이아웃 코드 편집 화면의 파일 목록과 선택한 파일 설명에 번역되지 않은 내부 표기(`$t:…` 나 `{{ … }}`)가 그대로 보이던 문제를 수정했습니다. 다른 템플릿의 레이아웃을 편집할 때 그 템플릿의 번역을 찾지 못해 생긴 문제로, 이제 해당 템플릿의 번역으로 표시하며 번역을 찾을 수 없으면 파일 이름을 대신 보여줍니다.
- 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
#### 확장·데이터베이스 안정성
- 관리자 역할 관리·언어팩 관리·알림 목록에서 같은 시각에 등록된 항목이 여러 건일 때, 페이지를 넘기면 같은 항목이 두 번 보이고 다른 항목은 아예 보이지 않던 문제를 수정했습니다. 이제 페이지를 모두 넘겨도 목록에 있는 항목이 빠짐없이 한 번씩만 나옵니다.
- 메뉴 관리 목록에서 검색 조건을 하나도 걸지 않은 채 정렬만 바꾸면 순서가 그대로이던 문제를 수정했습니다. 검색 조건이 있을 때만 정렬이 반영되고 있었으며, 이제 조건 유무와 관계없이 선택한 정렬이 적용됩니다.
- 메뉴 관리 검색에서 한글 메뉴명으로 찾으면 결과가 나오지 않던 문제를 수정했습니다. 이제 한국어·영어 어느 쪽 메뉴명으로도 검색되고, 이름 정렬도 화면에 보이는 글자 기준으로 정렬됩니다.
- 목록의 열 제목을 눌렀을 때 지금 보고 있는 페이지의 줄만 순서가 바뀌면서 전체가 정렬된 것처럼 보이던 문제를 수정했습니다. 페이지를 나눠 불러오는 목록에서 열 제목 정렬을 지원하지 않는 화면은 이제 정렬 표시를 띄우지 않으므로, 정렬 결과를 잘못 읽을 일이 없습니다. 정렬은 화면 위쪽의 정렬 선택으로 이용할 수 있습니다.
- 언어팩 관리 목록이 모듈의 내장 번역을 실제 화면에 쓰이지 않는 위치 기준으로 보여주던 문제를 수정했습니다. 그 결과 일부 모듈의 한국어·영어 내장 번역이 목록에 아예 나오지 않았고, 번역 대상으로 표시된 문구를 고쳐도 화면에 반영되지 않았습니다. 이제 목록과 실제 화면의 문구 출처가 같습니다.
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 본인인증 진행 상태를 조회하는 화면용 응답에 지금까지의 인증 시도 횟수가 함께 실려 나가던 것을 빼도록 바로잡았습니다. 원래 내보내지 않기로 한 값이며, 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있었습니다. 화면 동작에는 영향이 없습니다.
- 플러그인 설정 화면이 저장된 비밀값(암호화 키·API 시크릿 등)을 다시 내려받던 것을 가려서 표시하도록 바로잡았습니다. 값이 저장되어 있다는 표시만 보여주며, 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지됩니다.
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다.
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
- 데이터가 많은 사이트를 업그레이드할 때 기존 데이터를 채워 넣는 작업이 일부 행을 건너뛰던 문제를 수정했습니다. 100건씩 나눠 처리하면서 처리한 행이 대상 목록에서 빠지는 만큼 다음 묶음의 시작 위치가 밀려, 250건이면 100건이 처리되지 않은 채 남았습니다. 레이아웃 원본 대조값 채우기와 `json:convert-unicode` 명령이 대상이며, 이제 건수와 무관하게 전건이 처리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.)
#### 개발자 도구
- 디버그 모드에서 개발자 도구의 상태 내려받기가 오류를 내며 실패하던 문제를 수정했습니다. 모듈·플러그인을 설치하거나 켜고 끄면 사이트가 라우트 정보를 미리 만들어 두는데, 그 상태에서는 상태 내려받기 기능이 필요한 코드를 찾지 못했습니다. 확장을 한 번이라도 건드린 사이트에서는 항상 실패했습니다.
- 같은 조건에서 브라우저 콘솔 로그 수집이 조용히 멈추던 문제를 수정했습니다. 로그를 받는 주소가 사라진 상태였는데 실패가 화면에도 기록에도 남지 않아, 디버그 모드를 켜 두었는데도 `storage/logs/browser.log` 만 비어 있었습니다.
- 관리자 환경설정에서만 디버그 모드를 켰을 때(`.env` 는 꺼진 상태) 개발자 도구 요청이 접근 거부 대신 오류로 응답하던 문제를 수정했습니다.
- 개발자 도구 응답 문구가 사이트 언어와 무관하게 항상 한국어로만 나오던 것을 사이트 언어를 따르도록 바꿨습니다.
#### 설정 저장·입력 검증
- 모듈·플러그인 설정의 숫자 항목이 숫자가 아닌 형태로 보관되어 있어도, 조회 시 설정 기본값과 같은 숫자 형태로 처리합니다. 이전에는 저장된 형태에 따라 기한 계산 같은 후속 처리에서 오류가 발생할 수 있었습니다. 숫자가 아닌 값, 참/거짓 항목, 목록 항목은 그대로 유지됩니다. 설정 안에 다시 묶여 있는 하위 항목까지 같은 규칙이 적용됩니다.
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
- 한글로 쓴 설명이 글자 수 제한에 걸리지 않는데도 "너무 깁니다" 로 거부되던 문제를 수정했습니다. 글자 수를 바이트 단위로 세는 바람에 한글 한 글자가 세 글자로 계산되어, 500자 제한에서 167자만 넘어도 저장이 막혔습니다. 역할·권한·모듈·플러그인·템플릿 설명에 모두 해당합니다.
- 사용하던 언어팩을 끈 뒤 기존에 작성해 둔 내용을 수정하려 하면 저장이 통째로 막히던 문제를 수정했습니다. 꺼진 언어로 입력해 둔 번역이 남아 있으면 "지원하지 않는 언어" 로 거부되었습니다. 이제 그 번역은 그대로 보존한 채 수정할 수 있고, 언어팩을 다시 켜면 번역도 함께 되살아납니다.
- 알림 템플릿에서 제목을 비워 둘 수 있는데도 저장할 때 제목을 요구하던 문제를 수정했습니다. 제목 개념이 없는 발송 수단에서도 템플릿을 저장할 수 있습니다.
- 알림 정의에 존재하지 않는 발송 수단 이름이 그대로 저장되던 문제를 수정했습니다. 이제 사용할 수 없는 수단은 저장 시 안내와 함께 막히며, 플러그인이 추가한 수단은 정상적으로 선택할 수 있습니다. 수단을 제공하던 플러그인을 껐더라도 이미 저장된 설정은 계속 수정할 수 있습니다.
- 등록한 일정이 하나라도 있으면 일정 관리 목록이 열리지 않고 "총 0개"로만 표시되던 문제를 수정했습니다. 실행 이력 화면도 동일한 원인으로 열리지 않았으며, 이제 두 화면 모두 정상적으로 목록을 보여줍니다.
#### 안내 문구·주소 처리
- 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다.
- 회원 정보 수정, 권한·메뉴 설정, 본인인증, 알림 설정 등 여러 화면에서 처리를 마친 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 성공·실패·권한 없음 등 모든 안내가 사용 중인 언어에 맞는 문장으로 표시됩니다.
- 환경설정 저장이 실패했을 때 안내 문구에 항목 이름이 `security.password min length` 같은 내부 식별자로 표시되던 문제를 수정했습니다. 보안·업로드·SEO·고급·드라이버·메일 등 모든 설정 항목이 화면에 보이는 한글 항목명으로 안내됩니다.
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
- 목록의 열 제목을 눌러 정렬할 때, 이미 다른 열로 정렬 중이었다면 화살표는 새로 누른 열에 표시되면서 실제 정렬은 이전 열 기준으로 바뀌던 문제를 수정했습니다. 표시와 실제 정렬 기준이 어긋나 결과를 신뢰할 수 없었으며, 이제 누른 열 기준으로 한 번에 정렬됩니다.
- 목록에서 뒤쪽 페이지를 보던 중 정렬을 바꾸면 이전 페이지 번호에 그대로 머물러, 정렬을 바꿨는데 엉뚱한 구간이 보이거나 빈 목록이 나오던 문제를 수정했습니다. 이제 정렬을 바꾸면 항상 첫 페이지부터 보여줍니다. 사용자 관리·게시판 신고현황·페이지 관리 목록이 해당됩니다.
- 사용자 관리 목록에서 "페이지당 개수"가 10개로 표시되는데 실제로는 15개가 나오던 문제를 수정했습니다. 표시된 값과 실제 목록 건수가 일치합니다. 일정 관리 목록에서도 표시가 20개 목록을 10개로 잘못 안내하던 문제를 함께 바로잡았습니다.
- 알림 발송 이력에서 정렬을 "수신자명순" 또는 "제목순"으로 바꾸면 목록이 비면서 오류 안내가 뜨던 문제를 수정했습니다. 화면이 제공하는 정렬인데 서버가 거부하고 있었으며, 이제 두 정렬 모두 정상 동작합니다. 많은 이력에서도 빠르게 정렬되도록 색인을 함께 추가했습니다.
- 마이페이지 활동 목록에서 한 페이지에 표시할 개수를 0이나 음수로 요청하면 서버 오류가 나던 문제를 수정했습니다. 이제 허용 범위(1~100)로 자동 조정됩니다.
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
- 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다.
#### 메뉴·플러그인 설정 무결성
- 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다.
- 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다.
- 문자·알림톡처럼 확장 기능이 추가하는 알림 발송 수단을 그 확장을 끈 뒤에도 발송 대상으로 삼아, 발송 이력에 "건너뜀"으로 남던 문제를 수정했습니다. 이제 확장을 끄면 해당 발송 수단은 발송 대상에서 완전히 제외되어 이력에도 남지 않고, 다시 켜면 알림 설정 그대로 복구됩니다.
## [7.0.5] - 2026-07-16
### Added
@@ -43,6 +261,7 @@
- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
- 활동 로그 기록에 실패했을 때 그 기록을 유발한 요청까지 함께 실패하던 문제를 수정했습니다. 결제 입금통보 같은 외부 콜백이 활동 로그 문제로 오류를 반환하는 일이 없어집니다.
## [7.0.2] - 2026-07-08
@@ -1375,7 +1594,6 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 휴대폰 인증, 아이핀 인증 등 본인인증 기능 제공
- 사용자 관리 화면 레이아웃 확장 (본인인증 상세/폼)
- 역할 기반 접근 제어 (본인인증 관리자)
- 다국어 지원 (ko, en)
#### 템플릿: sirsoft-admin_basic (관리자)
@@ -1411,7 +1629,6 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- CMS 페이지 표시 화면
- 에러 페이지 (403, 404, 500, 503)
- 점검 모드(maintenance) 전용 페이지
- 반응형 레이아웃 (데스크톱/태블릿/모바일)
#### 다국어 시스템 (i18n)
+44 -1
View File
@@ -77,6 +77,49 @@ server {
}
```
#### 정적 파일 최적화 블록을 함께 쓰는 경우
아래처럼 확장자로 캐시 규칙을 거는 블록(aaPanel · CyberPanel · Plesk 기본 템플릿에
포함되어 있습니다)을 함께 사용한다면 주의가 필요합니다.
```nginx
location ~* \.(js|css|json|png|jpg|svg|woff2?)$ {
expires max;
access_log off;
}
```
nginx 는 **정규식 location 을 프리픽스 location(`location /`)보다 먼저** 매칭합니다.
G7 은 일부 동적 엔드포인트에 `.js` · `.css` · `.json` 확장자를 쓰므로, 위 블록 안에
PHP 핸들러가 없으면 그 요청들이 `try_files ... /index.php` 폴백에 닿지 못하고
nginx 가 직접 파일을 찾다가 404 를 반환합니다. 증상은 **모든 페이지가 백지**입니다.
해결 방법은 두 가지이며, 아무것도 하지 않아도 G7 이 스스로 복구합니다.
1. **그대로 두기** — 브라우저가 자산 로드 실패를 감지하면 확장자 없는 주소로 자동
전환합니다. 설치 마법사도 설치 시점에 이를 감지해 설정에 반영합니다.
설치 후 확정하려면 `php artisan g7:asset-url-mode extensionless` 를 실행하거나
관리자 > 환경설정 > 일반에서 "자산 파일 주소 방식" 을 변경하세요.
(검색엔진 봇은 JavaScript 를 실행하지 않으므로, SEO 를 쓴다면 이 확정을 권장합니다.)
2. **`/api/` 를 정규식 블록보다 우선시키기** — `^~` 는 정규식 location 보다 우선하므로
아래 블록을 추가하면 `/api/` 요청이 항상 PHP 로 갑니다. 확장자 기반 캐시 최적화를
정적 파일에만 그대로 유지할 수 있습니다.
```nginx
location ^~ /api/ {
try_files $uri $uri/ /index.php?$query_string;
}
```
서버가 동적 응답을 가로채는지는 아래 두 요청을 비교해 확인할 수 있습니다.
첫 번째만 실패하면 가로채는 것입니다.
```bash
curl -i https://example.com/api/system/asset-probe.js
curl -i https://example.com/api/system/asset-probe
```
### 3단계: 설치 마법사 실행
브라우저에서 접속합니다.
@@ -246,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.5 g7
# (필요 시) mv g7-7.0.6 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+6 -2
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.5-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.6-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>
@@ -316,7 +316,7 @@ HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
공식 일본어(ja) 번들 14종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
공식 일본어(ja) 번들 16종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
@@ -390,7 +390,9 @@ cp .env.example .env
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
| **sirsoft-pay_nicepayments** | 나이스페이먼츠 결제 연동 (통합결제창) |
| **sirsoft-pay_nhnkcp** | NHN KCP 결제 연동 (Standard Pay) |
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
| **sirsoft-verification_nhnkcp** | NHN KCP 휴대폰 본인확인 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
@@ -420,7 +422,9 @@ cp .env.example .env
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP 휴대폰 본인확인 플러그인 일본어 |
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
@@ -44,8 +44,8 @@ trait ResolvesActivityLogType
* properties.extension_origin 에 자동 주입합니다 (loggable 미지정 케이스의
* action 라벨 namespace fallback 용).
*
* @param string $action 액션명 (예: 'user.create')
* @param array $context Monolog context 배열
* @param string $action 액션명 (예: 'user.create')
* @param array $context Monolog context 배열
*/
protected function logActivity(string $action, array $context): void
{
@@ -58,9 +58,13 @@ trait ResolvesActivityLogType
$context['properties'] = $properties;
}
// 활동 로그는 부수 기록이다 — 실패해도 그것을 유발한 요청(결제 콜백 등)까지
// 죽여서는 안 된다. 채널 해석 실패는 Exception 이 아니라 Error 로 오므로
// (\Log::channel() 이 null 을 반환하면 "->info() on null" = \Error)
// Throwable 로 받아야 가드가 실제로 동작한다.
try {
Log::channel('activity')->info($action, $context);
} catch (\Exception $e) {
} catch (\Throwable $e) {
Log::error('Failed to record activity log', [
'action' => $action,
'error' => $e->getMessage(),
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Console\Output\BufferedOutput;
/**
* 배치 축 실행기 — 배치 커맨드 소요 시간 + 피크 메모리
*
* 배치는 한 번에 대량을 처리하므로 "느려짐"이 시간보다 메모리로 먼저 드러납니다(청크
* 없이 전건 로딩 → OOM). 그래서 시간과 함께 피크 메모리를 함께 잽니다.
*
* 다른 축과 달리 트랜잭션으로 감싸지 않습니다 — 배치 커맨드는 내부에서 커밋하거나 DDL 을
* 실행할 수 있고, 대량 처리를 긴 트랜잭션에 담으면 락 보유 시간이 계측 자체보다 위험해집니다.
* 대신 데이터를 변경하는 배치는 `--allow-write` 없이는 실행하지 않습니다.
*/
class BatchAxisRunner implements BenchmarkAxisRunner
{
public function __construct(private readonly QueryCollector $collector) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Batch;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
if ($profile->mutates() && ! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '데이터를 변경하는 배치입니다. --allow-write 를 붙여 실행하세요.');
}
$command = (string) $profile->option('command');
$arguments = (array) $profile->option('arguments', []);
if (! array_key_exists($command, Artisan::all())) {
return BenchmarkResult::skipped($profile, "등록되지 않은 커맨드: {$command} (해당 확장이 설치되어 있는지 확인)");
}
$notify("배치 실행: {$command}");
// 피크 메모리는 프로세스 단위 누적값이라 감소하지 않는다. 실행 전 값을 함께
// 기록해야 "이 배치가 피크를 밀어올렸는지"를 판정할 수 있다.
$peakBefore = memory_get_peak_usage(true);
$usageBefore = memory_get_usage(true);
$buffer = new BufferedOutput;
$start = microtime(true);
try {
$collected = $this->collector->collect(
static fn () => Artisan::call($command, $arguments, $buffer)
);
$exitCode = (int) $collected['value'];
$queries = $collected['queries'];
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
}
$elapsedMs = (microtime(true) - $start) * 1000;
$peakAfter = memory_get_peak_usage(true);
$usageAfter = memory_get_usage(true);
$summary = $this->collector->summarize($queries);
$notes = [
sprintf('실행 전 피크 %s → 실행 후 피크 %s', $this->formatBytes($peakBefore), $this->formatBytes($peakAfter)),
];
if ($peakAfter <= $peakBefore) {
$notes[] = '이 배치가 프로세스 피크를 밀어올리지 않았습니다 (실행 전 피크가 이미 더 높음).';
}
if ($exitCode !== 0) {
$notes[] = "커맨드가 실패 종료했습니다 (exit={$exitCode}) — 시간·메모리는 실패 지점까지의 값입니다.";
}
$output = trim($buffer->fetch());
if ($output !== '') {
$notes[] = '커맨드 출력: '.$output;
}
return new BenchmarkResult(
profile: $profile,
headers: ['커맨드', '종료코드', '소요(ms)', '피크 메모리', '메모리 증가', '쿼리(건)'],
rows: [[
$command,
(string) $exitCode,
number_format($elapsedMs, 1),
$this->formatBytes($peakAfter),
$this->formatBytes(max(0, $usageAfter - $usageBefore)),
number_format($summary['count']),
]],
metrics: [
'command' => $command,
'arguments' => $arguments,
'exit_code' => $exitCode,
'elapsed_ms' => round($elapsedMs, 2),
'peak_memory_before_bytes' => $peakBefore,
'peak_memory_after_bytes' => $peakAfter,
'memory_delta_bytes' => $usageAfter - $usageBefore,
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
],
notes: $notes,
);
}
/**
* 바이트를 사람이 읽는 단위로 바꿉니다.
*
* @param int $bytes 바이트
* @return string 포맷된 문자열
*/
private function formatBytes(int $bytes): string
{
if ($bytes >= 1024 ** 3) {
return number_format($bytes / 1024 ** 3, 2).' GB';
}
if ($bytes >= 1024 ** 2) {
return number_format($bytes / 1024 ** 2, 1).' MB';
}
return number_format($bytes / 1024, 1).' KB';
}
}
+350
View File
@@ -0,0 +1,350 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\SyntheticSeeder;
use App\Enums\BenchmarkAxis;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 목록 조회 축 실행기 — 깊은 OFFSET 비용을 컬럼 폭 3축으로 계측
*
* 목록 조회는 OFFSET 이 커질수록 느려지는데, 그 원인이 "건너뛸 행의 넓은 컬럼까지 읽기"
* 인지 확인하려면 같은 필터·정렬로 (1) 전체 컬럼 (2) 실제 목록 컬럼 (3) 키 컬럼만 조회한
* 비용을 나란히 재야 합니다. 셋째 축이 지연 조인(`PaginatesWithDeferredJoin`)의 inner
* 쿼리에 해당하므로, 이 세 값의 배수가 곧 지연 조인 적용의 기대 효과입니다.
*/
class ListAxisRunner implements BenchmarkAxisRunner
{
/**
* 목록 1페이지 상한 (OFFSET 스캔 비용 대비 무시할 수준이라 고정)
*/
private const PAGE_LIMIT = 20;
/**
* 필터 선언에 쓸 수 있는 연산자 (닫힌 집합)
*
* @var array<int, string>
*/
private const FILTER_OPERATORS = ['=', '!=', '<>', '<', '<=', '>', '>=', 'like', 'in', 'not in'];
public function __construct(private readonly SyntheticSeeder $seeder) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::ListQuery;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
$table = (string) $profile->option('table');
if (! Schema::hasTable($table)) {
return BenchmarkResult::skipped($profile, "테이블이 없습니다: {$table} (해당 확장이 설치되어 있는지 확인)");
}
if (($options->fresh || $options->seed > 0) && app()->environment('production')) {
return BenchmarkResult::skipped($profile, '운영 환경에서는 시딩/비움을 사용할 수 없습니다.');
}
$filterError = $this->validateFilters((array) $profile->option('filters', []));
if ($filterError !== null) {
return BenchmarkResult::skipped($profile, $filterError);
}
if ($options->fresh) {
$this->seeder->truncate($table);
$notify("비움: {$table}");
}
$columns = $this->existingColumns($table, (array) $profile->option('columns', ['*']));
$order = $this->existingOrder($table, (array) $profile->option('order', [['id', 'desc']]));
if ($options->seed > 0) {
$notify("시딩: {$table} ".number_format($options->seed).' 건');
$this->seeder->seed(
$table,
$options->seed,
(array) $profile->option('seed_overrides', []),
fn (int $inserted, int $total) => $notify(sprintf(' 시딩 %s / %s', number_format($inserted), number_format($total))),
// 정렬 기준 컬럼은 nullable 이어도 채운다 — 비워두면 합성 행이 전부 동률이 되어
// 계측이 정렬 인덱스가 아니라 tie-break 경로를 잰다.
array_column($order, 0),
);
}
$rows = [];
$notes = [];
foreach ($options->offsets as $offset) {
$allMs = $this->measure($profile, ['*'], $order, $offset, $options->runs);
$listMs = $this->measure($profile, $columns, $order, $offset, $options->runs);
$idOnlyMs = $this->measure($profile, ['id'], $order, $offset, $options->runs);
$rows[] = [
'offset' => $offset,
'all_ms' => $allMs,
'list_ms' => $listMs,
'id_only_ms' => $idOnlyMs,
'ratio' => $idOnlyMs > 0 ? round($allMs / $idOnlyMs, 1) : null,
];
if ($options->explain) {
$notes[] = "EXPLAIN @ OFFSET {$offset} — 목록 컬럼";
foreach ($this->explain($profile, $columns, $order, $offset) as $line) {
$notes[] = ' '.$line;
}
// 지연 조인의 inner 가 실제로 어떤 계획을 타는지가 인덱스 설계의 근거다
$notes[] = "EXPLAIN @ OFFSET {$offset} — ID 만 (지연 조인 inner)";
foreach ($this->explain($profile, ['id'], $order, $offset) as $line) {
$notes[] = ' '.$line;
}
}
}
return new BenchmarkResult(
profile: $profile,
headers: ['OFFSET', '전체 컬럼(ms)', '목록 컬럼(ms)', 'ID만(ms)', '전체÷ID'],
rows: array_map(fn (array $row) => [
number_format($row['offset']),
number_format($row['all_ms'], 1),
number_format($row['list_ms'], 1),
number_format($row['id_only_ms'], 1),
$row['ratio'] !== null ? $row['ratio'].'×' : '-',
], $rows),
metrics: [
'table' => $table,
'columns' => $columns,
'runs' => $options->runs,
'offsets' => $rows,
],
notes: $notes,
);
}
/**
* 스키마에 실제로 존재하는 컬럼만 남깁니다.
*
* `['*']` 는 "목록이 전 컬럼을 그대로 노출한다" 는 선언입니다. 응답 계약상 넓은 컬럼을
* 뺄 수 없는 목록(활동 로그의 changes, 알림 발송 이력의 body 등)이 여기 해당하며, 이
* 경우 계측의 비교 축은 select * vs select id 가 됩니다. 확장 버전에 따라 컬럼 구성이
* 달라도 계측이 죽지 않도록 스키마에 없는 컬럼은 걸러냅니다.
*
* @param string $table 테이블명
* @param array<int, string> $columns 후보 컬럼
* @return array<int, string> 존재하는 컬럼 목록
*/
private function existingColumns(string $table, array $columns): array
{
if ($columns === ['*']) {
return Schema::getColumnListing($table);
}
$existing = array_values(array_filter(
$columns,
fn (mixed $column) => is_string($column) && Schema::hasColumn($table, $column)
));
return $existing === [] ? ['id'] : $existing;
}
/**
* 스키마에 존재하는 정렬 컬럼만 남깁니다.
*
* @param string $table 테이블명
* @param array<int, array{0: string, 1: string}> $order 후보 정렬
* @return array<int, array{0: string, 1: string}> 적용 가능한 정렬 목록
*/
private function existingOrder(string $table, array $order): array
{
$existing = array_values(array_filter(
$order,
fn (mixed $spec) => is_array($spec) && isset($spec[0]) && Schema::hasColumn($table, (string) $spec[0])
));
return $existing === [] ? [['id', 'desc']] : $existing;
}
/**
* 한 조합을 여러 번 실행해 중앙값(ms)을 돌려줍니다.
*
* `DB::enableQueryLog()` 는 오버헤드와 메모리 누적이 있어 쓰지 않고 직접 시간을 잽니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @param int $runs 측정 횟수
* @return float 중앙값 (밀리초)
*/
private function measure(BenchmarkProfile $profile, array $columns, array $order, int $offset, int $runs): float
{
// 첫 회는 캐시 워밍 성격이라 버린다
$this->buildQuery($profile, $columns, $order, $offset)->get();
$samples = [];
for ($i = 0; $i < max(1, $runs); $i++) {
$start = microtime(true);
$this->buildQuery($profile, $columns, $order, $offset)->get();
$samples[] = (microtime(true) - $start) * 1000;
}
sort($samples);
return $samples[intdiv(count($samples), 2)];
}
/**
* 계측 대상 쿼리를 조립합니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @return Builder 조립된 쿼리
*/
private function buildQuery(BenchmarkProfile $profile, array $columns, array $order, int $offset): Builder
{
$table = (string) $profile->option('table');
$query = DB::table($table)->select($columns);
if ($profile->option('soft_delete', false) && Schema::hasColumn($table, 'deleted_at')) {
$query->whereNull('deleted_at');
}
$this->applyFilters($query, $table, (array) $profile->option('filters', []));
foreach ($order as $spec) {
$query->orderBy($spec[0], $spec[1] ?? 'asc');
}
return $query->offset($offset)->limit(self::PAGE_LIMIT);
}
/**
* 선언된 필터를 계측 쿼리에 적용합니다.
*
* 값이 `[연산자, 값]` 형태면 그 연산자로, 그 밖에는 등가 비교로 해석합니다. 등가 비교만
* 지원하면 화면이 실제로 거는 필터를 선언할 방법이 없는 목록이 생깁니다 — 주문 목록은
* 상태 미지정 시 임시 주문 상태를 `NOT IN` 으로 제외하므로, 그것을 선언하지 못하면
* 계측이 화면과 다른 인덱스를 타게 됩니다.
*
* 연산자는 닫힌 집합으로 해석합니다. 임의 문자열을 그대로 넘기면 선언 오타가 조용히
* 통과하거나 의도치 않은 SQL 이 조립됩니다.
*
* @param Builder $query 계측 쿼리
* @param string $table 대상 테이블
* @param array<string, mixed> $filters 선언된 필터
*/
private function applyFilters(Builder $query, string $table, array $filters): void
{
foreach ($filters as $column => $declared) {
if (! Schema::hasColumn($table, (string) $column)) {
continue;
}
// [연산자, 값] 형태가 아니면 등가 비교 (배열 값을 IN 으로 오해석하지 않도록 형태로 판정)
if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) {
$query->where($column, $declared);
continue;
}
[$operator, $value] = $declared;
match (strtolower($operator)) {
'=' => $query->where($column, '=', $value),
'!=', '<>' => $query->where($column, '!=', $value),
'<' => $query->where($column, '<', $value),
'<=' => $query->where($column, '<=', $value),
'>' => $query->where($column, '>', $value),
'>=' => $query->where($column, '>=', $value),
'like' => $query->where($column, 'like', $value),
'in' => $query->whereIn($column, (array) $value),
'not in' => $query->whereNotIn($column, (array) $value),
// 도달 불가 — run() 이 실행 전에 연산자를 검증해 거부한다
default => null,
};
}
}
/**
* 선언된 필터의 연산자를 실행 전에 검증합니다.
*
* 알 수 없는 연산자를 조용히 무시하면 그 필터가 빠진 채로 측정되어, 화면과 다른 것을
* 재면서도 정상 측정으로 보고됩니다. 실행 전에 사유와 함께 거부합니다.
*
* @param array<string, mixed> $filters 선언된 필터
* @return string|null 실패 사유 (문제 없으면 null)
*/
private function validateFilters(array $filters): ?string
{
foreach ($filters as $column => $declared) {
if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) {
continue;
}
if (! in_array(strtolower($declared[0]), self::FILTER_OPERATORS, true)) {
return sprintf(
'필터 %s 의 연산자를 알 수 없습니다: %s (허용: %s)',
$column,
$declared[0],
implode(', ', self::FILTER_OPERATORS)
);
}
}
return null;
}
/**
* 실행 계획을 수집합니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @return array<int, string> 실행 계획 요약 줄 목록
*/
private function explain(BenchmarkProfile $profile, array $columns, array $order, int $offset): array
{
$query = $this->buildQuery($profile, $columns, $order, $offset);
try {
$plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings());
} catch (\Throwable $e) {
return ['실행 계획 수집 실패: '.$e->getMessage()];
}
return array_map(function ($row) {
$row = (array) $row;
return sprintf(
'type=%s key=%s rows=%s filtered=%s extra=%s',
$row['type'] ?? '-',
$row['key'] ?? '-',
$row['rows'] ?? '-',
$row['filtered'] ?? '-',
$row['Extra'] ?? '-'
);
}, $plan);
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\BenchmarkIdentity;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
/**
* 화면 응답 축 실행기 — 화면 1장의 응답 시간 + 실행 쿼리 건수 + N+1 후보
*
* 사용자가 체감하는 단위가 "화면 한 장"이므로 네 축 중 값어치가 가장 큽니다. 목록 SELECT
* 는 빠른데 화면이 느린 경우(관계 지연 로딩으로 인한 N+1, 권한 조회 반복 등)를 잡는 것이
* 이 축의 목적입니다.
*
* 실행 방식은 라우트를 **HTTP 커널로 내부 요청** 처리하는 것입니다. `Route::dispatch` 로
* 라우트만 때리면 전역 미들웨어(인증·권한·로케일·타임존)를 건너뛰어 화면과 다른 것을 재게
* 됩니다. 커널을 쓰면 실제 요청과 같은 경로를 지납니다.
*
* 계측 계정 생성과 요청 처리 전체를 롤백되는 트랜잭션으로 감싸므로, 계측이 데이터에 흔적을
* 남기지 않습니다(`BenchmarkIdentity::withRolledBackTransaction`).
*/
class ScreenAxisRunner implements BenchmarkAxisRunner
{
public function __construct(
private readonly BenchmarkIdentity $identity,
private readonly QueryCollector $collector,
) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Screen;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
if ($profile->mutates() && ! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '데이터를 변경하는 화면입니다. --allow-write 를 붙여 실행하세요.');
}
[$uri, $uriError] = $this->resolveUri($profile);
if ($uriError !== null) {
return BenchmarkResult::skipped($profile, $uriError);
}
// `--as` 로 지정한 계정은 트랜잭션 진입 전에 확인한다 — 없는 계정으로 계측을 시작하면
// 롤백 구간 안에서 실패해 사유만 흐려진다.
if ($options->asUser !== null && $this->identity->findExistingUser($options->asUser) === null) {
return BenchmarkResult::skipped($profile, "계측에 사용할 계정을 찾을 수 없습니다: {$options->asUser}");
}
$method = strtoupper((string) $profile->option('method', 'GET'));
$query = (array) $profile->option('query', []);
$query['per_page'] ??= $options->perPage;
// 데이터를 변경하는 화면은 반복 측정 시 2회차부터 조건이 달라진다(중복 키, 재고 소진).
// 롤백은 계측 종료 시 한 번이므로 회차 간에는 되돌려지지 않는다 → 1회만 잰다.
$runs = $profile->mutates() ? 1 : max(1, $options->runs);
$notes = [];
if ($profile->mutates()) {
$notes[] = '데이터 변경 화면이라 1회만 측정했습니다 (회차 간 조건 변화 방지).';
}
try {
$measured = $this->identity->withRolledBackTransaction(
fn () => $this->measure($profile, (string) $uri, $method, $query, $runs, $options, $onProgress)
);
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
}
$summary = $measured['summary'];
foreach ($summary['n_plus_one'] as $candidate) {
$notes[] = sprintf('N+1 후보 %d회: %s', $candidate['count'], $candidate['sql']);
}
if ($summary['n_plus_one'] === []) {
$notes[] = 'N+1 후보 없음 (같은 SQL 5회 이상 반복 없음).';
}
return new BenchmarkResult(
profile: $profile,
headers: ['요청', '상태', '응답(ms)', '쿼리(건)', 'DB(ms)'],
rows: [[
$method.' '.$uri,
(string) $measured['status'],
number_format($measured['total_ms'], 1),
number_format($summary['count']),
number_format($summary['db_ms'], 1),
]],
metrics: [
'uri' => $uri,
'method' => $method,
'query' => $query,
'status' => $measured['status'],
'runs' => $runs,
'total_ms' => $measured['total_ms'],
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
'n_plus_one' => $summary['n_plus_one'],
'acting_user' => $measured['acting_user'],
],
notes: $notes,
);
}
/**
* 계측 대상 URI 를 해석합니다.
*
* 라우트명 선언을 권장하는 이유는 두 가지입니다 — 프리픽스를 문자열로 조립하지 않아도
* 되고, 라우트가 사라지면 계측이 조용히 404 를 재는 대신 사유를 남기고 건너뜁니다.
*
* 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이
* `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다.
*
* @param BenchmarkProfile $profile 프로파일
* @return array{0: string|null, 1: string|null} [경로, 실패 사유]
*/
private function resolveUri(BenchmarkProfile $profile): array
{
$routeName = $profile->option('route');
if (is_string($routeName) && $routeName !== '') {
if (Route::getRoutes()->getByName($routeName) === null) {
return [null, "등록되지 않은 라우트명: {$routeName} (해당 확장이 설치되어 있는지 확인)"];
}
return [route($routeName, (array) $profile->option('route_params', []), false), null];
}
return ['/'.ltrim((string) $profile->option('uri'), '/'), null];
}
/**
* 내부 요청을 반복 실행해 응답 시간 중앙값과 쿼리 요약을 냅니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param string $uri 경로
* @param string $method HTTP 메서드
* @param array<string, mixed> $query 쿼리스트링
* @param int $runs 측정 횟수
* @param BenchmarkRunOptions $options 실행 옵션
* @param \Closure|null $onProgress 진행 콜백
* @return array{status: int, total_ms: float, summary: array<string, mixed>, acting_user: array<string, mixed>} 계측 결과
*/
private function measure(
BenchmarkProfile $profile,
string $uri,
string $method,
array $query,
int $runs,
BenchmarkRunOptions $options,
?\Closure $onProgress,
): array {
$notify = $onProgress ?? static fn (string $message) => null;
$issued = $this->identity->issueToken(
(array) $profile->option('permissions', []),
$options->asUser !== null ? $this->identity->findExistingUser($options->asUser) : null
);
$notify(sprintf(
'인증: %s (%s)',
$issued['user']->email,
$issued['ephemeral'] ? '계측용 임시 계정 — 종료 시 롤백' : '지정 계정'
));
$samples = [];
$status = 0;
$queries = [];
// 첫 회는 라우트 매칭·컨테이너 해석 워밍이라 버린다 (측정 회차와 별도로 1회 더 실행)
for ($i = 0; $i <= $runs; $i++) {
$collected = $this->collector->collect(
fn () => $this->dispatch($uri, $method, $query, $issued['token'])
);
/** @var array{status: int, ms: float} $outcome */
$outcome = $collected['value'];
$status = $outcome['status'];
if ($i === 0) {
continue;
}
$samples[] = $outcome['ms'];
$queries = $collected['queries'];
}
sort($samples);
return [
'status' => $status,
'total_ms' => $samples[intdiv(count($samples), 2)],
'summary' => $this->collector->summarize($queries),
'acting_user' => [
'id' => $issued['user']->id,
'email' => $issued['user']->email,
'ephemeral' => $issued['ephemeral'],
],
];
}
/**
* 내부 요청 1회를 처리하고 소요 시간을 잽니다.
*
* 커널이 컨테이너의 `request` 인스턴스와 인증 가드 상태를 갈아치우므로, 처리 후
* 원상 복구합니다 — 복구하지 않으면 이어지는 회차/프로파일이 앞 요청의 인증 상태를
* 물려받아 계측이 서로 오염됩니다.
*
* @param string $uri 경로
* @param string $method HTTP 메서드
* @param array<string, mixed> $query 쿼리스트링/바디
* @param string $token Sanctum 토큰
* @return array{status: int, ms: float} 상태 코드와 소요 시간
*/
private function dispatch(string $uri, string $method, array $query, string $token): array
{
$previousRequest = app()->bound('request') ? app('request') : null;
$request = Request::create($uri, $method, $query);
$request->headers->set('Accept', 'application/json');
$request->headers->set('Authorization', 'Bearer '.$token);
$start = microtime(true);
try {
$response = app(HttpKernel::class)->handle($request);
$status = $response->getStatusCode();
// 스트리밍 응답은 본문 생성까지가 사용자 체감 시간이라 여기서 소비한다
$response->getContent();
} finally {
$ms = (microtime(true) - $start) * 1000;
Auth::forgetGuards();
if ($previousRequest !== null) {
app()->instance('request', $previousRequest);
}
}
return ['status' => $status, 'ms' => $ms];
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\BenchmarkIdentity;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
/**
* 쓰기 축 실행기 — 저장 경로 1회 소요 시간
*
* 목록 조회와 달리 저장 경로는 커맨드가 스스로 조립할 수 없습니다(주문 생성 하나에 재고·
* 쿠폰·마일리지·알림이 얽힘). 그래서 실행 자체는 소유 확장이 선언한 콜백에 맡기고, 이
* 실행기는 시간·쿼리 건수 계측과 안전장치만 담당합니다.
*
* 콜백은 `'Fqcn'`(invokable) 또는 `['Fqcn', 'method']` 형식만 허용합니다 — 코어 선언은
* `config/benchmark.php` 에 있고 이 파일은 `config:cache` 대상이라 클로저를 담을 수 없기
* 때문입니다. 확장 선언도 같은 스키마를 쓰므로 형식을 통일합니다.
*
* 선언 필드는 세 개입니다 — `prepare`(계측 제외 선행 준비, 회차마다 실행), `callback`
* (계측 대상, prepare 반환값을 인자로 받음), `cleanup`(트랜잭션이 되돌리지 못하는 잔여물 정리).
*
* 계측으로 생긴 행은 롤백되는 트랜잭션으로 되돌립니다. 트랜잭션이 되돌리지 못하는 것
* (파일·캐시·외부 호출)은 프로파일이 선언한 `cleanup` 콜백이 정리합니다.
*/
class WriteAxisRunner implements BenchmarkAxisRunner
{
public function __construct(
private readonly BenchmarkIdentity $identity,
private readonly QueryCollector $collector,
) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Write;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
if (! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '쓰기 축입니다. --allow-write 를 붙여 실행하세요.');
}
$resolved = [];
foreach (['callback', 'prepare', 'cleanup'] as $field) {
if ($field !== 'callback' && $profile->option($field) === null) {
$resolved[$field] = null;
continue;
}
[$callable, $error] = $this->resolveCallable($profile->option($field), $profile, $field);
if ($error !== null) {
return BenchmarkResult::skipped($profile, $error);
}
$resolved[$field] = $callable;
}
$callback = $resolved['callback'];
$prepare = $resolved['prepare'];
$cleanup = $resolved['cleanup'];
$runs = max(1, $options->runs);
try {
$measured = $this->identity->withRolledBackTransaction(
fn () => $this->measure($callback, $prepare, $runs, $onProgress)
);
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
} finally {
if ($cleanup !== null) {
// 트랜잭션 롤백으로 되돌지 않는 잔여물(파일·캐시)을 정리한다.
// 정리 실패가 계측 결과를 삼키면 안 되므로 사유만 알린다.
try {
$cleanup();
} catch (\Throwable $e) {
$notify('cleanup 실패: '.$e->getMessage());
}
}
}
$summary = $this->collector->summarize($measured['queries']);
return new BenchmarkResult(
profile: $profile,
headers: ['첫 회(ms)', '중앙값(ms)', '회차', '쿼리(건)', 'DB(ms)'],
rows: [[
number_format($measured['first_ms'], 1),
number_format($measured['median_ms'], 1),
(string) $runs,
number_format($summary['count']),
number_format($summary['db_ms'], 1),
]],
metrics: [
'first_ms' => $measured['first_ms'],
'median_ms' => $measured['median_ms'],
'runs' => $runs,
'samples_ms' => $measured['samples'],
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
'n_plus_one' => $summary['n_plus_one'],
],
notes: array_merge(
['계측으로 생긴 행은 롤백했습니다.'],
array_map(
fn (array $candidate) => sprintf('반복 쿼리 %d회: %s', $candidate['count'], $candidate['sql']),
$summary['n_plus_one']
)
),
);
}
/**
* 선언된 콜백을 호출 가능한 형태로 해석합니다.
*
* 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이
* `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다.
*
* @param mixed $declared 선언값
* @param BenchmarkProfile $profile 프로파일 (오류 메시지용)
* @param string $field 선언 필드명 (오류 메시지용)
* @return array{0: callable|null, 1: string|null} [해석된 콜백, 실패 사유]
*/
private function resolveCallable(mixed $declared, BenchmarkProfile $profile, string $field): array
{
$where = $profile->qualifiedKey().' 의 '.$field;
if ($declared instanceof \Closure) {
return [null, "{$where}: 클로저는 쓸 수 없습니다 (config:cache 불가). 'Fqcn' 또는 ['Fqcn', 'method'] 형식으로 선언하세요."];
}
if (is_string($declared)) {
if (! class_exists($declared)) {
return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$declared}"];
}
$instance = app($declared);
if (! is_callable($instance)) {
return [null, "{$where}: {$declared} 에 __invoke() 가 없습니다."];
}
return [$instance, null];
}
if (is_array($declared) && count($declared) === 2 && is_string($declared[0]) && is_string($declared[1])) {
[$class, $method] = $declared;
if (! class_exists($class)) {
return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$class}"];
}
$instance = app($class);
if (! method_exists($instance, $method)) {
return [null, "{$where}: {$class}::{$method}() 가 없습니다."];
}
return [[$instance, $method], null];
}
return [null, "{$where}: 'Fqcn' 또는 ['Fqcn', 'method'] 형식이어야 합니다."];
}
/**
* 콜백을 반복 실행해 첫 회 시간과 중앙값을 냅니다.
*
* 첫 회를 버리지 않고 따로 보고하는 이유는, 저장 경로에서는 첫 회가 포함하는 비용
* (클래스 로딩, 설정 해석, 관계 초기 조회)이 실사용에서도 발생하기 때문입니다.
* 쿼리 요약은 마지막 회차 기준입니다 — 첫 회는 위 초기화 쿼리가 섞입니다.
*
* `prepare` 는 계측 구간 **밖**에서 회차마다 실행합니다. 저장 경로에는 선행 상태가
* 필요한 경우가 많고(주문 생성에는 임시 주문이 필요하고 임시 주문은 1회만 전환됨),
* 그 준비 비용이 측정값에 섞이면 재려던 것을 재지 못하게 됩니다.
*
* @param callable $callback 저장 경로 콜백 (prepare 반환값을 인자로 받음)
* @param callable|null $prepare 회차별 선행 준비 콜백 (계측 제외, 회차 번호를 인자로 받음)
* @param int $runs 측정 횟수
* @param \Closure|null $onProgress 진행 콜백
* @return array{first_ms: float, median_ms: float, samples: array<int, float>, queries: array<int, array{sql: string, time: float}>} 계측 결과
*/
private function measure(callable $callback, ?callable $prepare, int $runs, ?\Closure $onProgress): array
{
$notify = $onProgress ?? static fn (string $message) => null;
$samples = [];
$queries = [];
for ($i = 1; $i <= $runs; $i++) {
$notify("쓰기 계측 {$i} / {$runs}");
$context = $prepare !== null ? $prepare($i) : null;
$start = microtime(true);
$collected = $this->collector->collect(static function () use ($callback, $context) {
$callback($context);
});
$samples[] = (microtime(true) - $start) * 1000;
$queries = $collected['queries'];
}
$sorted = $samples;
sort($sorted);
return [
'first_ms' => $samples[0],
'median_ms' => $sorted[intdiv(count($sorted), 2)],
'samples' => $samples,
'queries' => $queries,
];
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Benchmark;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* 화면 계측용 인증 주체 해석기
*
* 관리자 화면의 응답 시간은 로그인 상태에서만 잴 수 있습니다. 두 방식을 지원합니다.
*
* 기본 — 프로파일이 선언한 권한만 가진 임시 관리자를 즉석 생성해 Sanctum 토큰 발급
* `--as` — 기존 계정을 지정 (그 계정의 실제 역할/권한으로 재측정)
*
* 두 경로 모두 계정/역할/토큰 생성이라는 **쓰기**를 동반하므로, 호출자가 열어둔 트랜잭션
* 안에서 수행하고 계측 후 롤백해 흔적을 남기지 않습니다(`withRolledBackTransaction`).
* 트랜잭션이 열려 있으면 Laravel 이 읽기 쿼리도 write PDO 로 보내므로(`Connection::getReadPdo`
* 의 `transactions > 0` 분기), 읽기/쓰기 분리 환경에서도 계측 요청이 이 계정을 인증할 수
* 있습니다. 운영 DB 에서도 잔여 계정이 생기지 않는 이유가 이 롤백입니다.
*/
class BenchmarkIdentity
{
/**
* 롤백되는 트랜잭션 안에서 콜백을 실행합니다.
*
* 계측이 만든 계정·토큰과, 계측 대상이 만든 행까지 함께 되돌립니다. 쓰기 축의 결과를
* 되돌리는 것도 같은 장치를 씁니다.
*
* @param \Closure $callback 트랜잭션 안에서 실행할 작업
* @return mixed 콜백 반환값
*/
public function withRolledBackTransaction(\Closure $callback): mixed
{
DB::beginTransaction();
try {
return $callback();
} finally {
// 계측 결과 산출 여부와 무관하게 되돌린다 — 예외로 빠져나가도 잔여 데이터 없음
DB::rollBack();
}
}
/**
* 계측에 사용할 Sanctum 토큰을 발급합니다.
*
* 호출 전에 `--as` 계정 존재를 `findExistingUser()` 로 확인해야 합니다 — 계정 부재는
* 계측 실패가 아니라 실행 전 판정 대상이라 이 메서드는 그 경우를 다루지 않습니다.
*
* @param array<int, string> $permissions 임시 계정에 부여할 권한 식별자 목록
* @param User|null $actor `--as` 로 확인된 기존 계정 (null 이면 임시 계정 생성)
* @return array{token: string, user: User, ephemeral: bool} 토큰과 인증 주체
*/
public function issueToken(array $permissions = [], ?User $actor = null): array
{
$user = $actor ?? $this->makeEphemeralAdmin($permissions);
return [
'token' => $user->createToken('g7-bench-'.Str::random(8))->plainTextToken,
'user' => $user,
'ephemeral' => $actor === null,
];
}
/**
* 지정된 기존 계정을 찾습니다.
*
* @param string $identifier 계정 ID 또는 이메일
* @return User|null 찾은 계정 (없으면 null)
*/
public function findExistingUser(string $identifier): ?User
{
return ctype_digit($identifier)
? User::find((int) $identifier)
: User::where('email', $identifier)->first();
}
/**
* 선언된 권한만 가진 임시 관리자를 생성합니다.
*
* 권한을 프로파일 선언에서 받는 이유는, 계측 대상 화면이 요구하는 권한만 주어야
* 미들웨어 통과 여부까지 실제와 같아지기 때문입니다(전권 계정으로 재면 권한 검사
* 비용과 분기가 달라집니다).
*
* @param array<int, string> $permissions 권한 식별자 목록
* @return User 생성된 임시 관리자
*/
private function makeEphemeralAdmin(array $permissions): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$identifier = (string) $identifier;
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$benchRole = Role::create([
'identifier' => 'g7_bench_'.Str::random(8),
'name' => json_encode(['ko' => '성능 계측 전용', 'en' => 'Benchmark Only']),
'description' => json_encode(['ko' => '성능 계측 임시 역할', 'en' => 'Temporary benchmark role']),
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if ($permissionIds !== []) {
$benchRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($benchRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
}
}
+275
View File
@@ -0,0 +1,275 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Enums\BenchmarkAxis;
/**
* 계측 프로파일 레지스트리 — 코어 config + 활성 확장 선언 수집
*
* 계측 대상은 "지금 이 설치본에 실제로 존재하는 것"이어야 하므로, 커맨드에 대상을
* 하드코딩하지 않고 소유자가 선언한 것을 런타임에 모읍니다. 코어는
* `config/benchmark.php`, 확장은 `getBenchmarkProfiles()` 오버라이드가 선언 지점입니다
* (채널·알림 정의 등 기존 확장 선언 훅과 동일한 수집 모델).
*
* 확장 선언은 격리 호출합니다 — 확장 하나의 잘못된 선언이 전체 목록을 날리면 계측 자체를
* 못 하게 되므로, 실패한 선언만 사유와 함께 `warnings()` 로 드러내고 나머지는 살립니다.
* 조용히 버리지는 않습니다(버려진 선언이 곧 계측 사각이 됩니다).
*/
class BenchmarkProfileRegistry
{
/**
* 수집된 프로파일 (정규화 키 → 프로파일)
*
* @var array<string, BenchmarkProfile>|null
*/
private ?array $profiles = null;
/**
* 수집 중 발생한 선언 오류 메시지
*
* @var array<int, string>
*/
private array $warnings = [];
public function __construct(
private readonly ModuleManagerInterface $moduleManager,
private readonly PluginManagerInterface $pluginManager,
) {}
/**
* 모든 프로파일을 정규화 키 기준으로 반환합니다.
*
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
*/
public function all(): array
{
if ($this->profiles !== null) {
return $this->profiles;
}
$profiles = [];
foreach ($this->collectCore() as $profile) {
$profiles[$profile->qualifiedKey()] = $profile;
}
foreach ($this->collectExtensions() as $profile) {
$profiles[$profile->qualifiedKey()] = $profile;
}
return $this->profiles = $profiles;
}
/**
* 특정 축의 프로파일만 반환합니다.
*
* @param BenchmarkAxis $axis 대상 축
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
*/
public function byAxis(BenchmarkAxis $axis): array
{
return array_filter($this->all(), fn (BenchmarkProfile $profile) => $profile->axis === $axis);
}
/**
* 키로 프로파일을 해석합니다.
*
* 정규화 키(`sirsoft-ecommerce/orders`)를 우선 대조하고, 없으면 짧은 키로 찾습니다.
* 짧은 키가 둘 이상의 확장에서 선언돼 모호하면 후보 목록을 사유로 돌려줍니다 —
* 임의로 하나를 고르면 어느 확장의 목록을 잰 것인지 알 수 없게 됩니다.
*
* @param string $key 프로파일 키 (정규화 키 또는 짧은 키)
* @return array{0: BenchmarkProfile|null, 1: string|null} [찾은 프로파일, 실패 사유]
*/
public function resolve(string $key): array
{
$profiles = $this->all();
if (isset($profiles[$key])) {
return [$profiles[$key], null];
}
$matches = array_filter($profiles, fn (BenchmarkProfile $profile) => $profile->key === $key);
if ($matches === []) {
return [null, "등록되지 않은 프로파일: {$key} (--list-profiles 로 목록 확인)"];
}
if (count($matches) > 1) {
$candidates = implode(', ', array_keys($matches));
return [null, "프로파일 키가 모호합니다: {$key} — 다음 중 하나로 지정하세요: {$candidates}"];
}
return [reset($matches), null];
}
/**
* 키로 프로파일을 찾습니다. (해석 실패 시 null)
*
* 사유가 필요하면 `resolve()` 를 씁니다.
*
* @param string $key 프로파일 키 (정규화 키 또는 짧은 키)
* @return BenchmarkProfile|null 찾은 프로파일
*/
public function find(string $key): ?BenchmarkProfile
{
return $this->resolve($key)[0];
}
/**
* 수집 중 무시된 선언의 사유 목록을 반환합니다.
*
* @return array<int, string> 경고 메시지
*/
public function warnings(): array
{
$this->all();
return $this->warnings;
}
/**
* 코어 프로파일을 수집합니다.
*
* @return array<int, BenchmarkProfile> 코어 프로파일 목록
*/
private function collectCore(): array
{
$declared = config('benchmark.profiles', []);
return $this->normalize(is_array($declared) ? $declared : [], 'core', 'core');
}
/**
* 활성 모듈/플러그인 프로파일을 수집합니다.
*
* @return array<int, BenchmarkProfile> 확장 프로파일 목록
*/
private function collectExtensions(): array
{
$profiles = [];
foreach ($this->moduleManager->getActiveModules() as $module) {
$profiles = array_merge(
$profiles,
$this->normalize($this->extract($module), 'module', $module->getIdentifier())
);
}
foreach ($this->pluginManager->getActivePlugins() as $plugin) {
$profiles = array_merge(
$profiles,
$this->normalize($this->extract($plugin), 'plugin', $plugin->getIdentifier())
);
}
return $profiles;
}
/**
* 확장의 `getBenchmarkProfiles()` 선언을 격리 호출로 읽습니다.
*
* @param object $extension 확장 인스턴스
* @return array<string, mixed> 선언 배열 (실패 시 빈 배열)
*/
private function extract(object $extension): array
{
if (! method_exists($extension, 'getBenchmarkProfiles')) {
return [];
}
try {
$declared = $extension->getBenchmarkProfiles();
} catch (\Throwable $e) {
$this->warnings[] = sprintf(
'%s: getBenchmarkProfiles() 호출 실패 — %s',
method_exists($extension, 'getIdentifier') ? $extension->getIdentifier() : $extension::class,
$e->getMessage()
);
return [];
}
return is_array($declared) ? $declared : [];
}
/**
* 선언 배열을 검증해 프로파일 객체로 정규화합니다.
*
* @param array<string, mixed> $declared 선언 배열 (키 → 정의)
* @param string $sourceKind 출처 종류 (core|module|plugin)
* @param string $sourceIdentifier 출처 식별자
* @return array<int, BenchmarkProfile> 정규화된 프로파일 목록
*/
private function normalize(array $declared, string $sourceKind, string $sourceIdentifier): array
{
$profiles = [];
foreach ($declared as $key => $definition) {
if (! is_string($key) || $key === '') {
$this->warnings[] = "{$sourceIdentifier}: 프로파일 키는 빈 문자열이 아닌 문자열이어야 합니다.";
continue;
}
if (! is_array($definition)) {
$this->warnings[] = "{$sourceIdentifier}/{$key}: 프로파일 정의는 배열이어야 합니다.";
continue;
}
$axis = BenchmarkAxis::tryFrom((string) ($definition['type'] ?? ''));
if ($axis === null) {
$this->warnings[] = sprintf(
'%s/%s: 알 수 없는 type — %s (허용: %s)',
$sourceIdentifier,
$key,
var_export($definition['type'] ?? null, true),
implode('|', BenchmarkAxis::values())
);
continue;
}
$missing = array_values(array_filter(
$axis->requiredOptions(),
fn (array $group) => array_filter(
$group,
fn (string $option) => isset($definition[$option])
) === []
));
if ($missing !== []) {
$this->warnings[] = sprintf(
'%s/%s: %s 축 필수 옵션 누락 — %s',
$sourceIdentifier,
$key,
$axis->value,
implode(', ', array_map(fn (array $group) => implode('|', $group), $missing))
);
continue;
}
$label = $definition['label'] ?? null;
unset($definition['type'], $definition['label']);
$profiles[] = new BenchmarkProfile(
key: $key,
axis: $axis,
sourceKind: $sourceKind,
sourceIdentifier: $sourceIdentifier,
options: $definition,
label: is_string($label) ? $label : null,
);
}
return $profiles;
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
use Illuminate\Support\Facades\DB;
/**
* 계측 결과 출력기 — 표준출력 표 / JSON / 마크다운 리포트
*
* 축을 모르고도 출력할 수 있는 이유는 `BenchmarkResult` 가 표시용 표와 기계 판독용 수치를
* 함께 담기 때문입니다. 축이 늘어도 이 클래스는 고치지 않습니다.
*
* 마크다운 리포트에 환경 정보를 반드시 함께 적습니다 — 계측값은 실행 머신·DB 버전·
* OPcache 여부에 종속되므로, 환경이 빠진 수치는 다른 리포트와 비교할 수 없는 숫자입니다.
* 문서에 옮길 수치는 이 경로로만 산출해 눈대중 기재를 막는 것이 `--report` 의 목적입니다.
*/
class BenchmarkReporter
{
/**
* 실행 환경 정보를 수집합니다.
*
* @return array<string, string> 항목 → 값
*/
public function environment(): array
{
return [
'APP_ENV' => (string) config('app.env'),
'G7 버전' => (string) config('app.version'),
'DB 연결' => (string) config('database.default'),
'DB 스키마' => $this->databaseName(),
'DB 버전' => $this->databaseVersion(),
'PHP 버전' => PHP_VERSION,
'OPcache' => function_exists('opcache_get_status') && @opcache_get_status(false) !== false ? 'on' : 'off',
'memory_limit' => (string) ini_get('memory_limit'),
'config:cache' => app()->configurationIsCached() ? 'on' : 'off',
'실행 머신' => php_uname('s').' '.php_uname('r').' / '.php_uname('m'),
];
}
/**
* JSON 으로 직렬화합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string JSON 문자열
*/
public function toJson(array $results, BenchmarkRunOptions $options, array $warnings = []): string
{
return (string) json_encode([
'generated_at' => now()->toIso8601String(),
'environment' => $this->environment(),
'options' => $options->toArray(),
'warnings' => $warnings,
'results' => array_map(fn (BenchmarkResult $result) => $result->toArray(), $results),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* 마크다운 리포트를 만듭니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string 마크다운 문서
*/
public function toMarkdown(array $results, BenchmarkRunOptions $options, array $warnings = []): string
{
$lines = [
'# G7 성능 점검 리포트',
'',
'- 생성 시각: '.now()->toDateTimeString(),
'',
'## 실행 환경',
'',
'| 항목 | 값 |',
'| ------ | ------ |',
];
foreach ($this->environment() as $name => $value) {
$lines[] = "| {$name} | {$value} |";
}
$lines[] = '';
$lines[] = '계측값은 위 환경에 종속됩니다. 다른 리포트와 비교할 때는 환경이 같은지 먼저 확인하세요.';
$lines[] = '';
$lines[] = '## 실행 조건';
$lines[] = '';
$lines[] = '| 항목 | 값 |';
$lines[] = '| ------ | ------ |';
foreach ($options->toArray() as $name => $value) {
$lines[] = sprintf('| %s | %s |', $name, $this->stringify($value));
}
if ($warnings !== []) {
$lines[] = '';
$lines[] = '## 무시된 프로파일 선언';
$lines[] = '';
foreach ($warnings as $warning) {
$lines[] = '- '.$warning;
}
}
foreach (BenchmarkAxis::cases() as $axis) {
$axisResults = array_values(array_filter(
$results,
fn (BenchmarkResult $result) => $result->profile->axis === $axis
));
if ($axisResults === []) {
continue;
}
$lines[] = '';
$lines[] = sprintf('## %s (%s)', $axis->label(), $axis->value);
foreach ($axisResults as $result) {
$lines = array_merge($lines, $this->markdownSection($result));
}
}
return implode("\n", $lines)."\n";
}
/**
* 결과 1건의 마크다운 절을 만듭니다.
*
* @param BenchmarkResult $result 계측 결과
* @return array<int, string> 마크다운 줄 목록
*/
private function markdownSection(BenchmarkResult $result): array
{
$title = $result->profile->qualifiedKey();
if ($result->profile->label !== null) {
$title .= ' — '.$result->profile->label;
}
$lines = ['', '### '.$title, ''];
if ($result->skipped) {
$lines[] = '측정하지 않음: '.$result->skipReason;
return $lines;
}
$lines[] = '| '.implode(' | ', $result->headers).' |';
$lines[] = '| '.implode(' | ', array_fill(0, count($result->headers), '------')).' |';
foreach ($result->rows as $row) {
$lines[] = '| '.implode(' | ', $row).' |';
}
if ($result->notes !== []) {
$lines[] = '';
foreach ($result->notes as $note) {
// 실행 계획/SQL 은 표 안에서 깨지므로 코드 스팬으로 감싼다
$lines[] = '- '.(str_contains($note, '|') ? '`'.$note.'`' : $note);
}
}
return $lines;
}
/**
* 옵션 값을 표에 넣을 문자열로 바꿉니다.
*
* @param mixed $value 옵션 값
* @return string 표시 문자열
*/
private function stringify(mixed $value): string
{
return match (true) {
is_bool($value) => $value ? 'true' : 'false',
is_array($value) => implode(', ', array_map(fn (mixed $item) => (string) $item, $value)),
$value === null => '-',
default => (string) $value,
};
}
/**
* 계측이 사용한 DB 스키마명을 반환합니다.
*
* 읽기/쓰기 분리 설정에서는 스키마명이 `write`/`read` 하위에만 있어 최상위 `database`
* 가 비어 있습니다. 리포트에 스키마가 비면 어느 DB 를 잰 것인지 알 수 없으므로
* 세 위치를 순서대로 확인합니다.
*
* @return string 스키마명 (확인 불가 시 '-')
*/
private function databaseName(): string
{
$connection = (string) config('database.default');
foreach (['database', 'write.database', 'read.database'] as $key) {
$name = config("database.connections.{$connection}.{$key}");
if (is_string($name) && $name !== '') {
return $name;
}
}
return '-';
}
/**
* DB 서버 버전을 조회합니다.
*
* @return string DB 버전 (조회 실패 시 '-')
*/
private function databaseVersion(): string
{
try {
$row = DB::selectOne('select version() as version');
return (string) ($row->version ?? '-');
} catch (\Throwable) {
return '-';
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Benchmark\Contracts;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
/**
* 계측 축 실행기 계약
*
* 축이 늘어날 때 커맨드를 고치지 않고 실행기를 추가하도록 분리합니다. 커맨드는
* 프로파일의 `type` 으로 실행기를 고르는 일만 합니다.
*/
interface BenchmarkAxisRunner
{
/**
* 이 실행기가 담당하는 축을 반환합니다.
*
* @return BenchmarkAxis 담당 축
*/
public function axis(): BenchmarkAxis;
/**
* 프로파일 1건을 계측합니다.
*
* @param BenchmarkProfile $profile 계측 대상
* @param BenchmarkRunOptions $options 실행 옵션
* @param \Closure|null $onProgress 진행 상황 콜백 (string $message, 시딩 등 장시간 작업 알림용)
* @return BenchmarkResult 계측 결과
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult;
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Benchmark\DTO;
use App\Enums\BenchmarkAxis;
/**
* 계측 프로파일 — 코어 config / 확장 선언에서 수집된 계측 대상 1건 (Value Object)
*
* 레지스트리가 선언 배열을 검증해 이 객체로 정규화하고, 축 실행기가 그대로 받아 씁니다.
* 축별 옵션 스키마가 서로 다르므로 공통 필드(키·축·출처·라벨)만 프로퍼티로 승격하고
* 축 고유 옵션은 `options` 에 남깁니다 — 축이 늘 때 VO 를 고치지 않아도 되게 합니다.
*/
final readonly class BenchmarkProfile
{
/**
* @param string $key 선언된 프로파일 키 (확장 내부에서만 고유)
* @param BenchmarkAxis $axis 계측 축
* @param string $sourceKind 출처 종류 (core|module|plugin)
* @param string $sourceIdentifier 출처 식별자 (코어는 'core')
* @param array<string, mixed> $options 축 고유 옵션
* @param string|null $label 표시용 설명
*/
public function __construct(
public string $key,
public BenchmarkAxis $axis,
public string $sourceKind,
public string $sourceIdentifier,
public array $options = [],
public ?string $label = null,
) {}
/**
* 출처를 포함한 전역 고유 키를 반환합니다.
*
* 서로 다른 확장이 같은 키(`orders` 등)를 선언할 수 있으므로 충돌 시에는 이 키로
* 지목합니다. 커맨드는 짧은 키가 유일할 때만 짧은 키를 허용합니다.
*
* @return string `{출처}/{키}` 형태의 정규화 키
*/
public function qualifiedKey(): string
{
return $this->sourceIdentifier.'/'.$this->key;
}
/**
* 축 고유 옵션 값을 읽습니다.
*
* @param string $name 옵션 키
* @param mixed $default 미선언 시 기본값
* @return mixed 옵션 값
*/
public function option(string $name, mixed $default = null): mixed
{
return $this->options[$name] ?? $default;
}
/**
* 이 프로파일 실행이 데이터를 변경하는지 판정합니다.
*
* 축 기본값을 쓰되, 프로파일이 `mutating` 을 명시하면 그 선언을 따릅니다.
* `screen` 축은 GET 이 아니면 변경으로 봅니다.
*
* @return bool 데이터 변경 여부
*/
public function mutates(): bool
{
$declared = $this->options['mutating'] ?? null;
if (is_bool($declared)) {
return $declared;
}
if ($this->axis === BenchmarkAxis::Screen) {
return strtoupper((string) $this->option('method', 'GET')) !== 'GET';
}
return $this->axis->mutatesByDefault();
}
/**
* 배열로 직렬화합니다. (`--json` / 리포트 출력용)
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'key' => $this->key,
'qualified_key' => $this->qualifiedKey(),
'axis' => $this->axis->value,
'source' => ['kind' => $this->sourceKind, 'identifier' => $this->sourceIdentifier],
'label' => $this->label,
'options' => $this->options,
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Benchmark\DTO;
/**
* 계측 결과 1건 — 축 실행기의 산출물 (Value Object)
*
* 출력기(`BenchmarkReporter`)가 축을 몰라도 표/JSON/마크다운을 만들 수 있도록,
* 표시용 표(`headers`/`rows`)와 기계 판독용 수치(`metrics`)를 함께 담습니다.
* 축이 늘어날 때 출력기를 고치지 않아도 되는 지점이 여기입니다.
*/
final readonly class BenchmarkResult
{
/**
* @param BenchmarkProfile $profile 계측 대상 프로파일
* @param array<int, string> $headers 표 헤더
* @param array<int, array<int, string>> $rows 표 행 (표시용 문자열)
* @param array<string, mixed> $metrics 기계 판독용 원시 수치
* @param array<int, string> $notes 부가 설명 줄 (실행 계획, N+1 후보 등)
* @param bool $skipped 실행하지 않았는지 여부
* @param string|null $skipReason 실행하지 않은 사유
*/
public function __construct(
public BenchmarkProfile $profile,
public array $headers = [],
public array $rows = [],
public array $metrics = [],
public array $notes = [],
public bool $skipped = false,
public ?string $skipReason = null,
) {}
/**
* 실행하지 않은 결과를 만듭니다.
*
* 실패/건너뜀을 결과 목록에서 빼면 리포트가 "전부 측정됨"으로 읽히므로, 사유를 담은
* 결과로 남겨 표와 JSON 양쪽에 드러냅니다.
*
* @param BenchmarkProfile $profile 대상 프로파일
* @param string $reason 건너뛴 사유
* @return self 건너뜀 결과
*/
public static function skipped(BenchmarkProfile $profile, string $reason): self
{
return new self(profile: $profile, skipped: true, skipReason: $reason);
}
/**
* 배열로 직렬화합니다.
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'profile' => $this->profile->qualifiedKey(),
'axis' => $this->profile->axis->value,
'label' => $this->profile->label,
'source' => ['kind' => $this->profile->sourceKind, 'identifier' => $this->profile->sourceIdentifier],
'skipped' => $this->skipped,
'skip_reason' => $this->skipReason,
'headers' => $this->headers,
'rows' => $this->rows,
'metrics' => $this->metrics,
'notes' => $this->notes,
];
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Benchmark\DTO;
/**
* 계측 실행 옵션 — 커맨드 옵션을 축 실행기로 한 번 전달하는 값 묶음 (Value Object)
*
* 축마다 쓰는 옵션이 다르지만(offsets 는 list 축만, allowWrite 는 write/batch 축만)
* 실행기 시그니처를 하나로 두기 위해 한 객체로 모읍니다.
*/
final readonly class BenchmarkRunOptions
{
/**
* @param array<int, int> $offsets 측정할 OFFSET 목록 (list 축)
* @param int $runs 측정 횟수 (첫 회는 버림)
* @param int $seed 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)
* @param bool $fresh 시딩 전 대상 테이블 비움 (list 축)
* @param bool $explain 실행 계획 수집 (list 축)
* @param bool $allowWrite 데이터 변경 축 실행 허용
* @param string|null $asUser 계측에 사용할 기존 계정 (ID 또는 이메일, screen 축)
* @param int $perPage 목록/화면 1페이지 건수
*/
public function __construct(
public array $offsets = [0],
public int $runs = 3,
public int $seed = 0,
public bool $fresh = false,
public bool $explain = false,
public bool $allowWrite = false,
public ?string $asUser = null,
public int $perPage = 20,
) {}
/**
* 배열로 직렬화합니다. (리포트의 실행 조건 기재용)
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'offsets' => $this->offsets,
'runs' => $this->runs,
'seed' => $this->seed,
'fresh' => $this->fresh,
'explain' => $this->explain,
'allow_write' => $this->allowWrite,
'as_user' => $this->asUser,
'per_page' => $this->perPage,
];
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkProfile;
use Illuminate\Support\Facades\Schema;
/**
* 목록 프로파일 선언에서 필요한 색인을 도출하고 스키마와 대조합니다.
*
* 배경: 지연 조인은 inner 가 키 컬럼만 읽게 만들지만, 그 inner 가 **인덱스 순서 그대로**
* 끝나야 깊은 OFFSET 이 싸진다. 정렬을 덮는 인덱스가 없으면 inner 도 filesort 로 전체를
* 훑으므로 개선 폭이 사라진다. 그런데 어떤 색인이 필요한지는 지금까지 테이블마다 사람이
* 손으로 설계했고, 맞는지 검사하는 장치가 없었다.
*
* 필요한 정보는 이미 계측 프로파일에 선언돼 있다 — `filters` / `order` / `soft_delete` 를
* 이으면 그대로 색인 레시피가 된다:
*
* (등치 필터 컬럼들 → soft_delete 면 deleted_at → 정렬 컬럼들 → 기본키)
*
* 이 클래스는 그 도출을 한 곳에 두어, 새 목록이 프로파일을 선언하는 순간 필요한 색인이
* 자동으로 드러나게 한다. 색인 설계를 사람의 기억이 아니라 이미 있는 선언에 붙이는 것이라
* 호출자가 추가로 선언할 것은 없다.
*
* 등치가 아닌 필터(`in` / `not in` / 범위)는 선행 컬럼에 넣지 않는다 — 선행 컬럼이 등치로
* 고정되지 않으면 뒤따르는 정렬 컬럼을 인덱스 순서로 쓸 수 없기 때문이다. 이 판정을 틀리면
* "색인이 있는데도 filesort" 라는, 눈으로는 구분되지 않는 상태를 정상으로 보고하게 된다.
*/
class ListIndexAdvisor
{
/** 선행 컬럼으로 쓸 수 있는 연산자 (등치만) */
private const EQUALITY_OPERATORS = ['='];
/**
* 프로파일이 요구하는 색인 선행 컬럼 목록을 도출합니다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return array<int, string> 선행 컬럼 순서 (등치 → deleted_at → 정렬 → 기본키)
*/
public function requiredColumns(array $options, string $keyName = 'id'): array
{
$leading = [];
foreach (($options['filters'] ?? []) as $column => $declared) {
if (is_array($declared) && count($declared) === 2 && is_string($declared[0])) {
// [연산자, 값] 형태 — 등치만 선행 컬럼 자격이 있다
if (in_array(strtolower($declared[0]), self::EQUALITY_OPERATORS, true)) {
$leading[] = (string) $column;
}
continue;
}
// 값만 준 형태는 등가 비교
$leading[] = (string) $column;
}
if ($options['soft_delete'] ?? false) {
$leading[] = 'deleted_at';
}
foreach (($options['order'] ?? []) as $spec) {
$column = is_array($spec) ? ($spec[0] ?? null) : $spec;
if (is_string($column) && $column !== '') {
$leading[] = $column;
}
}
// 정렬 끝에 기본키가 없으면 동률 구간에서 filesort 가 남는다
if (end($leading) !== $keyName) {
$leading[] = $keyName;
}
return array_values(array_unique($leading));
}
/**
* 도출한 색인이 실제 스키마에 있는지 대조합니다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return array{
* table: string|null,
* required: array<int, string>,
* satisfied_by: string|null,
* partial_by: string|null,
* status: string
* } status: satisfied | tiebreak_missing | missing | table_absent | not_applicable
*/
public function inspect(array $options, string $keyName = 'id'): array
{
$table = $options['table'] ?? null;
$required = $this->requiredColumns($options, $keyName);
$result = [
'table' => $table,
'required' => $required,
'satisfied_by' => null,
'partial_by' => null,
'status' => 'not_applicable',
];
if (! is_string($table) || $table === '') {
return $result;
}
if (! Schema::hasTable($table)) {
$result['status'] = 'table_absent';
return $result;
}
// 기본키를 뺀 형태 — 색인은 있으나 tie-break 만 빠진 경우를 구분한다.
// 이 둘은 처방이 다르다: 전자는 색인 신설, 후자는 기존 색인에 기본키를 덧붙이는 교체.
$withoutKey = $required;
if (end($withoutKey) === $keyName) {
array_pop($withoutKey);
}
foreach (Schema::getIndexes($table) as $index) {
$columns = $index['columns'] ?? [];
if ($result['satisfied_by'] === null && $this->hasPrefix($columns, $required)) {
$result['satisfied_by'] = $index['name'];
}
if ($this->hasPrefix($columns, $withoutKey)) {
// UNIQUE 색인은 값이 중복되지 않으므로 그 컬럼만으로 이미 전순서다.
// 기본키를 덧붙일 필요가 없고, 붙여도 filesort 가 줄지 않는다.
if ($result['satisfied_by'] === null && ($index['unique'] ?? false)) {
$result['satisfied_by'] = $index['name'];
}
if ($result['partial_by'] === null) {
$result['partial_by'] = $index['name'];
}
}
}
$result['status'] = match (true) {
$result['satisfied_by'] !== null => 'satisfied',
$result['partial_by'] !== null => 'tiebreak_missing',
default => 'missing',
};
return $result;
}
/**
* 프로파일에 선언된 면제 사유를 돌려줍니다.
*
* 행 수가 고정되거나 상한이 작아 색인이 불필요한 목록은 프로파일에 사유를 적어 면제한다.
* 사유 없는 면제를 허용하지 않는 이유는, 면제가 쌓이면 검사 자체가 무의미해지는데
* 사유가 없으면 나중에 그 판단이 여전히 옳은지 확인할 방법이 없기 때문이다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return string|null 면제 사유 (없으면 null)
*/
public function exemptionReason(array $options): ?string
{
$reason = $options['index_exempt'] ?? null;
return is_string($reason) && trim($reason) !== '' ? trim($reason) : null;
}
/**
* 프로파일 목록을 한 번에 점검합니다.
*
* @param array<string, BenchmarkProfile> $profiles 목록 축 프로파일
* @return array<int, array<string, mixed>> 프로파일별 점검 결과
*/
public function inspectAll(array $profiles): array
{
$rows = [];
foreach ($profiles as $key => $profile) {
$options = $profile->options ?? [];
$rows[] = array_merge(
['profile' => is_string($key) ? $key : $profile->qualifiedKey()],
$this->inspect($options),
['exemption' => $this->exemptionReason($options)]
);
}
return $rows;
}
/**
* 인덱스 컬럼이 요구 목록을 선행 프리픽스로 포함하는지 판정합니다.
*
* @param array<int, string> $columns 인덱스 컬럼 순서
* @param array<int, string> $required 요구 선행 컬럼
*/
private function hasPrefix(array $columns, array $required): bool
{
if ($required === [] || count($columns) < count($required)) {
return false;
}
return array_slice(array_values($columns), 0, count($required)) === array_values($required);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Benchmark;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
/**
* 실행 쿼리 수집기 — 화면/쓰기/배치 축의 쿼리 건수·시간·N+1 후보 산출
*
* 목록 SELECT 는 빨라도 화면이 느린 대표적 원인이 N+1 이므로, 응답 시간만 재고 끝내면
* 원인을 못 찾습니다. 이 수집기는 계측 구간에서 실행된 쿼리를 모아 건수·DB 시간과, 같은
* SQL 이 반복 실행된 그룹(N+1 후보)을 함께 돌려줍니다.
*
* `DB::listen` 은 한 번 등록하면 해제할 수 없으므로, 리스너는 인스턴스당 한 번만 등록하고
* 수집 여부는 플래그로 켰다 끕니다 — 계측 구간 밖의 쿼리가 섞이지 않게 합니다.
*/
class QueryCollector
{
/**
* 같은 SQL 이 이 횟수 이상 반복되면 N+1 후보로 본다
*/
private const N_PLUS_ONE_THRESHOLD = 5;
/**
* 수집 구간 여부
*/
private bool $collecting = false;
/**
* 수집된 쿼리 (sql, time)
*
* @var array<int, array{sql: string, time: float}>
*/
private array $queries = [];
public function __construct()
{
DB::listen(function (QueryExecuted $event) {
if (! $this->collecting) {
return;
}
$this->queries[] = ['sql' => $event->sql, 'time' => (float) $event->time];
});
}
/**
* 콜백 실행 구간의 쿼리를 수집합니다.
*
* @template TReturn
*
* @param \Closure(): TReturn $callback 계측 대상 작업
* @return array{value: TReturn, queries: array<int, array{sql: string, time: float}>} 반환값과 수집 결과
*/
public function collect(\Closure $callback): array
{
$this->queries = [];
$this->collecting = true;
try {
$value = $callback();
} finally {
$this->collecting = false;
}
return ['value' => $value, 'queries' => $this->queries];
}
/**
* 수집된 쿼리를 요약합니다.
*
* @param array<int, array{sql: string, time: float}> $queries 수집 결과
* @return array{count: int, db_ms: float, n_plus_one: array<int, array{count: int, sql: string}>} 요약
*/
public function summarize(array $queries): array
{
$grouped = [];
foreach ($queries as $query) {
$grouped[$query['sql']] = ($grouped[$query['sql']] ?? 0) + 1;
}
arsort($grouped);
$candidates = [];
foreach ($grouped as $sql => $count) {
if ($count < self::N_PLUS_ONE_THRESHOLD) {
continue;
}
$candidates[] = ['count' => $count, 'sql' => $sql];
}
return [
'count' => count($queries),
'db_ms' => round(array_sum(array_column($queries, 'time')), 2),
'n_plus_one' => $candidates,
];
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Benchmark;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* 계측용 합성 행 시딩기
*
* 깊은 OFFSET 비용은 행 수와 행 폭이 있어야 재현되므로, 계측 전에 대상 테이블을 원하는
* 규모까지 채웁니다. 스키마를 introspect 해 NOT NULL + 기본값 없는 컬럼만 타입에 맞춰
* 채우므로 계측 대상이 늘 때마다 시더를 따라 만들지 않아도 됩니다.
*
* 합성 값은 실제 부모 행을 가리키지 않으므로 외래키 제약을 일시 해제합니다. 본 시딩이
* 재현하려는 것은 대상 테이블의 OFFSET 스캔 비용(행 수·행 폭)이고 조인은 계측 대상이
* 아니므로, 부모 무결성 없이 목적에 충분합니다 — 폐기 가능한 계측용 DB 에서만 쓰는 것을
* 전제로 하며, 운영 환경 거부는 호출자(`ListAxisRunner`)가 담당합니다.
*/
class SyntheticSeeder
{
/**
* 한 번에 삽입할 행 수
*/
private const CHUNK_SIZE = 500;
/**
* 계측용 합성 행을 시딩합니다.
*
* @param string $table 대상 테이블
* @param int $count 시딩 건수
* @param array<string, mixed> $overrides 컬럼별 고정값 (실제 조회 조건과 맞추기 위한 지정)
* @param \Closure|null $onProgress 진행 콜백 (int $inserted, int $total)
* @param array<int, string> $requiredColumns nullable 이어도 반드시 채울 컬럼 (계측이 정렬 기준으로 삼는 컬럼)
*/
public function seed(string $table, int $count, array $overrides = [], ?\Closure $onProgress = null, array $requiredColumns = []): void
{
$fillable = $this->fillableColumns($table);
$required = $this->requiredColumnDefinitions($table, $requiredColumns, $fillable);
$chunk = [];
$inserted = 0;
for ($i = 1; $i <= $count; $i++) {
$chunk[] = $this->synthesizeRow($table, array_merge($fillable, $required), $overrides, $i);
if (count($chunk) >= self::CHUNK_SIZE) {
$this->insert($table, $chunk);
$inserted += count($chunk);
$chunk = [];
if ($onProgress !== null) {
$onProgress($inserted, $count);
}
}
}
if ($chunk !== []) {
$this->insert($table, $chunk);
$inserted += count($chunk);
if ($onProgress !== null) {
$onProgress($inserted, $count);
}
}
}
/**
* 대상 테이블을 비웁니다.
*
* 다른 테이블이 FK 로 참조하면 TRUNCATE 가 거부되므로 제약을 잠시 내립니다.
*
* @param string $table 대상 테이블
*/
public function truncate(string $table): void
{
Schema::withoutForeignKeyConstraints(function () use ($table) {
DB::table($table)->truncate();
});
}
/**
* 값을 반드시 채워야 하는 컬럼 목록을 반환합니다.
*
* @param string $table 대상 테이블
* @return array<int, array<string, mixed>> Schema::getColumns() 항목 목록
*/
private function fillableColumns(string $table): array
{
return array_values(array_filter(
Schema::getColumns($table),
fn (array $column) => ! ($column['auto_increment'] ?? false)
&& ! $column['nullable']
&& ($column['default'] === null)
));
}
/**
* 계측이 정렬 기준으로 삼는 컬럼의 정의를 반환합니다.
*
* `fillableColumns()` 는 "NOT NULL + 기본값 없음" 만 남기므로, Laravel 기본 `timestamps()`
* 처럼 nullable 로 선언된 `created_at` 계열은 여기서 빠진다. 그런데 목록 프로파일 대부분이
* `created_at` 으로 정렬하므로, 그대로 두면 합성 행이 전부 NULL 동률이 되어 계측이
* 정렬 인덱스가 아니라 tie-break 경로를 재게 된다.
*
* @param string $table 대상 테이블
* @param array<int, string> $names 반드시 채울 컬럼명 목록
* @param array<int, array<string, mixed>> $already 이미 채우기로 한 컬럼 목록
* @return array<int, array<string, mixed>> 추가로 채울 컬럼 정의 목록
*/
private function requiredColumnDefinitions(string $table, array $names, array $already): array
{
if ($names === []) {
return [];
}
$covered = array_column($already, 'name');
$columns = collect(Schema::getColumns($table))->keyBy('name');
$extra = [];
foreach (array_unique($names) as $name) {
if (in_array($name, $covered, true) || ! $columns->has($name)) {
continue;
}
$column = $columns->get($name);
// auto increment 키는 DB 가 채운다
if ($column['auto_increment'] ?? false) {
continue;
}
$extra[] = $column;
}
return $extra;
}
/**
* 합성 행 1건을 만듭니다.
*
* @param string $table 대상 테이블
* @param array<int, array<string, mixed>> $fillable 채워야 하는 컬럼 목록
* @param array<string, mixed> $overrides 컬럼별 고정값
* @param int $index 행 인덱스 (고유값 생성용)
* @return array<string, mixed> 합성 행
*/
private function synthesizeRow(string $table, array $fillable, array $overrides, int $index): array
{
$row = [];
foreach ($fillable as $column) {
$row[$column['name']] = array_key_exists($column['name'], $overrides)
? $overrides[$column['name']]
: $this->synthesizeValue($column, $index);
}
// NULL 허용 컬럼이라 위 루프에서 빠졌더라도, 실제 조회 조건에 쓰이는 컬럼은
// 채워야 계측이 화면과 같은 인덱스를 타게 된다.
foreach ($overrides as $name => $value) {
if (! array_key_exists($name, $row) && Schema::hasColumn($table, $name)) {
$row[$name] = $value;
}
}
return $row;
}
/**
* 합성 행을 삽입합니다. (외래키 제약 일시 해제)
*
* @param string $table 대상 테이블
* @param array<int, array<string, mixed>> $chunk 삽입할 행 묶음
*/
private function insert(string $table, array $chunk): void
{
Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) {
DB::table($table)->insert($chunk);
});
}
/**
* 컬럼 타입에 맞는 합성 값을 만듭니다.
*
* @param array<string, mixed> $column Schema::getColumns() 항목
* @param int $index 행 인덱스 (고유값 생성용)
* @return mixed 합성 값
*/
private function synthesizeValue(array $column, int $index): mixed
{
$type = strtolower((string) ($column['type_name'] ?? $column['type'] ?? 'varchar'));
return match (true) {
str_contains($type, 'int') => $index,
str_contains($type, 'decimal'), str_contains($type, 'float'), str_contains($type, 'double') => 1000,
str_contains($type, 'bool'), str_contains($type, 'tinyint') => 0,
str_contains($type, 'json') => '{}',
// 시간 컬럼을 한 값으로 채우면 정렬이 전부 동률이 되어 filesort 가 지배해 버린다.
// 정렬 인덱스 효과를 재는 것이 목적이므로 행마다 값을 분산시킨다.
str_contains($type, 'date'), str_contains($type, 'time') => now()->subMinutes($index),
str_contains($type, 'text') => str_repeat('벤치마크 본문 ', 100),
default => $this->synthesizeString($column, $index),
};
}
/**
* 문자열 컬럼의 선언 길이에 맞는 합성 값을 만듭니다.
*
* 컬럼 길이를 무시하고 고정 길이 문자열을 넣으면 `varchar(10)` 류(신고 대상 타입,
* 우편번호, 로그 타입 등)에서 "Data too long" 으로 시딩 자체가 실패한다.
*
* @param array<string, mixed> $column Schema::getColumns() 항목
* @param int $index 행 인덱스 (고유값 생성용)
* @return string 합성 문자열
*/
private function synthesizeString(array $column, int $index): string
{
$length = 40;
// type 은 'varchar(10)' 형태로 선언 길이를 담고 있다
if (preg_match('/\((\d+)\)/', (string) ($column['type'] ?? ''), $m) === 1) {
$length = max(1, (int) $m[1]);
}
// 짧은 컬럼은 고유성 확보가 우선이라 인덱스 자체를 문자열로 쓴다
if ($length <= 12) {
return substr((string) $index, -$length);
}
return substr("bench-{$index}-".Str::random(8), 0, $length);
}
}
+134 -5
View File
@@ -126,6 +126,7 @@ class ApiDocgenCommand extends Command
$stats = ['files' => 0, 'endpoints' => 0, 'probed' => 0, 'skipped' => 0, 'examples' => 0];
$checkFindings = [];
$skippedProbes = [];
$examplesOnly = (bool) $this->option('examples-only');
// 대상(코어/확장)별 README 목차 항목 누적: readmeKey => ['label' => ..., 'entries' => [...]]
@@ -136,6 +137,17 @@ class ApiDocgenCommand extends Command
$sectionKeys = [];
$exampleBlocks = [];
// `Route::match(['get','post'], ...)->name('x')` 는 하나의 라우트명으로 두 엔드포인트를
// 등록한다. 생성 블록 키가 라우트명뿐이면 한 문서에 같은 키가 둘 생겨, 블록 조회(strpos)가
// 언제나 첫 블록만 잡아 두 번째 블록의 사람 서술이 재생성마다 유실된다.
// 문서 안에서 중복되는 라우트명만 골라 키에 메서드를 붙인다 (나머지 키는 그대로 — 전 문서의
// 키를 바꾸면 기존 블록과 대조가 어긋나 사람 서술이 통째로 사라진다).
$nameCounts = array_count_values(array_map(
static fn (array $r): string => $r['name'] ?: $r['uri'],
$items,
));
$duplicatedNames = array_keys(array_filter($nameCounts, static fn (int $n): bool => $n > 1));
// 목차용 도메인 파일 항목 (소유별). 예시-only/check 모드에서도 목차는 최신화한다.
$owner = $items[0]['owner'];
$readmeKey = $this->readmeFile($owner);
@@ -156,10 +168,14 @@ class ApiDocgenCommand extends Command
$stats['probed']++;
} else {
$stats['skipped']++;
$checkFindings[] = "{$route['method']} {$route['uri']} — {$probeMeta['skipped_reason']}";
// 실측 제외는 설계상 정상이다 — 부수효과 쓰기·미치환 path·검증 실패는 의도적으로
// 호출하지 않고, 그 자리는 사람이 코드 근거로 채운다. drift 로 세면 문서가 완전해도
// --check 가 통과할 수 없어 기준이 무의미해진다. 정보로만 남긴다.
$skippedProbes[] = "{$route['method']} {$route['uri']} — {$probeMeta['skipped_reason']}";
}
$key = $route['name'] ?: $route['uri'];
$methodScopedKey = in_array($route['name'] ?: $route['uri'], $duplicatedNames, true);
$key = $scaffolder->generatedKey($route, $methodScopedKey);
if ($examplesOnly) {
// 표·서술을 건드리지 않고 예시 2블록만 산출 (SSoT=스캐폴더 예시 메서드).
@@ -179,7 +195,7 @@ class ApiDocgenCommand extends Command
}
$commentMap = $this->columnComments($route, $commentResolver, $sampleMap);
$sections[] = $scaffolder->endpointSection($route, $request, $schema, $probeMeta, $commentMap);
$sections[] = $scaffolder->endpointSection($route, $request, $schema, $probeMeta, $commentMap, $methodScopedKey);
$sectionKeys[] = $key;
$stats['endpoints']++;
}
@@ -187,6 +203,12 @@ class ApiDocgenCommand extends Command
if ($this->option('check')) {
if (! File::exists($file)) {
$checkFindings[] = "문서 파일 없음: {$file}";
} else {
// 실측 불가 자리를 사람이 채우지 않고 마커로 남겨 두면 문서가 미완이다.
// (규정: docs/backend/api-documentation.md "미채움 마커 5종")
foreach ($this->unfilledMarkers(File::get($file)) as $marker) {
$checkFindings[] = "미채움 마커: {$file} — {$marker}";
}
}
continue;
@@ -216,6 +238,15 @@ class ApiDocgenCommand extends Command
$header = $this->documentHeader($file, $items[0]);
$existing = File::exists($file) ? File::get($file) : null;
// 기존 문서는 중복 라우트명 블록을 구 키(라우트명만)로 갖고 있다. 그대로 두면 새 키
// (라우트명::메서드)와 대조되지 않아 이번 재생성에서 그 블록의 사람 서술이 유실된다.
// 병합 전에 구 키를 등장 순서대로 새 키로 승격한다 — 문서 내 블록 순서는 $items 순서와
// 같으므로(같은 그룹을 같은 순서로 순회) 메서드 대응이 어긋나지 않는다.
if ($existing !== null && $duplicatedNames !== []) {
$existing = $this->migrateDuplicateBlockKeys($existing, $items, $duplicatedNames, $scaffolder);
}
$content = $scaffolder->mergeDocument($existing, $header, $sections, $sectionKeys);
File::ensureDirectoryExists(dirname($file));
@@ -253,8 +284,20 @@ class ApiDocgenCommand extends Command
}
if ($this->option('check')) {
// 실측 제외는 정상 동작이므로 참고 정보로만 표시한다 (drift 아님).
if ($skippedProbes !== []) {
$this->line('실측 제외 '.count($skippedProbes).'건 (정상 — 해당 자리는 사람이 작성):');
foreach (array_slice($skippedProbes, 0, 10) as $s) {
$this->line(' · '.$s);
}
if (count($skippedProbes) > 10) {
$this->line(' · ... 외 '.(count($skippedProbes) - 10).'건');
}
$this->newLine();
}
if ($checkFindings !== []) {
$this->warn('실측 제외/문서 누락 '.count($checkFindings).'건:');
$this->warn('drift '.count($checkFindings).'건:');
foreach (array_slice($checkFindings, 0, 50) as $f) {
$this->line(' - '.$f);
}
@@ -899,6 +942,92 @@ class ApiDocgenCommand extends Command
return base_path("{$base}/_bundled/{$owner['id']}/docs/api/README.md");
}
/**
* 문서에 남아 있는 미채움 마커를 수집합니다.
*
* `api:docgen` 은 실측 불가한 자리에 "사람이 작성하세요" 마커를 남긴다. 실측 불가는 그 자리를
* 비워둘 사유가 아니며, 컨트롤러/Resource/FormRequest/Enum/lang 을 읽어 채우는 것이 규정이다
* (docs/backend/api-documentation.md "미채움 마커 5종"). 마커가 남아 있으면 문서는 미완이다.
*
* 잔량 전수 집계는 `check-api-doc-unfilled.cjs` 가 담당하고, 여기서는 `--check` 가 자기 scope
* 문서의 미완을 drift 로 잡기 위해 마커 종류만 요약한다.
*
* @param string $content 문서 내용
* @return array<int, string> 발견된 마커 요약 (종류 => 건수)
*/
private function unfilledMarkers(string $content): array
{
$markers = [
'실측 제외:' => '실측 제외 (응답 필드 표 / 응답 예시)',
'TODO: 용도' => '요청 파라미터 설명',
'TODO: 설명' => '응답 필드 설명',
'실측 응답에 필드 없음' => '빈 목록 관측 (응답 필드 표)',
'대표 에러 없음' => '도메인 특이 에러',
];
$found = [];
foreach ($markers as $needle => $label) {
$count = substr_count($content, $needle);
if ($count > 0) {
$found[] = "{$label} {$count}건";
}
}
return $found;
}
/**
* 기존 문서의 중복 생성 블록 키를 메서드 스코프 키로 승격합니다.
*
* `Route::match(['get','post'], ...)->name('x')` 로 등록된 라우트는 한 문서에 같은
* `@generated:start:x` 블록을 둘 만든다. 새 키(`x::get` / `x::post`)로 재생성하면 구 키를
* 가진 기존 블록과 대조되지 않아 그 블록의 사람 서술이 유실되므로, 병합 전에 구 키를
* 등장 순서대로 새 키로 바꿔 준다.
*
* 문서 내 블록 순서는 `$items` 순서와 같다(같은 그룹을 같은 순서로 순회해 생성했다).
* 따라서 n 번째 구 키 블록에는 그 라우트명을 가진 n 번째 라우트의 메서드를 붙인다.
*
* @param string $existing 기존 문서 내용
* @param array<int, array<string, mixed>> $items 이 문서의 라우트 목록 (문서 순서)
* @param array<int, string> $duplicatedNames 문서 안에서 중복되는 라우트명
* @param ApiDocScaffolder $scaffolder 키 생성기
* @return string 키가 승격된 문서 내용
*/
private function migrateDuplicateBlockKeys(
string $existing,
array $items,
array $duplicatedNames,
ApiDocScaffolder $scaffolder
): string {
foreach ($duplicatedNames as $name) {
$methods = array_values(array_map(
static fn (array $r): string => $r['method'],
array_filter($items, static fn (array $r): bool => ($r['name'] ?: $r['uri']) === $name),
));
$marker = '<!-- @generated:start:'.$name.' -->';
$offset = 0;
foreach ($methods as $method) {
$pos = strpos($existing, $marker, $offset);
if ($pos === false) {
break;
}
$newKey = $scaffolder->generatedKey(['name' => $name, 'uri' => $name, 'method' => $method], true);
$newMarker = '<!-- @generated:start:'.$newKey.' -->';
$existing = substr_replace($existing, $newMarker, $pos, strlen($marker));
$offset = $pos + strlen($newMarker);
}
}
return $existing;
}
/**
* 문서 헤더(제목 + TL;DR)를 생성합니다.
*
@@ -923,7 +1052,7 @@ class ApiDocgenCommand extends Command
```text
1. 이 문서는 실제 API 호출로 실측한 {$domain} 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(raw HTTP) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
@@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Services\SettingsService;
use App\Support\AssetUrl;
use Illuminate\Console\Command;
/**
* 자산 URL 모드 조회/전환 Artisan 커맨드 (이슈 #486 §9)
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 동적 응답을 가로채는 서버에서는
* **관리자 화면 자체가 뜨지 않는다**. "브라우저로 설정을 바꾸세요" 는 순환 참조이므로
* CLI 탈출구가 반드시 필요하다.
*
* ```
* php artisan g7:asset-url-mode # 현재 모드 + 진단 안내
* php artisan g7:asset-url-mode extensionless # 전환
* ```
*/
class AssetUrlModeCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'g7:asset-url-mode
{mode? : 전환할 모드 (extension | extensionless). 생략 시 현재 모드만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = '자산 URL 모드를 조회하거나 전환합니다 (정적 최적화 서버 대응)';
/**
* 커맨드를 실행합니다.
*
* @param SettingsService $settingsService 환경설정 서비스
* @param SeoCacheManagerInterface $seoCacheManager SEO 캐시 매니저
* @return int 종료 코드
*/
public function handle(SettingsService $settingsService, SeoCacheManagerInterface $seoCacheManager): int
{
$current = AssetUrl::mode();
$requested = $this->argument('mode');
if ($requested === null) {
$this->showStatus($current);
return Command::SUCCESS;
}
if (! in_array($requested, [AssetUrl::MODE_EXTENSION, AssetUrl::MODE_EXTENSIONLESS], true)) {
$this->error(__('asset_url_mode.invalid', ['mode' => $requested]));
return Command::FAILURE;
}
if ($requested === $current) {
$this->info(__('asset_url_mode.already', ['mode' => $current]));
return Command::SUCCESS;
}
$saved = $settingsService->saveSettings([
'_tab' => 'general',
'general' => ['asset_url_mode' => $requested],
]);
if (! $saved) {
$this->error(__('asset_url_mode.save_failed'));
return Command::FAILURE;
}
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
// 모드를 바꾸면 그 URL 들이 전부 어긋나므로 함께 비운다 (계획서 §알려진 한계).
$seoCacheManager->clearAll();
$this->info(__('asset_url_mode.switched', ['from' => $current, 'to' => $requested]));
$this->line(__('asset_url_mode.seo_cache_cleared'));
return Command::SUCCESS;
}
/**
* 현재 모드와 진단 안내를 출력합니다.
*
* @param string $current 현재 모드
*/
private function showStatus(string $current): void
{
$this->line('');
$this->line(' '.__('asset_url_mode.current', ['mode' => $current]));
$this->line('');
// audit:allow asset-url-builder-required reason: 두 모드의 URL 형태를 나란히
// 보여주는 대조표라 빌더로 생성할 수 없다 (빌더는 현재 모드 하나만 만든다).
$rows = $current === AssetUrl::MODE_EXTENSION
? [
['/api/templates/{id}/routes.json', '/api/templates/{id}/routes'],
['/api/modules/bundle.js', '/api/modules/bundle/js'],
['/api/templates/assets/{id}/js/a.js', '/api/templates/assets/{id}?file=js/a.js'],
]
: [
['/api/templates/{id}/routes', '/api/templates/{id}/routes.json'],
['/api/modules/bundle/js', '/api/modules/bundle.js'],
['/api/templates/assets/{id}?file=js/a.js', '/api/templates/assets/{id}/js/a.js'],
];
$this->table(
[__('asset_url_mode.table.in_use'), __('asset_url_mode.table.alternative')],
$rows,
);
$this->line(' '.__('asset_url_mode.diagnose_title'));
$this->line(' curl -i '.rtrim(config('app.url'), '/').'/api/system/asset-probe.js');
$this->line(' curl -i '.rtrim(config('app.url'), '/').'/api/system/asset-probe');
$this->line('');
$this->line(' '.__('asset_url_mode.diagnose_hint'));
$this->line('');
$this->line(' '.__('asset_url_mode.switch_hint'));
$this->line('');
}
}
+395
View File
@@ -0,0 +1,395 @@
<?php
namespace App\Console\Commands;
use App\Benchmark\BenchmarkProfileRegistry;
use App\Benchmark\BenchmarkReporter;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 성능 계측 커맨드 — 목록 조회 / 화면 응답 / 쓰기 작업 / 배치 작업 4축
*
* 계측 대상은 소유자가 선언합니다 — 코어는 `config/benchmark.php`, 확장은
* `getBenchmarkProfiles()` 오버라이드입니다. 커맨드에 대상을 하드코딩하지 않는 이유는,
* 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 때문입니다.
*
* 축별 실행은 `App\Benchmark\Axes\*` 실행기가 담당하고, 이 커맨드는 프로파일 선택·옵션
* 해석·출력만 합니다.
*
* tinker 스크립트 대신 커맨드로 만든 이유: tinker 는 REPL 이라 스크립트 실행 후에도 STDIN 을
* 기다려 출력이 유실된다.
*/
class BenchCommand extends Command
{
protected $signature = 'g7:bench
{--profile=* : 계측할 프로파일 키 (짧은 키 또는 출처/키 형태). 다중 지정 가능}
{--axis= : 축 단위 실행 (list|screen|write|batch)}
{--all : 등록된 모든 프로파일 실행}
{--offsets=0,20000,50000,99980 : 측정할 OFFSET 목록 (쉼표 구분, list 축)}
{--runs=3 : 측정 횟수 (첫 회는 버림)}
{--per-page=20 : 목록/화면 1페이지 건수}
{--seed=0 : 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)}
{--fresh : 시딩 전에 대상 테이블을 비움 (운영 환경에서는 거부)}
{--explain : 각 OFFSET 의 실행 계획도 함께 수집 (list 축)}
{--as= : 화면 계측에 사용할 기존 계정 (ID 또는 이메일). 미지정 시 계측용 임시 계정}
{--allow-write : 데이터를 변경하는 축(write/batch/비-GET 화면) 실행 허용}
{--database= : 계측에 사용할 데이터베이스명 (미지정 시 기본 연결. 개발 DB 오염 방지용)}
{--json : 기계 판독용 JSON 출력}
{--report= : 마크다운 리포트 저장 경로 (값 없이 --report 만 주면 storage/app/benchmarks/ 아래 자동 생성)}
{--list-profiles : 등록된 프로파일 목록 출력}';
protected $description = '목록 조회·화면 응답·쓰기·배치 4축 성능을 계측합니다 (대상은 코어 config + 확장 선언에서 수집)';
protected $aliases = ['g7:bench:pagination'];
/**
* 리포트 기본 저장 디렉토리 (저장소 미추적 — 계측값은 실행 머신 사양에 종속되므로 축적하지 않음)
*/
private const REPORT_DIR = 'app/benchmarks';
/**
* @param BenchmarkProfileRegistry $registry 프로파일 레지스트리
* @param BenchmarkReporter $reporter 결과 출력기
* @param iterable<BenchmarkAxisRunner> $runners 축 실행기 목록 (컨테이너 태그 주입)
*/
public function __construct(
private readonly BenchmarkProfileRegistry $registry,
private readonly BenchmarkReporter $reporter,
private readonly iterable $runners,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$this->applyDatabaseOverride();
if ($this->option('list-profiles')) {
return $this->listProfiles();
}
[$profiles, $selectionError] = $this->selectProfiles();
if ($selectionError !== null) {
$this->error($selectionError);
return self::FAILURE;
}
if ($profiles === []) {
$this->error('계측할 프로파일이 없습니다. --profile / --axis / --all 중 하나를 지정하세요.');
return self::FAILURE;
}
$options = $this->runOptions();
// 계측 결과가 환경에 좌우되므로 실행 환경을 먼저 드러낸다
if (! $this->option('json')) {
foreach ($this->reporter->environment() as $name => $value) {
$this->line(sprintf(' %-14s %s', $name, $value));
}
$this->newLine();
}
$this->reportCollectionWarnings();
$results = [];
foreach ($profiles as $profile) {
$results[] = $this->runProfile($profile, $options);
}
return $this->output($results, $options);
}
/**
* 계측용 데이터베이스 지정을 반영합니다.
*
* 계측은 대량 시딩을 동반하므로 개발 DB 대신 폐기 가능한 DB 를 지정할 수 있어야 합니다.
* config 가 캐시된 환경에서는 `.env` / `--env` 로 연결을 바꿀 수 없어 런타임 치환이
* 유일한 수단입니다.
*/
private function applyDatabaseOverride(): void
{
$database = (string) $this->option('database');
if ($database === '') {
return;
}
$connection = config('database.default');
config([
"database.connections.{$connection}.database" => $database,
"database.connections.{$connection}.write.database" => $database,
"database.connections.{$connection}.read.database" => $database,
]);
DB::purge($connection);
}
/**
* 등록된 프로파일 목록을 출력합니다.
*
* @return int 종료 코드
*/
private function listProfiles(): int
{
$profiles = $this->registry->all();
if ($this->option('json')) {
$this->line((string) json_encode(
array_map(fn (BenchmarkProfile $profile) => $profile->toArray(), array_values($profiles)),
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
));
return self::SUCCESS;
}
$this->table(
['프로파일', '축', '출처', '설명'],
array_map(fn (BenchmarkProfile $profile) => [
$profile->qualifiedKey(),
$profile->axis->value,
$profile->sourceKind,
$profile->label ?? '-',
], array_values($profiles))
);
$this->reportCollectionWarnings();
return self::SUCCESS;
}
/**
* 실행할 프로파일을 고릅니다.
*
* @return array{0: array<int, BenchmarkProfile>, 1: string|null} [실행 대상, 실패 사유]
*/
private function selectProfiles(): array
{
$keys = array_filter((array) $this->option('profile'), 'strlen');
if ($keys !== []) {
$selected = [];
foreach ($keys as $key) {
[$profile, $error] = $this->registry->resolve((string) $key);
if ($error !== null) {
return [[], $error];
}
$selected[] = $profile;
}
return [$selected, null];
}
$axisOption = (string) $this->option('axis');
if ($axisOption !== '') {
$axis = BenchmarkAxis::tryFrom($axisOption);
if ($axis === null) {
return [[], "알 수 없는 축: {$axisOption} (허용: ".implode('|', BenchmarkAxis::values()).')'];
}
return [array_values($this->registry->byAxis($axis)), null];
}
return [$this->option('all') ? array_values($this->registry->all()) : [], null];
}
/**
* 커맨드 옵션을 실행 옵션으로 해석합니다.
*
* @return BenchmarkRunOptions 실행 옵션
*/
private function runOptions(): BenchmarkRunOptions
{
$asUser = (string) $this->option('as');
return new BenchmarkRunOptions(
offsets: $this->parseOffsets(),
runs: max(1, (int) $this->option('runs')),
seed: max(0, (int) $this->option('seed')),
fresh: (bool) $this->option('fresh'),
explain: (bool) $this->option('explain'),
allowWrite: (bool) $this->option('allow-write'),
asUser: $asUser === '' ? null : $asUser,
perPage: max(1, (int) $this->option('per-page')),
);
}
/**
* OFFSET 목록을 파싱합니다.
*
* @return array<int, int> 오름차순 정렬된 OFFSET 목록
*/
private function parseOffsets(): array
{
$offsets = array_map('intval', array_filter(explode(',', (string) $this->option('offsets')), 'strlen'));
sort($offsets);
return $offsets === [] ? [0] : $offsets;
}
/**
* 프로파일 1건을 계측합니다.
*
* @param BenchmarkProfile $profile 대상 프로파일
* @param BenchmarkRunOptions $options 실행 옵션
* @return BenchmarkResult 계측 결과
*/
private function runProfile(BenchmarkProfile $profile, BenchmarkRunOptions $options): BenchmarkResult
{
$runner = $this->runnerFor($profile->axis);
if ($runner === null) {
return BenchmarkResult::skipped($profile, "축 실행기가 없습니다: {$profile->axis->value}");
}
if (! $this->option('json')) {
$this->line(sprintf('▶ %s (%s)', $profile->qualifiedKey(), $profile->axis->label()));
}
$onProgress = $this->option('json')
? null
: fn (string $message) => $this->line(' '.$message);
return $runner->run($profile, $options, $onProgress);
}
/**
* 축에 대응하는 실행기를 찾습니다.
*
* @param BenchmarkAxis $axis 대상 축
* @return BenchmarkAxisRunner|null 실행기 (없으면 null)
*/
private function runnerFor(BenchmarkAxis $axis): ?BenchmarkAxisRunner
{
foreach ($this->runners as $runner) {
if ($runner->axis() === $axis) {
return $runner;
}
}
return null;
}
/**
* 무시된 프로파일 선언을 알립니다.
*
* 선언 오류를 조용히 버리면 그 대상이 계측 사각으로 남으므로 매 실행에 드러냅니다.
*/
private function reportCollectionWarnings(): void
{
if ($this->option('json')) {
return;
}
foreach ($this->registry->warnings() as $warning) {
$this->warn('무시된 프로파일 선언 — '.$warning);
}
}
/**
* 계측 결과를 출력합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @return int 종료 코드
*/
private function output(array $results, BenchmarkRunOptions $options): int
{
$warnings = $this->registry->warnings();
if ($this->option('json')) {
$this->line($this->reporter->toJson($results, $options, $warnings));
} else {
foreach ($results as $result) {
$this->newLine();
$this->line(sprintf('%s (%s)', $result->profile->qualifiedKey(), $result->profile->axis->label()));
if ($result->skipped) {
$this->warn(' 측정하지 않음: '.$result->skipReason);
continue;
}
$this->table($result->headers, $result->rows);
foreach ($result->notes as $note) {
$this->line(' '.$note);
}
}
}
if ($this->wantsReport()) {
$path = $this->writeReport($results, $options, $warnings);
$this->newLine();
$this->info('리포트 저장: '.$path);
}
// 전부 건너뛴 실행을 성공으로 보고하면 "측정했다"로 읽히므로 실패로 돌려준다
$measured = array_filter($results, fn (BenchmarkResult $result) => ! $result->skipped);
return $measured === [] ? self::FAILURE : self::SUCCESS;
}
/**
* 리포트 출력이 요청되었는지 판정합니다.
*
* `--report` 는 값이 선택이라 `option()` 만으로는 "미지정"과 "값 없이 지정"을
* 구분할 수 없으므로 원시 입력을 확인합니다.
*
* @return bool 리포트 출력 여부
*/
private function wantsReport(): bool
{
return $this->input->hasParameterOption('--report')
|| $this->input->hasParameterOption('--report=')
|| (string) $this->option('report') !== '';
}
/**
* 마크다운 리포트를 저장합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string 저장된 절대 경로
*/
private function writeReport(array $results, BenchmarkRunOptions $options, array $warnings): string
{
$given = (string) $this->option('report');
$path = $given !== ''
? $given
: storage_path(self::REPORT_DIR.'/bench-'.now()->format('Ymd-His').'.md');
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
file_put_contents($path, $this->reporter->toMarkdown($results, $options, $warnings));
return $path;
}
}
@@ -109,7 +109,7 @@ class ConvertJsonUnicodeEscapes extends Command
/**
* 테이블 존재 여부를 확인합니다.
*
* @param string $table 테이블명 (프리픽스 포함)
* @param string $table 테이블명 (프리픽스 포함)
* @return bool 존재 여부
*/
private function tableExists(string $table): bool
@@ -126,10 +126,10 @@ class ConvertJsonUnicodeEscapes extends Command
/**
* 특정 테이블의 특정 컬럼을 변환합니다.
*
* @param string $table 테이블명
* @param string $column 컬럼명
* @param int $chunkSize 청크 크기
* @param bool $dryRun 드라이런 모드
* @param string $table 테이블명
* @param string $column 컬럼명
* @param int $chunkSize 청크 크기
* @param bool $dryRun 드라이런 모드
* @return array{int, int} [변환 건수, 스킵 건수]
*/
private function processColumn(string $table, string $column, int $chunkSize, bool $dryRun): array
@@ -152,11 +152,13 @@ class ConvertJsonUnicodeEscapes extends Command
$this->components->twoColumnDetail("{$table}.{$column}", "대상 {$total}건 처리 중...");
// chunkById(키셋 순회) 필수 — 콜백이 필터 조건 컬럼을 UTF-8 로 다시 써서 처리된
// 행이 `LIKE '%\u%'` 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려
// 아직 변환되지 않은 행을 건너뛴다. (대상 테이블 8종 모두 `id` auto-increment PK)
DB::table($table)
->whereNotNull($column)
->where($column, 'LIKE', '%\\\\u%')
->orderBy('id')
->chunk($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
->chunkById($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
foreach ($rows as $row) {
$original = $row->{$column};
$decoded = json_decode($original, true);
@@ -7,6 +7,7 @@ use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Search\SearchIndexMaintenanceManager;
use App\Services\CoreUpdateService;
use App\Services\LanguagePackService;
use Illuminate\Support\Facades\File;
@@ -109,6 +110,10 @@ trait BundledExtensionUpdatePrompt
// 확장별 전략 오버라이드
$strategies = $this->collectPerExtensionStrategies($updates, $globalStrategy, $force);
// 검색 인덱스 재생성 여부 — 인덱스 잠금·재색인 비용이 있어 운영 중인 사이트에서는
// 기본값(아니오)을 유지하고 유지보수 시간에 별도로 수행하는 것이 안전하다.
$rebuildSearchIndex = $this->askRebuildSearchIndex($force);
// 일괄 실행
return $this->executeBulkUpdate(
$moduleManager,
@@ -116,6 +121,7 @@ trait BundledExtensionUpdatePrompt
$templateManager,
$updates,
$strategies,
$rebuildSearchIndex,
);
}
@@ -200,8 +206,11 @@ trait BundledExtensionUpdatePrompt
TemplateManager $templateManager,
array $updates,
array $strategies,
bool $rebuildSearchIndex = false,
): array {
$manifest = $this->buildBundledUpdateManifest($updates, $strategies);
// 운영자의 선택을 매니페스트에 실어 spawn 자식과 in-process fallback 이 같은 결정을 따르게 한다
$manifest['rebuild_search_index'] = $rebuildSearchIndex;
if ($this->bundledManifestIsEmpty($manifest)) {
return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
}
@@ -228,6 +237,7 @@ trait BundledExtensionUpdatePrompt
$templateManager,
$updates,
$strategies,
$rebuildSearchIndex,
);
}
@@ -385,6 +395,7 @@ trait BundledExtensionUpdatePrompt
TemplateManager $templateManager,
array $updates,
array $strategies,
bool $rebuildSearchIndex = false,
): array {
$success = 0;
$failed = 0;
@@ -452,6 +463,8 @@ trait BundledExtensionUpdatePrompt
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
$this->runSearchIndexRebuildIfRequested($rebuildSearchIndex);
return [
'success' => $success,
'failed' => $failed,
@@ -460,4 +473,60 @@ trait BundledExtensionUpdatePrompt
];
}
/**
* 검색 인덱스 재생성 여부를 운영자에게 확인합니다.
*
* 재생성은 인덱스 잠금 또는 전체 재색인을 유발하므로 기본값은 "아니오" 입니다.
* `--force` (무인 실행) 시에도 묻지 않고 수행하지 않습니다 — 무인 실행이
* 대용량 테이블을 잠그는 일이 없어야 합니다. 필요하면 `core:update
* --rebuild-search-index` 로 명시하거나 이후 `search:index --repair` 를 실행합니다.
*
* @param bool $force 강제(무인) 실행 여부
* @return bool 재생성 수행 여부
*/
private function askRebuildSearchIndex(bool $force): bool
{
// 커맨드가 옵션으로 이미 선택을 받았으면 그대로 따른다
if ($this->hasOption('rebuild-search-index') && (bool) $this->option('rebuild-search-index')) {
return true;
}
if ($force) {
return false;
}
$manager = app(SearchIndexMaintenanceManager::class);
// 점검을 제공하지 않는 엔진에서는 물을 것이 없다
if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) {
return false;
}
$this->newLine();
$this->line(' '.__('search.index.rebuild_cost_warning'));
return $this->unifiedConfirm(__('search.index.rebuild_after_bulk_confirm'), false);
}
/**
* 요청이 있었으면 검색 인덱스를 재생성합니다.
*
* @param bool $requested 재생성 요청 여부
* @return void
*/
private function runSearchIndexRebuildIfRequested(bool $requested): void
{
if (! $requested) {
return;
}
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
$this->newLine();
$this->info('검색 인덱스: '.$report->summary());
foreach ($report->failed as $identifier => $message) {
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
}
}
@@ -17,6 +17,7 @@ use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -34,7 +35,8 @@ class CoreUpdateCommand extends Command
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
{--zip= : 수동 업데이트용 ZIP 파일 경로 (GitHub 다운로드 대신 지정 ZIP 추출 사용)}
{--vendor-mode=auto : vendor 설치 모드 (auto|composer|bundled)}';
{--vendor-mode=auto : vendor 설치 모드 (auto|composer|bundled)}
{--rebuild-search-index : 번들 확장 일괄 업데이트 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
protected $description = '그누보드7 코어를 최신 버전으로 업데이트합니다';
@@ -542,6 +544,11 @@ class CoreUpdateCommand extends Command
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
// 라우트 캐시도 같은 이유로 되살린다. 코어 업데이트는 routes/*.php 와 vendor 를
// 교체하므로 흐름 중간에 비우는 것이 맞지만, 비운 채로 끝내면 이후 모든 요청이
// 라우트를 다시 등록한다. 재생성은 파일이 전부 안착한 이 지점에서만 안전하다.
RouteCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
@@ -2,10 +2,12 @@
namespace App\Console\Commands\Core;
use App\Extension\ExtensionManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Search\SearchIndexMaintenanceManager;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@@ -45,7 +47,8 @@ class ExecuteBundledUpdatesCommand extends Command
protected $signature = 'core:execute-bundled-updates
{--manifest= : 업데이트 매니페스트 JSON 파일 경로 (필수)}
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}';
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}
{--rebuild-search-index : 일괄 업데이트 후 검색 인덱스 재생성 (매니페스트의 선택보다 우선)}';
protected $description = '번들 확장 일괄 업데이트를 실행합니다 (CoreUpdateCommand 내부용 — fresh PHP 프로세스에서 호출)';
@@ -63,7 +66,7 @@ class ExecuteBundledUpdatesCommand extends Command
// (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
// output buffer 를 덮어쓰는 Laravel 동작 회피)
try {
app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
app(ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
@@ -77,6 +80,9 @@ class ExecuteBundledUpdatesCommand extends Command
return self::FAILURE;
}
// 부모(core:update)가 운영자 선택을 매니페스트에 실어 보낸다 — 자식이 임의로 정하지 않는다
$rebuildSearchIndex = (bool) $this->option('rebuild-search-index') || (bool) ($manifest['rebuild_search_index'] ?? false);
$modules = $manifest['modules'] ?? [];
$plugins = $manifest['plugins'] ?? [];
$templates = $manifest['templates'] ?? [];
@@ -166,6 +172,17 @@ class ExecuteBundledUpdatesCommand extends Command
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
// 검색 인덱스 재생성 — 운영자가 부모 단계에서 선택했을 때만 수행 (인덱스 잠금·재색인 비용)
if ($rebuildSearchIndex) {
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
$this->newLine();
$this->info('검색 인덱스: '.$report->summary());
foreach ($report->failed as $identifier => $message) {
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
}
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
$this->line(self::RESULT_PREFIX.json_encode([
'success' => $success,
@@ -174,5 +191,4 @@ class ExecuteBundledUpdatesCommand extends Command
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -12,6 +12,8 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -251,7 +253,10 @@ class ExecuteUpgradeStepsCommand extends Command
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
\App\Support\ConfigCacheHelper::rebuild();
ConfigCacheHelper::rebuild();
// 라우트 캐시도 같은 이유로 되살린다 — 업그레이드 스텝이나 번들 확장 업데이트가
// 라우트를 바꿨을 수 있고, 비운 채로 끝내면 이후 모든 요청이 라우트를 재등록한다.
RouteCacheHelper::rebuild();
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
@@ -41,6 +41,12 @@ class UpdateAutoloadCommand extends Command
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
$this->line(' → bootstrap/cache/autoload-extensions.php');
// 방금 생성한 PSR-4 매핑을 현재 프로세스의 ClassLoader 에 즉시 반영.
// 이 커맨드는 매핑이 깨진 상태를 복구하는 경로이기도 한데, 재등록 없이 아래
// loadModules/loadPlugins 를 호출하면 부팅 시점의 (깨진) 매핑으로 확장 클래스를
// 찾지 못해 훅 캐시가 modules/plugins 0건으로 생성된다 — 모든 확장 훅이 조용히 소실.
$extensionManager->reregisterRuntimeAutoload();
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
@@ -2,13 +2,16 @@
namespace App\Console\Commands\LanguagePack;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Models\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
/**
* 설치된 언어팩 목록을 출력합니다.
* 언어팩 목록을 출력합니다.
*
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구.
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구. Service::list() 를 경유하므로
* 설치된 DB 행뿐 아니라 "번들에 있으나 미설치"(uninstalled) 및 "active 로 기록됐으나
* 설치본 파일 부재(드리프트)" 상태를 함께 표면화한다(이슈 #496).
*/
class ListLanguagePackCommand extends Command
{
@@ -20,13 +23,13 @@ class ListLanguagePackCommand extends Command
/**
* @var string
*/
protected $description = '설치된 언어팩 목록을 출력합니다.';
protected $description = '언어팩 목록을 출력합니다 (미설치 번들 및 드리프트 상태 포함).';
/**
* @param LanguagePackRepositoryInterface $repository Repository
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackRepositoryInterface $repository,
private readonly LanguagePackService $service,
) {
parent::__construct();
}
@@ -43,11 +46,11 @@ class ListLanguagePackCommand extends Command
$filters['scope'] = $scope;
}
$paginator = $this->repository->paginate($filters, 100);
$paginator = $this->service->list($filters, 100);
$packs = $paginator->items();
if (empty($packs)) {
$this->info('설치된 언어팩이 없습니다.');
$this->info('언어팩이 없습니다.');
return self::SUCCESS;
}
@@ -61,7 +64,7 @@ class ListLanguagePackCommand extends Command
$pack->locale,
$pack->vendor,
$pack->version,
$pack->status,
$this->formatStatus($pack),
];
}
@@ -72,4 +75,19 @@ class ListLanguagePackCommand extends Command
return self::SUCCESS;
}
/**
* 표시용 상태 문자열을 만듭니다. 드리프트(active + 설치본 부재)는 명시적으로 표기합니다.
*
* @param LanguagePack $pack 대상 팩
* @return string 표시 상태
*/
private function formatStatus(LanguagePack $pack): string
{
if ((bool) $pack->getAttribute('files_missing')) {
return $pack->status.' (파일 없음)';
}
return (string) $pack->status;
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Models\LanguagePack;
use App\Services\LanguagePack\LanguagePackBaseLocales;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 정책(supported_locales) 기준으로 미설치 번들 언어팩을 멱등 설치(프로비저닝)합니다.
*
* fresh install / 시더 / 수동 복구가 공유하는 단일 프로비저닝 SSoT 입니다.
* base locale(ko/en)은 가상 보호 행으로 항상 서빙되므로 대상에서 제외되고,
* 나머지 로케일(예: ja)은 `lang-packs/_bundled/` 에 소스가 있어도 설치본 디렉토리로
* 복사·등록되어야 서빙되므로 이 커맨드가 그 프로비저닝을 담당합니다.
*
* 대상은 두 갈래입니다 — 미설치 번들 팩(신규 프로비저닝)과, 설치 행은 있으나 설치본 파일이
* 사라진 드리프트 팩(수동 복구). 후자는 슬롯이 점유돼 있어 `getUninstalledBundledPacks()` 가
* 잡지 못하므로 `getDriftedInstalledPacks()` 로 따로 모읍니다. 이 커맨드가 복구 경로를 겸하지 않으면
* 드리프트는 화면에서 보이기만 하고 CLI 로는 고칠 수 없습니다.
*
* 멱등성: 정상 설치된 팩은 두 집합 어디에도 속하지 않으므로 재실행 시 신규 설치 0 건으로 수렴합니다.
*/
class ProvisionLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:provision
{--locale=* : 대상 로케일(미지정 시 supported_locales 에서 base locale 제외)}
{--scope=* : core/module/plugin/template 로 대상 스코프 한정}
{--no-activate : 설치 후 자동 활성화하지 않음}';
/**
* @var string
*/
protected $description = '정책(supported_locales) 기준 미설치 번들 언어팩을 멱등 설치합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$targetLocales = $this->resolveTargetLocales();
if (empty($targetLocales)) {
$this->info('프로비저닝 대상 로케일이 없습니다 (base locale 외 supported_locales 미선언).');
return self::SUCCESS;
}
$scopeFilter = array_values(array_filter(array_map('strval', (array) $this->option('scope'))));
$autoActivate = ! (bool) $this->option('no-activate');
$inScope = fn (LanguagePack $pack) => in_array($pack->locale, $targetLocales, true)
&& (empty($scopeFilter) || in_array($pack->scope, $scopeFilter, true));
// 미설치 번들(신규 프로비저닝) + 드리프트 설치 행(수동 복구) 이 모두 대상이다.
// 드리프트 행은 슬롯이 점유돼 있어 getUninstalledBundledPacks() 가 잡지 못하므로 따로 합친다.
$candidates = $this->service->getUninstalledBundledPacks()->filter($inScope)
->concat($this->service->getDriftedInstalledPacks()->filter($inScope))
->values();
if ($candidates->isEmpty()) {
$this->info(sprintf(
'설치하거나 복구할 번들 언어팩이 없습니다 (로케일: %s). 이미 프로비저닝된 상태입니다.',
implode(', ', $targetLocales),
));
return self::SUCCESS;
}
$installed = 0;
$repaired = 0;
$skipped = 0;
$failed = 0;
foreach ($candidates as $pack) {
$identifier = (string) ($pack->getAttribute('bundled_identifier') ?? $pack->identifier);
$blockedReason = $pack->getAttribute('install_blocked_reason');
// 대상 확장 미설치/미활성 등으로 설치가 불가한 팩은 best-effort 로 건너뛴다.
if ($blockedReason !== null && $blockedReason !== '') {
$this->warn(sprintf('건너뜀 (설치 불가: %s): %s', $blockedReason, $identifier));
$skipped++;
continue;
}
try {
$isRepair = (bool) $pack->getAttribute('files_missing');
$result = $this->service->installFromBundled($identifier, $autoActivate);
$this->info(sprintf(
'%s: %s v%s (status=%s)',
$isRepair ? '복구' : '설치',
$result->identifier,
$result->version,
$result->status,
));
$isRepair ? $repaired++ : $installed++;
} catch (Throwable $e) {
$this->warn(sprintf('실패 (건너뜀): %s — %s', $identifier, $e->getMessage()));
$failed++;
}
}
$this->info(sprintf(
'프로비저닝 완료 — 설치 %d, 복구 %d, 건너뜀 %d, 실패 %d',
$installed,
$repaired,
$skipped,
$failed,
));
// 성과가 하나도 없고 실패만 있었다면 실패로 보고 (멱등 재실행/전량 스킵은 성공).
return $installed === 0 && $repaired === 0 && $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* 프로비저닝 대상 로케일을 해석합니다.
*
* `--locale` 지정 시 그 값, 미지정 시 `config('app.supported_locales')` 에서 base locale(ko/en)을 제외한 집합.
*
* @return array<int, string> 대상 로케일 목록 (base locale 제외)
*/
private function resolveTargetLocales(): array
{
$explicit = array_values(array_filter(array_map('strval', (array) $this->option('locale'))));
$source = ! empty($explicit)
? $explicit
: array_map('strval', (array) config('app.supported_locales', []));
return array_values(array_unique(array_filter(
$source,
fn (string $locale) => $locale !== '' && ! LanguagePackBaseLocales::isBaseLocale($locale),
)));
}
}
@@ -116,7 +116,7 @@ class CheckModuleUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckModuleUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('modules.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Validator;
class InstallModuleCommand extends Command
{
use HasProgressBar;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -24,7 +26,8 @@ class InstallModuleCommand extends Command
protected $signature = 'module:install
{identifier : 설치할 모듈 식별자}
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -114,6 +117,9 @@ class InstallModuleCommand extends Command
Log::info(__('modules.commands.install.success', ['module' => $identifier]));
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ModuleManager;
@@ -16,6 +17,7 @@ class UpdateModuleCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -26,7 +28,8 @@ class UpdateModuleCommand extends Command
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 module.json 기준)}';
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 module.json 기준)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -203,6 +206,9 @@ class UpdateModuleCommand extends Command
'layout_strategy' => $layoutStrategy,
]);
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
+103 -21
View File
@@ -37,10 +37,15 @@ use Illuminate\Support\Facades\Config;
class PlaywrightIssueToken extends Command
{
protected $signature = 'playwright:issue-token
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}';
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}
{--no-admin-role : admin 역할을 부여하지 않고 지정한 권한만 가진 계정을 만든다 (권한 분기 검증용)}
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 테스트 유저/역할을 발급 전 정리. 0 이면 정리 안 함}';
protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)';
/** 테스트 전용 역할 식별자 접두사 */
private const TEST_ROLE_PREFIX = 'playwright_test_';
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
@@ -61,9 +66,20 @@ class PlaywrightIssueToken extends Command
// SettingsServiceProvider::applyDebugConfig 는 bypass flag 가 있으면 settings JSON 덮어쓰기를 이미 건너뛴 상태.
Config::set('app.debug', true);
// 발급 전 오래된 테스트 아티팩트 정리 — 이 커맨드는 호출마다 유저 1명 + 역할 1개를
// 새로 만들지만 회수 주체가 없어 그대로 누적된다. 정리하지 않으면 회원/역할 관리 화면이
// 테스트 잔재로 뒤덮인다(실측: 발급 2974회 → 유저 2974명·역할 2974개 잔존).
// 시간 임계값을 두어 동시 실행 중인 다른 워커의 계정은 건드리지 않는다.
$gcHours = (int) $this->option('gc-hours');
if ($gcHours > 0) {
$this->pruneStaleTestArtifacts($gcHours);
}
$permissions = $this->option('permissions') ?: [];
$user = $this->makeAdminUser($permissions);
// --no-admin-role: 지정한 권한만 가진 계정을 만든다.
// 기본값(플래그 없음)은 종전대로 admin 역할을 함께 부여한다 — 기존 spec 무영향.
$user = $this->makeAdminUser($permissions, ! $this->option('no-admin-role'));
$token = $user->createToken('playwright-'.uniqid())->plainTextToken;
$this->line($token);
@@ -71,6 +87,54 @@ class PlaywrightIssueToken extends Command
return self::SUCCESS;
}
/**
* 임계 시간보다 오래된 playwright 테스트 유저/역할을 정리한다.
*
* 대상: `playwright_test_*` 역할과, 그 역할이 유일한 확장 역할인 유저(= 이 커맨드가
* factory 로 만든 계정). 실제 회원과 구분되도록 반드시 역할 소유 관계로만 판별한다.
*
* chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반 chunk()/each()
* 는 다음 페이지가 줄어든 결과 집합을 지나쳐 일부를 건너뛴다.
*
* @param int $hours 이 시간보다 오래된 아티팩트만 정리
* @return void
*/
private function pruneStaleTestArtifacts(int $hours): void
{
$threshold = now()->subHours($hours);
$roles = 0;
$users = 0;
Role::where('identifier', 'like', self::TEST_ROLE_PREFIX.'%')
->where('created_at', '<', $threshold)
->chunkById(100, function ($chunk) use (&$roles, &$users) {
foreach ($chunk as $role) {
// 이 역할을 가진 유저 중, 다른 playwright 역할이 없는 계정만 제거 대상
foreach ($role->users()->get() as $user) {
$otherTestRoles = $user->roles()
->where('roles.id', '!=', $role->id)
->where('roles.identifier', 'like', self::TEST_ROLE_PREFIX.'%')
->count();
if ($otherTestRoles === 0) {
$user->tokens()->delete();
$user->roles()->detach();
$user->forceDelete();
$users++;
}
}
$role->permissions()->detach();
$role->users()->detach();
$role->delete();
$roles++;
}
});
if ($roles > 0) {
$this->info("[gc] playwright 테스트 잔재 정리: 역할 {$roles}건, 유저 {$users}건");
}
}
/**
* 권한 식별자 배열로 관리자 유저를 생성하고 권한을 부여한다.
*
@@ -78,19 +142,32 @@ class PlaywrightIssueToken extends Command
* 1. User factory 로 신규 유저 생성
* 2. 권한 식별자별로 Permission 행 보장 (firstOrCreate)
* 3. uniqid 접미사로 격리된 test role 생성 + 권한 sync
* 4. admin role 보장 (firstOrCreate) + 유저-역할 부여
* 4. (withAdminRole 일 때만) admin role 보장 (firstOrCreate) + 유저-역할 부여
*
* `withAdminRole = false` 는 **권한 분기(읽기 전용 등) 검증 전용**이다. 기본값 true 는
* admin 역할을 함께 붙이므로, 요청한 권한만 가진 세션을 만들 수 없다 —
* admin 역할이 사이트의 전체 권한을 보유하기 때문에 `--permissions` 로 좁혀도
* 화면은 항상 최대 권한으로 렌더된다(실측: admin 역할 권한 263건).
*
* @param array<int, string> $permissions 부여할 권한 식별자 목록
* @param bool $withAdminRole admin 역할 동반 부여 여부
* @return User 생성된 유저
*/
private function makeAdminUser(array $permissions): User
private function makeAdminUser(array $permissions, bool $withAdminRole = true): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
// Role/Permission 의 name·description 은 모델에서 array 로 캐스팅된다.
// 여기서 json_encode 한 문자열을 넣으면 Eloquent 가 한 번 더 인코딩해
// 이중 인코딩된 값이 저장되고, 관리자 화면(역할 선택 등)에 JSON 원문이
// `{"ko":"\uXXXX…"}` 형태로 그대로 노출된다. 배열을 그대로 넘긴다.
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'name' => ['ko' => $identifier, 'en' => $identifier],
'description' => ['ko' => $identifier, 'en' => $identifier],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
@@ -101,28 +178,33 @@ class PlaywrightIssueToken extends Command
$testRole = Role::create([
'identifier' => 'playwright_test_'.uniqid(),
'name' => json_encode(['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin']),
'description' => json_encode(['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only']),
'name' => ['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin'],
'description' => ['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only'],
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if (! empty($permissionIds)) {
$testRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
if ($withAdminRole) {
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => ['ko' => '관리자', 'en' => 'Admin'],
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
}
// test role 은 항상 부여한다 — GC(pruneStaleTestArtifacts)가 이 역할로 테스트 계정을
// 식별하므로, 빠지면 --no-admin-role 로 만든 계정이 영구 잔존한다.
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
@@ -0,0 +1,377 @@
<?php
namespace App\Console\Commands;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\Traits\ComputesLayoutContentHash;
use App\Extension\Traits\InvalidatesLayoutCache;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
/**
* Playwright E2E 용 시드 레이아웃 설치/제거 커맨드.
*
* 레이아웃 편집기 E2E 중 **저장(PUT)까지 수행하는** spec 은 편집 결과가 그대로 영속된다.
* 이들이 제품 화면(`home` / `admin_dashboard` 등)을 대상으로 하면 실행할 때마다 빈 표·차트가
* 누적돼 개발 사이트가 오염된다(실측: `home` 20,321 → 33,696 bytes, 빈 표 7개). spec 안에
* 원복 장치를 넣어도 편집기가 캐시한 문서를 다시 밀어 넣어 오염 시점 크기로 되돌아왔다.
*
* 그래서 저장 spec 전용 시드 화면(`e2e_sandbox`)을 제품 레이아웃과 분리한다. 이 커맨드가
* 그 화면을 **활성 템플릿 디렉토리에만** 설치하므로 `_bundled`(배포 원본)은 손대지 않고,
* 활성 디렉토리는 Git 무시 대상이라 릴리스 산출물에도 포함되지 않는다.
*
* 설치 절차(템플릿별):
* ① `tests/Playwright/fixtures/seed-layouts/{identifier}.e2e_sandbox.json` 을
* `templates/{identifier}/layouts/e2e_sandbox.json` 으로 복사
* ② 활성 `templates/{identifier}/routes.json` 에 라우트 1건 추가(멱등 — 마커로 식별).
* 편집기 라우트 트리는 routes.json 에서 만들어지므로 이 항목이 없으면 `?route=` 로
* 시드 화면을 열 수 없다
* ③ 시드 레이아웃 DB 행만 upsert. 편집기의 조회/저장 대상은 DB 행이라 이 단계가 없으면 404
*
* ③ 에서 `template:refresh-layout`(전체 재동기화)을 쓰지 않는 이유: 그 경로는 파일에 없는
* DB 레이아웃을 지우고 모든 레이아웃을 파일 기준으로 되돌린다. 편집기 UI 로 저장한 변경은
* 파일이 아니라 DB 에만 있으므로, E2E 를 돌릴 때마다 사람이 편집기로 만든 결과가 사라진다.
* 시드 설치는 시드 행 하나만 건드린다.
*
* `--remove` 는 ①②를 되돌리고 시드 DB 행만 삭제한다 (다른 레이아웃 무영향).
*
* 보안 가드 (2중) — `PlaywrightIssueToken` 과 동일 규약:
* ① CLI 한정 — `php_sapi_name() === 'cli'` 확인. production 웹 요청에서 절대 도달 불가
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 부여 필수.
* `.env` 영구 수정 없이 인라인 환경변수로만 활성화 가능 → 무심코 production 으로 새지 않음
*
* 호출 예시 (PowerShell):
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-layout
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-layout --remove
*/
class PlaywrightSeedLayout extends Command
{
use ComputesLayoutContentHash, InvalidatesLayoutCache;
protected $signature = 'playwright:seed-layout
{--template=* : 대상 템플릿 identifier (생략 시 기본 대상 전체)}
{--remove : 시드 레이아웃/라우트를 제거한다}';
protected $description = 'Playwright E2E 전용 시드 레이아웃 설치/제거 (CLI + G7_PLAYWRIGHT_BYPASS 가드)';
/** 시드 레이아웃 이름 — 파일명·DB name·라우트 layout 이 모두 이 값을 공유한다. */
private const LAYOUT_NAME = 'e2e_sandbox';
/**
* routes.json 의 시드 라우트 식별 마커.
*
* 경로 문자열은 템플릿 타입마다 다르므로(사용자 템플릿 vs 관리자 템플릿), 제거 시에는
* 경로가 아니라 이 마커로 대상을 찾는다.
*/
private const ROUTE_MARKER = 'playwright-seed-layout';
/** 원본 routes.json 보관 파일 접미사 — 제거 시 서식까지 바이트 동일 복원용 */
private const ROUTES_BACKUP_SUFFIX = '.playwright-backup';
/**
* 기본 대상 템플릿 → 라우트 경로.
*
* 관리자 템플릿 라우트는 관리자 URL 프리픽스 규약을 따른다.
*
* @var array<string, string>
*/
private const DEFAULT_TARGETS = [
'sirsoft-basic' => '/e2e-sandbox',
'sirsoft-admin_basic' => '*/admin/e2e-sandbox',
];
public function __construct(
private LayoutRepositoryInterface $layoutRepository,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드 (0 성공)
*/
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
return self::FAILURE;
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
return self::FAILURE;
}
// settings JSON 이 debug 를 덮어써도 시드 작업이 막히지 않도록 인라인 강제
// (PlaywrightIssueToken 과 동일 근거 — bypass flag 인지 시 SettingsServiceProvider 는 이미 우회 상태).
Config::set('app.debug', true);
$remove = (bool) $this->option('remove');
$targets = $this->resolveTargets();
if ($targets === []) {
$this->error('대상 템플릿이 없습니다.');
return self::FAILURE;
}
foreach ($targets as $identifier => $routePath) {
if (! $this->processTemplate($identifier, $routePath, $remove)) {
return self::FAILURE;
}
}
return self::SUCCESS;
}
/**
* `--template` 옵션을 기본 대상 목록과 대조해 처리 대상을 결정합니다.
*
* @return array<string, string> identifier => 라우트 경로
*/
private function resolveTargets(): array
{
$requested = $this->option('template') ?: [];
if ($requested === []) {
return self::DEFAULT_TARGETS;
}
$targets = [];
foreach ($requested as $identifier) {
if (! isset(self::DEFAULT_TARGETS[$identifier])) {
$this->warn("시드 라우트 경로가 정의되지 않은 템플릿입니다 — 건너뜁니다: {$identifier}");
continue;
}
$targets[$identifier] = self::DEFAULT_TARGETS[$identifier];
}
return $targets;
}
/**
* 템플릿 1개에 대해 시드 레이아웃 설치 또는 제거를 수행합니다.
*
* @param string $identifier 템플릿 identifier
* @param string $routePath 시드 라우트 경로
* @param bool $remove true 면 제거, false 면 설치
* @return bool 성공 여부
*/
private function processTemplate(string $identifier, string $routePath, bool $remove): bool
{
$templateDir = base_path("templates/{$identifier}");
// 활성 디렉토리가 없으면 그 템플릿은 설치되지 않은 것 — 조용히 건너뛴다
// (E2E 대상 템플릿이 환경마다 다를 수 있으므로 실패로 취급하지 않는다).
if (! File::isDirectory($templateDir)) {
$this->warn("활성 템플릿 디렉토리가 없어 건너뜁니다: {$identifier}");
return true;
}
$template = $this->templateRepository->findByIdentifier($identifier);
if (! $template) {
$this->warn("템플릿이 DB 에 없어 건너뜁니다: {$identifier}");
return true;
}
$layoutPath = "{$templateDir}/layouts/".self::LAYOUT_NAME.'.json';
if ($remove) {
if (File::exists($layoutPath)) {
File::delete($layoutPath);
}
$this->removeSeedRoute($identifier, $templateDir);
$this->layoutRepository->deleteByName($template->id, self::LAYOUT_NAME);
} else {
$fixturePath = base_path(
'tests/Playwright/fixtures/seed-layouts/'.$identifier.'.'.self::LAYOUT_NAME.'.json'
);
if (! File::exists($fixturePath)) {
$this->error("시드 레이아웃 fixture 가 없습니다: {$fixturePath}");
return false;
}
$content = json_decode((string) File::get($fixturePath), true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($content)) {
$this->error("시드 레이아웃 fixture 파싱 실패: {$fixturePath}");
return false;
}
File::copy($fixturePath, $layoutPath);
$this->addSeedRoute($identifier, $templateDir, $routePath);
$this->layoutRepository->updateOrCreate(
['template_id' => $template->id, 'name' => self::LAYOUT_NAME],
[
'content' => $content,
'source_type' => 'template',
'original_content_hash' => $this->computeContentHash($content),
'original_content_size' => $this->computeContentSize($content),
]
);
}
// 편집기/공개 서빙 캐시에서 시드 레이아웃 키를 지운다. 삭제 경로에서도 캐시가 남으면
// 편집기가 stale 문서를 계속 받는다.
$this->forgetSeedLayoutCache($template->id, $identifier);
$this->info(($remove ? '제거' : '설치')." 완료: {$identifier} ({$routePath})");
return true;
}
/**
* 활성 routes.json 에 시드 라우트를 추가합니다.
*
* 재작성은 JSON 재직렬화라 원본의 주석 그룹 사이 빈 줄 같은 서식이 사라진다. 그래서 첫
* 설치 시 원본을 `.playwright-backup` 으로 보관하고, 제거 때 그 파일을 그대로 되돌린다
* (바이트 동일 복원). 백업이 이미 있으면 덮어쓰지 않는다 — 비정상 종료로 시드가 남은 상태의
* routes.json 을 "원본" 으로 굳히지 않기 위함.
*
* @param string $identifier 템플릿 identifier
* @param string $templateDir 활성 템플릿 디렉토리 절대 경로
* @param string $routePath 등록할 시드 라우트 경로
*/
private function addSeedRoute(string $identifier, string $templateDir, string $routePath): void
{
$path = "{$templateDir}/routes.json";
$routes = $this->readRoutes($identifier, $path);
if ($routes === null) {
return;
}
$backupPath = $path.self::ROUTES_BACKUP_SUFFIX;
if (! File::exists($backupPath)) {
File::copy($path, $backupPath);
}
// 마커 항목을 먼저 걷어내 반복 실행이 라우트를 중복 추가하지 않게 한다.
$entries = $this->withoutSeedRoutes($routes);
$entries[] = [
'_marker' => self::ROUTE_MARKER,
'_comment' => 'Playwright E2E 전용 시드 화면 — playwright:seed-layout 이 설치/제거한다. 제품 라우트가 아니다.',
'path' => $routePath,
'layout' => self::LAYOUT_NAME,
'auth_required' => str_starts_with($routePath, '*/admin'),
'meta' => [
'title' => 'E2E Sandbox',
],
];
$routes['routes'] = $entries;
File::put(
$path,
json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"
);
}
/**
* 활성 routes.json 에서 시드 라우트를 제거합니다.
*
* 백업이 있으면 그것을 그대로 복원해 서식까지 원상 복구한다. 백업이 없으면(다른 경로로 시드가
* 들어간 경우) 마커 항목만 걷어낸 재직렬화로 대체한다.
*
* @param string $identifier 템플릿 identifier
* @param string $templateDir 활성 템플릿 디렉토리 절대 경로
*/
private function removeSeedRoute(string $identifier, string $templateDir): void
{
$path = "{$templateDir}/routes.json";
$backupPath = $path.self::ROUTES_BACKUP_SUFFIX;
if (File::exists($backupPath)) {
File::put($path, File::get($backupPath));
File::delete($backupPath);
return;
}
$routes = $this->readRoutes($identifier, $path);
if ($routes === null) {
return;
}
$routes['routes'] = $this->withoutSeedRoutes($routes);
File::put(
$path,
json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"
);
}
/**
* routes.json 을 디코드해 반환합니다 (부재/파싱 실패 시 경고 후 null).
*
* @param string $identifier 템플릿 identifier (경고 메시지용)
* @param string $path routes.json 절대 경로
* @return array<string, mixed>|null 디코드 결과
*/
private function readRoutes(string $identifier, string $path): ?array
{
if (! File::exists($path)) {
$this->warn("routes.json 이 없어 라우트 처리를 건너뜁니다: {$identifier}");
return null;
}
$routes = json_decode((string) File::get($path), true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($routes)) {
$this->warn("routes.json 을 파싱할 수 없어 라우트 처리를 건너뜁니다: {$identifier}");
return null;
}
return $routes;
}
/**
* 라우트 배열에서 시드 마커가 붙은 항목을 제외해 반환합니다.
*
* @param array<string, mixed> $routes 디코드된 routes.json
* @return array<int, mixed> 마커 항목이 제거된 라우트 배열
*/
private function withoutSeedRoutes(array $routes): array
{
return array_values(array_filter(
$routes['routes'] ?? [],
fn ($route) => ! is_array($route) || ($route['_marker'] ?? null) !== self::ROUTE_MARKER
));
}
/**
* 시드 레이아웃의 캐시 키를 삭제합니다.
*
* `InvalidatesLayoutCache::forgetLayoutCacheKeys` 는 레이아웃 객체의
* `template_id` / `name` / `source_type` / `source_identifier` 만 읽으므로,
* 삭제 후(모델 부재) 에도 동일 형태의 경량 객체로 호출할 수 있다.
*
* @param int $templateId 템플릿 ID
* @param string $identifier 템플릿 identifier (버전 포함 공개 서빙 키에 사용)
*/
private function forgetSeedLayoutCache(int $templateId, string $identifier): void
{
$this->forgetLayoutCacheKeys(
(object) [
'template_id' => $templateId,
'name' => self::LAYOUT_NAME,
'source_type' => null,
'source_identifier' => null,
],
$identifier
);
}
}
@@ -116,7 +116,7 @@ class CheckPluginUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckPluginUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('plugins.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\PluginManager;
@@ -16,6 +17,7 @@ use Illuminate\Support\Facades\Validator;
class InstallPluginCommand extends Command
{
use HasProgressBar;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -23,7 +25,8 @@ class InstallPluginCommand extends Command
protected $signature = 'plugin:install
{identifier : 설치할 플러그인 식별자}
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -111,6 +114,9 @@ class InstallPluginCommand extends Command
Log::info(__('plugins.commands.install.success', ['plugin' => $identifier]));
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\PluginManager;
@@ -16,6 +17,7 @@ class UpdatePluginCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -26,7 +28,8 @@ class UpdatePluginCommand extends Command
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 plugin.json 기준)}';
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 plugin.json 기준)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -202,6 +205,9 @@ class UpdatePluginCommand extends Command
'layout_strategy' => $layoutStrategy,
]);
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -0,0 +1,194 @@
<?php
namespace App\Console\Commands\Search;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Enums\SearchIndexStatus;
use App\Search\DTO\SearchIndexHealth;
use App\Search\SearchIndexMaintenanceManager;
use Illuminate\Console\Command;
/**
* 검색 인덱스 점검·재생성 커맨드
*
* 인덱스는 있는데 내용이 색인되지 않아 검색이 조용히 0건을 돌려주는 상태를 검출하고,
* 운영자가 선택하면 재생성합니다. 이 상태는 예외도 로그도 남기지 않으므로
* "원래 검색이 안 되는 줄" 알고 지나가기 쉽습니다.
*
* 점검 방법은 **활성 Scout 엔진의 유지보수기**가 정합니다. 확장이 자체 검색 엔진을
* 등록하면서 유지보수기를 함께 등록하면 이 커맨드가 그대로 그 엔진을 다룹니다
* (`core.search.index_maintainers` 필터 훅).
*
* 사용 예시:
* php artisan search:index # 활성 엔진의 인덱스 점검 (읽기 전용)
* php artisan search:index --repair # 색인 누락 인덱스 재생성
* php artisan search:index --filter=table=pages # 엔진별 필터 전달
* php artisan search:index --json # 기계 판독용 출력
*/
class SearchIndexCommand extends Command
{
use HasUnifiedConfirm;
protected $signature = 'search:index
{--repair : 색인이 누락된 인덱스를 재생성 (미지정 시 점검만)}
{--filter=* : 엔진별 필터 (key=value 형태, 다중 지정 가능. FULLTEXT: table, index, samples)}
{--json : 기계 판독용 JSON 출력}';
protected $description = '활성 검색 엔진의 인덱스가 실제로 내용을 색인하고 있는지 점검하고, 누락 시 재생성합니다';
/**
* 커맨드를 실행합니다.
*
* @param SearchIndexMaintenanceManager $manager 검색 인덱스 유지보수 진입점
* @return int 종료 코드 (색인 누락 잔존 시 1)
*/
public function handle(SearchIndexMaintenanceManager $manager): int
{
$driver = $manager->driver();
$filters = $this->parseFilters();
if (! $manager->hasMaintainer()) {
$this->components->warn(__('search.index.no_maintainer', ['driver' => $driver]));
return self::SUCCESS;
}
if (($reason = $manager->unavailableReason()) !== null) {
$this->components->warn($reason);
return self::SUCCESS;
}
$results = $manager->inspect($filters);
if ($results === []) {
$this->components->warn(__('search.index.no_targets', ['driver' => $driver]));
return self::SUCCESS;
}
$report = null;
if ($this->option('repair')) {
$targets = array_values(array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild()));
if ($targets !== [] && $this->confirmRebuild($targets)) {
$report = $manager->repairStale($filters);
$results = $manager->inspect($filters);
}
}
if ($this->option('json')) {
$this->line((string) json_encode([
'driver' => $driver,
'results' => array_map(fn (SearchIndexHealth $h) => $h->toArray(), $results),
'repair' => $report?->toArray(),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
} else {
$this->render($driver, $results, $report?->summary());
}
$remaining = array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild());
return ($remaining === [] && ($report === null || $report->failed === [])) ? self::SUCCESS : self::FAILURE;
}
/**
* `--filter=key=value` 옵션을 연관 배열로 해석합니다.
*
* @return array<string, string>
*/
private function parseFilters(): array
{
$filters = [];
foreach ((array) $this->option('filter') as $raw) {
if (! is_string($raw) || ! str_contains($raw, '=')) {
continue;
}
[$key, $value] = explode('=', $raw, 2);
$key = trim($key);
if ($key !== '') {
$filters[$key] = trim($value);
}
}
return $filters;
}
/**
* 재생성 대상을 보여 주고 진행 여부를 확인합니다.
*
* @param array<int, SearchIndexHealth> $targets 재생성 대상
* @return bool 진행 여부
*/
private function confirmRebuild(array $targets): bool
{
$this->newLine();
$this->components->info(__('search.index.rebuild_targets', ['count' => count($targets)]));
foreach ($targets as $target) {
$this->line(' - '.$target->identifier.' ('.$target->measurement.')');
}
$this->components->warn(__('search.index.rebuild_cost_warning'));
if ($this->unifiedConfirm(__('search.index.rebuild_confirm'), true)) {
return true;
}
$this->components->info(__('search.index.rebuild_skipped'));
return false;
}
/**
* 판정 결과를 표로 출력합니다.
*
* @param string $driver 드라이버명
* @param array<int, SearchIndexHealth> $results 판정 결과
* @param string|null $repairSummary 재생성 요약 (수행했을 때만)
* @return void
*/
private function render(string $driver, array $results, ?string $repairSummary): void
{
$rows = array_map(fn (SearchIndexHealth $health) => [
$health->identifier,
'<fg='.$health->status->consoleColor().'>'.$health->status->label().'</>',
$health->measurement,
], $results);
$this->newLine();
$this->line(' '.__('search.index.driver_label', ['driver' => $driver]));
$this->table([__('search.index.col.index'), __('search.index.col.status'), __('search.index.col.measurement')], $rows);
$counts = [];
foreach (SearchIndexStatus::cases() as $case) {
$counts[$case->value] = count(array_filter($results, fn (SearchIndexHealth $h) => $h->status === $case));
}
$this->line(' '.__('search.index.counts', [
'healthy' => $counts[SearchIndexStatus::Healthy->value],
'degraded' => $counts[SearchIndexStatus::Degraded->value],
'stale' => $counts[SearchIndexStatus::Stale->value],
'skipped' => $counts[SearchIndexStatus::Skipped->value],
'total' => count($results),
]));
if ($repairSummary !== null) {
$this->newLine();
$this->components->info($repairSummary);
}
if ($counts[SearchIndexStatus::Stale->value] > 0 && ! $this->option('repair')) {
$this->newLine();
$this->components->warn(__('search.index.stale_hint'));
}
if ($counts[SearchIndexStatus::Degraded->value] > 0) {
$this->line(' <comment>'.__('search.index.degraded_hint').'</comment>');
}
}
}
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Enums\SitemapGenerationMode;
use App\Jobs\GenerateSitemapJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
@@ -17,7 +18,10 @@ class SeoGenerateSitemapCommand extends Command
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}';
protected $signature = 'seo:generate-sitemap
{--sync : 큐 드라이버를 무시하고 동기 실행}
{--rebuild : 저장소 상태와 무관하게 전체 재생성 (--mode=full 과 동일)}
{--mode=auto : 재생성 모드 (full|auto|incremental)}';
/**
* @var string 커맨드 설명
@@ -31,6 +35,14 @@ class SeoGenerateSitemapCommand extends Command
*/
public function handle(): int
{
$mode = $this->resolveMode();
if ($mode === null) {
$allowed = implode(', ', array_map(fn (SitemapGenerationMode $m): string => $m->value, SitemapGenerationMode::cases()));
$this->error('잘못된 모드입니다. 허용: '.$allowed);
return Command::INVALID;
}
$forceSync = (bool) $this->option('sync');
// queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되,
// testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다.
@@ -38,13 +50,29 @@ class SeoGenerateSitemapCommand extends Command
$isSyncDriver = $connection === 'sync';
if ($forceSync || $isSyncDriver) {
GenerateSitemapJob::dispatchSync();
$this->info('Sitemap이 생성되었습니다.');
GenerateSitemapJob::dispatchSync($mode);
$this->info("Sitemap이 생성되었습니다. (모드: {$mode->value})");
} else {
GenerateSitemapJob::dispatch();
$this->info('Sitemap 생성이 큐에 디스패치되었습니다.');
GenerateSitemapJob::dispatch($mode);
$this->info("Sitemap 생성이 큐에 디스패치되었습니다. (모드: {$mode->value})");
}
return Command::SUCCESS;
}
/**
* --rebuild / --mode 옵션으로부터 재생성 모드를 해석합니다.
*
* --rebuild 는 --mode 보다 우선하며 Full 로 강제합니다.
*
* @return SitemapGenerationMode|null 재생성 모드 (유효하지 않으면 null)
*/
private function resolveMode(): ?SitemapGenerationMode
{
if ((bool) $this->option('rebuild')) {
return SitemapGenerationMode::Full;
}
return SitemapGenerationMode::tryFrom((string) $this->option('mode'));
}
}
@@ -116,7 +116,7 @@ class CheckTemplateUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckTemplateUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('templates.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands\Traits;
use App\Search\DTO\SearchIndexHealth;
use App\Search\DTO\SearchIndexRepairReport;
use App\Search\SearchIndexMaintenanceManager;
/**
* 설치·업데이트 커맨드에 "검색 인덱스 재생성" 선택 옵션을 제공하는 트레이트.
*
* 재생성 비용은 엔진마다 다르지만(테이블 잠금 / 전체 재색인) 어느 쪽이든 운영 중인
* 사이트에 영향을 줍니다. 그래서 **기본값은 재생성하지 않음**이고, 운영자가
* `--rebuild-search-index` 로 명시했을 때만 수행합니다.
*
* 옵션을 주지 않아도 점검 결과는 안내합니다 — 색인이 누락되면 검색이 오류 없이 0건을
* 돌려주므로, 알려주지 않으면 운영자가 알 방법이 없습니다.
*/
trait RebuildsSearchIndex
{
/**
* 커맨드 `$signature` 에 붙일 옵션 정의.
*
* @return string 옵션 시그니처 조각
*/
public static function rebuildSearchIndexOption(): string
{
return '{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 점검 결과만 확인하고 유지보수 시간에 수행하세요)}';
}
/**
* 작업 완료 후 검색 인덱스를 점검하고, 요청이 있었으면 재생성합니다.
*
* @param bool|null $requested 재생성 요청 여부 (null 이면 커맨드 옵션에서 읽음)
* @return SearchIndexRepairReport|null 재생성 보고 (재생성을 하지 않았으면 null)
*/
protected function handleSearchIndexRebuild(?bool $requested = null): ?SearchIndexRepairReport
{
$manager = app(SearchIndexMaintenanceManager::class);
// 점검을 제공하지 않는 엔진에서는 조용히 넘어간다 (검색 자체는 정상 동작)
if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) {
return null;
}
$requested ??= (bool) $this->option('rebuild-search-index');
if ($requested) {
$report = $manager->repairStale();
$this->reportRebuild($report);
return $report;
}
$this->warnStale($manager->inspect());
return null;
}
/**
* 재생성하지 않은 경우, 색인 누락 사실만 안내합니다.
*
* @param array<int, SearchIndexHealth> $results 점검 결과
* @return void
*/
private function warnStale(array $results): void
{
$stale = array_values(array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild()));
if ($stale === []) {
return;
}
$this->newLine();
$this->warn('⚠️ '.__('search.index.stale_after_update', ['count' => count($stale)]));
foreach ($stale as $health) {
$this->line(' - '.$health->identifier);
}
$this->line(' '.__('search.index.stale_hint'));
}
/**
* 재생성 보고를 콘솔에 출력합니다.
*
* @param SearchIndexRepairReport $report 재생성 보고
* @return void
*/
private function reportRebuild(SearchIndexRepairReport $report): void
{
$this->newLine();
if (! $report->didRebuild()) {
$this->info('✅ '.$report->summary());
return;
}
$this->info('✅ '.$report->summary());
foreach ($report->repaired as $identifier) {
$this->line(' '.__('search.index.rebuilt_item', ['index' => $identifier]));
}
foreach ($report->failed as $identifier => $message) {
$this->error(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
foreach ($report->remaining as $identifier) {
$this->warn(' '.__('search.index.still_stale_item', ['index' => $identifier]));
}
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Contracts\Pagination;
use App\Enums\TotalRelation;
/**
* 총 건수 정확도를 스스로 밝히는 페이지 결과 계약
*
* 표준 `LengthAwarePaginator` 는 `total()` 이 항상 정확하다고 가정한다. 대용량 목록에서
* 상한을 걸고 센 결과는 그 가정을 만족하지 못하므로, 정확도를 함께 실어 나른다.
*
* 이 계약을 구현한 페이지는 `BaseApiCollection::paginationMeta()` 가 자동으로 알아보고
* `total_relation` / `total_is_exact` / `result_cap` 메타를 응답에 덧붙인다. 컬렉션 쪽
* 코드 변경은 필요 없다.
*/
interface BoundedTotalAware
{
/**
* 총 건수와 실제 매칭 건수의 관계를 반환합니다.
*
* @return TotalRelation 정확(Exact) 또는 하한(AtLeast)
*/
public function totalRelation(): TotalRelation;
/**
* 총 건수 집계에 적용된 상한을 반환합니다.
*
* @return int|null 상한 (무제한이면 null)
*/
public function resultCap(): ?int;
/**
* 총 건수가 상한에 걸려 잘렸는지 여부를 반환합니다.
*
* @return bool 잘렸으면 true (= totalRelation 이 AtLeast)
*/
public function isTruncated(): bool;
}
@@ -2,6 +2,7 @@
namespace App\Contracts\Repositories;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -18,20 +19,22 @@ interface ActivityLogRepositoryInterface
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator;
/**
* 활동 로그 목록을 페이지네이션하여 조회합니다.
*
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로 응답합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getPaginated(array $filters = []): LengthAwarePaginator;
public function getPaginated(array $filters = []): LengthAwarePaginator|CursorPaginator;
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
@@ -39,7 +42,7 @@ interface ActivityLogRepositoryInterface
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int;
@@ -24,7 +24,20 @@ interface LayoutExtensionVersionRepositoryInterface
* @param int $extensionId 레이아웃 확장 ID
* @return Collection 버전 컬렉션
*/
public function getVersions(int $extensionId): Collection;
public function getVersions(int $extensionId, int $limit = 100): Collection;
/**
* 확장의 특정 버전 번호 조회 (본문 포함)
*
* 목록(getVersions)은 경량 조회라 `content` 를 담지 않고 건수 상한이 있다. 단건은 그
* 대체 경로이므로 반드시 전용 조회를 쓴다 — 목록 조회를 재사용하면 본문이 사라지고
* 상한 밖의 오래된 버전을 찾지 못한다.
*
* @param int $extensionId 레이아웃 확장 ID
* @param int $version 버전 번호
* @return TemplateLayoutExtensionVersion|null 찾은 버전 모델 또는 null
*/
public function findVersionByNumber(int $extensionId, int $version): ?TemplateLayoutExtensionVersion;
/**
* 확장 ID 목록의 현재(최신) 버전 번호 맵 조회
@@ -6,6 +6,7 @@ use App\Enums\LayoutSourceType;
use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
interface LayoutRepositoryInterface
{
@@ -17,6 +18,17 @@ interface LayoutRepositoryInterface
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 목록 표시용 경량 레이아웃 행 조회
*
* 본문(`content`)을 반환에 포함하지 않는다 — 설명·크기 등 본문 파생값만 계산해 담는다.
* 편집 대상 본문은 상세 조회가 제공한다.
*
* @param int $templateId 템플릿 ID
* @return Collection<int, array> 목록 행 배열 컬렉션
*/
public function getListByTemplateId(int $templateId): SupportCollection;
/**
* 특정 레이아웃 조회 (템플릿 ID와 이름으로)
*
@@ -26,6 +38,19 @@ interface LayoutRepositoryInterface
*/
public function findByName(int $templateId, string $name): ?TemplateLayout;
/**
* 특정 레이아웃을 영구 삭제 (템플릿 ID와 이름으로)
*
* soft delete 가 아닌 `forceDelete` 다. 이름으로 재등록될 수 있는 레이아웃
* (파일 → DB 동기화 대상) 은 soft delete 잔여 행이 남으면 재등록이 충돌하므로
* 파일 기준 동기화 경로와 동일하게 영구 삭제한다.
*
* @param int $templateId 템플릿 ID
* @param string $name 레이아웃 이름
* @return bool 삭제 여부 (대상 부재 시 false)
*/
public function deleteByName(int $templateId, string $name): bool;
/**
* ID로 레이아웃 조회
*
@@ -113,12 +138,16 @@ interface LayoutRepositoryInterface
public function updateContent(int $id, array $content, int $newLockVersion): TemplateLayout;
/**
* 특정 레이아웃의 모든 버전 조회
* 특정 레이아웃의 최근 버전 목록 조회 (최신순)
*
* 버전 행은 저장할 때마다 쌓이고 정리되지 않으므로 조회 건수에 상한이 있습니다.
* 목록이 쓰지 않는 `content`(버전마다 레이아웃 본문 사본)는 조회하지 않습니다.
*
* @param int $layoutId 레이아웃 ID
* @param int $limit 조회할 최대 버전 수
* @return Collection 버전 컬렉션
*/
public function getVersionsByLayoutId(int $layoutId): Collection;
public function getVersionsByLayoutId(int $layoutId, int $limit = 100): Collection;
/**
* 특정 버전 조회
@@ -5,6 +5,7 @@ namespace App\Contracts\Repositories;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
interface NotificationLogRepositoryInterface
@@ -44,5 +45,5 @@ interface NotificationLogRepositoryInterface
*
* @param User|null $scopeUser 스코프 적용 대상 사용자 (null이면 스코프 미적용)
*/
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator;
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator|CursorPaginator;
}
@@ -0,0 +1,43 @@
<?php
namespace App\Contracts\Repositories;
use Carbon\Carbon;
/**
* SEO 캐시 통계 Repository 인터페이스
*/
interface SeoCacheStatRepositoryInterface
{
/**
* 캐시 통계 레코드를 기록합니다.
*
* @param array<string, mixed> $attributes 기록할 속성 (url, locale, layout_name, module_identifier, type, response_time_ms)
*/
public function record(array $attributes): void;
/**
* 전체 캐시 통계를 집계합니다.
*
* @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간)
* @return array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} 집계 결과
*/
public function aggregate(?Carbon $since = null): array;
/**
* 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다.
*
* @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier)
* @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간)
* @return array<int, array{group: string|null, total: int, hits: int, misses: int, avg_response_time_ms: float|null}> 그룹별 집계 결과
*/
public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array;
/**
* 기준 시점보다 오래된 통계 레코드를 삭제합니다.
*
* @param Carbon $cutoff 기준 시점
* @return int 삭제된 레코드 수
*/
public function deleteOlderThan(Carbon $cutoff): int;
}
@@ -0,0 +1,62 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\SitemapUrl;
/**
* 사이트맵 URL 저장소 인터페이스
*
* sitemap_urls 테이블에 대한 리소스 단위 증분 갱신과 스트리밍 조회를 제공합니다.
* 리스너는 SitemapIndexer 를 경유해 이 저장소를 사용하며(직접 Model/DB 접근 금지),
* 전체 재생성/증분 파일 작성은 이 저장소의 스트림을 소비합니다.
*/
interface SitemapUrlRepositoryInterface
{
/**
* 한 리소스의 사이트맵 URL 행을 현재 상태로 대체합니다(멱등).
*
* (resource_type, resource_id) 로 기존 행을 제거한 뒤 전달된 항목을 삽입하므로,
* 같은 입력으로 여러 번 호출해도 테이블 내용이 동일합니다(loc 변경/슬러그 변경 흡수).
*
* @param string $type 리소스 유형
* @param string $id 리소스 PK (문자열)
* @param array<int, array{loc: string, contributor: string, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목
*/
public function upsertForResource(string $type, string $id, array $entries): void;
/**
* 한 리소스의 사이트맵 URL 행을 모두 제거합니다(비공개/삭제 시).
*
* @param string $type 리소스 유형
* @param string $id 리소스 PK (문자열)
*/
public function removeForResource(string $type, string $id): void;
/**
* 공개(is_visible) URL 을 id 순으로 스트리밍합니다(유계 메모리).
*
* @param string|null $contributor 기여자 식별자로 스코핑 (null = 전체)
* @return iterable<int, SitemapUrl> URL 순회자
*/
public function streamVisible(?string $contributor = null): iterable;
/**
* 공개(is_visible) URL 총 개수를 반환합니다.
*
* @return int 공개 URL 수
*/
public function countVisible(): int;
/**
* 한 기여자의 모든 사이트맵 URL 행을 전량 대체합니다(전체 재생성).
*
* 기여자 스코프로 기존 행을 제거한 뒤 스트림을 청크 삽입하여
* 대용량에서도 메모리를 유계로 유지합니다.
*
* @param string $contributor 기여자 식별자
* @param iterable<int, array{loc: string, resource_type?: string, resource_id?: mixed, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목 스트림
* @return int 삽입된 행 수 (진행상황 누적 URL 표기용 — count 쿼리 없이 스트림 누적)
*/
public function replaceAllForContributor(string $contributor, iterable $entries): int;
}
@@ -144,17 +144,21 @@ interface UserRepositoryInterface
* `locked_until` 을 현재 시각 + $minutes 로 설정하고 `failed_login_attempts` 를
* 0 으로 리셋합니다 (다음 잠금 윈도우 시작점). 잠금 해제 시각을 반환합니다.
*
* `$minutes <= 0` 은 보안 환경설정의 "0 = 무한대" 규약에 따라 영구 잠금으로 처리하며,
* 이때 `locked_until` 은 NULL, `locked_permanently` 는 true 가 되고 null 을 반환합니다.
*
* @param User $user 잠글 사용자
* @param int $minutes 잠금 유지 시간(분)
* @return Carbon 잠금 해제 시각
* @param int $minutes 잠금 유지 시간(분). 0 이하는 무기한
* @return Carbon|null 잠금 해제 시각 (영구 잠금은 null)
*/
public function lockAccount(User $user, int $minutes): Carbon;
public function lockAccount(User $user, int $minutes): ?Carbon;
/**
* 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
*
* 정상 로그인 성공 시 호출됩니다 (`failed_login_attempts=0`,
* `locked_until=null`, `last_failed_login_at=null`).
* 정상 로그인 성공 시 또는 관리자 수동 해제 시 호출됩니다
* (`failed_login_attempts=0`, `locked_until=null`, `locked_permanently=false`,
* `last_failed_login_at=null`).
*
* @param User $user 대상 사용자
*/
@@ -163,10 +167,55 @@ interface UserRepositoryInterface
/**
* 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다.
*
* `locked_until` 이 NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다.
* `locked_permanently` 가 true 면 항상 true 입니다. 그 외에는 `locked_until` 이
* NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다.
*
* @param User $user 대상 사용자
* @return bool 잠금 여부
*/
public function isLocked(User $user): bool;
/**
* UUID 로 사용자를 찾습니다.
*
* @param string $uuid 사용자 UUID
* @return User|null 찾은 사용자 모델 또는 null
*/
public function findByUuid(string $uuid): ?User;
/**
* UUID 목록에 해당하는 사용자의 정수 ID 배열을 반환합니다.
*
* @param array $uuids 사용자 UUID 배열
* @return array<int, int> 사용자 ID 배열
*/
public function getIdsByUuids(array $uuids): array;
/**
* 사용자 ID 목록에 해당하는 이름을 ID 로 색인해 반환합니다.
*
* 목록 화면이 작성자 이름을 행마다 조회하면 N+1 이 되므로, 표시에 필요한
* 이름만 한 번에 모아 오기 위한 배치 조회입니다.
*
* @param array $ids 사용자 ID 배열
* @return array<int, string> 사용자 ID => 이름
*/
public function getNamesByIds(array $ids): array;
/**
* 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다.
*
* @param array $ids 사용자 ID 배열
* @param array $data 갱신할 컬럼 값
* @return int 갱신된 행 수
*/
public function updateManyByIds(array $ids, array $data): int;
/**
* 사용자 ID 목록의 인증 토큰을 모두 삭제합니다.
*
* @param array $ids 사용자 ID 배열
* @return int 삭제된 토큰 수
*/
public function deleteTokensByUserIds(array $ids): int;
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Enums;
/**
* 성능 계측 축 Enum
*
* `g7:bench` 커맨드가 재는 네 가지 대상을 구분합니다. 프로파일 선언의 `type` 필드
* 값 도메인이며, 축마다 필수 옵션과 실행기(`App\Benchmark\Axes\*`)가 다릅니다.
*/
enum BenchmarkAxis: string
{
/**
* 목록 SELECT 비용 (전체 컬럼 / 목록 컬럼 / 키 컬럼 3축 비교)
*/
case ListQuery = 'list';
/**
* 화면 1장 응답 시간 + 실행 쿼리 건수 + N+1 후보
*/
case Screen = 'screen';
/**
* 저장 경로 1회 소요 시간 (주문 생성, 게시글 등록 등)
*/
case Write = 'write';
/**
* 배치 커맨드 소요 시간 + 피크 메모리
*/
case Batch = 'batch';
/**
* 사람이 읽을 수 있는 라벨
*
* @return string 축 라벨
*/
public function label(): string
{
return match ($this) {
self::ListQuery => '목록 조회',
self::Screen => '화면 응답',
self::Write => '쓰기 작업',
self::Batch => '배치 작업',
};
}
/**
* 프로파일 선언에서 반드시 채워야 하는 옵션 키 목록
*
* 각 원소는 "대안 그룹"이며, 그룹마다 최소 하나가 선언되어야 합니다
* (`screen` 축은 라우트명 또는 URI 중 하나). 레지스트리가 이 목록으로 선언을
* 검증하며, 누락된 선언은 사유와 함께 경고로 드러내고 목록에서 제외합니다
* (조용히 버리면 계측 사각이 됩니다).
*
* @return array<int, array<int, string>> 필수 옵션 대안 그룹 목록
*/
public function requiredOptions(): array
{
return match ($this) {
self::ListQuery => [['table']],
self::Screen => [['route', 'uri']],
self::Write => [['callback']],
self::Batch => [['command']],
};
}
/**
* 기본적으로 데이터를 변경하는 축인지 여부
*
* true 인 축은 `--allow-write` 없이는 실행을 거부합니다. `screen` 축은 선언한
* HTTP 메서드에 따라 달라지므로 프로파일 단위로 다시 판정합니다.
*
* @return bool 데이터 변경 축 여부
*/
public function mutatesByDefault(): bool
{
return match ($this) {
self::ListQuery, self::Screen => false,
self::Write, self::Batch => true,
};
}
/**
* 모든 값 배열
*
* @return array<int, string> 축 값 목록
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
+16 -2
View File
@@ -5,10 +5,15 @@ namespace App\Enums;
/**
* 본인인증 코어 purpose Enum.
*
* 코어가 계약으로 보장하는 4종 — `MailIdentityProvider` 가 모두 지원합니다.
* 코어가 계약으로 보장하는 5종 — `MailIdentityProvider` 가 모두 지원합니다.
* 모듈/플러그인은 `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()`
* 로 추가 purpose 를 선언할 수 있으며, 그 값은 `IdentityVerificationManager::declaredPurposes`
* 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 4종만 정의합니다.
* 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 5종만 정의합니다.
*
* case 를 추가하면 `IdentityVerificationManager::$corePurposes` 등록과
* `lang/{ko,en}/identity.php` 의 `purposes.{value}` 라벨을 함께 추가해야 합니다.
* 하나라도 빠지면 `label()` 이 i18n 키 원문을 그대로 돌려주고, 관리자 화면의
* 목적 목록에서 그 목적이 통째로 빠집니다.
*
* @since 7.0.0-beta.5
*/
@@ -26,6 +31,15 @@ enum IdentityVerificationPurpose: string
/** 민감 작업 (결제 등) */
case SensitiveAction = 'sensitive_action';
/**
* 로그인 2단계 인증
*
* 비밀번호 확인을 통과한 뒤 한 단계를 더 요구하는 용도입니다. 다른 purpose 와 달리
* 이 challenge 는 **아직 로그인하지 않은 주체**를 대상으로 하므로, 검증을 마치기
* 전까지 토큰이 발급되지 않습니다.
*/
case Login = 'login';
/**
* 코어 purpose 값 배열.
*
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Enums;
/**
* 검색 인덱스 건강도 등급 (엔진 중립)
*
* 판정 방법은 엔진마다 다릅니다 — 각 `SearchIndexMaintainer` 구현이 자기 방식으로
* 판정한 뒤 이 등급 중 하나로 답합니다. 코어는 등급만 보고 재생성 대상을 고릅니다.
*/
enum SearchIndexStatus: string
{
/** 정상 — 색인된 내용으로 검색이 성립한다 */
case Healthy = 'healthy';
/** 부분 — 일부만 성립. 엔진 특성일 수 있어 자동 재생성 대상이 아니다 */
case Degraded = 'degraded';
/** 색인 누락 — 검색이 성립하지 않는다. 재생성 대상 */
case Stale = 'stale';
/** 판정 불가 — 표본·연결 부재 등 (사유를 함께 기록) */
case Skipped = 'skipped';
/**
* 사용자 친화 라벨을 반환합니다.
*
* @return string lang 키 해석 결과 (locale 자동 반영)
*/
public function label(): string
{
return __('search.index.status.'.$this->value);
}
/**
* 콘솔 출력용 색상명을 반환합니다.
*
* @return string Symfony 콘솔 색상명
*/
public function consoleColor(): string
{
return match ($this) {
self::Healthy => 'green',
self::Degraded => 'yellow',
self::Stale => 'red',
self::Skipped => 'gray',
};
}
/**
* 모든 케이스의 string 값 목록.
*
* @return array<int, string>
*/
public static function allValues(): array
{
return array_column(self::cases(), 'value');
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Enums;
/**
* 사이트맵 <changefreq> 값 Enum (sitemaps.org 폐쇄 어휘)
*
* https://www.sitemaps.org/protocol.html 의 <changefreq> 는 크롤러에게 페이지 갱신
* 빈도를 알려주는 힌트로, 허용 값이 7개로 고정된 폐쇄 어휘다. 이 Enum 이 그 SSoT 이며,
* 잘못된 문자열이 사이트맵 XML 에 유입되는 것을 저장(SitemapIndexer)과 렌더
* (SitemapXmlRenderer) 두 경계에서 차단한다.
*
* 기여자(getUrlsLazy)·리스너·정적 URL 은 리터럴 문자열 대신 이 Enum 의 case 를 사용해
* 오타를 작성 시점에 잡는다.
*/
enum SitemapChangeFreq: string
{
/**
* 접근할 때마다 변경 (예: 실시간 시세)
*/
case Always = 'always';
/**
* 시간 단위 변경
*/
case Hourly = 'hourly';
/**
* 일 단위 변경
*/
case Daily = 'daily';
/**
* 주 단위 변경
*/
case Weekly = 'weekly';
/**
* 월 단위 변경
*/
case Monthly = 'monthly';
/**
* 연 단위 변경
*/
case Yearly = 'yearly';
/**
* 사실상 변경 없음 (예: 보관 URL)
*/
case Never = 'never';
/**
* 임의 문자열을 유효한 changefreq 값으로 정규화합니다.
*
* 대소문자·앞뒤 공백을 흡수하고, 폐쇄 어휘에 없는 값은 null 로 떨어뜨려
* 사이트맵 XML 에 비표준 값이 출력되는 것을 방지합니다.
*
* @param string|null $value 검증할 원본 값
* @return string|null 유효하면 정규화된 값, 아니면 null
*/
public static function normalize(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
return self::tryFrom(strtolower(trim($value)))?->value;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Enums;
/**
* 사이트맵 재생성 모드 Enum
*
* SitemapManager::regenerate 와 GenerateSitemapJob, seo:generate-sitemap 커맨드가
* 공유하는 재생성 모드 도메인입니다.
*
* - Full: 저장소 상태와 무관하게 각 기여자를 스트리밍해 sitemap_urls 를 전량 대체한 뒤 파일 재작성.
* - Auto: 저장소가 비어 있으면 Full, 아니면 Incremental 로 동작(스케줄러 기본).
* - Incremental: 기여자 재쿼리 없이 저장소의 현재 델타만으로 파일 재작성.
*/
enum SitemapGenerationMode: string
{
/**
* 전체 재생성 (관리자 수동 = 항상 Full)
*/
case Full = 'full';
/**
* 자동 판정 (빈 저장소=Full / 채워짐=Incremental)
*/
case Auto = 'auto';
/**
* 증분 재생성 (저장소 델타만 파일로 반영)
*/
case Incremental = 'incremental';
/**
* 저장소 상태를 반영해 실제 실행 모드(Full 또는 Incremental)로 해석합니다.
*
* Auto 는 저장소가 비어 있으면 Full, 아니면 Incremental 로 해석됩니다.
*
* @param int $visibleCount 저장소의 공개 URL 수
* @return self 실제 실행 모드 (Full | Incremental)
*/
public function resolve(int $visibleCount): self
{
return match ($this) {
self::Full => self::Full,
self::Incremental => self::Incremental,
self::Auto => $visibleCount === 0 ? self::Full : self::Incremental,
};
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Enums;
/**
* 목록 총 건수(total)와 실제 매칭 건수의 관계
*
* 대용량 목록에서 `COUNT(*)` 전량 집계는 비용이 커서 상한을 걸고 센다.
* 상한 이하면 센 값이 곧 실제 건수이고, 상한을 넘으면 "그 이상"이라는 사실만 알 수 있다.
* 화면은 이 값을 보고 "1,234건" 과 "10,000건 이상" 을 구분해 표기한다.
*/
enum TotalRelation: string
{
/** 정확 — total 이 실제 매칭 건수와 같다 */
case Exact = 'exact';
/** 하한 — 실제 매칭 건수가 total 이상이다 (상한 초과로 정확히 세지 않음) */
case AtLeast = 'at_least';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 총 건수가 정확한지 여부를 반환합니다.
*
* @return bool 정확하면 true
*/
public function isExact(): bool
{
return $this === self::Exact;
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string lang 키 해석 결과
*/
public function label(): string
{
return __('pagination.total_relation.'.$this->value);
}
}
+19 -4
View File
@@ -12,6 +12,10 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
* HTTP 423 Locked 응답으로 매핑되며, 프론트엔드 토스트는 다국어 키
* `auth.account_locked` 로 잔여 분(`minutes` 플레이스홀더)을 노출합니다.
*
* `login_lockout_time = 0`(무한대) 으로 잠긴 계정은 해제 시각이 없으므로
* `lockedUntil` / `remainingMinutes` 가 모두 null 입니다. 이 경우 프론트엔드는
* 잔여 시간 대신 `auth.account_locked_permanently` 안내를 노출합니다.
*
* 컨트롤러는 본 예외를 별도로 catch 하여 ResponseHelper 응답을 만들거나,
* 글로벌 예외 핸들러가 자동으로 423 JSON 응답으로 변환합니다.
*
@@ -20,15 +24,26 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
class AccountLockedException extends HttpException
{
public function __construct(
public readonly Carbon $lockedUntil,
public readonly int $remainingMinutes,
public readonly ?Carbon $lockedUntil,
public readonly ?int $remainingMinutes,
?string $message = null,
) {
parent::__construct(
423,
$message ?? 'auth.account_locked',
$message ?? ($lockedUntil === null ? 'auth.account_locked_permanently' : 'auth.account_locked'),
null,
['Retry-After' => max(1, $remainingMinutes * 60)]
// 영구 잠금은 재시도 시점을 제시할 수 없으므로 Retry-After 를 붙이지 않는다.
$remainingMinutes === null ? [] : ['Retry-After' => max(1, $remainingMinutes * 60)]
);
}
/**
* 영구 잠금 여부를 반환합니다.
*
* @return bool 해제 시각이 없는 무기한 잠금이면 true
*/
public function isPermanent(): bool
{
return $this->lockedUntil === null;
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 스케줄 실행 실패 예외
*
* 저장된 스케줄을 실행하는 시점에 차단 목록에 걸리거나(artisan/shell 화이트리스트, 내부망 URL),
* 실행 자체가 실패했을 때 발생합니다. `ScheduleService` 가 이 예외를 잡아 실행 이력에 기록합니다.
*/
class ScheduleExecutionException extends Exception
{
/**
* 거부 사유 코드 (`ScheduleCommandValidator::ARTISAN_REASON_*`).
*
* 관리자에게 보이는 메시지는 사유와 무관하게 동일하지만, 운영 진단 로그에는
* 어느 규칙에 걸렸는지가 남아야 한다 — 업그레이드 점검 스텝을 두지 않기로 했으므로
* 실행 이력과 로그가 유일한 통로다.
*/
public ?string $reasonCode = null;
/**
* 허용되지 않은 Artisan 명령
*
* @param string|null $reasonCode 거부 사유 코드 (`ScheduleCommandValidator::ARTISAN_REASON_*`)
* @return self 생성된 예외
*/
public static function artisanNotAllowed(?string $reasonCode = null): self
{
$exception = new self(__('schedule.artisan_not_allowed'));
$exception->reasonCode = $reasonCode;
return $exception;
}
/**
* 허용되지 않은 쉘 명령
*
* @return self 생성된 예외
*/
public static function shellNotAllowed(): self
{
return new self(__('schedule.shell_not_allowed'));
}
/**
* 쉘 명령 실행 실패
*
* @param string $errorOutput 프로세스 표준 에러 출력
* @param int $exitCode 프로세스 종료 코드
* @return self 생성된 예외
*/
public static function shellCommandFailed(string $errorOutput, int $exitCode): self
{
return new self($errorOutput !== '' ? $errorOutput : __('schedule.shell_command_failed'), $exitCode);
}
/**
* 공개되지 않은(내부망) URL 호출 차단
*
* @return self 생성된 예외
*/
public static function urlNotPublic(): self
{
return new self(__('schedule.url_not_public'));
}
/**
* HTTP 요청 실패
*
* @param int $status 응답 상태 코드
* @return self 생성된 예외
*/
public static function httpRequestFailed(int $status): self
{
return new self(__('schedule.http_request_failed', ['status' => $status]), $status);
}
}
+32
View File
@@ -749,6 +749,38 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return [];
}
/**
* 성능 계측 프로파일 정의를 반환합니다.
*
* 이 모듈이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다.
* `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다
* (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지
* 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기
* 때문입니다. 키는 모듈 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가
* `{식별자}/{키}` 로 지목합니다.
*
* `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고
* 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는
* `['Fqcn', 'method']` 로 통일합니다.
*
* @return array<string, array<string, mixed>> 프로파일 키 → 정의
* [
* 'orders' => [
* 'type' => 'list', // list | screen | write | batch
* 'label' => '주문 목록',
* 'table' => 'ecommerce_orders',
* 'columns' => ['id', 'order_number', ...],
* 'order' => [['ordered_at', 'desc']],
* 'filters' => ['order_status' => 'paid'],
* 'soft_delete' => true,
* ],
* ]
*/
public function getBenchmarkProfiles(): array
{
return [];
}
/**
* 모듈 설치 시 실행할 시더 클래스 목록 반환
*
+31
View File
@@ -658,6 +658,37 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return [];
}
/**
* 성능 계측 프로파일 정의를 반환합니다.
*
* 이 플러그인이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다.
* `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다
* (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지
* 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기
* 때문입니다. 키는 플러그인 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가
* `{식별자}/{키}` 로 지목합니다.
*
* `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고
* 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는
* `['Fqcn', 'method']` 로 통일합니다.
*
* @return array<string, array<string, mixed>> 프로파일 키 → 정의
* [
* 'consent_history' => [
* 'type' => 'list', // list | screen | write | batch
* 'label' => '동의 이력 목록',
* 'table' => 'gdpr_user_consent_histories',
* 'columns' => ['*'],
* 'order' => [['created_at', 'desc']],
* 'soft_delete' => false,
* ],
* ]
*/
public function getBenchmarkProfiles(): array
{
return [];
}
/**
* 플러그인 설치 시 실행할 시더 클래스 목록 반환
*
+74 -2
View File
@@ -106,7 +106,7 @@ class ExtensionManager
* 매핑을 즉시 유효화한다. 기존 매핑에 경로가 추가되거나 새 네임스페이스가
* 등록되며, 동일 매핑은 중복 없이 merge 된다.
*/
protected function reregisterRuntimeAutoload(): void
public function reregisterRuntimeAutoload(): void
{
if (! class_exists(ClassLoader::class, false)) {
return;
@@ -135,6 +135,14 @@ class ExtensionManager
*/
public function generateAutoloadFile(): void
{
// 테스트 환경에서는 실 캐시 파일을 재생성하지 않는다.
// testing DB 의 활성 확장 구성은 개발 DB 와 다르므로, 어떤 경로로든 실 캐시가
// 재생성되면 개발 환경 PSR-4 매핑이 testing 기준으로 교체되어 모든 모듈/플러그인
// 라우트가 500 이 된다. 생성 로직 자체를 검증하는 테스트는 경로를 치환해 사용한다.
if ($this->writesRealCacheFileDuringTests()) {
return;
}
// 모듈별 오토로드 수집
$moduleAutoloads = $this->collectModuleAutoloads();
@@ -170,6 +178,24 @@ class ExtensionManager
// 즉시 경로를 반환하도록 한다(성능). 클래스 로딩은 여전히 lazy — 사용 시점에만 include.
$srcClassmap = $this->buildSourceClassmap($psr4);
// 공집합 산출물로 정상 매핑을 덮어쓰지 않는다.
// 이 매핑은 "DB 설치 목록 × 디스크 활성 디렉토리" 교집합이라, DB 쪽만 일시적으로
// 비어도(테이블 부재 · 설치 실패로 행 미기록 · 다른 DB 접속) 교집합이 공집합이 된다.
// 디스크에 설치 형태의 확장 디렉토리가 남아 있는데 매핑이 0건이면 DB 쪽 조회를
// 신뢰할 수 없다는 뜻이므로, 기존 파일을 보존하고 경고만 남긴다.
// (정상적인 0건 — 신규 설치 직후 · 마지막 확장 삭제 — 은 디스크도 함께 비어 통과)
$extensionDirectories = empty($psr4) ? $this->findInstalledExtensionDirectories() : [];
if (empty($psr4) && ! empty($extensionDirectories)) {
Log::warning('확장 오토로드 매핑이 0건으로 산출되어 기존 캐시를 보존합니다', [
'path' => $this->autoloadFilePath,
'extension_directories' => $extensionDirectories,
'hint' => '확장 테이블(modules/plugins) 조회 결과가 비어 있습니다. DB 연결·마이그레이션 상태를 확인한 뒤 extension:update-autoload 를 다시 실행하세요.',
]);
return;
}
// 파일 내용 생성
$content = $this->buildAutoloadFileContent($psr4, $classmap, $files, $vendorAutoloads, $srcClassmap);
@@ -192,6 +218,52 @@ class ExtensionManager
]);
}
/**
* 현재 호출이 테스트 환경에서 실 캐시 파일을 건드리는지 판정합니다.
*
* 경로가 치환된(임시 경로) 인스턴스는 생성 로직 검증용이므로 통과시킨다.
*
* @return bool 테스트 환경에서 실 캐시 파일을 쓰려는 경우 true
*/
protected function writesRealCacheFileDuringTests(): bool
{
return app()->environment('testing')
&& $this->autoloadFilePath === base_path('bootstrap/cache/autoload-extensions.php');
}
/**
* 디스크에서 설치 형태를 갖춘 확장 디렉토리를 찾습니다.
*
* `_bundled` / `_pending` 등 내부 디렉토리는 제외하고, composer.json 을 가진
* 활성 디렉토리만 센다 (설치된 확장이 남긴 흔적).
*
* @return array<int, string> 확장 식별자 목록 (예: `modules/sirsoft-board`)
*/
protected function findInstalledExtensionDirectories(): array
{
$found = [];
foreach (['modules' => $this->modulesPath, 'plugins' => $this->pluginsPath] as $type => $basePath) {
if (! File::isDirectory($basePath)) {
continue;
}
foreach (File::directories($basePath) as $dir) {
$name = basename($dir);
if (str_starts_with($name, '_')) {
continue;
}
if (File::exists($dir.'/composer.json')) {
$found[] = $type.'/'.$name;
}
}
}
return $found;
}
/**
* 오토로드 파일 내용을 생성합니다.
*
@@ -1183,7 +1255,7 @@ PHP;
return $pharPath;
}
throw new \RuntimeException(__('exceptions.extension.composer_binary_not_found'));
throw new \RuntimeException(__('exceptions.vendor.composer_binary_not_found'));
}
/**
+4 -9
View File
@@ -124,15 +124,10 @@ class HookListenerRegistrar
self::addQueuedAction($hookName, $listenerClass, $method, $priority);
}
Log::info('훅 리스너 등록 완료', [
'hook' => $hookName,
'listener' => $listenerClass,
'method' => $method,
'priority' => $priority,
'type' => $type,
'sync' => $forceSync,
'source' => $source,
]);
// 등록 성공은 로그로 남기지 않는다. 리스너 112개 × 구독 400건이 요청마다 부팅되므로
// 한 줄씩만 남겨도 매 요청 400줄이 쌓인다. 기본 설치가 production + LOG_LEVEL 조합상
// 이 줄들을 그대로 기록하므로, 정상 동작을 알리는 데 드는 비용이 동작 자체보다 커진다.
// 등록 실패는 아래 catch 에서 계속 기록한다.
}
}
+46
View File
@@ -19,6 +19,13 @@ class HookManager implements HookManagerInterface
private static array $dispatching = [];
/**
* 구 훅 이름 사용 경고를 이미 남긴 훅 이름 집합 — 요청마다 한 번씩만 경고합니다.
*
* @var array<string, bool>
*/
private static array $legacyHookNoticeShown = [];
/**
* 현재 실행 중인 훅 이름 스택 — 정책 Listener 등 "어느 훅에서 호출되었는지" 알아야 하는
* 단일 핸들러 패턴에 사용됩니다. (내부 전용)
@@ -362,4 +369,43 @@ class HookManager implements HookManagerInterface
return static::applyFilters($hookName, $value, ...$args);
}
/**
* 표준 이름과 구 이름을 함께 발행하는 Filter 실행
*
* 이름이 표준(`core.{대상}.{동작}_validation_rules`)과 어긋난 채 이미 공개된 훅을 표준 이름으로
* 옮길 때 사용합니다. 표준 이름을 먼저 적용하고 그 결과에 구 이름을 다시 적용하므로,
* 새로 구독하는 확장과 구 이름을 구독 중인 기존 확장이 함께 동작합니다.
*
* 구 이름에 실제 구독자가 있을 때만 한 번 경고를 남깁니다 — 개명 사실을 확장 개발자가 알 수 있게
* 하되, 구독자가 없는 환경에서 로그를 어지럽히지 않기 위함입니다.
*
* @param string $hookName 표준 훅 이름
* @param string $legacyHookName 구 훅 이름 (하위호환용)
* @param mixed $value 필터링할 값
* @param mixed ...$args 필터에 전달할 추가 인자
* @return mixed 필터링된 값
*/
public static function applyFiltersWithLegacyName(
string $hookName,
string $legacyHookName,
mixed $value = null,
...$args
): mixed {
$value = static::applyFilters($hookName, $value, ...$args);
if (! isset(self::$filters[$legacyHookName])) {
return $value;
}
if (! isset(self::$legacyHookNoticeShown[$legacyHookName])) {
self::$legacyHookNoticeShown[$legacyHookName] = true;
Log::warning('구 훅 이름 사용 중 — 표준 이름으로 옮겨 주세요', [
'legacy' => $legacyHookName,
'standard' => $hookName,
]);
}
return static::applyFilters($legacyHookName, $value, ...$args);
}
}
@@ -38,9 +38,13 @@ class IdentityVerificationManager
/**
* 코어 기본 purpose 목록.
*
* `signup` / `password_reset` / `self_update` / `sensitive_action` — 이 4종은
* `signup` / `password_reset` / `self_update` / `sensitive_action` / `login` — 이 5종은
* 코어가 계약으로 보장하며 `MailIdentityProvider` 가 모두 지원합니다.
*
* `IdentityVerificationPurpose` 에 case 를 추가하면 이 레지스트리에도 반드시 등록해야
* 합니다. 등록하지 않으면 `getAllPurposes()` 에서 빠져 관리자가 그 목적의 메시지 템플릿·
* 정책을 만들 수 없고, `hasPurpose()` 도 false 가 됩니다.
*
* @var array<string, array<string, mixed>>
*/
protected array $corePurposes = [
@@ -76,6 +80,14 @@ class IdentityVerificationManager
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
IdentityVerificationPurpose::Login->value => [
'label' => 'identity.purposes.login.label',
'description' => 'identity.purposes.login.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
];
/**
@@ -87,7 +99,6 @@ class IdentityVerificationManager
* 프로바이더를 등록합니다.
*
* @param IdentityVerificationInterface $provider IDV 프로바이더 인스턴스
* @return void
*/
public function register(IdentityVerificationInterface $provider): void
{
@@ -98,7 +109,6 @@ class IdentityVerificationManager
* 프로바이더 등록을 해제합니다.
*
* @param string $id 프로바이더 식별자 (예: g7:core.mail)
* @return void
*/
public function unregister(string $id): void
{
@@ -249,7 +259,6 @@ class IdentityVerificationManager
* @param array<string, array<string, mixed>> $purposes key => metadata 매핑
* @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin' 으로 마킹)
* @param string|null $sourceIdentifier source 식별자 (module/plugin id; 미명시 시 'admin')
* @return void
*/
public function registerDeclaredPurposes(array $purposes, ?string $sourceType = null, ?string $sourceIdentifier = null): void
{
@@ -284,7 +293,6 @@ class IdentityVerificationManager
* @param array<string, mixed> $meta label/description/allowed_channels 등 메타데이터
* @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin')
* @param string|null $sourceIdentifier source 식별자 (미명시 시 'admin')
* @return void
*/
public function registerPurpose(string $key, array $meta, ?string $sourceType = null, ?string $sourceIdentifier = null): void
{
@@ -29,6 +29,15 @@ use Illuminate\Support\Str;
*/
class MailIdentityProvider implements IdentityVerificationInterface
{
/** @var int 인증코드 최소 길이 */
public const MIN_CODE_LENGTH = 4;
/** @var int 인증코드 최대 길이 */
public const MAX_CODE_LENGTH = 10;
/** @var int 인증코드 기본 길이 (경계 위반 시 폴백) */
public const DEFAULT_CODE_LENGTH = 6;
public const ID = 'g7:core.mail';
/**
@@ -314,7 +323,11 @@ class MailIdentityProvider implements IdentityVerificationInterface
'code_length' => [
'label' => __('identity.providers.mail.settings.code_length'),
'type' => 'integer',
'default' => 6,
'default' => self::DEFAULT_CODE_LENGTH,
// 경계는 스키마가 선언한다 — 서비스가 자체 상수로 조용히 클램프하면
// 관리자가 설정한 값이 화면 안내와 다르게 동작한다.
'min' => self::MIN_CODE_LENGTH,
'max' => self::MAX_CODE_LENGTH,
'help' => __('identity.providers.mail.settings.code_length_help'),
],
'from_address' => [
@@ -345,9 +358,30 @@ class MailIdentityProvider implements IdentityVerificationInterface
return $purpose === 'password_reset' ? 'link' : 'text_code';
}
/**
* 지정 길이의 숫자 인증코드를 생성합니다.
*
* 경계를 벗어난 값은 조용히 클램프하지 않습니다 — 클램프는 관리자가 설정한 값과
* 실제 동작을 어긋나게 만듭니다. 다만 런타임 인증 흐름을 중단시키는 것은 위험하므로
* 예외 대신 경고 로그 + 스키마 기본값 폴백으로 처리합니다.
*
* @param int $length 요청 코드 길이
* @return string 생성된 숫자 코드
*/
protected function generateNumericCode(int $length): string
{
$length = max(4, min(10, $length));
if ($length < self::MIN_CODE_LENGTH || $length > self::MAX_CODE_LENGTH) {
Log::warning('IDV 인증코드 길이가 허용 범위를 벗어나 기본값으로 대체됨', [
'provider_id' => self::ID,
'requested' => $length,
'min' => self::MIN_CODE_LENGTH,
'max' => self::MAX_CODE_LENGTH,
'applied' => self::DEFAULT_CODE_LENGTH,
]);
$length = self::DEFAULT_CODE_LENGTH;
}
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= (string) random_int(0, 9);
+51 -12
View File
@@ -30,6 +30,7 @@ use App\Extension\Helpers\GithubHelper;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\Testing\ExtensionTestAllowlist;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
@@ -41,6 +42,8 @@ use App\Models\Plugin;
use App\Models\Template;
use App\Providers\CoreServiceProvider;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use App\Support\RouteCacheHelper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
@@ -515,6 +518,7 @@ class ModuleManager implements ModuleManagerInterface
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 모듈의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
@@ -644,6 +648,7 @@ class ModuleManager implements ModuleManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
@@ -786,6 +791,7 @@ class ModuleManager implements ModuleManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module);
@@ -966,6 +972,7 @@ class ModuleManager implements ModuleManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 모듈 자체 캐시 전체 정리
$this->flushModuleCache($module);
@@ -1066,8 +1073,8 @@ class ModuleManager implements ModuleManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/modules/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/modules/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::moduleAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::moduleAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1156,8 +1163,8 @@ class ModuleManager implements ModuleManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/modules/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/modules/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::moduleAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::moduleAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1304,8 +1311,8 @@ class ModuleManager implements ModuleManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/modules/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/modules/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::moduleAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::moduleAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1427,11 +1434,22 @@ class ModuleManager implements ModuleManagerInterface
return null;
}
try {
require_once $moduleFile;
$namespace = $this->convertDirectoryToNamespace($moduleName);
$moduleClass = "Modules\\{$namespace}\\Module";
$namespace = $this->convertDirectoryToNamespace($moduleName);
$moduleClass = "Modules\\{$namespace}\\Module";
try {
// 같은 모듈의 활성 디렉토리 사본이 이미 로드돼 있으면 `_bundled`/`_pending`
// 파일을 그대로 require 할 수 없다 — 같은 FQN 을 두 번 선언하게 되어
// "Cannot declare class ..., because the name is already in use" 로 죽는다.
// 이것은 Error 라 아래 catch(\Exception) 에 걸리지 않아 프로세스가 그대로 종료된다.
//
// 파일 내용을 임시 클래스명으로 eval 해 번들 쪽 메타데이터를 얻는다
// (loadModuleFromDirectory / getFreshModuleInstance 와 동일한 처리).
if (class_exists($moduleClass, false)) {
return $this->evalFreshModule($moduleFile, $moduleClass, dirname($moduleFile));
}
require_once $moduleFile;
if (class_exists($moduleClass)) {
$module = new $moduleClass;
@@ -1439,7 +1457,7 @@ class ModuleManager implements ModuleManagerInterface
return $module;
}
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
Log::debug("Failed to load bundled module instance for {$moduleName}: ".$e->getMessage());
}
@@ -2981,6 +2999,13 @@ class ModuleManager implements ModuleManagerInterface
return;
}
// 테스트 allowlist 확인 — allowlist 밖 모듈은 ServiceProvider 가 등록되지 않으므로
// 리스너만 등록하면 훅 발화 시 Repository 바인딩이 없어 컨테이너 해석이 실패한다.
// (모듈 등록 행은 테스트 프로세스 간 DB 에 남을 수 있어 활성 판정만으로는 부족하다)
if (ExtensionTestAllowlist::isActive() && ! ExtensionTestAllowlist::isAllowed('module', $module->getIdentifier())) {
return;
}
// 모듈 활성화 상태 확인 (비활성화된 모듈의 훅은 등록하지 않음)
$activeIdentifiers = self::getActiveModuleIdentifiers();
if (! in_array($module->getIdentifier(), $activeIdentifiers, true)) {
@@ -3951,9 +3976,11 @@ class ModuleManager implements ModuleManagerInterface
$moduleRecords = $this->moduleRepository->getAllKeyedByIdentifier();
$details = [];
$updatedCount = 0;
$checkedCount = 0;
foreach ($moduleRecords as $identifier => $record) {
$result = $this->checkModuleUpdate($identifier);
$checkedCount++;
// DB 갱신
$updateData = [
@@ -3977,6 +4004,7 @@ class ModuleManager implements ModuleManagerInterface
$updatedCount++;
$details[] = [
'identifier' => $identifier,
'update_available' => true,
'current_version' => $result['current_version'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
@@ -3986,6 +4014,7 @@ class ModuleManager implements ModuleManagerInterface
return [
'updated_count' => $updatedCount,
'checked_count' => $checkedCount,
'details' => $details,
];
}
@@ -4471,7 +4500,11 @@ class ModuleManager implements ModuleManagerInterface
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
if ($previousStatus === ExtensionStatus::Active->value && $module) {
$preserveModified = ($layoutStrategy === 'keep');
$this->registerModuleLayouts($identifier);
// registerModuleLayouts() 를 여기서 호출하지 않는다 — 그 메서드는 전략을
// 모른 채 모든 레이아웃의 content 와 original_content_hash 를 파일 기준으로
// 덮어써서, 뒤따르는 refreshModuleLayouts($preserveModified) 가 비교할
// "사용자 수정본" 을 이미 지워버린다(= keep 전략이 항상 무효화).
// 신규 레이아웃 생성은 refreshModuleLayouts 의 created 분기가 담당한다.
$this->registerLayoutExtensions($module);
$this->refreshModuleLayouts($identifier, $preserveModified);
}
@@ -4484,6 +4517,7 @@ class ModuleManager implements ModuleManagerInterface
$this->clearAllTemplateLanguageCaches();
$this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
self::invalidateModuleStatusCache();
// 훅 발행: 모듈 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
@@ -4534,6 +4568,11 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
// 상태 캐시 무효화 (성공 경로와 동일) — updating 창에서 누군가 활성 목록을 읽었다면
// 그 목록에는 이 모듈이 빠져 있다. 여기서 비우지 않으면 상태를 되돌려 놓고도
// 캐시 TTL(기본 하루) 동안 이 모듈의 화면이 계속 404 로 남는다.
self::invalidateModuleStatusCache();
throw new \RuntimeException(
__('modules.errors.update_failed', [
'module' => $identifier,
+50 -12
View File
@@ -29,6 +29,7 @@ use App\Extension\Helpers\GithubHelper;
use App\Extension\Helpers\IdentityMessageSyncHelper;
use App\Extension\Helpers\IdentityPolicySyncHelper;
use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\Testing\ExtensionTestAllowlist;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
@@ -41,6 +42,8 @@ use App\Models\Template;
use App\Providers\CoreServiceProvider;
use App\Services\DriverRegistryService;
use App\Services\LayoutExtensionService;
use App\Support\AssetUrl;
use App\Support\RouteCacheHelper;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
@@ -493,6 +496,7 @@ class PluginManager implements PluginManagerInterface
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 확장 미들웨어 인덱스 무효화 — 새 플러그인의 미들웨어 선언이 즉시 게이트에 반영.
ExtensionMiddlewareRegistry::flush();
@@ -625,6 +629,7 @@ class PluginManager implements PluginManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
@@ -770,6 +775,7 @@ class PluginManager implements PluginManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin);
@@ -994,6 +1000,7 @@ class PluginManager implements PluginManagerInterface
// 확장 기능 캐시 버전 증가 (프론트엔드 캐시 무효화)
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
// 플러그인 자체 캐시 전체 정리
$this->flushPluginCache($plugin);
@@ -1094,8 +1101,8 @@ class PluginManager implements PluginManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1190,8 +1197,8 @@ class PluginManager implements PluginManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1268,8 +1275,8 @@ class PluginManager implements PluginManagerInterface
if (isset($builtPaths['js']) || isset($builtPaths['css'])) {
$assets = [
'js' => isset($builtPaths['js']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['js'] : null,
'css' => isset($builtPaths['css']) ? "/api/plugins/assets/{$identifier}/".$builtPaths['css'] : null,
'js' => isset($builtPaths['js']) ? AssetUrl::pluginAsset($identifier, $builtPaths['js']) : null,
'css' => isset($builtPaths['css']) ? AssetUrl::pluginAsset($identifier, $builtPaths['css']) : null,
'priority' => $loadingConfig['priority'] ?? 100,
];
}
@@ -1428,11 +1435,21 @@ class PluginManager implements PluginManagerInterface
return null;
}
try {
require_once $pluginFile;
$namespace = $this->convertDirectoryToNamespace($pluginName);
$pluginClass = "Plugins\\{$namespace}\\Plugin";
$namespace = $this->convertDirectoryToNamespace($pluginName);
$pluginClass = "Plugins\\{$namespace}\\Plugin";
try {
// 같은 플러그인의 활성 디렉토리 사본이 이미 로드돼 있으면 `_bundled`/`_pending`
// 파일을 그대로 require 할 수 없다 — 같은 FQN 을 두 번 선언하게 되어
// "Cannot declare class ..., because the name is already in use" 로 죽는다.
// 이것은 Error 라 아래 catch(\Exception) 에 걸리지 않아 프로세스가 그대로 종료된다.
//
// 파일 내용을 임시 클래스명으로 eval 해 번들 쪽 메타데이터를 얻는다.
if (class_exists($pluginClass, false)) {
return $this->evalFreshPlugin($pluginFile, $pluginClass, dirname($pluginFile));
}
require_once $pluginFile;
if (class_exists($pluginClass)) {
$plugin = new $pluginClass;
@@ -1440,7 +1457,7 @@ class PluginManager implements PluginManagerInterface
return $plugin;
}
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
Log::debug("Failed to load bundled plugin instance for {$pluginName}: ".$e->getMessage());
}
@@ -2817,6 +2834,13 @@ class PluginManager implements PluginManagerInterface
protected function registerPluginHookListeners(PluginInterface $plugin): void
{
// 테스트 allowlist 확인 — allowlist 밖 플러그인은 ServiceProvider 가 등록되지 않으므로
// 리스너만 등록하면 훅 발화 시 의존 바인딩이 없어 컨테이너 해석이 실패한다.
// (플러그인 등록 행은 테스트 프로세스 간 DB 에 남을 수 있어 활성 판정만으로는 부족하다)
if (ExtensionTestAllowlist::isActive() && ! ExtensionTestAllowlist::isAllowed('plugin', $plugin->getIdentifier())) {
return;
}
// 플러그인 활성화 상태 확인 (비활성화된 플러그인의 훅은 등록하지 않음)
$activeIdentifiers = self::getActivePluginIdentifiers();
if (! in_array($plugin->getIdentifier(), $activeIdentifiers, true)) {
@@ -4134,9 +4158,11 @@ class PluginManager implements PluginManagerInterface
$pluginRecords = $this->pluginRepository->getAllKeyedByIdentifier();
$details = [];
$updatedCount = 0;
$checkedCount = 0;
foreach ($pluginRecords as $identifier => $record) {
$result = $this->checkPluginUpdate($identifier);
$checkedCount++;
// DB 갱신
$updateData = [
@@ -4160,6 +4186,7 @@ class PluginManager implements PluginManagerInterface
$updatedCount++;
$details[] = [
'identifier' => $identifier,
'update_available' => true,
'current_version' => $result['current_version'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
@@ -4169,6 +4196,7 @@ class PluginManager implements PluginManagerInterface
return [
'updated_count' => $updatedCount,
'checked_count' => $checkedCount,
'details' => $details,
];
}
@@ -4652,7 +4680,11 @@ class PluginManager implements PluginManagerInterface
$onProgress?->__invoke('layout', '레이아웃 갱신 중...');
if ($previousStatus === ExtensionStatus::Active->value && $plugin) {
$preserveModified = ($layoutStrategy === 'keep');
$this->registerPluginLayouts($identifier);
// registerPluginLayouts() 를 여기서 호출하지 않는다 — 그 메서드는 전략을
// 모른 채 모든 레이아웃의 content 와 original_content_hash 를 파일 기준으로
// 덮어써서, 뒤따르는 refreshPluginLayouts($preserveModified) 가 비교할
// "사용자 수정본" 을 이미 지워버린다(= keep 전략이 항상 무효화).
// 신규 레이아웃 생성은 refreshPluginLayouts 의 created 분기가 담당한다.
$this->registerLayoutExtensions($plugin);
$this->refreshPluginLayouts($identifier, $preserveModified);
}
@@ -4665,6 +4697,7 @@ class PluginManager implements PluginManagerInterface
$this->clearAllTemplateLanguageCaches();
$this->clearAllTemplateRoutesCaches();
$this->incrementExtensionCacheVersion();
RouteCacheHelper::rebuild();
self::invalidatePluginStatusCache();
// 훅 발행: 플러그인 업데이트 완료 (Artisan 직접 호출 시에도 리스너 트리거)
@@ -4715,6 +4748,11 @@ class PluginManager implements PluginManagerInterface
'updated_at' => now(),
]);
// 상태 캐시 무효화 (성공 경로와 동일) — updating 창에서 누군가 활성 목록을 읽었다면
// 그 목록에는 이 플러그인이 빠져 있다. 여기서 비우지 않으면 상태를 되돌려 놓고도
// 캐시 TTL(기본 하루) 동안 이 플러그인의 화면이 계속 404 로 남는다.
self::invalidatePluginStatusCache();
throw new \RuntimeException(
__('plugins.errors.update_failed', [
'plugin' => $identifier,
+13 -1
View File
@@ -2685,9 +2685,11 @@ class TemplateManager implements TemplateManagerInterface
$templateRecords = $this->templateRepository->getAllKeyedByIdentifier();
$details = [];
$updatedCount = 0;
$checkedCount = 0;
foreach ($templateRecords as $identifier => $record) {
$result = $this->checkTemplateUpdate($identifier);
$checkedCount++;
$updateData = [
'update_available' => $result['update_available'],
@@ -2711,6 +2713,7 @@ class TemplateManager implements TemplateManagerInterface
$updatedCount++;
$details[] = [
'identifier' => $identifier,
'update_available' => true,
'current_version' => $result['current_version'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
@@ -2720,6 +2723,7 @@ class TemplateManager implements TemplateManagerInterface
return [
'updated_count' => $updatedCount,
'checked_count' => $checkedCount,
'details' => $details,
];
}
@@ -2806,7 +2810,15 @@ class TemplateManager implements TemplateManagerInterface
];
}
$allLayouts = $this->layoutRepository->getByTemplateId($record->id);
// 템플릿 자신이 소유한 레이아웃만 센다 — getByTemplateId() 는 같은 template_id 에
// 등록된 모듈/플러그인 소유 레이아웃까지 반환해서, 남의 확장 수정본이 템플릿
// 업데이트 모달에 자기 것으로 집계된다. 실제 갱신 범위(refreshTemplateLayouts)는
// source_type='template' + source_identifier=null 뿐이므로 표시와 동작이 어긋난다.
$allLayouts = $this->layoutRepository->getByTemplateIdWithFilter(
$record->id,
'template',
null
);
$modifiedLayouts = $allLayouts->filter(function ($layout) {
if (! $layout->original_content_hash) {
return false; // hash 없으면 (레거시 데이터) 미수정 취급
@@ -2,6 +2,9 @@
namespace App\Extension\Testing;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
/**
* 테스트 환경 확장 로딩 allowlist
*
@@ -50,6 +53,8 @@ class ExtensionTestAllowlist
*/
public static function set(array $extensions): void
{
$previous = self::signature();
self::$plugins = [];
self::$modules = [];
self::$configured = true;
@@ -63,6 +68,27 @@ class ExtensionTestAllowlist
self::$modules[] = basename($extension);
}
}
if (self::signature() !== $previous) {
self::forgetRegisteredHooks();
}
}
/**
* allowlist 가 바뀌었을 때 남아 있는 훅 등록을 비웁니다.
*
* `HookManager` 의 훅/필터 맵과 `HookListenerRegistrar` 의 등록 이력은 static 이라
* 테스트 클래스가 바뀌어도 프로세스에 그대로 남습니다. 앞 클래스가 허용했던 확장의
* 리스너가 남아 있으면, 그 확장을 배선하지 않은 다음 클래스의 앱에서 훅이 발화할 때
* `app($listenerClass)` 해석이 실패합니다 (Filter 훅은 동기 실행이라 그대로 500 이 된다).
*
* 비운 직후 새 앱이 부팅되며 코어·허용 확장 리스너를 다시 등록하므로, 이 초기화는
* "이번 allowlist 에 맞는 등록만 남긴다" 는 의미가 됩니다.
*/
private static function forgetRegisteredHooks(): void
{
HookManager::resetAll();
HookListenerRegistrar::clear();
}
/**
@@ -77,6 +103,25 @@ class ExtensionTestAllowlist
self::$configured = false;
}
/**
* 현재 allowlist 를 비교 가능한 문자열로 반환합니다.
*
* @return string allowlist 서명 (미설정이면 빈 문자열)
*/
private static function signature(): string
{
if (! self::$configured) {
return '';
}
$modules = self::$modules;
$plugins = self::$plugins;
sort($modules);
sort($plugins);
return 'm:'.implode(',', $modules).'|p:'.implode(',', $plugins);
}
/**
* 가드 활성 여부를 반환합니다.
*
+39 -8
View File
@@ -29,16 +29,49 @@ trait CachesModuleStatus
return [];
}
return self::resolveStatusCache()->remember(
return self::rememberNonEmpty(
'ext.modules.active_identifiers',
fn () => Module::where('status', ExtensionStatus::Active->value)
->pluck('identifier')
->toArray(),
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.modules']
->toArray()
);
}
/**
* 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다.
*
* 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서
* 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든 확장이
* 꺼진 것처럼 동작한다 — 관리자 화면이 통째로 404 가 되고 스스로 회복되지 않는다.
* 빈 결과는 재계산 비용이 사실상 없는 단순 조회이므로 캐시하지 않는 편이 안전하다.
*
* @param string $key 캐시 키
* @param \Closure $resolver 목록 계산 클로저
* @return array<string> 조회된 identifier 배열
*/
private static function rememberNonEmpty(string $key, \Closure $resolver): array
{
$cache = self::resolveStatusCache();
$cached = $cache->get($key);
if (is_array($cached) && $cached !== []) {
return $cached;
}
$fresh = $resolver();
if ($fresh !== []) {
$cache->put(
$key,
$fresh,
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.modules']
);
}
return $fresh;
}
/**
* 설치된 모듈 (active + inactive) identifier 목록을 조회합니다.
*
@@ -50,14 +83,12 @@ trait CachesModuleStatus
return [];
}
return self::resolveStatusCache()->remember(
return self::rememberNonEmpty(
'ext.modules.installed_identifiers',
fn () => Module::whereIn('status', [
ExtensionStatus::Active->value,
ExtensionStatus::Inactive->value,
])->pluck('identifier')->toArray(),
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.modules']
])->pluck('identifier')->toArray()
);
}
+39 -8
View File
@@ -29,16 +29,49 @@ trait CachesPluginStatus
return [];
}
return self::resolvePluginStatusCache()->remember(
return self::rememberNonEmptyPluginList(
'ext.plugins.active_identifiers',
fn () => Plugin::where('status', ExtensionStatus::Active->value)
->pluck('identifier')
->toArray(),
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.plugins']
->toArray()
);
}
/**
* 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다.
*
* 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서
* 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든
* 플러그인이 꺼진 것처럼 동작하며 스스로 회복되지 않는다. 빈 결과는 재계산 비용이 사실상
* 없는 단순 조회이므로 캐시하지 않는 편이 안전하다.
*
* @param string $key 캐시 키
* @param \Closure $resolver 목록 계산 클로저
* @return array<string> 조회된 identifier 배열
*/
private static function rememberNonEmptyPluginList(string $key, \Closure $resolver): array
{
$cache = self::resolvePluginStatusCache();
$cached = $cache->get($key);
if (is_array($cached) && $cached !== []) {
return $cached;
}
$fresh = $resolver();
if ($fresh !== []) {
$cache->put(
$key,
$fresh,
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.plugins']
);
}
return $fresh;
}
/**
* 설치된 플러그인 (active + inactive) identifier 목록을 조회합니다.
*
@@ -50,14 +83,12 @@ trait CachesPluginStatus
return [];
}
return self::resolvePluginStatusCache()->remember(
return self::rememberNonEmptyPluginList(
'ext.plugins.installed_identifiers',
fn () => Plugin::whereIn('status', [
ExtensionStatus::Active->value,
ExtensionStatus::Inactive->value,
])->pluck('identifier')->toArray(),
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.plugins']
])->pluck('identifier')->toArray()
);
}
+41 -12
View File
@@ -29,13 +29,11 @@ trait CachesTemplateStatus
return [];
}
return self::resolveTemplateStatusCache()->remember(
return self::rememberNonEmptyTemplateList(
'ext.templates.active_identifiers',
fn () => Template::where('status', ExtensionStatus::Active->value)
->pluck('identifier')
->toArray(),
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.templates']
->toArray()
);
}
@@ -51,14 +49,12 @@ trait CachesTemplateStatus
return [];
}
return self::resolveTemplateStatusCache()->remember(
return self::rememberNonEmptyTemplateList(
"ext.templates.active_identifiers_{$type}",
fn () => Template::where('status', ExtensionStatus::Active->value)
->where('type', $type)
->pluck('identifier')
->toArray(),
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.templates']
->toArray()
);
}
@@ -73,17 +69,50 @@ trait CachesTemplateStatus
return [];
}
return self::resolveTemplateStatusCache()->remember(
return self::rememberNonEmptyTemplateList(
'ext.templates.installed_identifiers',
fn () => Template::whereIn('status', [
ExtensionStatus::Active->value,
ExtensionStatus::Inactive->value,
])->pluck('identifier')->toArray(),
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.templates']
])->pluck('identifier')->toArray()
);
}
/**
* 목록 캐시를 조회하되, 빈 결과는 캐시에 남기지 않습니다.
*
* 확장 상태는 install/activate/update 도중 잠시 active 가 아닌 값으로 바뀐다. 그 창에서
* 누가 이 목록을 읽으면 빈 배열이 TTL(기본 하루) 동안 굳어, 작업이 끝난 뒤에도 모든
* 템플릿이 꺼진 것처럼 동작하며 스스로 회복되지 않는다. 빈 결과는 재계산 비용이 사실상
* 없는 단순 조회이므로 캐시하지 않는 편이 안전하다.
*
* @param string $key 캐시 키
* @param \Closure $resolver 목록 계산 클로저
* @return array<string> 조회된 identifier 배열
*/
private static function rememberNonEmptyTemplateList(string $key, \Closure $resolver): array
{
$cache = self::resolveTemplateStatusCache();
$cached = $cache->get($key);
if (is_array($cached) && $cached !== []) {
return $cached;
}
$fresh = $resolver();
if ($fresh !== []) {
$cache->put(
$key,
$fresh,
(int) g7_core_settings('cache.extension_status_ttl', 86400),
['ext.status', 'ext.templates']
);
}
return $fresh;
}
/**
* 템플릿 상태 캐시를 무효화합니다.
* 템플릿 상태 변경 시 (install, activate, deactivate, uninstall) 호출해야 합니다.
+14 -2
View File
@@ -2,7 +2,6 @@
namespace App\Helpers;
use App\Enums\ScopeType;
use App\Models\Permission;
use App\Models\User;
use App\Providers\AuthServiceProvider;
@@ -26,6 +25,20 @@ class PermissionHelper
*/
protected static array $permissionCache = [];
/**
* 권한 스코프 캐시를 무효화합니다.
*
* 캐시는 프로세스 수명 동안 유지되므로, 권한 행이 다시 만들어지는 시점
* (권한 재시드 · 코어 업데이트 · 확장 install) 에 비워 주어야 한다.
* 테스트에서는 매 케이스마다 DB 가 초기화되는데 캐시만 남아, 앞 케이스가 심은
* `resource_route_key = null` 이 뒤 케이스로 새어 스코프 검사가 통째로 건너뛰어진다
* (개별 실행은 통과하고 스위트에서만 어긋나는 형태로 드러난다).
*/
public static function clearPermissionScopeCache(): void
{
self::$permissionCache = [];
}
/**
* 단일 권한 체크
*
@@ -104,7 +117,6 @@ class PermissionHelper
* @param Builder $query Eloquent 쿼리 빌더
* @param string $permission 권한 식별자
* @param User|null $user 사용자 (null이면 현재 인증 사용자)
* @return void
*/
public static function applyPermissionScope(Builder $query, string $permission, ?User $user = null): void
{
+34 -20
View File
@@ -21,7 +21,7 @@ class ResponseHelper
/**
* 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.success')
* @param string $messageKey 메시지 키 (기본값: 'common.success')
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 메시지 매개변수
@@ -29,7 +29,7 @@ class ResponseHelper
* @return JsonResponse JSON 응답
*/
public static function success(
string $messageKey = 'messages.success',
string $messageKey = 'common.success',
mixed $data = null,
int $statusCode = 200,
array $messageParams = [],
@@ -45,7 +45,7 @@ class ResponseHelper
/**
* 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.failed')
* @param string $messageKey 메시지 키 (기본값: 'common.failed')
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
@@ -53,7 +53,7 @@ class ResponseHelper
* @return JsonResponse JSON 응답
*/
public static function error(
string $messageKey = 'messages.failed',
string $messageKey = 'common.failed',
int $statusCode = 400,
mixed $errors = null,
array $messageParams = [],
@@ -87,14 +87,14 @@ class ResponseHelper
* 입력 검증 실패 응답을 생성합니다.
*
* @param mixed $errors 검증 오류 정보
* @param string $messageKey 메시지 키 (기본값: 'messages.validation_failed')
* @param string $messageKey 메시지 키 (기본값: 'common.validation_failed')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 422 상태 코드를 가진 JSON 응답
*/
public static function validationError(
mixed $errors,
string $messageKey = 'messages.validation_failed',
string $messageKey = 'common.validation_failed',
array $messageParams = [],
string $domain = 'core'
): JsonResponse {
@@ -108,13 +108,13 @@ class ResponseHelper
/**
* 인증 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키 (기본값: 'messages.unauthorized')
* @param string $messageKey 메시지 키 (기본값: 'common.unauthorized')
* @param array $messageParams 메시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 401 상태 코드를 가진 JSON 응답
*/
public static function unauthorized(
string $messageKey = 'messages.unauthorized',
string $messageKey = 'common.unauthorized',
array $messageParams = [],
string $domain = 'core'
): JsonResponse {
@@ -127,13 +127,13 @@ class ResponseHelper
/**
* 권한 부족 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.forbidden')
* @param string $messageKey 멤시지 키 (기본값: 'common.forbidden')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 403 상태 코드를 가진 JSON 응답
*/
public static function forbidden(
string $messageKey = 'messages.forbidden',
string $messageKey = 'common.forbidden',
array $messageParams = [],
string $domain = 'core'
): JsonResponse {
@@ -146,13 +146,13 @@ class ResponseHelper
/**
* 리소스를 찾을 수 없음 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.not_found')
* @param string $messageKey 멤시지 키 (기본값: 'common.not_found')
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 404 상태 코드를 가진 JSON 응답
*/
public static function notFound(
string $messageKey = 'messages.not_found',
string $messageKey = 'common.not_found',
array $messageParams = [],
string $domain = 'core'
): JsonResponse {
@@ -165,14 +165,14 @@ class ResponseHelper
/**
* 서버 내부 오류 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.error_occurred')
* @param string $messageKey 멤시지 키 (기본값: 'common.error_occurred')
* @param mixed $error 오류 정보 (디버그 모드에서만 표시)
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 500 상태 코드를 가진 JSON 응답
*/
public static function serverError(
string $messageKey = 'messages.error_occurred',
string $messageKey = 'common.error_occurred',
mixed $error = null,
array $messageParams = [],
string $domain = 'core'
@@ -335,7 +335,13 @@ class ResponseHelper
/**
* JSON Resource를 사용한 성공 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* `$resource->additional([...])` 로 실은 부가 데이터는 응답 최상위에 함께 나갑니다.
* `JsonResource::resolve()` 는 리소스 본문만 돌려주므로, 그것만 쓰면 호출자가 실은
* 부가 데이터가 조용히 사라집니다 — 예를 들어 확장 업데이트 응답의 `search_index`
* (색인 누락 안내)가 사라지면 검색이 0건을 돌려주는 사실을 운영자가 알 방법이 없습니다.
* `success`/`message`/`data` 키는 부가 데이터로 덮어쓰지 않습니다.
*
* @param string $messageKey 메시지 키 (기본값: 'common.success')
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
* @param array $messageParams 멤시지 매개변수
@@ -343,7 +349,7 @@ class ResponseHelper
* @return JsonResponse JSON 응답
*/
public static function successWithResource(
string $messageKey = 'messages.success',
string $messageKey = 'common.success',
JsonResource|ResourceCollection|null $resource = null,
int $statusCode = 200,
array $messageParams = [],
@@ -351,11 +357,19 @@ class ResponseHelper
): JsonResponse {
$data = $resource ? $resource->resolve() : null;
return response()->json([
$payload = [
'success' => true,
'message' => self::trans($messageKey, $messageParams, $domain),
'data' => $data,
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
];
$additional = $resource instanceof JsonResource ? (array) $resource->additional : [];
if ($additional !== []) {
$payload += array_diff_key($additional, $payload);
}
return response()->json($payload, $statusCode, [], self::JSON_ENCODE_OPTIONS);
}
/**
@@ -401,14 +415,14 @@ class ResponseHelper
/**
* 페이지네이션된 리소스 응답을 생성합니다.
*
* @param string $messageKey 멤시지 키 (기본값: 'messages.success')
* @param string $messageKey 메시지 키 (기본값: 'common.success')
* @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션
* @param array $messageParams 멤시지 매개변수
* @param string $domain 번역 도메인 (기본값: 'core')
* @return JsonResponse 페이지네이션 메타 데이터를 포함한 JSON 응답
*/
public static function successWithPagination(
string $messageKey = 'messages.success',
string $messageKey = 'common.success',
?ResourceCollection $collection = null,
array $messageParams = [],
string $domain = 'core'
+27
View File
@@ -198,4 +198,31 @@ class TimezoneHelper
return Carbon::parse($normalized, static::getSiteTimezone())->utc();
}
/**
* 기간 필터의 **종료값**을 사이트 기본 타임존 기준으로 해석하여 UTC Carbon을 반환합니다.
*
* `<input type="date">` 는 시각 없이 'Y-m-d' 만 보낸다. 이 값을 그대로 datetime 비교에 쓰면
* '00:00:00' 으로 해석되어 **종료일 당일이 통째로 빠진다**. 시각이 없는 입력만 그날 끝
* (23:59:59)까지 확장하고, 시각이 함께 온 입력은 그 시각 그대로 둔다.
*
* 예: '2026-03-15' + Asia/Seoul → 2026-03-15 23:59:59 KST → 2026-03-15 14:59:59 UTC
* '2026-03-15 09:30' + Asia/Seoul → 2026-03-15 00:30:00 UTC (확장하지 않음)
*
* @param string|null $dateTimeString Y-m-d 또는 Y-m-d\TH:i / Y-m-d H:i:s 형식
* @return Carbon|null UTC Carbon 인스턴스
*/
public static function fromSiteRangeEnd(?string $dateTimeString): ?Carbon
{
if (! $dateTimeString) {
return null;
}
$normalized = trim(str_replace('T', ' ', $dateTimeString));
$dateOnly = preg_match('/^\d{4}-\d{2}-\d{2}$/', $normalized) === 1;
$parsed = Carbon::parse($normalized, static::getSiteTimezone());
return ($dateOnly ? $parsed->endOfDay() : $parsed)->utc();
}
}
@@ -8,6 +8,7 @@ use App\Extension\Helpers\EditorSpecAssembler;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\PermissionService;
use App\Services\TemplateService;
use App\Support\AssetUrl;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
@@ -88,17 +89,19 @@ class AdminTemplateAssetController extends AdminBaseController
}
$extensionCacheVersion = (int) app(CacheInterface::class)->get('ext.cache_version', 0);
$version = $extensionCacheVersion > 0 ? "?v={$extensionCacheVersion}" : '';
$version = $extensionCacheVersion > 0 ? $extensionCacheVersion : null;
return $this->success(
__('templates.messages.editor_assets_retrieved'),
[
'identifier' => $identifier,
'js' => ["/api/templates/assets/{$identifier}/js/components.iife.js{$version}"],
'js' => [AssetUrl::templateAsset($identifier, 'js/components.iife.js', $version)],
// CSS 는 편집기 전용 엔드포인트로 — 다크 셀렉터를 프리뷰 마커로 치환해 서빙
// 일반 자산 서빙은 원본.
// URI 가 `components` 가 아니라 `component-styles` 인 이유: 확장자를 떼면
// `editor/components.json` 의 확장자 없는 형태와 충돌한다.
'css' => $cssAvailable
? ["/api/admin/templates/{$identifier}/editor/components.css{$version}"]
? [AssetUrl::suffixed("/api/admin/templates/{$identifier}/editor/component-styles", 'css', $version)]
: [],
'manifest_present' => true,
'manifest_source' => $jsSource,
@@ -4,11 +4,11 @@ namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\Auth\AccountLockedException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Auth\AuthenticatedRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class AuthController extends AdminBaseController
@@ -22,7 +22,7 @@ class AuthController extends AdminBaseController
/**
* 관리자를 로그인시킵니다.
*
* @param LoginRequest $request 로그인 요청 데이터
* @param LoginRequest $request 로그인 요청 데이터
* @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답
*/
public function login(LoginRequest $request): JsonResponse
@@ -36,7 +36,7 @@ class AuthController extends AdminBaseController
$user = $data['user'];
// 관리자 권한 확인
if (!$user->isAdmin()) {
if (! $user->isAdmin()) {
return $this->forbidden('auth.admin_required');
}
@@ -45,10 +45,17 @@ class AuthController extends AdminBaseController
return $this->success('auth.admin_login_success', $data);
} catch (AccountLockedException $e) {
return $this->error('auth.account_locked', 423, [
'locked_until' => $e->lockedUntil->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes * 60,
], ['minutes' => $e->remainingMinutes]);
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
@@ -57,10 +64,10 @@ class AuthController extends AdminBaseController
/**
* 관리자를 로그아웃시킵니다.
*
* @param Request $request HTTP 요청
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logout(Request $request): JsonResponse
public function logout(AuthenticatedRequest $request): JsonResponse
{
$this->authService->logout($request->user());
@@ -70,10 +77,10 @@ class AuthController extends AdminBaseController
/**
* 현재 로그인된 관리자의 정보를 반환합니다.
*
* @param Request $request HTTP 요청
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 관리자 정보를 포함한 JSON 응답
*/
public function user(Request $request): JsonResponse
public function user(AuthenticatedRequest $request): JsonResponse
{
$user = $request->user();
@@ -89,10 +96,10 @@ class AuthController extends AdminBaseController
/**
* 관리자의 인증 토큰을 갱신합니다.
*
* @param Request $request HTTP 요청
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 새로운 토큰과 관리자 정보를 포함한 JSON 응답
*/
public function refresh(Request $request): JsonResponse
public function refresh(AuthenticatedRequest $request): JsonResponse
{
try {
$data = $this->authService->refreshToken($request->user());
@@ -101,6 +108,7 @@ class AuthController extends AdminBaseController
if (isset($data['user'])) {
$data['user'] = new UserResource($data['user']);
}
return $this->success('common.success', $data);
} catch (ValidationException $e) {
return $this->unauthorized('auth.unauthenticated');
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Admin\Identity;
use App\Enums\PermissionType;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Identity\AdminIdentityLogIndexRequest;
use App\Http\Requests\Identity\AdminIdentityLogPurgeRequest;
@@ -45,7 +46,7 @@ class AdminIdentityLogController extends AdminBaseController
$paginated = $this->logService->search($filters, (int) ($validated['per_page'] ?? 20));
return $this->success('messages.success', [
return $this->success('common.success', [
'data' => IdentityLogResource::collection(collect($paginated->items()))->resolve(),
'pagination' => [
'current_page' => $paginated->currentPage(),
@@ -59,7 +60,7 @@ class AdminIdentityLogController extends AdminBaseController
'abilities' => [
'can_purge' => $request->user()?->hasPermission(
'core.admin.identity.logs.purge',
\App\Enums\PermissionType::Admin,
PermissionType::Admin,
) ?? false,
],
]);
@@ -76,7 +77,7 @@ class AdminIdentityLogController extends AdminBaseController
$days = (int) ($request->validated()['older_than_days'] ?? 180);
$count = $this->logService->purge($days);
return $this->success('messages.success', [
return $this->success('common.success', [
'purged_count' => $count,
'older_than_days' => $days,
]);
@@ -60,7 +60,7 @@ class AdminIdentityPolicyController extends AdminBaseController
$paginated = $this->policyService->search($filters, $perPage);
$collection = (new PolicyCollection($paginated))->toArray($request);
return $this->success('messages.success', [
return $this->success('common.success', [
'data' => $collection['data'],
'abilities' => $collection['abilities'] ?? [],
'meta' => [
@@ -83,7 +83,7 @@ class AdminIdentityPolicyController extends AdminBaseController
$policy = $this->policyService->createAdminPolicy($request->validated());
return $this->success(
'messages.created',
'common.created',
(new PolicyResource($policy))->toArray($request),
201,
);
@@ -101,7 +101,7 @@ class AdminIdentityPolicyController extends AdminBaseController
$policy = $this->policyService->findById($id);
if (! $policy) {
return $this->error('messages.not_found', 404);
return $this->error('common.not_found', 404);
}
$validated = $request->validated();
@@ -119,13 +119,13 @@ class AdminIdentityPolicyController extends AdminBaseController
}
if (! $this->policyService->updatePolicy($policy, $validated)) {
return $this->error('messages.failed', 500);
return $this->error('common.failed', 500);
}
$policy->refresh();
return $this->success(
'messages.updated',
'common.updated',
(new PolicyResource($policy))->toArray($request),
);
}
@@ -142,16 +142,16 @@ class AdminIdentityPolicyController extends AdminBaseController
$policy = $this->policyService->findById($id);
if (! $policy) {
return $this->error('messages.not_found', 404);
return $this->error('common.not_found', 404);
}
if ($policy->source_type !== IdentityPolicySourceType::Admin) {
return $this->forbidden('messages.cannot_delete_system_resource');
return $this->forbidden('identity.errors.cannot_delete_system_policy');
}
return $this->policyService->deleteAdminPolicy($policy)
? $this->success('messages.deleted')
: $this->error('messages.failed', 500);
? $this->success('common.deleted')
: $this->error('common.failed', 500);
}
/**
@@ -170,7 +170,7 @@ class AdminIdentityPolicyController extends AdminBaseController
$policy = $this->policyService->findById($id);
if (! $policy) {
return $this->error('messages.not_found', 404);
return $this->error('common.not_found', 404);
}
if ($policy->source_type === IdentityPolicySourceType::Admin) {
@@ -184,6 +184,6 @@ class AdminIdentityPolicyController extends AdminBaseController
return $this->error('identity.errors.reset_field_failed', 422);
}
return $this->successWithResource('messages.success', new PolicyResource($policy->fresh()));
return $this->successWithResource('common.success', new PolicyResource($policy->fresh()));
}
}
@@ -5,9 +5,9 @@ namespace App\Http\Controllers\Api\Admin\Identity;
use App\Extension\HookManager;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Identity\AdminIdentityProviderIndexRequest;
use App\Http\Resources\Identity\ProviderResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 관리자 — IDV 프로바이더 조회 + 설정 스키마 수집 컨트롤러.
@@ -28,10 +28,10 @@ class AdminIdentityProviderController extends AdminBaseController
/**
* 등록된 프로바이더 목록과 각 프로바이더의 설정 스키마를 반환합니다.
*
* @param Request $request HTTP 요청
* @param AdminIdentityProviderIndexRequest $request 검증된 요청 (파라미터 없음)
* @return JsonResponse 프로바이더 목록 (settings_schema 포함)
*/
public function index(Request $request): JsonResponse
public function index(AdminIdentityProviderIndexRequest $request): JsonResponse
{
$providers = array_values($this->manager->all());
@@ -48,6 +48,6 @@ class AdminIdentityProviderController extends AdminBaseController
return $resource + ['settings_schema' => is_array($schema) ? $schema : []];
}, $providers);
return $this->success('messages.success', $rows);
return $this->success('common.success', $rows);
}
}
@@ -4,8 +4,10 @@ namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\LayoutVersionListRequest;
use App\Http\Requests\Layout\StoreLayoutPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutContentRequest;
use App\Http\Resources\LayoutListResource;
use App\Http\Resources\LayoutResource;
use App\Http\Resources\LayoutVersionResource;
use App\Services\LayoutPreviewService;
@@ -38,15 +40,26 @@ class LayoutController extends AdminBaseController
return $this->notFound('common.not_found');
}
$layouts = $this->layoutService->getLayoutsByTemplateId($template->id);
// 목록은 본문(`content`)을 담지 않는 경량 조회를 쓴다 — 파일 목록 화면은 이름·설명·
// 크기·수정일만 표시하고, 편집 대상 본문은 상세 엔드포인트가 따로 제공한다.
// 종전에는 목록·상세가 같은 Resource 를 공용해 102개 레이아웃의 본문이 전부 실렸고
// (응답 18.30MB 중 content 17.41MB), 디버그 모드에서 렌더러가 메모리 한계를 넘었다.
$layouts = $this->layoutService->getLayoutListByTemplateId($template->id);
// 레이아웃 이름 → 라우트 path 매핑 — 코드 편집기가 파일 선택 시 ?route= URL
// 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용한다.
$routePathMap = $this->templateService->getLayoutRoutePathMap($templateName);
$collection = LayoutResource::collection($layouts);
// 레이아웃 설명(`meta.description`)은 그 레이아웃을 소유한 템플릿의 사전 키를 쓴다.
// 코드 편집 화면은 관리자 템플릿 사전으로 렌더하므로 유저 템플릿 키를 알지 못해,
// 해석하지 않고 내보내면 설명 칸에 `$t:user.…` 가 원문으로 노출된다.
$translations = $this->resolveTemplateTranslations($templateName);
$collection = LayoutListResource::collection($layouts);
$collection->collection->transform(
fn (LayoutResource $resource) => $resource->withRoutePathMap($routePathMap)
fn (LayoutListResource $resource) => $resource
->withRoutePathMap($routePathMap)
->withTranslations($translations)
);
return $this->success('common.success', $collection);
@@ -75,7 +88,8 @@ class LayoutController extends AdminBaseController
return $this->success(
'common.success',
new LayoutResource($layout)
(new LayoutResource($layout))
->withTranslations($this->resolveTemplateTranslations($templateName))
);
}
@@ -104,7 +118,8 @@ class LayoutController extends AdminBaseController
return $this->success(
'common.success',
new LayoutResource($layout)
(new LayoutResource($layout))
->withTranslations($this->resolveTemplateTranslations($templateName))
);
} catch (ConcurrentModificationException $e) {
DB::rollBack();
@@ -134,11 +149,12 @@ class LayoutController extends AdminBaseController
/**
* 레이아웃의 모든 버전 목록 조회
*
* @param LayoutVersionListRequest $request 버전 목록 조회 요청 (limit)
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse 버전 목록 응답
*/
public function versions(string $templateName, string $name): JsonResponse
public function versions(LayoutVersionListRequest $request, string $templateName, string $name): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
@@ -152,12 +168,19 @@ class LayoutController extends AdminBaseController
return $this->notFound('common.not_found');
}
$versions = $this->layoutService->getLayoutVersions($template->id, $name);
return $this->success(
'common.success',
LayoutVersionResource::collection($versions)
$versions = $this->layoutService->getLayoutVersions(
$template->id,
$name,
(int) ($request->validated()['limit'] ?? LayoutVersionListRequest::DEFAULT_LIMIT)
);
// 목록은 경량 표현만 내려준다 — 분해된 본문은 버전 비교 diff 전용이라 단건 조회가 공급한다.
$items = $versions
->map(fn ($version) => (new LayoutVersionResource($version))->toListArray($request))
->values()
->all();
return $this->success('common.success', $items);
}
/**
@@ -259,4 +282,25 @@ class LayoutController extends AdminBaseController
);
}
}
/**
* 목록 설명 해석에 쓸 소유 템플릿 사전을 로드합니다.
*
* 활성 로케일 기준이며, 로드에 실패하면 빈 배열을 돌려준다 — 그 경우
* {@see LayoutListResource} 가 레이아웃 이름으로 폴백하므로 목록은 계속 그려진다.
*
* @param string $templateName 템플릿 식별자
* @return array<string, mixed> 템플릿 프론트엔드 다국어 데이터
*/
private function resolveTemplateTranslations(string $templateName): array
{
$result = $this->templateService->getLanguageDataWithModules(
$templateName,
app()->getLocale()
);
return ($result['success'] ?? false) && is_array($result['data'] ?? null)
? $result['data']
: [];
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\LayoutVersionListRequest;
use App\Http\Requests\Layout\StoreLayoutExtensionPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutExtensionContentRequest;
use App\Http\Resources\LayoutExtensionResource;
@@ -58,7 +59,11 @@ class LayoutExtensionController extends AdminBaseController
$this->layoutExtensionService->getExtensionHostLayouts($extension)
);
}
$group['extensions'] = LayoutExtensionResource::collection($group['extensions']);
// 목록은 경량 표현만 내려준다 — 편집 본문(content)은 단건 조회가 공급한다.
$group['extensions'] = collect($group['extensions'])
->map(fn ($extension) => (new LayoutExtensionResource($extension))->toListArray(request()))
->values()
->all();
return $group;
}, $groups);
@@ -156,11 +161,12 @@ class LayoutExtensionController extends AdminBaseController
/**
* 레이아웃 확장의 모든 버전 목록 조회
*
* @param LayoutVersionListRequest $request 버전 목록 조회 요청 (limit)
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 버전 목록 응답
*/
public function versions(string $templateName, int $extensionId): JsonResponse
public function versions(LayoutVersionListRequest $request, string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
@@ -168,9 +174,18 @@ class LayoutExtensionController extends AdminBaseController
return $this->notFound('common.not_found');
}
$versions = $this->layoutExtensionService->getExtensionVersions($extensionId);
$versions = $this->layoutExtensionService->getExtensionVersions(
$extensionId,
(int) ($request->validated()['limit'] ?? LayoutVersionListRequest::DEFAULT_LIMIT)
);
return $this->success('common.success', LayoutExtensionVersionResource::collection($versions));
// 목록은 경량 표현만 내려준다 — 확장 본문은 버전 비교 diff 전용이라 단건 조회가 공급한다.
$items = $versions
->map(fn ($version) => (new LayoutExtensionVersionResource($version))->toListArray($request))
->values()
->all();
return $this->success('common.success', $items);
}
/**
@@ -29,18 +29,22 @@ class MenuController extends AdminBaseController
parent::__construct();
}
/**
* 관리용 메뉴 목록을 조회합니다.
*
* @param MenuListRequest $request 목록 필터·정렬 요청
* @return JsonResponse 메뉴 컬렉션을 담은 JSON 응답
*/
public function index(MenuListRequest $request): JsonResponse
{
try {
$filters = $request->validated();
$user = Auth::user();
// 필터가 있으면 필터링된 메뉴 조회, 없으면 전체 조회
if (isset($filters['is_active']) || ! empty($filters['filters'])) {
$menus = $this->menuService->getFilteredMenusForManagement($filters, $user);
} else {
$menus = $this->menuService->getTopLevelMenusForManagement($user);
}
// 필터 유무와 무관하게 같은 경로로 조회한다. 예전에는 필터가 없으면 filters 를
// 받지 않는 메서드로 분기했는데, 그 경로는 sort_by/sort_order 를 통째로 버려
// "필터를 하나라도 걸어야 정렬이 먹는" 상태가 됐다 (#492 D-22).
$menus = $this->menuService->getFilteredMenusForManagement($filters, $user);
return $this->successWithResource(
'menu.fetch_success',
@@ -3,9 +3,12 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
use App\Extension\Vendor\VendorMode;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Controllers\Concerns\RebuildsSearchIndexOnDemand;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Module\ActivateModuleRequest;
use App\Http\Requests\Module\DeactivateModuleRequest;
use App\Http\Requests\Module\IndexModuleRequest;
@@ -16,10 +19,10 @@ use App\Http\Requests\Module\PerformModuleUpdateRequest;
use App\Http\Requests\Module\PreviewModuleManifestRequest;
use App\Http\Requests\Module\RefreshModuleLayoutsRequest;
use App\Http\Requests\Module\UninstallModuleRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\ModuleCollection;
use App\Http\Resources\ModuleResource;
use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LicenseService;
use App\Services\ModuleService;
use App\Services\TemplateService;
@@ -36,6 +39,7 @@ class ModuleController extends AdminBaseController
{
use InjectsExtensionLanguagePacks;
use OrchestratesCascadeInstall;
use RebuildsSearchIndexOnDemand;
public function __construct(
private ModuleService $moduleService,
@@ -169,7 +173,7 @@ class ModuleController extends AdminBaseController
public function installPreview(string $moduleName, ExtensionInstallPreviewBuilder $builder): JsonResponse
{
try {
$preview = $builder->build(\App\Enums\LanguagePackScope::Module, $moduleName);
$preview = $builder->build(LanguagePackScope::Module, $moduleName);
return $this->success('module.fetch_success', $preview);
} catch (\Exception $e) {
@@ -188,7 +192,7 @@ class ModuleController extends AdminBaseController
try {
$validated = $request->validated();
$moduleName = $validated['module_name'];
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$vendorMode = VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
@@ -253,7 +257,7 @@ class ModuleController extends AdminBaseController
$moduleInfo = $result['module_info'] ?? null;
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함 (요구사항 #8: 빈 배열이면 모달 표시 안 함)
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
$pendingLanguagePacks = app(LanguagePackBundledRegistrar::class)
->getPendingForReactivation('module', $moduleName);
if ($moduleInfo) {
@@ -490,6 +494,13 @@ class ModuleController extends AdminBaseController
public function checkModifiedLayouts(string $moduleName): JsonResponse
{
try {
// 미존재 식별자는 404 로 구분한다. 존재 확인 없이 조회하면 레이아웃 0건과
// 모듈 부재가 똑같이 "수정된 레이아웃 없음" 으로 보고되어, 오타·제거된 모듈이
// 조용히 "수정 없음" 으로 통과한다 (show/uninstall-info 와 동일 규약).
if (! $this->moduleService->getModuleInfo($moduleName)) {
return $this->error('module.not_found', 404, null, ['module' => $moduleName]);
}
$result = $this->moduleService->checkModifiedLayouts($moduleName);
return $this->success('modules.check_modified_layouts_success', $result);
@@ -515,23 +526,43 @@ class ModuleController extends AdminBaseController
{
try {
$validated = $request->validated();
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$vendorMode = VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
$force = (bool) ($validated['force'] ?? false);
$result = $this->moduleService->updateModule($moduleName, $vendorMode, $layoutStrategy, $force);
// 검색 인덱스 재생성은 운영자가 체크했을 때만 수행한다 — 인덱스 잠금·재색인 비용이
// 있어 운영 중인 사이트에서 업데이트만으로 발생해서는 안 된다.
$searchIndex = $this->rebuildSearchIndexIfRequested(
(bool) ($validated['rebuild_search_index'] ?? false)
);
$moduleInfo = $result['module_info'] ?? null;
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":module"/":version"
// 플레이스홀더가 그대로 사용자에게 노출된다.
$messageParams = [
'module' => $moduleName,
'version' => (string) ($result['to_version'] ?? data_get($moduleInfo, 'version') ?? ''),
];
if ($moduleInfo) {
return $this->successWithResource(
'modules.update_success',
new ModuleResource($moduleInfo)
(new ModuleResource($moduleInfo))->additional(['search_index' => $searchIndex]),
200,
$messageParams
);
}
return $this->success('modules.update_success', $result);
return $this->success(
'modules.update_success',
$result + ['search_index' => $searchIndex],
200,
$messageParams
);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
@@ -601,7 +632,7 @@ class ModuleController extends AdminBaseController
/**
* 모듈의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 모듈 식별자
* @param string $identifier 모듈 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
@@ -3,24 +3,27 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\LanguagePackScope;
use App\Extension\Vendor\VendorMode;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Controllers\Concerns\RebuildsSearchIndexOnDemand;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Plugin\ActivatePluginRequest;
use App\Http\Requests\Plugin\DeactivatePluginRequest;
use App\Http\Requests\Plugin\IndexPluginRequest;
use App\Http\Requests\Plugin\InstallPluginFromFileRequest;
use App\Http\Requests\Plugin\PreviewPluginManifestRequest;
use App\Http\Requests\Plugin\InstallPluginFromGithubRequest;
use App\Http\Requests\Plugin\InstallPluginRequest;
use App\Http\Requests\Plugin\PerformPluginUpdateRequest;
use App\Http\Requests\Plugin\PreviewPluginManifestRequest;
use App\Http\Requests\Plugin\RefreshPluginLayoutsRequest;
use App\Http\Requests\Plugin\UninstallPluginRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\PluginCollection;
use App\Http\Resources\PluginResource;
use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LicenseService;
use App\Services\PluginService;
use App\Services\TemplateService;
@@ -37,6 +40,7 @@ class PluginController extends AdminBaseController
{
use InjectsExtensionLanguagePacks;
use OrchestratesCascadeInstall;
use RebuildsSearchIndexOnDemand;
public function __construct(
private PluginService $pluginService,
@@ -177,7 +181,7 @@ class PluginController extends AdminBaseController
try {
$validated = $request->validated();
$pluginName = $validated['plugin_name'];
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$vendorMode = VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
@@ -242,7 +246,7 @@ class PluginController extends AdminBaseController
$pluginInfo = $result['plugin_info'] ?? null;
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
$pendingLanguagePacks = app(LanguagePackBundledRegistrar::class)
->getPendingForReactivation('plugin', $pluginName);
if ($pluginInfo) {
@@ -498,6 +502,12 @@ class PluginController extends AdminBaseController
public function checkModifiedLayouts(string $pluginName): JsonResponse
{
try {
// 미존재 식별자는 404 로 구분한다. 존재 확인 없이 조회하면 레이아웃 0건과
// 플러그인 부재가 똑같이 "수정된 레이아웃 없음" 으로 보고된다.
if (! $this->pluginService->getPluginInfo($pluginName)) {
return $this->error('plugins.not_found', 404, null, ['plugin' => $pluginName]);
}
$result = $this->pluginService->checkModifiedLayouts($pluginName);
return $this->success('plugins.check_modified_layouts_success', $result);
@@ -523,23 +533,42 @@ class PluginController extends AdminBaseController
{
try {
$validated = $request->validated();
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$vendorMode = VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
$force = (bool) ($validated['force'] ?? false);
$result = $this->pluginService->updatePlugin($pluginName, $vendorMode, $layoutStrategy, $force);
// 검색 인덱스 재생성은 운영자가 체크했을 때만 수행한다 — 인덱스 잠금·재색인 비용이
// 있어 운영 중인 사이트에서 업데이트만으로 발생해서는 안 된다.
$searchIndex = $this->rebuildSearchIndexIfRequested(
(bool) ($validated['rebuild_search_index'] ?? false)
);
$pluginInfo = $result['plugin_info'] ?? null;
// 성공 메시지는 ":plugin"/":version" 치환자를 쓰므로 값을 함께 넘긴다
$messageParams = [
'plugin' => $pluginName,
'version' => (string) ($result['to_version'] ?? data_get($pluginInfo, 'version') ?? ''),
];
if ($pluginInfo) {
return $this->successWithResource(
'plugins.update_success',
new PluginResource($pluginInfo)
(new PluginResource($pluginInfo))->additional(['search_index' => $searchIndex]),
200,
$messageParams
);
}
return $this->success('plugins.update_success', $result);
return $this->success(
'plugins.update_success',
$result + ['search_index' => $searchIndex],
200,
$messageParams
);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
@@ -623,7 +652,7 @@ class PluginController extends AdminBaseController
/**
* 플러그인의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 플러그인 식별자
* @param string $identifier 플러그인 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
@@ -2,9 +2,11 @@
namespace App\Http\Controllers\Api\Admin;
use App\Extension\PluginManager;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\UpdatePluginSettingsRequest;
use App\Services\PluginSettingsService;
use App\Support\SensitiveSettingMask;
use Illuminate\Http\JsonResponse;
/**
@@ -18,13 +20,35 @@ class PluginSettingsController extends AdminBaseController
* PluginSettingsController 생성자
*
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
* @param PluginManager $pluginManager 설정 스키마(sensitive 플래그) 조회용
*/
public function __construct(
private PluginSettingsService $pluginSettingsService
private PluginSettingsService $pluginSettingsService,
private PluginManager $pluginManager
) {
parent::__construct();
}
/**
* 응답에 실을 설정에서 민감값을 마스크로 치환한다.
*
* 이 응답은 관리자 화면으로 나가므로 암호화 키·시크릿의 평문이 브라우저·개발자 도구·프록시
* 로그에 남지 않아야 한다. 값이 저장되어 있다는 사실만 마스크로 알린다. 운영자가 값을 건드리지
* 않으면 화면이 마스크를 그대로 되돌려 보내고, 저장 단계가 그것을 걸러 기존 값을 보존한다.
*
* @param string $identifier 플러그인 식별자
* @param array<string, mixed> $settings 복호화된 설정
* @return array<string, mixed> 마스킹된 설정
*/
private function maskSensitive(string $identifier, array $settings): array
{
$plugin = $this->pluginManager->getPlugin($identifier);
return $plugin === null
? $settings
: SensitiveSettingMask::apply($settings, $plugin->getSettingsSchema());
}
/**
* 플러그인 설정을 조회합니다.
*
@@ -39,7 +63,7 @@ class PluginSettingsController extends AdminBaseController
return $this->notFound('plugins.not_found');
}
return $this->success('messages.success', $settings);
return $this->success('common.success', $this->maskSensitive($identifier, $settings));
}
/**
@@ -51,12 +75,11 @@ class PluginSettingsController extends AdminBaseController
*/
public function update(UpdatePluginSettingsRequest $request, string $identifier): JsonResponse
{
// validated()가 빈 배열이면 all()에서 설정값을 가져옴
// (PluginManager에 등록되지 않은 플러그인의 경우)
// 검증을 통과한 필드만 저장한다. 과거에는 validated() 가 비면 all() 로 폴백했으나,
// 그 명분이던 "PluginManager 미등록 플러그인" 은 PluginSettingsService::save() 가
// 이미 false 로 차단하므로 도달할 수 없었고, 실제로는 설정 스키마 밖의 키가
// 그대로 설정 파일에 병합되는 경로로만 동작했다 (mass-assignment).
$settings = $request->validated();
if (empty($settings)) {
$settings = $request->all();
}
$result = $this->pluginSettingsService->save($identifier, $settings);
@@ -66,7 +89,7 @@ class PluginSettingsController extends AdminBaseController
return $this->success(
'plugins.settings.updated',
$this->pluginSettingsService->get($identifier)
$this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? [])
);
}
@@ -86,6 +109,6 @@ class PluginSettingsController extends AdminBaseController
return $this->notFound('plugins.not_found');
}
return $this->success('messages.success', $layout);
return $this->success('common.success', $layout);
}
}
@@ -2,13 +2,17 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\SitemapGenerationMode;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoCacheClearRequest;
use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheStatsService;
use App\Seo\SitemapManager;
use App\Seo\SitemapProgress;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* SEO 캐시 관리 컨트롤러
@@ -20,7 +24,8 @@ class SeoCacheController extends AdminBaseController
public function __construct(
private SeoCacheStatsService $statsService,
private SeoCacheManagerInterface $cacheManager,
private SitemapManager $sitemapManager
private SitemapManager $sitemapManager,
private SitemapProgress $sitemapProgress
) {
parent::__construct();
}
@@ -43,9 +48,9 @@ class SeoCacheController extends AdminBaseController
'by_module' => $this->statsService->getStatsByModule($since),
];
return $this->success('messages.success', $data);
return $this->success('common.success', $data);
} catch (\Exception $e) {
return $this->error('messages.error_occurred', 500, $e->getMessage());
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
@@ -54,7 +59,7 @@ class SeoCacheController extends AdminBaseController
*
* 레이아웃 또는 모듈 지정 시 해당 캐시만, 미지정 시 전체 캐시를 삭제합니다.
*
* @param SeoCacheClearRequest $request 캐시 삭제 요청
* @param SeoCacheClearRequest $request 캐시 삭제 요청
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function clearCache(SeoCacheClearRequest $request): JsonResponse
@@ -65,14 +70,14 @@ class SeoCacheController extends AdminBaseController
if ($layout) {
$count = $this->cacheManager->invalidateByLayout($layout);
return $this->success('messages.success', ['cleared' => $count]);
return $this->success('common.success', ['cleared' => $count]);
}
$this->cacheManager->clearAll();
return $this->success('messages.success', ['cleared' => 'all']);
return $this->success('common.success', ['cleared' => 'all']);
} catch (\Exception $e) {
return $this->error('messages.error_occurred', 500, $e->getMessage());
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
@@ -87,38 +92,49 @@ class SeoCacheController extends AdminBaseController
{
try {
// Phase 5의 SeoDeclarationCollector 구현 후 실제 워밍업 로직 추가 예정
return $this->success('messages.success', [
return $this->success('common.success', [
'status' => 'dispatched',
'message' => __('seo.warmup_dispatched'),
]);
} catch (\Exception $e) {
return $this->error('messages.error_occurred', 500, $e->getMessage());
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
/**
* Sitemap XML 을 즉시 재생성합니다.
* Sitemap XML 재생성을 큐에 예약합니다.
*
* 큐 드라이버와 무관하게 동기 실행되며, 생성 완료 후 last_updated_at 을 갱신합니다.
* 대용량(수백만 URL) 사이트에서 요청 스레드 동기 생성은 메모리/타임아웃 붕괴를 일으키므로
* 큐 잡으로 위임합니다. 응답은 예약 시점의 상태이며, 실제 완료 시각은 이후 갱신됩니다.
*
* @return JsonResponse 재생성 결과 JSON 응답
* @return JsonResponse 예약 결과 JSON 응답
*/
public function regenerateSitemap(): JsonResponse
{
$result = $this->sitemapManager->regenerate();
if ($result['success']) {
return $this->success('seo.sitemap_regenerated', $result['data'] ?? null);
if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) {
return $this->error('seo.sitemap_disabled', 400);
}
$messageKey = match ($result['status']) {
'disabled' => 'seo.sitemap_disabled',
default => 'seo.sitemap_regenerate_failed',
};
// 관리자 수동 재생성은 항상 전체(Full) — 현 생성 상태와 무관하게 전량 재생성 (D7)
// 실행한 관리자 ID 를 함께 실어, 완료/실패 시 그 관리자에게만 알림이 발송되게 한다.
GenerateSitemapJob::dispatch(SitemapGenerationMode::Full, Auth::id());
$statusCode = $result['status'] === 'disabled' ? 400 : 500;
// 큐 대기 중에도 UI 가 'queued' 를 표시하도록 즉시 기록
$this->sitemapProgress->start(SitemapGenerationMode::Full->value);
return $this->error($messageKey, $statusCode, $result['message'] ?? null);
return $this->success('seo.sitemap_regenerate_dispatched', $this->sitemapManager->getStatus());
}
/**
* Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다.
*
* SEO 탭 진입 시 초기 로드용이며, 폴링(Reverb OFF)일 때 주기적으로 재조회됩니다.
*
* @return JsonResponse 진행상황(progress) + last_updated_at + realtime_enabled 를 포함한 JSON 응답
*/
public function sitemapStatus(): JsonResponse
{
return $this->success('messages.success', $this->sitemapManager->getStatus());
}
/**
@@ -133,9 +149,9 @@ class SeoCacheController extends AdminBaseController
try {
$urls = $this->cacheManager->getCachedUrls();
return $this->success('messages.success', ['urls' => $urls, 'count' => count($urls)]);
return $this->success('common.success', ['urls' => $urls, 'count' => count($urls)]);
} catch (\Exception $e) {
return $this->error('messages.error_occurred', 500, $e->getMessage());
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
}
@@ -42,6 +42,7 @@ class SettingsController extends AdminBaseController
try {
$settings = $this->settingsService->getAllSettings();
$settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$settings['_meta'] = ['limits' => config('core.settings_limits', [])];
return $this->success('settings.fetch_success',
(new SettingsResource($settings))->toArray(request())
@@ -69,6 +70,7 @@ class SettingsController extends AdminBaseController
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$allSettings = $this->settingsService->getAllSettings();
$allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$allSettings['_meta'] = ['limits' => config('core.settings_limits', [])];
return $this->success('settings.save_success', [
'settings' => $allSettings,
@@ -7,20 +7,21 @@ use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Requests\Template\ActivateTemplateRequest;
use App\Http\Requests\Template\DeactivateTemplateRequest;
use App\Http\Requests\Template\IndexTemplateRequest;
use App\Http\Requests\Template\InstallTemplateFromFileRequest;
use App\Http\Requests\Template\PreviewTemplateManifestRequest;
use App\Http\Requests\Template\InstallTemplateFromGithubRequest;
use App\Http\Requests\Template\InstallTemplateRequest;
use App\Http\Requests\Template\PerformTemplateUpdateRequest;
use App\Http\Requests\Template\PreviewTemplateManifestRequest;
use App\Http\Requests\Template\RefreshTemplateLayoutsRequest;
use App\Http\Requests\Template\UninstallTemplateRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\TemplateCollection;
use App\Http\Resources\TemplateResource;
use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LicenseService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
@@ -215,7 +216,7 @@ class TemplateController extends AdminBaseController
$templateInfo = $result['template_info'] ?? null;
// 요구사항 #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
$pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
$pendingLanguagePacks = app(LanguagePackBundledRegistrar::class)
->getPendingForReactivation('template', $templateName);
if ($templateInfo) {
@@ -435,6 +436,12 @@ class TemplateController extends AdminBaseController
public function checkModifiedLayouts(string $templateName): JsonResponse
{
try {
// 미존재 식별자는 404 로 구분한다. 존재 확인 없이 조회하면 레이아웃 0건과
// 템플릿 부재가 똑같이 "수정된 레이아웃 없음" 으로 보고된다.
if (! $this->templateService->getTemplateInfo($templateName)) {
return $this->error('templates.not_found', 404, null, ['template' => $templateName]);
}
$result = $this->templateService->checkModifiedLayouts($templateName);
return $this->success('templates.check_modified_layouts_success', $result);
@@ -466,14 +473,23 @@ class TemplateController extends AdminBaseController
$templateInfo = $result['template_info'] ?? null;
// 메시지 치환 파라미터를 반드시 전달한다 — 누락 시 ":template"/":version"
// 플레이스홀더가 그대로 사용자에게 노출된다.
$messageParams = [
'template' => $templateName,
'version' => $result['to_version'] ?? ($templateInfo['version'] ?? ''),
];
if ($templateInfo) {
return $this->successWithResource(
'templates.update_success',
new TemplateResource($templateInfo)
new TemplateResource($templateInfo),
200,
$messageParams
);
}
return $this->success('templates.update_success', $result);
return $this->success('templates.update_success', $result, 200, $messageParams);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
@@ -516,7 +532,7 @@ class TemplateController extends AdminBaseController
/**
* 템플릿의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 템플릿 식별자
* @param string $identifier 템플릿 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
@@ -129,6 +129,30 @@ class UserController extends AdminBaseController
}
}
/**
* 사용자의 계정 잠금을 해제합니다.
*
* 로그인 실패 누적으로 잠긴 계정(특히 잠금 시간 0 = 무한대 설정으로 영구 잠긴 계정)을
* 관리자가 수동으로 풀어 줍니다. 이 경로가 없으면 영구 잠금 계정은 성공 로그인 자체가
* 불가하므로 복구 수단이 없습니다.
*
* @param User $user 잠금을 해제할 사용자 모델
* @return JsonResponse 갱신된 사용자 정보를 포함한 JSON 응답
*/
public function unlock(User $user): JsonResponse
{
try {
$unlocked = $this->userService->unlockAccount($user);
return $this->successWithResource(
'auth.account_unlocked',
new UserResource($unlocked)
);
} catch (Exception $e) {
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
}
}
/**
* 사용자를 삭제합니다.
*
@@ -4,15 +4,16 @@ namespace App\Http\Controllers\Api\Auth;
use App\Exceptions\Auth\AccountLockedException;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Auth\AuthenticatedRequest;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class AuthController extends AuthBaseController
@@ -26,6 +27,8 @@ class AuthController extends AuthBaseController
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
$this->middleware('auth:sanctum')->except([
'login',
// 2단계 인증 확인은 아직 토큰이 없는 상태에서 호출된다 — 주체는 challenge 가 식별한다
'verifyTwoFactor',
'register',
'forgotPassword',
'resetPassword',
@@ -47,20 +50,59 @@ class AuthController extends AuthBaseController
$request->validated()['password']
);
// 2단계 인증이 켜져 있으면 아직 토큰이 없다 — 인증 코드 확인 단계로 안내한다
if ($data['two_factor_required'] ?? false) {
return $this->success('auth.two_factor_required', $data);
}
// 사용자 정보는 Resource로, 토큰은 그대로
$data['user'] = new UserResource($data['user']);
return $this->success('auth.login_success', $data);
} catch (AccountLockedException $e) {
return $this->error('auth.account_locked', 423, [
'locked_until' => $e->lockedUntil->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes * 60,
], ['minutes' => $e->remainingMinutes]);
// 영구 잠금(무한대 설정)은 해제 시각·잔여 시간이 없다 — null 그대로 노출.
return $this->error(
$e->isPermanent() ? 'auth.account_locked_permanently' : 'auth.account_locked',
423,
[
'locked_until' => $e->lockedUntil?->toIso8601String(),
'retry_after_seconds' => $e->remainingMinutes === null ? null : $e->remainingMinutes * 60,
'permanent' => $e->isPermanent(),
],
['minutes' => $e->remainingMinutes]
);
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
}
/**
* 2단계 인증 코드를 확인하고 로그인을 완료합니다.
*
* 비밀번호 확인 단계(`login`)는 토큰 대신 challenge 를 돌려주며, 이 엔드포인트가
* 코드 확인에 성공해야 비로소 토큰이 발급됩니다.
*
* @param TwoFactorChallengeRequest $request challenge 확인 요청
* @return JsonResponse 로그인 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function verifyTwoFactor(TwoFactorChallengeRequest $request): JsonResponse
{
$validated = $request->validated();
try {
$data = $this->authService->completeTwoFactor(
$validated['challenge_id'],
['code' => $validated['code']]
);
$data['user'] = new UserResource($data['user']);
return $this->success('auth.login_success', $data);
} catch (ValidationException $e) {
return $this->unauthorized('auth.two_factor_failed');
}
}
/**
* 새로운 사용자를 등록시킵니다.
*
@@ -84,10 +126,10 @@ class AuthController extends AuthBaseController
/**
* 사용자를 로그아웃시킵니다. (현재 디바이스만)
*
* @param Request $request HTTP 요청
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logout(Request $request): JsonResponse
public function logout(AuthenticatedRequest $request): JsonResponse
{
$this->authService->logout($request->user());
@@ -97,10 +139,10 @@ class AuthController extends AuthBaseController
/**
* 모든 디바이스에서 사용자를 로그아웃시킵니다.
*
* @param Request $request HTTP 요청
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logoutFromAllDevices(Request $request): JsonResponse
public function logoutFromAllDevices(AuthenticatedRequest $request): JsonResponse
{
$this->authService->logoutFromAllDevices($request->user());
@@ -110,10 +152,10 @@ class AuthController extends AuthBaseController
/**
* 현재 로그인된 사용자의 정보를 반환합니다.
*
* @param Request $request HTTP 요청
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 사용자 정보를 포함한 JSON 응답
*/
public function user(Request $request): JsonResponse
public function user(AuthenticatedRequest $request): JsonResponse
{
$user = $request->user();
@@ -131,10 +173,10 @@ class AuthController extends AuthBaseController
/**
* 사용자의 인증 토큰을 갱신합니다.
*
* @param Request $request HTTP 요청
* @param AuthenticatedRequest $request 인증 세션 요청 (본문 입력 없음)
* @return JsonResponse 새로운 토큰과 사용자 정보를 포함한 JSON 응답
*/
public function refresh(Request $request): JsonResponse
public function refresh(AuthenticatedRequest $request): JsonResponse
{
$data = $this->authService->refreshToken($request->user());
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Base;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
@@ -21,7 +22,7 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자 소유권을 확인합니다.
*
* @param int $userId 확인할 사용자 ID
* @param int $userId 확인할 사용자 ID
* @return bool
*/
protected function isOwner(int $userId): bool
@@ -32,7 +33,7 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자가 특정 리소스에 접근할 수 있는지 확인합니다.
*
* @param mixed $resource 접근하려는 리소스
* @param mixed $resource 접근하려는 리소스
* @return bool
*/
protected function canAccessResource($resource): bool
@@ -48,8 +49,8 @@ abstract class AuthBaseController extends BaseApiController
/**
* 사용자 활동을 기록합니다.
*
* @param string $action 수행한 작업
* @param array $data 관련 데이터
* @param string $action 수행한 작업
* @param array $data 관련 데이터
* @return void
*/
protected function logUserActivity(string $action, array $data = []): void
@@ -58,20 +59,20 @@ abstract class AuthBaseController extends BaseApiController
Log::info("User Activity: {$action}", [
'user_id' => $this->getCurrentUser()?->uuid,
'data' => $data,
'timestamp' => now()
'timestamp' => now(),
]);
}
/**
* 리소스 소유권을 확인하고, 소유자가 아니면 Forbidden 응답을 반환합니다.
*
* @param mixed $resource 확인할 리소스
* @param string $messageKey 오류 메시지 키
* @return \Illuminate\Http\JsonResponse|null 소유자이면 null, 아니면 Forbidden 응답
* @param mixed $resource 확인할 리소스
* @param string $messageKey 오류 메시지 키
* @return JsonResponse|null 소유자이면 null, 아니면 Forbidden 응답
*/
protected function checkOwnership($resource, string $messageKey = 'common.forbidden')
{
if (!$this->canAccessResource($resource)) {
if (! $this->canAccessResource($resource)) {
return $this->forbidden($messageKey);
}
@@ -81,14 +82,21 @@ abstract class AuthBaseController extends BaseApiController
/**
* API 사용량을 기록합니다.
*
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @return void
*/
protected function logApiUsage(string $endpoint, array $data = []): void
{
// TODO: API 사용량 통계 시스템 구현
Log::info("Auth API Usage: {$endpoint}", [
//
// PublicBaseController 와 같은 이유로 디버그 모드에서만 기록한다.
// 통계 시스템이 서기 전까지 요청마다 로그 파일에 줄을 쌓지 않는다.
if (! config('app.debug')) {
return;
}
Log::debug("Auth API Usage: {$endpoint}", [
'user_id' => $this->getCurrentUser()?->uuid,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
@@ -3,9 +3,7 @@
namespace App\Http\Controllers\Api\Base;
use App\Contracts\Extension\CacheInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 API용 베이스 컨트롤러
@@ -27,9 +25,9 @@ abstract class PublicBaseController extends BaseApiController
* 이는 확장 설치 직후·활성화 전 등의 일시적 상태에서 얻은 "not found" 같은
* 에러 응답이 영구 캐시되어 복구 후에도 잘못된 응답을 반환하는 문제를 방지한다.
*
* @param string $key 캐시 키
* @param callable $callback 데이터 생성 콜백
* @param int $ttl 캐시 유지 시간 (초)
* @param string $key 캐시 키
* @param callable $callback 데이터 생성 콜백
* @param int $ttl 캐시 유지 시간 (초)
* @return mixed
*/
protected function cached(string $key, callable $callback, int $ttl = 3600)
@@ -54,18 +52,27 @@ abstract class PublicBaseController extends BaseApiController
/**
* API 사용량을 기록합니다.
*
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @return void
*/
protected function logApiUsage(string $endpoint, array $data = []): void
{
// TODO: API 사용량 통계 시스템 구현
Log::info("Public API Usage: {$endpoint}", [
//
// 호출 지점은 레이아웃·확장 번들·정적 자산처럼 페이지 로드마다 여러 번 열리는
// 공개 엔드포인트다. 통계 시스템이 서기 전까지 이 자리가 요청마다 로그 파일에
// 줄을 쌓지 않도록, 디버그 모드에서만 기록한다. 기본 설치는 APP_DEBUG=false 이므로
// 아무것도 쓰지 않는다.
if (! config('app.debug')) {
return;
}
Log::debug("Public API Usage: {$endpoint}", [
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'data' => $data,
'timestamp' => now()
'timestamp' => now(),
]);
}
@@ -80,8 +87,7 @@ abstract class PublicBaseController extends BaseApiController
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'referer' => request()->header('referer'),
'timestamp' => now()
'timestamp' => now(),
];
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Identity;
use App\Enums\IdentityOriginType;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Identity\CancelChallengeRequest;
@@ -15,6 +16,7 @@ use App\Http\Requests\Identity\VerifyChallengeRequest;
use App\Http\Resources\Identity\ChallengeResource;
use App\Http\Resources\Identity\ProviderResource;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Services\IdentityPolicyService;
use App\Services\IdentityVerificationService;
use Illuminate\Http\JsonResponse;
@@ -53,7 +55,7 @@ class IdentityVerificationController extends PublicBaseController
$user = $request->user();
$target = $user ?: ($validated['target'] ?? []);
if (! ($user instanceof \App\Models\User) && empty($target['email']) && empty($target['phone'])) {
if (! ($user instanceof User) && empty($target['email']) && empty($target['phone'])) {
return $this->error('identity.errors.missing_target', 422);
}
@@ -68,7 +70,7 @@ class IdentityVerificationController extends PublicBaseController
context: [
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 512),
'origin_type' => \App\Enums\IdentityOriginType::Api->value,
'origin_type' => IdentityOriginType::Api->value,
'origin_identifier' => '/api/identity/challenges',
],
providerId: $providerId,
@@ -136,7 +138,7 @@ class IdentityVerificationController extends PublicBaseController
*
* @param CancelChallengeRequest $request 검증된 요청
* @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그
* @return JsonResponse
* @return JsonResponse 취소 결과
*/
public function cancel(CancelChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
{
@@ -159,7 +161,8 @@ class IdentityVerificationController extends PublicBaseController
*
* @param ShowChallengeRequest $request 검증된 요청
* @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그
* @return JsonResponse
* @return JsonResponse 공개 안전 상태 필드
*
* @since engine-v1.46.0
*/
public function show(ShowChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
@@ -170,7 +173,7 @@ class IdentityVerificationController extends PublicBaseController
return $this->error('identity.errors.challenge_not_found', 404);
}
return $this->success('messages.success', $status);
return $this->success('common.success', $status);
}
/**
@@ -188,6 +191,7 @@ class IdentityVerificationController extends PublicBaseController
* @param IdentityCallbackRequest $request 검증된 요청
* @param string $providerId 콜백을 보낸 provider 식별자
* @return JsonResponse|RedirectResponse
*
* @since engine-v1.46.0
*/
public function callback(IdentityCallbackRequest $request, string $providerId)
@@ -271,7 +275,7 @@ class IdentityVerificationController extends PublicBaseController
$providers,
);
return $this->success('messages.success', $data);
return $this->success('common.success', $data);
}
/**
@@ -300,7 +304,7 @@ class IdentityVerificationController extends PublicBaseController
];
}
return $this->success('messages.success', $data);
return $this->success('common.success', $data);
}
/**
@@ -353,11 +357,11 @@ class IdentityVerificationController extends PublicBaseController
$policy = $this->policyService->resolve($scope, $target);
if (! $policy || ! $policy->enabled) {
return $this->success('messages.success', null);
return $this->success('common.success', null);
}
// 민감 필드는 노출하지 않고 UI 힌트에 필요한 최소 필드만 반환
return $this->success('messages.success', [
return $this->success('common.success', [
'policy_key' => $policy->key,
'scope' => $policy->scope,
'target' => $policy->target,
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\Response;
/**
* 자산 URL 모드 감지 프로브.
*
* 서버(nginx/Apache)의 정적 최적화 블록이 확장자 붙은 동적 응답을 가로채는지
* 판정하기 위한 대조 엔드포인트다. 브라우저가 아래 두 URL 을 쌍으로 요청한다.
*
* ```text
* GET /api/system/asset-probe.js → 확장자 형태 (정적 블록의 표적)
* GET /api/system/asset-probe → 대조군
* ```
*
* | probe.js | probe | 판정 |
* |---|---|---|
* | 성공 | 성공 | `extension` — 확장자 유지 가능 |
* | 실패 | 성공 | `extensionless` — 정적 블록 가로채기 확정 |
* | 실패 | 실패 | 모드 문제 아님 (PHP/라우팅 장애) — 별도 안내 |
*
* ## 판정은 상태코드가 아니라 본문이다
*
* 클라이언트는 `res.ok && body.includes(PROBE_TOKEN)` 로 판정해야 한다.
* 상태코드만 보면 "404 대신 200 + 에러 HTML" 이나 catch-all 200 페이지를
* 반환하는 설정에서 영원히 `extension` 으로 오판해, 재감지를 몇 번 눌러도
* 같은 오답이 나온다.
*
* ## 감지는 반드시 브라우저에서 수행한다
*
* 서버측에서 자기 `APP_URL` 로 curl 하면 loopback 이 nginx vhost·SSL·프록시
* 체인을 우회하거나 다른 vhost 를 타서 오판한다.
*
* ## 실물 파일을 두지 않는다
*
* `public/` 하위에 실제 `asset-probe.js` 파일이 존재하면 nginx 가 그것을
* 성공적으로 서빙해 거짓 양성이 된다. 본 라우트는 반드시 실물 파일이 없는
* 경로여야 한다.
*/
class AssetProbeController extends PublicBaseController
{
/**
* 프로브 성공 판정용 매직 토큰.
*
* 클라이언트·테스트가 응답 본문에서 이 문자열을 찾아 성공을 판정한다.
*/
public const PROBE_TOKEN = 'G7_ASSET_PROBE_OK';
/**
* 프로브 응답을 반환합니다.
*
* DB 에 접근하지 않으며(설치 전에도 응답 가능) 캐시되지 않습니다.
* ResponseHelper 의 JSON 봉투를 쓰지 않는 이유: 이 엔드포인트의 목적은
* "정적 자산으로 오인될 응답"을 실제로 흉내내는 것이므로, 자바스크립트
* Content-Type 과 본문 형태를 그대로 유지해야 대표성이 있다.
*
* @return Response 매직 토큰을 담은 자바스크립트 응답
*/
public function probe(): Response
{
$body = "/* G7 asset URL mode probe */\n"
."window.__g7AssetProbe = '".self::PROBE_TOKEN."';\n";
return response($body, 200, [
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'X-Content-Type-Options' => 'nosniff',
]);
}
}
@@ -71,14 +71,16 @@ class PublicModuleController extends PublicBaseController
*
* @param ServeModuleAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 모듈 식별자 (vendor-module 형식)
* @param string $path 에셋 경로 (dist/js/module.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServeModuleAssetRequest $request,
string $identifier,
string $path
string $identifier
): BinaryFileResponse|JsonResponse|Response {
// 파일 경로는 FormRequest 에서 받는다 — 확장자 모드는 `{path}` 라우트 세그먼트,
// 확장자 없는 모드는 `?file=` 쿼리로 오며 prepareForValidation() 이 이를 흡수한다.
$path = (string) $request->validated('path');
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('modules.assets', ['identifier' => $identifier, 'path' => $path]);
@@ -70,14 +70,16 @@ class PublicPluginController extends PublicBaseController
*
* @param ServePluginAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
* @param string $path 에셋 경로 (dist/js/plugin.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 응답
*/
public function serveAsset(
ServePluginAssetRequest $request,
string $identifier,
string $path
string $identifier
): BinaryFileResponse|JsonResponse|Response {
// 파일 경로는 FormRequest 에서 받는다 — 확장자 모드는 `{path}` 라우트 세그먼트,
// 확장자 없는 모드는 `?file=` 쿼리로 오며 prepareForValidation() 이 이를 흡수한다.
$path = (string) $request->validated('path');
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('plugins.assets', ['identifier' => $identifier, 'path' => $path]);

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