diff --git a/.env.example b/.env.example index 27d61180..46373dbb 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.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 diff --git a/.env.testing.example b/.env.testing.example index bec41e3b..ecfb2ee2 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.5 +APP_VERSION=7.0.6 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.gitignore b/.gitignore index 009482e0..30b9125b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 43d3de89..37b3d34b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ -### 백엔드 [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 | @@ -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 은 `` 글리프라 박스 크기가 곧 `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 은 `` 글리프라 박스 크기가 곧 `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` diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7ce37e..1a82be45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/INSTALL.md b/INSTALL.md index 6c55f3cf..0a5d6d57 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 diff --git a/README.md b/README.md index 8a4c6494..ae189eca 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React @@ -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** | 사용자 기본 템플릿 일본어 | diff --git a/app/ActivityLog/Traits/ResolvesActivityLogType.php b/app/ActivityLog/Traits/ResolvesActivityLogType.php index d0268296..e977b0e0 100644 --- a/app/ActivityLog/Traits/ResolvesActivityLogType.php +++ b/app/ActivityLog/Traits/ResolvesActivityLogType.php @@ -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(), diff --git a/app/Benchmark/Axes/BatchAxisRunner.php b/app/Benchmark/Axes/BatchAxisRunner.php new file mode 100644 index 00000000..7d50b0fe --- /dev/null +++ b/app/Benchmark/Axes/BatchAxisRunner.php @@ -0,0 +1,141 @@ + 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'; + } +} diff --git a/app/Benchmark/Axes/ListAxisRunner.php b/app/Benchmark/Axes/ListAxisRunner.php new file mode 100644 index 00000000..ed701f49 --- /dev/null +++ b/app/Benchmark/Axes/ListAxisRunner.php @@ -0,0 +1,350 @@ + + */ + 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 $columns 후보 컬럼 + * @return array 존재하는 컬럼 목록 + */ + 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 $order 후보 정렬 + * @return array 적용 가능한 정렬 목록 + */ + 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 $columns select 컬럼 + * @param array $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 $columns select 컬럼 + * @param array $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 $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 $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 $columns select 컬럼 + * @param array $order 정렬 + * @param int $offset OFFSET + * @return array 실행 계획 요약 줄 목록 + */ + 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); + } +} diff --git a/app/Benchmark/Axes/ScreenAxisRunner.php b/app/Benchmark/Axes/ScreenAxisRunner.php new file mode 100644 index 00000000..787cadec --- /dev/null +++ b/app/Benchmark/Axes/ScreenAxisRunner.php @@ -0,0 +1,261 @@ +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 $query 쿼리스트링 + * @param int $runs 측정 횟수 + * @param BenchmarkRunOptions $options 실행 옵션 + * @param \Closure|null $onProgress 진행 콜백 + * @return array{status: int, total_ms: float, summary: array, acting_user: array} 계측 결과 + */ + 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 $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]; + } +} diff --git a/app/Benchmark/Axes/WriteAxisRunner.php b/app/Benchmark/Axes/WriteAxisRunner.php new file mode 100644 index 00000000..ca92dc20 --- /dev/null +++ b/app/Benchmark/Axes/WriteAxisRunner.php @@ -0,0 +1,227 @@ + 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, queries: array} 계측 결과 + */ + 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, + ]; + } +} diff --git a/app/Benchmark/BenchmarkIdentity.php b/app/Benchmark/BenchmarkIdentity.php new file mode 100644 index 00000000..cb2af9c1 --- /dev/null +++ b/app/Benchmark/BenchmarkIdentity.php @@ -0,0 +1,144 @@ + 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 $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 $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(); + } +} diff --git a/app/Benchmark/BenchmarkProfileRegistry.php b/app/Benchmark/BenchmarkProfileRegistry.php new file mode 100644 index 00000000..93ac970c --- /dev/null +++ b/app/Benchmark/BenchmarkProfileRegistry.php @@ -0,0 +1,275 @@ +|null + */ + private ?array $profiles = null; + + /** + * 수집 중 발생한 선언 오류 메시지 + * + * @var array + */ + private array $warnings = []; + + public function __construct( + private readonly ModuleManagerInterface $moduleManager, + private readonly PluginManagerInterface $pluginManager, + ) {} + + /** + * 모든 프로파일을 정규화 키 기준으로 반환합니다. + * + * @return array 정규화 키 → 프로파일 + */ + 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 정규화 키 → 프로파일 + */ + 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 경고 메시지 + */ + public function warnings(): array + { + $this->all(); + + return $this->warnings; + } + + /** + * 코어 프로파일을 수집합니다. + * + * @return array 코어 프로파일 목록 + */ + private function collectCore(): array + { + $declared = config('benchmark.profiles', []); + + return $this->normalize(is_array($declared) ? $declared : [], 'core', 'core'); + } + + /** + * 활성 모듈/플러그인 프로파일을 수집합니다. + * + * @return array 확장 프로파일 목록 + */ + 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 선언 배열 (실패 시 빈 배열) + */ + 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 $declared 선언 배열 (키 → 정의) + * @param string $sourceKind 출처 종류 (core|module|plugin) + * @param string $sourceIdentifier 출처 식별자 + * @return array 정규화된 프로파일 목록 + */ + 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; + } +} diff --git a/app/Benchmark/BenchmarkReporter.php b/app/Benchmark/BenchmarkReporter.php new file mode 100644 index 00000000..08443a76 --- /dev/null +++ b/app/Benchmark/BenchmarkReporter.php @@ -0,0 +1,226 @@ + 항목 → 값 + */ + 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 $results 계측 결과 + * @param BenchmarkRunOptions $options 실행 옵션 + * @param array $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 $results 계측 결과 + * @param BenchmarkRunOptions $options 실행 옵션 + * @param array $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 마크다운 줄 목록 + */ + 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 '-'; + } + } +} diff --git a/app/Benchmark/Contracts/BenchmarkAxisRunner.php b/app/Benchmark/Contracts/BenchmarkAxisRunner.php new file mode 100644 index 00000000..1c264b26 --- /dev/null +++ b/app/Benchmark/Contracts/BenchmarkAxisRunner.php @@ -0,0 +1,34 @@ + $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 직렬화 결과 + */ + 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, + ]; + } +} diff --git a/app/Benchmark/DTO/BenchmarkResult.php b/app/Benchmark/DTO/BenchmarkResult.php new file mode 100644 index 00000000..a39170ca --- /dev/null +++ b/app/Benchmark/DTO/BenchmarkResult.php @@ -0,0 +1,68 @@ + $headers 표 헤더 + * @param array> $rows 표 행 (표시용 문자열) + * @param array $metrics 기계 판독용 원시 수치 + * @param array $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 직렬화 결과 + */ + 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, + ]; + } +} diff --git a/app/Benchmark/DTO/BenchmarkRunOptions.php b/app/Benchmark/DTO/BenchmarkRunOptions.php new file mode 100644 index 00000000..f93366b3 --- /dev/null +++ b/app/Benchmark/DTO/BenchmarkRunOptions.php @@ -0,0 +1,52 @@ + $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 직렬화 결과 + */ + 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, + ]; + } +} diff --git a/app/Benchmark/ListIndexAdvisor.php b/app/Benchmark/ListIndexAdvisor.php new file mode 100644 index 00000000..e0123728 --- /dev/null +++ b/app/Benchmark/ListIndexAdvisor.php @@ -0,0 +1,203 @@ + $options 목록 프로파일 옵션 + * @return array 선행 컬럼 순서 (등치 → 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 $options 목록 프로파일 옵션 + * @return array{ + * table: string|null, + * required: array, + * 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 $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 $profiles 목록 축 프로파일 + * @return array> 프로파일별 점검 결과 + */ + 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 $columns 인덱스 컬럼 순서 + * @param array $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); + } +} diff --git a/app/Benchmark/QueryCollector.php b/app/Benchmark/QueryCollector.php new file mode 100644 index 00000000..9d51691e --- /dev/null +++ b/app/Benchmark/QueryCollector.php @@ -0,0 +1,102 @@ + + */ + 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} 반환값과 수집 결과 + */ + 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 $queries 수집 결과 + * @return array{count: int, db_ms: float, n_plus_one: array} 요약 + */ + 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, + ]; + } +} diff --git a/app/Benchmark/SyntheticSeeder.php b/app/Benchmark/SyntheticSeeder.php new file mode 100644 index 00000000..74ba17ac --- /dev/null +++ b/app/Benchmark/SyntheticSeeder.php @@ -0,0 +1,234 @@ + $overrides 컬럼별 고정값 (실제 조회 조건과 맞추기 위한 지정) + * @param \Closure|null $onProgress 진행 콜백 (int $inserted, int $total) + * @param array $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> 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 $names 반드시 채울 컬럼명 목록 + * @param array> $already 이미 채우기로 한 컬럼 목록 + * @return array> 추가로 채울 컬럼 정의 목록 + */ + 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> $fillable 채워야 하는 컬럼 목록 + * @param array $overrides 컬럼별 고정값 + * @param int $index 행 인덱스 (고유값 생성용) + * @return array 합성 행 + */ + 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> $chunk 삽입할 행 묶음 + */ + private function insert(string $table, array $chunk): void + { + Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) { + DB::table($table)->insert($chunk); + }); + } + + /** + * 컬럼 타입에 맞는 합성 값을 만듭니다. + * + * @param array $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 $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); + } +} diff --git a/app/Console/Commands/ApiDocgenCommand.php b/app/Console/Commands/ApiDocgenCommand.php index a6596c00..53c07d72 100644 --- a/app/Console/Commands/ApiDocgenCommand.php +++ b/app/Console/Commands/ApiDocgenCommand.php @@ -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 발견된 마커 요약 (종류 => 건수) + */ + 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> $items 이 문서의 라우트 목록 (문서 순서) + * @param array $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 = ''; + $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 = ''; + + $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) 칸은 사람이 채웁니다 diff --git a/app/Console/Commands/AssetUrlModeCommand.php b/app/Console/Commands/AssetUrlModeCommand.php new file mode 100644 index 00000000..8122e771 --- /dev/null +++ b/app/Console/Commands/AssetUrlModeCommand.php @@ -0,0 +1,125 @@ +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(''); + } +} diff --git a/app/Console/Commands/BenchCommand.php b/app/Console/Commands/BenchCommand.php new file mode 100644 index 00000000..45618db1 --- /dev/null +++ b/app/Console/Commands/BenchCommand.php @@ -0,0 +1,395 @@ + $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, 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 오름차순 정렬된 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 $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 $results 계측 결과 + * @param BenchmarkRunOptions $options 실행 옵션 + * @param array $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; + } +} diff --git a/app/Console/Commands/ConvertJsonUnicodeEscapes.php b/app/Console/Commands/ConvertJsonUnicodeEscapes.php index e631005c..84c07b1b 100644 --- a/app/Console/Commands/ConvertJsonUnicodeEscapes.php +++ b/app/Console/Commands/ConvertJsonUnicodeEscapes.php @@ -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); diff --git a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php index ee1858cd..768695de 100644 --- a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php +++ b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php @@ -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])); + } + } } diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index b15986f7..bb15fdb2 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -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(); diff --git a/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php b/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php index 66dfb893..d01375ba 100644 --- a/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php +++ b/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php @@ -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; } - } diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php index 04c37a98..66c09a83 100644 --- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php +++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php @@ -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 모드에서는 부모가 처리하므로 스킵. diff --git a/app/Console/Commands/Extension/UpdateAutoloadCommand.php b/app/Console/Commands/Extension/UpdateAutoloadCommand.php index ff3501db..e61bbcd1 100644 --- a/app/Console/Commands/Extension/UpdateAutoloadCommand.php +++ b/app/Console/Commands/Extension/UpdateAutoloadCommand.php @@ -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 매핑으로 훅 발화 누락/과잉 위험). diff --git a/app/Console/Commands/LanguagePack/ListLanguagePackCommand.php b/app/Console/Commands/LanguagePack/ListLanguagePackCommand.php index 7e82a798..c36d8e8f 100644 --- a/app/Console/Commands/LanguagePack/ListLanguagePackCommand.php +++ b/app/Console/Commands/LanguagePack/ListLanguagePackCommand.php @@ -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; + } } diff --git a/app/Console/Commands/LanguagePack/ProvisionLanguagePackCommand.php b/app/Console/Commands/LanguagePack/ProvisionLanguagePackCommand.php new file mode 100644 index 00000000..76d5a505 --- /dev/null +++ b/app/Console/Commands/LanguagePack/ProvisionLanguagePackCommand.php @@ -0,0 +1,150 @@ +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 대상 로케일 목록 (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), + ))); + } +} diff --git a/app/Console/Commands/Module/CheckModuleUpdatesCommand.php b/app/Console/Commands/Module/CheckModuleUpdatesCommand.php index 45997dbf..f8937df4 100644 --- a/app/Console/Commands/Module/CheckModuleUpdatesCommand.php +++ b/app/Console/Commands/Module/CheckModuleUpdatesCommand.php @@ -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, ])); diff --git a/app/Console/Commands/Module/InstallModuleCommand.php b/app/Console/Commands/Module/InstallModuleCommand.php index 297e1d71..a681d8a1 100644 --- a/app/Console/Commands/Module/InstallModuleCommand.php +++ b/app/Console/Commands/Module/InstallModuleCommand.php @@ -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; } diff --git a/app/Console/Commands/Module/UpdateModuleCommand.php b/app/Console/Commands/Module/UpdateModuleCommand.php index d87444c2..e04b8d04 100644 --- a/app/Console/Commands/Module/UpdateModuleCommand.php +++ b/app/Console/Commands/Module/UpdateModuleCommand.php @@ -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; } diff --git a/app/Console/Commands/PlaywrightIssueToken.php b/app/Console/Commands/PlaywrightIssueToken.php index dec5d9b8..d64b9670 100644 --- a/app/Console/Commands/PlaywrightIssueToken.php +++ b/app/Console/Commands/PlaywrightIssueToken.php @@ -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 $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(); diff --git a/app/Console/Commands/PlaywrightSeedLayout.php b/app/Console/Commands/PlaywrightSeedLayout.php new file mode 100644 index 00000000..812a3599 --- /dev/null +++ b/app/Console/Commands/PlaywrightSeedLayout.php @@ -0,0 +1,377 @@ + + */ + 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 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|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 $routes 디코드된 routes.json + * @return array 마커 항목이 제거된 라우트 배열 + */ + 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 + ); + } +} diff --git a/app/Console/Commands/Plugin/CheckPluginUpdatesCommand.php b/app/Console/Commands/Plugin/CheckPluginUpdatesCommand.php index 8e41bbcc..17356375 100644 --- a/app/Console/Commands/Plugin/CheckPluginUpdatesCommand.php +++ b/app/Console/Commands/Plugin/CheckPluginUpdatesCommand.php @@ -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, ])); diff --git a/app/Console/Commands/Plugin/InstallPluginCommand.php b/app/Console/Commands/Plugin/InstallPluginCommand.php index d4ab9488..47869484 100644 --- a/app/Console/Commands/Plugin/InstallPluginCommand.php +++ b/app/Console/Commands/Plugin/InstallPluginCommand.php @@ -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; } diff --git a/app/Console/Commands/Plugin/UpdatePluginCommand.php b/app/Console/Commands/Plugin/UpdatePluginCommand.php index 82e95e3f..1a5038f9 100644 --- a/app/Console/Commands/Plugin/UpdatePluginCommand.php +++ b/app/Console/Commands/Plugin/UpdatePluginCommand.php @@ -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; } diff --git a/app/Console/Commands/Search/SearchIndexCommand.php b/app/Console/Commands/Search/SearchIndexCommand.php new file mode 100644 index 00000000..82e0dd10 --- /dev/null +++ b/app/Console/Commands/Search/SearchIndexCommand.php @@ -0,0 +1,194 @@ +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 + */ + 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 $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 $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, + '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(' '.__('search.index.degraded_hint').''); + } + } +} diff --git a/app/Console/Commands/SeoGenerateSitemapCommand.php b/app/Console/Commands/SeoGenerateSitemapCommand.php index 1198e680..9bf5f1aa 100644 --- a/app/Console/Commands/SeoGenerateSitemapCommand.php +++ b/app/Console/Commands/SeoGenerateSitemapCommand.php @@ -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')); + } } diff --git a/app/Console/Commands/Template/CheckTemplateUpdatesCommand.php b/app/Console/Commands/Template/CheckTemplateUpdatesCommand.php index ea17ed92..de4f66bd 100644 --- a/app/Console/Commands/Template/CheckTemplateUpdatesCommand.php +++ b/app/Console/Commands/Template/CheckTemplateUpdatesCommand.php @@ -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, ])); diff --git a/app/Console/Commands/Traits/RebuildsSearchIndex.php b/app/Console/Commands/Traits/RebuildsSearchIndex.php new file mode 100644 index 00000000..1cee0e28 --- /dev/null +++ b/app/Console/Commands/Traits/RebuildsSearchIndex.php @@ -0,0 +1,114 @@ +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 $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])); + } + } +} diff --git a/app/Contracts/Pagination/BoundedTotalAware.php b/app/Contracts/Pagination/BoundedTotalAware.php new file mode 100644 index 00000000..72cf351f --- /dev/null +++ b/app/Contracts/Pagination/BoundedTotalAware.php @@ -0,0 +1,39 @@ + $ids 삭제할 활동 로그 ID 목록 + * @param array $ids 삭제할 활동 로그 ID 목록 * @return int 삭제된 건수 */ public function deleteMany(array $ids): int; diff --git a/app/Contracts/Repositories/LayoutExtensionVersionRepositoryInterface.php b/app/Contracts/Repositories/LayoutExtensionVersionRepositoryInterface.php index 9e05cc67..1800a6b0 100644 --- a/app/Contracts/Repositories/LayoutExtensionVersionRepositoryInterface.php +++ b/app/Contracts/Repositories/LayoutExtensionVersionRepositoryInterface.php @@ -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 목록의 현재(최신) 버전 번호 맵 조회 diff --git a/app/Contracts/Repositories/LayoutRepositoryInterface.php b/app/Contracts/Repositories/LayoutRepositoryInterface.php index 492313eb..c675d249 100644 --- a/app/Contracts/Repositories/LayoutRepositoryInterface.php +++ b/app/Contracts/Repositories/LayoutRepositoryInterface.php @@ -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 목록 행 배열 컬렉션 + */ + 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; /** * 특정 버전 조회 diff --git a/app/Contracts/Repositories/NotificationLogRepositoryInterface.php b/app/Contracts/Repositories/NotificationLogRepositoryInterface.php index e53edf92..0673c73d 100644 --- a/app/Contracts/Repositories/NotificationLogRepositoryInterface.php +++ b/app/Contracts/Repositories/NotificationLogRepositoryInterface.php @@ -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; } diff --git a/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php b/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php new file mode 100644 index 00000000..c8b7cda9 --- /dev/null +++ b/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php @@ -0,0 +1,43 @@ + $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 그룹별 집계 결과 + */ + public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array; + + /** + * 기준 시점보다 오래된 통계 레코드를 삭제합니다. + * + * @param Carbon $cutoff 기준 시점 + * @return int 삭제된 레코드 수 + */ + public function deleteOlderThan(Carbon $cutoff): int; +} diff --git a/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php new file mode 100644 index 00000000..3e97a5b9 --- /dev/null +++ b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php @@ -0,0 +1,62 @@ + $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 URL 순회자 + */ + public function streamVisible(?string $contributor = null): iterable; + + /** + * 공개(is_visible) URL 총 개수를 반환합니다. + * + * @return int 공개 URL 수 + */ + public function countVisible(): int; + + /** + * 한 기여자의 모든 사이트맵 URL 행을 전량 대체합니다(전체 재생성). + * + * 기여자 스코프로 기존 행을 제거한 뒤 스트림을 청크 삽입하여 + * 대용량에서도 메모리를 유계로 유지합니다. + * + * @param string $contributor 기여자 식별자 + * @param iterable $entries URL 항목 스트림 + * @return int 삽입된 행 수 (진행상황 누적 URL 표기용 — count 쿼리 없이 스트림 누적) + */ + public function replaceAllForContributor(string $contributor, iterable $entries): int; +} diff --git a/app/Contracts/Repositories/UserRepositoryInterface.php b/app/Contracts/Repositories/UserRepositoryInterface.php index 703811aa..d6726dbf 100644 --- a/app/Contracts/Repositories/UserRepositoryInterface.php +++ b/app/Contracts/Repositories/UserRepositoryInterface.php @@ -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 사용자 ID 배열 + */ + public function getIdsByUuids(array $uuids): array; + + /** + * 사용자 ID 목록에 해당하는 이름을 ID 로 색인해 반환합니다. + * + * 목록 화면이 작성자 이름을 행마다 조회하면 N+1 이 되므로, 표시에 필요한 + * 이름만 한 번에 모아 오기 위한 배치 조회입니다. + * + * @param array $ids 사용자 ID 배열 + * @return array 사용자 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; } diff --git a/app/Enums/BenchmarkAxis.php b/app/Enums/BenchmarkAxis.php new file mode 100644 index 00000000..9b6bc53d --- /dev/null +++ b/app/Enums/BenchmarkAxis.php @@ -0,0 +1,93 @@ + '목록 조회', + self::Screen => '화면 응답', + self::Write => '쓰기 작업', + self::Batch => '배치 작업', + }; + } + + /** + * 프로파일 선언에서 반드시 채워야 하는 옵션 키 목록 + * + * 각 원소는 "대안 그룹"이며, 그룹마다 최소 하나가 선언되어야 합니다 + * (`screen` 축은 라우트명 또는 URI 중 하나). 레지스트리가 이 목록으로 선언을 + * 검증하며, 누락된 선언은 사유와 함께 경고로 드러내고 목록에서 제외합니다 + * (조용히 버리면 계측 사각이 됩니다). + * + * @return array> 필수 옵션 대안 그룹 목록 + */ + 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 축 값 목록 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/IdentityVerificationPurpose.php b/app/Enums/IdentityVerificationPurpose.php index 32fc9a21..b720fe62 100644 --- a/app/Enums/IdentityVerificationPurpose.php +++ b/app/Enums/IdentityVerificationPurpose.php @@ -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 값 배열. * diff --git a/app/Enums/SearchIndexStatus.php b/app/Enums/SearchIndexStatus.php new file mode 100644 index 00000000..b6b0cf55 --- /dev/null +++ b/app/Enums/SearchIndexStatus.php @@ -0,0 +1,59 @@ +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 + */ + public static function allValues(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/SitemapChangeFreq.php b/app/Enums/SitemapChangeFreq.php new file mode 100644 index 00000000..28c2046c --- /dev/null +++ b/app/Enums/SitemapChangeFreq.php @@ -0,0 +1,70 @@ + 값 Enum (sitemaps.org 폐쇄 어휘) + * + * https://www.sitemaps.org/protocol.html 의 는 크롤러에게 페이지 갱신 + * 빈도를 알려주는 힌트로, 허용 값이 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; + } +} diff --git a/app/Enums/SitemapGenerationMode.php b/app/Enums/SitemapGenerationMode.php new file mode 100644 index 00000000..32384d88 --- /dev/null +++ b/app/Enums/SitemapGenerationMode.php @@ -0,0 +1,48 @@ + self::Full, + self::Incremental => self::Incremental, + self::Auto => $visibleCount === 0 ? self::Full : self::Incremental, + }; + } +} diff --git a/app/Enums/TotalRelation.php b/app/Enums/TotalRelation.php new file mode 100644 index 00000000..d7212976 --- /dev/null +++ b/app/Enums/TotalRelation.php @@ -0,0 +1,60 @@ + + */ + 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); + } +} diff --git a/app/Exceptions/Auth/AccountLockedException.php b/app/Exceptions/Auth/AccountLockedException.php index 71ba0fec..192c7772 100644 --- a/app/Exceptions/Auth/AccountLockedException.php +++ b/app/Exceptions/Auth/AccountLockedException.php @@ -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; + } } diff --git a/app/Exceptions/ScheduleExecutionException.php b/app/Exceptions/ScheduleExecutionException.php new file mode 100644 index 00000000..4cde5bce --- /dev/null +++ b/app/Exceptions/ScheduleExecutionException.php @@ -0,0 +1,80 @@ +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); + } +} diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php index c0347f53..66fea241 100644 --- a/app/Extension/AbstractModule.php +++ b/app/Extension/AbstractModule.php @@ -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> 프로파일 키 → 정의 + * [ + * '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 []; + } + /** * 모듈 설치 시 실행할 시더 클래스 목록 반환 * diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 3e811cd6..1d41aa54 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -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> 프로파일 키 → 정의 + * [ + * '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 []; + } + /** * 플러그인 설치 시 실행할 시더 클래스 목록 반환 * diff --git a/app/Extension/ExtensionManager.php b/app/Extension/ExtensionManager.php index aa085ff1..8facbd27 100644 --- a/app/Extension/ExtensionManager.php +++ b/app/Extension/ExtensionManager.php @@ -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 확장 식별자 목록 (예: `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')); } /** diff --git a/app/Extension/HookListenerRegistrar.php b/app/Extension/HookListenerRegistrar.php index 685e3ef7..280498db 100644 --- a/app/Extension/HookListenerRegistrar.php +++ b/app/Extension/HookListenerRegistrar.php @@ -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 에서 계속 기록한다. } } diff --git a/app/Extension/HookManager.php b/app/Extension/HookManager.php index 6af97a4b..9d214950 100644 --- a/app/Extension/HookManager.php +++ b/app/Extension/HookManager.php @@ -19,6 +19,13 @@ class HookManager implements HookManagerInterface private static array $dispatching = []; + /** + * 구 훅 이름 사용 경고를 이미 남긴 훅 이름 집합 — 요청마다 한 번씩만 경고합니다. + * + * @var array + */ + 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); + } } diff --git a/app/Extension/IdentityVerification/IdentityVerificationManager.php b/app/Extension/IdentityVerification/IdentityVerificationManager.php index 761dd6bc..05836573 100644 --- a/app/Extension/IdentityVerification/IdentityVerificationManager.php +++ b/app/Extension/IdentityVerification/IdentityVerificationManager.php @@ -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> */ 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> $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 $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 { diff --git a/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php b/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php index 968a8d9c..4a4ba975 100644 --- a/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php +++ b/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php @@ -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); diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php index 5defac44..f3b4ce63 100644 --- a/app/Extension/ModuleManager.php +++ b/app/Extension/ModuleManager.php @@ -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, diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php index 197ee9b1..192185ff 100644 --- a/app/Extension/PluginManager.php +++ b/app/Extension/PluginManager.php @@ -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, diff --git a/app/Extension/TemplateManager.php b/app/Extension/TemplateManager.php index 4767727f..15b2a6b2 100644 --- a/app/Extension/TemplateManager.php +++ b/app/Extension/TemplateManager.php @@ -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 없으면 (레거시 데이터) 미수정 취급 diff --git a/app/Extension/Testing/ExtensionTestAllowlist.php b/app/Extension/Testing/ExtensionTestAllowlist.php index 7efb9564..f803ca09 100644 --- a/app/Extension/Testing/ExtensionTestAllowlist.php +++ b/app/Extension/Testing/ExtensionTestAllowlist.php @@ -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); + } + /** * 가드 활성 여부를 반환합니다. * diff --git a/app/Extension/Traits/CachesModuleStatus.php b/app/Extension/Traits/CachesModuleStatus.php index 73a1b88e..dcb3051c 100644 --- a/app/Extension/Traits/CachesModuleStatus.php +++ b/app/Extension/Traits/CachesModuleStatus.php @@ -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 조회된 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() ); } diff --git a/app/Extension/Traits/CachesPluginStatus.php b/app/Extension/Traits/CachesPluginStatus.php index de1caa82..14ce9b16 100644 --- a/app/Extension/Traits/CachesPluginStatus.php +++ b/app/Extension/Traits/CachesPluginStatus.php @@ -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 조회된 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() ); } diff --git a/app/Extension/Traits/CachesTemplateStatus.php b/app/Extension/Traits/CachesTemplateStatus.php index 746e06cd..e581ec7b 100644 --- a/app/Extension/Traits/CachesTemplateStatus.php +++ b/app/Extension/Traits/CachesTemplateStatus.php @@ -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 조회된 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) 호출해야 합니다. diff --git a/app/Helpers/PermissionHelper.php b/app/Helpers/PermissionHelper.php index b8c4c32c..de3656c4 100644 --- a/app/Helpers/PermissionHelper.php +++ b/app/Helpers/PermissionHelper.php @@ -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 { diff --git a/app/Helpers/ResponseHelper.php b/app/Helpers/ResponseHelper.php index f83a3ea6..77283cac 100644 --- a/app/Helpers/ResponseHelper.php +++ b/app/Helpers/ResponseHelper.php @@ -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' diff --git a/app/Helpers/TimezoneHelper.php b/app/Helpers/TimezoneHelper.php index b34a3bfc..7398a140 100644 --- a/app/Helpers/TimezoneHelper.php +++ b/app/Helpers/TimezoneHelper.php @@ -198,4 +198,31 @@ class TimezoneHelper return Carbon::parse($normalized, static::getSiteTimezone())->utc(); } + + /** + * 기간 필터의 **종료값**을 사이트 기본 타임존 기준으로 해석하여 UTC Carbon을 반환합니다. + * + * `` 는 시각 없이 '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(); + } } diff --git a/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php b/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php index d53ac9a9..05283b03 100644 --- a/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php +++ b/app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php @@ -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, diff --git a/app/Http/Controllers/Api/Admin/AuthController.php b/app/Http/Controllers/Api/Admin/AuthController.php index 90fbbec6..439f274d 100644 --- a/app/Http/Controllers/Api/Admin/AuthController.php +++ b/app/Http/Controllers/Api/Admin/AuthController.php @@ -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'); diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php index 59c00b67..07e2b7ee 100644 --- a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php +++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php @@ -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, ]); diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php index 277c8886..7df11c8a 100644 --- a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php +++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php @@ -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())); } } diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php index 42fbe090..44ff405d 100644 --- a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php +++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php @@ -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); } } diff --git a/app/Http/Controllers/Api/Admin/LayoutController.php b/app/Http/Controllers/Api/Admin/LayoutController.php index 79b456a0..8ee039cb 100644 --- a/app/Http/Controllers/Api/Admin/LayoutController.php +++ b/app/Http/Controllers/Api/Admin/LayoutController.php @@ -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 템플릿 프론트엔드 다국어 데이터 + */ + private function resolveTemplateTranslations(string $templateName): array + { + $result = $this->templateService->getLanguageDataWithModules( + $templateName, + app()->getLocale() + ); + + return ($result['success'] ?? false) && is_array($result['data'] ?? null) + ? $result['data'] + : []; + } } diff --git a/app/Http/Controllers/Api/Admin/LayoutExtensionController.php b/app/Http/Controllers/Api/Admin/LayoutExtensionController.php index 51bdf5fa..2233e296 100644 --- a/app/Http/Controllers/Api/Admin/LayoutExtensionController.php +++ b/app/Http/Controllers/Api/Admin/LayoutExtensionController.php @@ -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); } /** diff --git a/app/Http/Controllers/Api/Admin/MenuController.php b/app/Http/Controllers/Api/Admin/MenuController.php index 2ea4e92f..1672b8b0 100644 --- a/app/Http/Controllers/Api/Admin/MenuController.php +++ b/app/Http/Controllers/Api/Admin/MenuController.php @@ -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', diff --git a/app/Http/Controllers/Api/Admin/ModuleController.php b/app/Http/Controllers/Api/Admin/ModuleController.php index 7cf5d4ac..74ffb478 100644 --- a/app/Http/Controllers/Api/Admin/ModuleController.php +++ b/app/Http/Controllers/Api/Admin/ModuleController.php @@ -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 diff --git a/app/Http/Controllers/Api/Admin/PluginController.php b/app/Http/Controllers/Api/Admin/PluginController.php index c9dbd370..0fcc883e 100644 --- a/app/Http/Controllers/Api/Admin/PluginController.php +++ b/app/Http/Controllers/Api/Admin/PluginController.php @@ -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 diff --git a/app/Http/Controllers/Api/Admin/PluginSettingsController.php b/app/Http/Controllers/Api/Admin/PluginSettingsController.php index 7ab28df7..192a5335 100644 --- a/app/Http/Controllers/Api/Admin/PluginSettingsController.php +++ b/app/Http/Controllers/Api/Admin/PluginSettingsController.php @@ -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 $settings 복호화된 설정 + * @return array 마스킹된 설정 + */ + 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); } } diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php index c449ab55..c0ebee0c 100644 --- a/app/Http/Controllers/Api/Admin/SeoCacheController.php +++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php @@ -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()); } } } diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index f3f3fc9e..c3e6d14f 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -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, diff --git a/app/Http/Controllers/Api/Admin/TemplateController.php b/app/Http/Controllers/Api/Admin/TemplateController.php index eb3dcdc7..8eeabc3a 100644 --- a/app/Http/Controllers/Api/Admin/TemplateController.php +++ b/app/Http/Controllers/Api/Admin/TemplateController.php @@ -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 diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php index b399270d..65baa1b1 100644 --- a/app/Http/Controllers/Api/Admin/UserController.php +++ b/app/Http/Controllers/Api/Admin/UserController.php @@ -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()]); + } + } + /** * 사용자를 삭제합니다. * diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php index 4f5e0d40..d481397e 100644 --- a/app/Http/Controllers/Api/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -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()); diff --git a/app/Http/Controllers/Api/Base/AuthBaseController.php b/app/Http/Controllers/Api/Base/AuthBaseController.php index 52432b15..1a2abdc0 100644 --- a/app/Http/Controllers/Api/Base/AuthBaseController.php +++ b/app/Http/Controllers/Api/Base/AuthBaseController.php @@ -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(), diff --git a/app/Http/Controllers/Api/Base/PublicBaseController.php b/app/Http/Controllers/Api/Base/PublicBaseController.php index 84c1ccfd..cc108813 100644 --- a/app/Http/Controllers/Api/Base/PublicBaseController.php +++ b/app/Http/Controllers/Api/Base/PublicBaseController.php @@ -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(), ]; } } - diff --git a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php index a8df7880..2d898b0c 100644 --- a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php +++ b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php @@ -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, diff --git a/app/Http/Controllers/Api/Public/AssetProbeController.php b/app/Http/Controllers/Api/Public/AssetProbeController.php new file mode 100644 index 00000000..e30d6250 --- /dev/null +++ b/app/Http/Controllers/Api/Public/AssetProbeController.php @@ -0,0 +1,74 @@ + 'application/javascript; charset=utf-8', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma' => 'no-cache', + 'X-Content-Type-Options' => 'nosniff', + ]); + } +} diff --git a/app/Http/Controllers/Api/Public/PublicModuleController.php b/app/Http/Controllers/Api/Public/PublicModuleController.php index 85de88f1..ffecc46e 100644 --- a/app/Http/Controllers/Api/Public/PublicModuleController.php +++ b/app/Http/Controllers/Api/Public/PublicModuleController.php @@ -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]); diff --git a/app/Http/Controllers/Api/Public/PublicPluginController.php b/app/Http/Controllers/Api/Public/PublicPluginController.php index b187f984..b062b2a6 100644 --- a/app/Http/Controllers/Api/Public/PublicPluginController.php +++ b/app/Http/Controllers/Api/Public/PublicPluginController.php @@ -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]); diff --git a/app/Http/Controllers/Api/Public/PublicSearchController.php b/app/Http/Controllers/Api/Public/PublicSearchController.php index 551187a3..5bcf8cce 100644 --- a/app/Http/Controllers/Api/Public/PublicSearchController.php +++ b/app/Http/Controllers/Api/Public/PublicSearchController.php @@ -2,9 +2,11 @@ namespace App\Http\Controllers\Api\Public; +use App\Enums\TotalRelation; use App\Extension\HookManager; use App\Http\Controllers\Api\Base\PublicBaseController; use App\Http\Requests\Public\SearchRequest; +use App\Support\Query\PaginationLimits; use Illuminate\Http\JsonResponse; /** @@ -18,7 +20,7 @@ class PublicSearchController extends PublicBaseController /** * 통합 검색 수행 * - * @param SearchRequest $request HTTP 요청 + * @param SearchRequest $request HTTP 요청 * @return JsonResponse 검색 결과 */ public function search(SearchRequest $request): JsonResponse @@ -36,6 +38,14 @@ class PublicSearchController extends PublicBaseController return $this->success(__('search.empty_keyword'), $this->buildEmptyResponse()); } + // 특정 탭을 보는 중이라면, 다른 탭의 배지 건수를 아예 세지 않을 수 있다. + // 기본값은 세는 쪽이다 — 탭 배지가 화면 요소라 끄면 숫자가 사라진다. 다만 배지가 + // 필요 없는 화면(더 보기 페이지 등)은 with_counts=false 로 그 비용을 없앤다. + // 전체 탭은 배지가 화면의 주 정보라 이 값과 무관하게 항상 센다. + $includeInactiveCounts = $type === 'all' + ? true + : $request->boolean('with_counts', true); + // 검색 컨텍스트 구성 (모듈별 파라미터는 request 전체를 전달하여 각 모듈이 직접 처리) $context = [ 'q' => $q, @@ -44,6 +54,10 @@ class PublicSearchController extends PublicBaseController 'page' => $page, 'per_page' => $perPage, 'all_tab_limit' => 5, + // 커서는 특정 탭을 볼 때만 의미가 있다. 전체 탭은 카테고리마다 몇 건씩만 + // 보여 주므로 깊은 페이지 자체가 없고, 카테고리별 커서를 하나로 합칠 수도 없다. + 'cursor' => $type === 'all' ? null : ($validated['cursor'] ?? null), + 'include_inactive_counts' => $includeInactiveCounts, 'user' => $request->user(), 'request' => $request, ]; @@ -56,8 +70,14 @@ class PublicSearchController extends PublicBaseController // 프론트엔드용 응답 구조로 변환 (훅을 통해 모듈이 처리) $response = $this->buildResponse($q, $results, $context); + // 총 건수가 상한에 걸렸으면 "N건" 이 아니라 "N건 이상" 으로 알린다. + // 세지 않은 값을 정확한 것처럼 말하지 않는다. + $messageKey = ($response['total_is_exact'] ?? true) + ? 'search.results_found' + : 'search.results_found_at_least'; + return $this->success( - __('search.results_found', ['count' => number_format($response['total'])]), + __($messageKey, ['count' => number_format($response['total'])]), $response ); } @@ -72,6 +92,9 @@ class PublicSearchController extends PublicBaseController return [ 'q' => '', 'total' => 0, + 'total_relation' => TotalRelation::Exact->value, + 'total_is_exact' => true, + 'result_cap' => null, ]; } @@ -81,9 +104,9 @@ class PublicSearchController extends PublicBaseController * 코어는 기본 응답 구조만 생성하고, * 각 모듈이 core.search.build_response 훅을 통해 자신의 카테고리 응답을 구성합니다. * - * @param string $q 검색어 - * @param array $results Hook에서 반환된 검색 결과 - * @param array $context 검색 컨텍스트 + * @param string $q 검색어 + * @param array $results Hook에서 반환된 검색 결과 + * @param array $context 검색 컨텍스트 * @return array 프론트엔드용 응답 구조 */ private function buildResponse(string $q, array $results, array $context): array @@ -97,8 +120,15 @@ class PublicSearchController extends PublicBaseController // 모듈은 $results에서 자신의 데이터를 가져와 $response에 추가 $response = HookManager::applyFilters('core.search.build_response', $response, $results, $context); - // 전체 합계를 항상 보존 (탭 UI에서 전체 건수 표시용) + // 전체 합계를 항상 보존 (탭 UI에서 전체 건수 표시용). + // "전체" 탭 배지도 잘린 합계인지 알아야 하므로 정확도를 함께 붙인다. $response['all_count'] = $response['total']; + $response['all_count_is_exact'] = $this->resolveTotalAccuracy($results)['total_is_exact']; + + // 탭 배지는 카테고리마다 하나씩 그려지므로 정확도도 카테고리마다 필요하다. + // 모듈이 각자 자기 키를 내보내면 어떤 배지는 정확도를 받고 어떤 배지는 못 받아, + // 상한에 걸린 숫자가 그 배지에서만 정확한 값처럼 보인다. 코어가 일괄로 붙인다. + $response['counts_are_exact'] = $this->resolveCategoryAccuracy($results); // 특정 탭 조회 시 total을 해당 탭의 count로 설정 $type = $context['type'] ?? 'all'; @@ -109,13 +139,14 @@ class PublicSearchController extends PublicBaseController } } - return $response; + // 정확도 메타 — 전체 탭은 카테고리 합집합, 특정 탭은 그 카테고리 하나만 본다. + return array_merge($response, $this->resolveTotalAccuracy($results, $type)); } /** * 전체 검색 결과 수 계산 * - * @param array $results Hook에서 반환된 검색 결과 + * @param array $results Hook에서 반환된 검색 결과 * @return int 전체 결과 수 */ private function calculateTotal(array $results): int @@ -127,4 +158,60 @@ class PublicSearchController extends PublicBaseController return $total; } + + /** + * 카테고리별 총 건수 정확도를 모읍니다. + * + * 탭 배지는 카테고리 수만큼 그려지므로 정확도도 그 수만큼 있어야 합니다. 하나라도 + * 빠지면 그 배지에서만 상한에 걸린 숫자가 정확한 값처럼 보이고, 오류로는 드러나지 + * 않은 채 그냥 틀린 숫자로 남습니다. + * + * @param array $results Hook에서 반환된 검색 결과 + * @return array 카테고리 => 정확 여부 + */ + private function resolveCategoryAccuracy(array $results): array + { + $accuracy = []; + + foreach ($results as $category => $categoryData) { + $accuracy[$category] = ($categoryData['total_is_exact'] ?? true) === true; + } + + return $accuracy; + } + + /** + * 응답 전체의 총 건수 정확도를 정합니다. + * + * 전체 탭에서는 카테고리 중 하나라도 상한에 걸리면 합계도 "이상" 이다 — 정확한 + * 카테고리 몇 개를 더해 봐야 전체가 정확해지지 않는다. + * + * 특정 탭을 보는 중이면 `total` 이 그 카테고리 건수로 바뀌므로 정확도도 그 카테고리 + * 하나만 본다. 다른 카테고리가 잘렸다는 이유로 정확한 값을 "이상" 이라 말하지 않는다. + * + * @param array $results Hook에서 반환된 검색 결과 + * @param string $type 조회 탭 (all 이면 전체 합계) + * @return array{total_relation: string, total_is_exact: bool, result_cap: int|null} 정확도 메타 + */ + private function resolveTotalAccuracy(array $results, string $type = 'all'): array + { + $scoped = ($type !== 'all' && array_key_exists($type, $results)) + ? [$results[$type]] + : $results; + + $isExact = true; + + foreach ($scoped as $categoryData) { + if (($categoryData['total_is_exact'] ?? true) === false) { + $isExact = false; + break; + } + } + + return [ + 'total_relation' => $isExact ? TotalRelation::Exact->value : TotalRelation::AtLeast->value, + 'total_is_exact' => $isExact, + 'result_cap' => PaginationLimits::resultCap('search'), + ]; + } } diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php index ee62eb1c..13d39d9e 100644 --- a/app/Http/Controllers/Api/Public/PublicTemplateController.php +++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Public; +use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Extension\Helpers\EditorSpecAssembler; use App\Extension\Traits\ClearsTemplateCaches; @@ -12,6 +13,7 @@ use App\Services\TemplateLayoutAttachmentService; use App\Services\TemplateService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\BinaryFileResponse; /** @@ -40,22 +42,36 @@ class PublicTemplateController extends PublicBaseController // 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화 $cacheVersion = request()->query('v', 0); - // 캐싱된 응답 반환 (1시간 유효) - $routesData = $this->cached( - "template.routes.{$identifier}.v{$cacheVersion}", - function () use ($identifier) { - // Service에서 템플릿 + 모듈 routes 데이터 병합 조회 - $result = $this->templateService->getRoutesDataWithModules($identifier); + // 확장 업데이트 중에는 활성 디렉토리가 잠시 비어 그 모듈의 라우트가 통째로 빠진다. + // 그 순간의 응답을 버전 키에 캐시하면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지 + // 해당 모듈의 모든 화면이 404 로 남는다 — 버전은 이미 올라간 뒤라 스스로 회복되지 않는다. + // 따라서 열화 스냅샷은 그대로 응답하되 캐시에 남기지 않는다. + $buildRoutes = function () use ($identifier) { + $result = $this->templateService->getRoutesDataWithModules($identifier); - // 에러 처리 - if (! $result['success']) { - return ['error' => $result['error']]; - } + if (! $result['success']) { + return ['error' => $result['error']]; + } - return ['success' => true, 'data' => $result['data']]; - }, - 3600 - ); + return ['success' => true, 'data' => $result['data']]; + }; + + $cacheKey = "template.routes.{$identifier}.v{$cacheVersion}"; + $cache = app(CacheInterface::class); + $routesData = $cache->get($cacheKey); + + if ($routesData === null) { + $routesData = $buildRoutes(); + + if ($this->templateService->lastRouteMergeWasDegraded()) { + Log::warning('라우트 병합이 열화 상태여서 캐시하지 않습니다 (확장 업데이트 진행 중 추정)', [ + 'template' => $identifier, + 'cache_version' => $cacheVersion, + ]); + } else { + $cache->put($cacheKey, $routesData, 3600); + } + } // 에러 응답 처리 if (isset($routesData['error'])) { @@ -88,13 +104,16 @@ class PublicTemplateController extends PublicBaseController /** * 템플릿 정적 파일 서빙 * - * @param ServeTemplateAssetRequest $request 요청 (FormRequest 검증) + * @param ServeTemplateAssetRequest $request 요청 (FormRequest 검증, 파일 경로 `path` 포함) * @param string $identifier 템플릿 식별자 - * @param string $path 요청 경로 * @return BinaryFileResponse|JsonResponse|Response 파일 응답 또는 에러 */ - public function serveAsset(ServeTemplateAssetRequest $request, string $identifier, string $path): BinaryFileResponse|JsonResponse|Response + public function serveAsset(ServeTemplateAssetRequest $request, string $identifier): BinaryFileResponse|JsonResponse|Response { + // 파일 경로는 FormRequest 에서 받는다 — 확장자 모드는 `{path}` 라우트 세그먼트, + // 확장자 없는 모드는 `?file=` 쿼리로 오며 prepareForValidation() 이 이를 흡수한다. + $path = (string) $request->validated('path'); + // FormRequest에서 이미 보안 검증 완료 // API 사용량 기록 $this->logApiUsage('templates.assets', ['identifier' => $identifier, 'path' => $path]); diff --git a/app/Http/Controllers/Api/Public/SitemapController.php b/app/Http/Controllers/Api/Public/SitemapController.php index 9a5d6e88..fdd6d2d6 100644 --- a/app/Http/Controllers/Api/Public/SitemapController.php +++ b/app/Http/Controllers/Api/Public/SitemapController.php @@ -4,42 +4,92 @@ namespace App\Http\Controllers\Api\Public; use App\Contracts\Extension\CacheInterface; use App\Http\Controllers\Controller; -use App\Seo\SitemapGenerator; -use Illuminate\Http\Response; +use App\Jobs\GenerateSitemapJob; +use App\Seo\SitemapFileStore; +use App\Seo\SitemapManager; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * Sitemap XML 컨트롤러 * - * sitemap.xml 요청을 처리하여 캐시된 또는 실시간 생성된 - * Sitemap XML을 반환합니다. + * 비공개 디스크에 커밋된 sitemap 인덱스/자식 파일을 스트리밍으로 서빙합니다. + * 봇 요청 스레드에서 sitemap 을 생성하지 않으며(대용량에서 OOM/타임아웃 원인), + * 신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale)를 그대로 내보냅니다. */ class SitemapController extends Controller { /** - * sitemap.xml을 반환합니다. - * - * 캐시가 있으면 캐시된 XML, 없으면 실시간 생성 후 캐시 저장. - * - * @param SitemapGenerator $generator Sitemap 생성기 - * @param CacheInterface $cache 코어 캐시 드라이버 + * 세트가 없어 503 을 반환할 때 봇에 안내할 재시도 간격 (초) */ - public function index(SitemapGenerator $generator, CacheInterface $cache): Response + private const RETRY_AFTER_SECONDS = 120; + + /** + * sitemapindex 를 반환합니다. + * + * 커밋된 세트가 있으면 스트리밍하고, 신선도(메타 캐시)가 만료되었으면 + * 생성 잡을 예약합니다. 세트가 전혀 없으면 503 + Retry-After 를 반환합니다. + * + * @param SitemapFileStore $store sitemap 파일 저장소 (읽기측) + * @param CacheInterface $cache 코어 캐시 드라이버 + * @return StreamedResponse sitemapindex 스트림 + */ + public function index(SitemapFileStore $store, CacheInterface $cache): StreamedResponse { - $enabled = (bool) g7_core_settings('seo.sitemap_enabled', true); - if (! $enabled) { + $this->assertSitemapEnabled(); + + $isFresh = $cache->get(SitemapManager::META_CACHE_KEY) !== null; + + if ($isFresh) { + $response = $store->indexResponse(); + if ($response !== null) { + return $response; + } + } + + // 신선도 만료 또는 세트 부재 — 생성은 큐에 맡긴다 (ShouldBeUnique 로 중복 디스패치 방지). + GenerateSitemapJob::dispatch(); + + $serveStale = (bool) g7_core_settings('seo.sitemap_serve_stale_on_miss', true); + + if ($serveStale) { + $response = $store->indexResponse(); + if ($response !== null) { + return $response; + } + } + + abort(503, __('seo.sitemap_not_ready'), ['Retry-After' => (string) self::RETRY_AFTER_SECONDS]); + } + + /** + * 자식 sitemap 을 반환합니다. + * + * gzip 여부는 manifest 가 결정하므로 `.xml` / `.xml.gz` 요청을 같은 액션에서 처리합니다. + * + * @param int $n 자식 번호 (1부터) + * @param SitemapFileStore $store sitemap 파일 저장소 (읽기측) + * @return StreamedResponse 자식 sitemap 스트림 + */ + public function child(int $n, SitemapFileStore $store): StreamedResponse + { + $this->assertSitemapEnabled(); + + $response = $store->childResponse($n); + + if ($response === null) { abort(404); } - $xml = $cache->get('seo.sitemap'); + return $response; + } - if (! $xml) { - $xml = $generator->generate(); - $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400)); - $cache->put('seo.sitemap', $xml, $ttl); + /** + * Sitemap 기능이 비활성화되어 있으면 404 로 중단합니다. + */ + private function assertSitemapEnabled(): void + { + if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) { + abort(404); } - - return response($xml, 200, [ - 'Content-Type' => 'application/xml', - ]); } } diff --git a/app/Http/Controllers/Concerns/RebuildsSearchIndexOnDemand.php b/app/Http/Controllers/Concerns/RebuildsSearchIndexOnDemand.php new file mode 100644 index 00000000..98ae4497 --- /dev/null +++ b/app/Http/Controllers/Concerns/RebuildsSearchIndexOnDemand.php @@ -0,0 +1,46 @@ +|null 응답에 실을 페이로드 (점검 불가 엔진이면 null) + */ + protected function rebuildSearchIndexIfRequested(bool $requested): ?array + { + $manager = app(SearchIndexMaintenanceManager::class); + + // 점검을 제공하지 않는 엔진에서는 실을 정보가 없다 (검색 자체는 정상 동작) + if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) { + return null; + } + + if ($requested) { + return ['rebuilt' => true] + $manager->repairStale()->toArray(); + } + + $stale = array_values(array_filter($manager->inspect(), fn ($health) => $health->needsRebuild())); + + return [ + 'rebuilt' => false, + 'driver' => $manager->driver(), + 'stale' => array_map(fn ($health) => $health->identifier, $stale), + 'stale_count' => count($stale), + ]; + } +} diff --git a/app/Http/Middleware/Concerns/DetectsRequestLocale.php b/app/Http/Middleware/Concerns/DetectsRequestLocale.php new file mode 100644 index 00000000..58ef71f5 --- /dev/null +++ b/app/Http/Middleware/Concerns/DetectsRequestLocale.php @@ -0,0 +1,42 @@ +header('Accept-Language'); + if ($acceptLanguage) { + $languages = explode(',', $acceptLanguage); + foreach ($languages as $language) { + $lang = trim(explode(';', $language)[0]); + if (strpos($lang, '-') !== false) { + $lang = explode('-', $lang)[0]; + } + if (in_array($lang, $supportedLocales)) { + return $lang; + } + } + } + + return config('app.locale', 'ko'); + } +} diff --git a/app/Http/Middleware/DatabaseCredentialGuard.php b/app/Http/Middleware/DatabaseCredentialGuard.php new file mode 100644 index 00000000..26aa69de --- /dev/null +++ b/app/Http/Middleware/DatabaseCredentialGuard.php @@ -0,0 +1,85 @@ +resolveUsername(); + + if (PrivilegedDatabaseAccounts::isUsable($username)) { + return $next($request); + } + + // SetLocale 미실행 상태이므로 로케일을 자체 감지한다. + // API/HTML 분기보다 먼저 적용해야 JSON 메시지도 활성 로케일을 반영한다. + app()->setLocale($this->detectLocale($request)); + + if ($request->expectsJson() || $request->is('api/*')) { + return response()->json([ + 'success' => false, + 'message' => __('database_credential.message'), + ], 503); + } + + // 확장·템플릿이 로딩되지 않은 상태이므로 템플릿 뷰나 errors/layout 을 + // 쓸 수 없다. DB/JS 무의존 자체 완결 정적 페이지를 반환한다. + return response()->view('database-credential-error', [ + // 실제 계정명은 전달하지 않는다 — 미인증 공개 페이지이므로 정보 유출. + 'reason' => PrivilegedDatabaseAccounts::isBlocked($username) ? 'blocked' : 'empty', + ], 503); + } + + /** + * 현재 DB 커넥션 설정에서 사용자명을 해석합니다. + * + * DB::connection() 을 거치지 않고 config 배열만 읽습니다. 이 가드는 DB 설정이 + * 잘못된 상황에서 동작해야 하므로, 판정을 위해 커넥션을 해석하는 것 자체가 + * 새로운 실패 지점이 됩니다. read/write 우선순위 규칙은 + * PrivilegedDatabaseAccounts 가 CoreServiceProvider 와 공유합니다. + * + * @return string|null 해석된 사용자명 (없으면 null) + */ + protected function resolveUsername(): ?string + { + $connection = config('database.default'); + + return PrivilegedDatabaseAccounts::resolveUsername( + config("database.connections.{$connection}", []) + ); + } +} diff --git a/app/Http/Middleware/MaintenanceModePage.php b/app/Http/Middleware/MaintenanceModePage.php index 3c036c80..b183dd58 100644 --- a/app/Http/Middleware/MaintenanceModePage.php +++ b/app/Http/Middleware/MaintenanceModePage.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Http\Middleware\Concerns\DetectsRequestLocale; use Closure; use Illuminate\Foundation\Http\MaintenanceModeBypassCookie; use Illuminate\Http\Request; @@ -9,6 +10,8 @@ use Symfony\Component\HttpFoundation\Response; class MaintenanceModePage { + use DetectsRequestLocale; + /** * Maintenance 모드일 때 전용 정적 페이지를 렌더링합니다. * @@ -76,31 +79,4 @@ class MaintenanceModePage return MaintenanceModeBypassCookie::isValid($cookie, $data['secret']); } - - /** - * 로케일을 감지합니다. SetLocale 미들웨어의 로직을 준용합니다. - * - * @param Request $request HTTP 요청 - * @return string 감지된 로케일 - */ - protected function detectLocale(Request $request): string - { - $supportedLocales = config('app.supported_locales', ['ko', 'en']); - - $acceptLanguage = $request->header('Accept-Language'); - if ($acceptLanguage) { - $languages = explode(',', $acceptLanguage); - foreach ($languages as $language) { - $lang = trim(explode(';', $language)[0]); - if (strpos($lang, '-') !== false) { - $lang = explode('-', $lang)[0]; - } - if (in_array($lang, $supportedLocales)) { - return $lang; - } - } - } - - return config('app.locale', 'ko'); - } } diff --git a/app/Http/Middleware/PermissionMiddleware.php b/app/Http/Middleware/PermissionMiddleware.php index 32a75e38..2c31a3d5 100644 --- a/app/Http/Middleware/PermissionMiddleware.php +++ b/app/Http/Middleware/PermissionMiddleware.php @@ -6,6 +6,7 @@ use App\Enums\PermissionType; use App\Helpers\PermissionHelper; use App\Helpers\ResponseHelper; use App\Models\Role; +use App\Support\GuestRoleResolver; use Closure; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; @@ -14,13 +15,6 @@ use Symfony\Component\HttpFoundation\Response; class PermissionMiddleware { - /** - * guest role 캐시 - * - * @var Role|null - */ - protected static $guestRoleCache = null; - /** * 특정 권한을 가진 사용자 또는 비회원(guest)의 접근을 허용합니다. * @@ -125,10 +119,9 @@ class PermissionMiddleware return false; } - return $guestRole->permissions() - ->where('identifier', $permission) - ->where('type', $type) - ->exists(); + // 적재해 둔 권한 컬렉션에서 판정한다. permissions() 로 빌더를 다시 열면 + // 캐시해 둔 의미가 사라져 판정마다 쿼리가 나간다. + return GuestRoleResolver::hasPermission($permission, $type); } /** @@ -138,13 +131,7 @@ class PermissionMiddleware */ protected function getGuestRole(): ?Role { - if (self::$guestRoleCache === null) { - self::$guestRoleCache = Role::where('identifier', 'guest') - ->with('permissions') - ->first(); - } - - return self::$guestRoleCache; + return GuestRoleResolver::resolve(); } /** @@ -158,7 +145,7 @@ class PermissionMiddleware */ public static function clearGuestRoleCache(): void { - self::$guestRoleCache = null; + GuestRoleResolver::flush(); } /** diff --git a/app/Http/Middleware/RoleMiddleware.php b/app/Http/Middleware/RoleMiddleware.php index f8549503..33676b03 100644 --- a/app/Http/Middleware/RoleMiddleware.php +++ b/app/Http/Middleware/RoleMiddleware.php @@ -13,17 +13,17 @@ class RoleMiddleware /** * 특정 역할을 가진 사용자만 접근을 허용합니다. * - * @param Request $request HTTP 요청 - * @param Closure $next 다음 미들웨어 - * @param string $role 필요한 역할명 - * @param bool $requireAll 여러 역할시 모두 필요한지 여부 + * @param Request $request HTTP 요청 + * @param Closure $next 다음 미들웨어 + * @param string $role 필요한 역할명 + * @param bool $requireAll 여러 역할시 모두 필요한지 여부 * @return Response HTTP 응답 */ public function handle(Request $request, Closure $next, string $role, bool $requireAll = true): Response { // 인증되지 않은 사용자 - if (!Auth::check()) { - return ResponseHelper::unauthorized('messages.auth.unauthenticated'); + if (! Auth::check()) { + return ResponseHelper::unauthorized('auth.unauthenticated'); } $user = Auth::user(); @@ -31,16 +31,16 @@ class RoleMiddleware // 역할 확인 if (count($roles) === 1) { - if (!$user->hasRole($roles[0])) { - return ResponseHelper::forbidden('messages.auth.role_denied', [ - 'required_role' => $roles[0] + if (! $user->hasRole($roles[0])) { + return ResponseHelper::forbidden('auth.role_denied', [ + 'required_role' => $roles[0], ]); } } else { - if (!$user->hasRoles($roles, $requireAll)) { - return ResponseHelper::forbidden('messages.auth.role_denied', [ + if (! $user->hasRoles($roles, $requireAll)) { + return ResponseHelper::forbidden('auth.role_denied', [ 'required_roles' => $roles, - 'require_all' => $requireAll + 'require_all' => $requireAll, ]); } } diff --git a/app/Http/Middleware/StartApiSession.php b/app/Http/Middleware/StartApiSession.php index 6223191a..b43ac58a 100644 --- a/app/Http/Middleware/StartApiSession.php +++ b/app/Http/Middleware/StartApiSession.php @@ -3,9 +3,11 @@ namespace App\Http\Middleware; use Closure; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; +use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Http\Request; use Illuminate\Pipeline\Pipeline; -use Illuminate\Support\Facades\Log; +use Illuminate\Session\Middleware\StartSession; use Symfony\Component\HttpFoundation\Response; /** @@ -23,34 +25,16 @@ class StartApiSession /** * 요청을 처리합니다. * - * @param Request $request HTTP 요청 - * @param Closure $next 다음 미들웨어 + * @param Request $request HTTP 요청 + * @param Closure $next 다음 미들웨어 * @return Response HTTP 응답 */ public function handle(Request $request, Closure $next): Response { - Log::info('[StartApiSession] 미들웨어 시작', ['url' => $request->url()]); - return (new Pipeline(app())) ->send($request) ->through($this->sessionPipeline()) - ->then(function ($request) use ($next) { - Log::info('[StartApiSession] 세션 파이프라인 완료', [ - 'hasSession' => $request->hasSession(), - 'sessionStarted' => $request->hasSession() ? $request->session()->isStarted() : false, - 'sessionId' => $request->hasSession() ? $request->session()->getId() : null, - ]); - - $response = $next($request); - - Log::info('[StartApiSession] 컨트롤러 응답 후', [ - 'status' => $response->getStatusCode(), - 'cookies' => collect($response->headers->getCookies())->map(fn($c) => $c->getName())->all(), - 'webGuardCheck' => \Illuminate\Support\Facades\Auth::guard('web')->check(), - ]); - - return $response; - }); + ->then(fn ($request) => $next($request)); } /** @@ -63,9 +47,9 @@ class StartApiSession protected function sessionPipeline(): array { return [ - \Illuminate\Cookie\Middleware\EncryptCookies::class, - \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, - \Illuminate\Session\Middleware\StartSession::class, + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, ]; } } diff --git a/app/Http/Requests/ActivityLog/ActivityLogIndexRequest.php b/app/Http/Requests/ActivityLog/ActivityLogIndexRequest.php index 243fdc43..0b52df4d 100644 --- a/app/Http/Requests/ActivityLog/ActivityLogIndexRequest.php +++ b/app/Http/Requests/ActivityLog/ActivityLogIndexRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\ActivityLog; use App\Enums\ActivityLogType; use App\Extension\HookManager; +use App\Models\User; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -33,7 +34,7 @@ class ActivityLogIndexRequest extends FormRequest 'log_type' => ['nullable', 'array'], 'log_type.*' => ['string', Rule::in(ActivityLogType::values())], 'action' => ['nullable', 'string', 'max:100'], - 'user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], + 'user_id' => ['nullable', 'integer', Rule::exists(User::class, 'id')], 'loggable_type' => ['nullable', 'string', 'max:255'], 'search' => ['nullable', 'string', 'max:255'], 'search_type' => ['nullable', 'string', Rule::in(['all', 'action', 'description', 'ip_address'])], @@ -41,8 +42,15 @@ class ActivityLogIndexRequest extends FormRequest 'date_from' => ['nullable', 'date'], 'date_to' => ['nullable', 'date', 'after_or_equal:date_from'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + // 게이트는 화면이 제공하는 정렬만 연다. ActivityLogRepository::SORTABLE_COLUMNS 는 + // 이보다 넓게(id·user_id 포함) 두어, 확장이 이 규칙을 훅으로 넓혔을 때 저장소가 + // 조용히 기본 정렬로 되돌리지 않도록 한다 (service-repository.md "정렬 컬럼 화이트리스트"). 'sort_by' => ['nullable', 'string', Rule::in(['created_at', 'action', 'log_type'])], 'sort_order' => ['nullable', 'string', Rule::in(['asc', 'desc'])], + // 커서를 주면 목록이 키셋 방식으로 응답한다. 로그는 계속 쌓이기만 하므로 깊은 + // 페이지를 OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 한다. + // 형식이 깨진 값은 KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다. + 'cursor' => ['nullable', 'string', 'max:500'], ]; return HookManager::applyFilters('core.activity_log.index_validation_rules', $rules, $this); diff --git a/app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php b/app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php index 3d8ed7ec..b5a18f45 100644 --- a/app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php +++ b/app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php @@ -15,7 +15,7 @@ class AdminIdentityMessageDefinitionIndexRequest extends FormRequest /** * 권한 확인 (미들웨어에서 처리). * - * @return bool + * @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당) */ public function authorize(): bool { @@ -25,7 +25,7 @@ class AdminIdentityMessageDefinitionIndexRequest extends FormRequest /** * 검증 규칙. * - * @return array + * @return array 검증 규칙 배열 */ public function rules(): array { @@ -50,7 +50,7 @@ class AdminIdentityMessageDefinitionIndexRequest extends FormRequest ]; return HookManager::applyFilters( - 'core.identity.message_definition.filter_index_rules', + 'core.identity.message_definition.index_validation_rules', $rules ); } @@ -58,7 +58,7 @@ class AdminIdentityMessageDefinitionIndexRequest extends FormRequest /** * 검증 메시지. * - * @return array + * @return array 검증 오류 메시지 배열 */ public function messages(): array { diff --git a/app/Http/Requests/Admin/Identity/StoreIdentityMessageDefinitionRequest.php b/app/Http/Requests/Admin/Identity/StoreIdentityMessageDefinitionRequest.php index d8c18ab0..1b798cf4 100644 --- a/app/Http/Requests/Admin/Identity/StoreIdentityMessageDefinitionRequest.php +++ b/app/Http/Requests/Admin/Identity/StoreIdentityMessageDefinitionRequest.php @@ -27,7 +27,7 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest /** * 라우트 permission 미들웨어가 담당하므로 true 고정. * - * @return bool + * @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당) */ public function authorize(): bool { @@ -37,7 +37,7 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest /** * 검증 규칙을 반환합니다. * - * @return array + * @return array 검증 규칙 배열 */ public function rules(): array { @@ -65,15 +65,13 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest ]; return HookManager::applyFilters( - 'core.identity.message_definition.filter_store_rules', + 'core.identity.message_definition.store_validation_rules', $rules, ); } /** * provider_id 가 등록된 IDV 프로바이더인지 확인하는 closure 룰. - * - * @return Closure */ protected function providerExistsRule(): Closure { @@ -91,8 +89,6 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest /** * scope_value 가 source_type='admin' 인 IdentityPolicy.key 와 일치하는지 확인. - * - * @return Closure */ protected function adminPolicyKeyRule(): Closure { @@ -113,8 +109,6 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest /** * (provider_id, scope_type, scope_value) 조합 중복 검사. - * - * @return Closure */ protected function scopeUniqueRule(): Closure { diff --git a/app/Http/Requests/Admin/Identity/UpdateIdentityMessageDefinitionRequest.php b/app/Http/Requests/Admin/Identity/UpdateIdentityMessageDefinitionRequest.php index 05b343bb..e2d63ead 100644 --- a/app/Http/Requests/Admin/Identity/UpdateIdentityMessageDefinitionRequest.php +++ b/app/Http/Requests/Admin/Identity/UpdateIdentityMessageDefinitionRequest.php @@ -18,7 +18,7 @@ class UpdateIdentityMessageDefinitionRequest extends FormRequest /** * 권한 확인 (미들웨어에서 처리). * - * @return bool + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -28,20 +28,22 @@ class UpdateIdentityMessageDefinitionRequest extends FormRequest /** * 검증 규칙. * - * @return array + * @return array 필드별 검증 규칙 */ public function rules(): array { $rules = [ 'name' => ['sometimes', 'array', new LocaleRequiredTranslatable(maxLength: 200)], 'description' => ['sometimes', 'nullable', 'array', new TranslatableField(maxLength: 1000)], + // audit:allow formrequest-sometimes-array-min-inert reason: 채널 0개는 유효한 상태가 아니다. + // 발송 채널을 모두 해제하면 본인인증 메시지가 전달되지 않으므로 오류를 노출하는 것이 올바른 동작이다. 'channels' => ['sometimes', 'array', 'min:1'], 'channels.*' => ['string', 'max:20'], 'is_active' => ['sometimes', 'boolean'], ]; return HookManager::applyFilters( - 'core.identity.message_definition.filter_update_rules', + 'core.identity.message_definition.update_validation_rules', $rules, $this->route('definition') ); diff --git a/app/Http/Requests/Admin/Identity/UpdateIdentityMessageTemplateRequest.php b/app/Http/Requests/Admin/Identity/UpdateIdentityMessageTemplateRequest.php index 8330b484..e7856459 100644 --- a/app/Http/Requests/Admin/Identity/UpdateIdentityMessageTemplateRequest.php +++ b/app/Http/Requests/Admin/Identity/UpdateIdentityMessageTemplateRequest.php @@ -18,7 +18,7 @@ class UpdateIdentityMessageTemplateRequest extends FormRequest /** * 권한 확인 (미들웨어에서 처리). * - * @return bool + * @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당) */ public function authorize(): bool { @@ -28,7 +28,7 @@ class UpdateIdentityMessageTemplateRequest extends FormRequest /** * 검증 규칙. * - * @return array + * @return array 검증 규칙 배열 */ public function rules(): array { @@ -39,7 +39,7 @@ class UpdateIdentityMessageTemplateRequest extends FormRequest ]; return HookManager::applyFilters( - 'core.identity.message_template.filter_update_rules', + 'core.identity.message_template.update_validation_rules', $rules, $this->route('template') ); diff --git a/app/Http/Requests/Admin/LayoutVersionListRequest.php b/app/Http/Requests/Admin/LayoutVersionListRequest.php new file mode 100644 index 00000000..d4b7afbc --- /dev/null +++ b/app/Http/Requests/Admin/LayoutVersionListRequest.php @@ -0,0 +1,61 @@ + 검증 규칙 + */ + public function rules(): array + { + $rules = [ + 'limit' => ['nullable', 'integer', 'min:1', 'max:'.self::MAX_LIMIT], + ]; + + // 모듈/플러그인이 목록 조회 파라미터를 확장할 수 있도록 훅을 노출한다 + return HookManager::applyFilters('core.layout_version.list_validation_rules', $rules, $this); + } + + /** + * 검증 오류 메시지 커스터마이징 + * + * @return array 오류 메시지 + */ + public function messages(): array + { + return [ + 'limit.integer' => __('validation.layout_version.limit.integer'), + 'limit.min' => __('validation.layout_version.limit.min'), + 'limit.max' => __('validation.layout_version.limit.max', ['max' => self::MAX_LIMIT]), + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php index 3cd0a073..2b25a1e6 100644 --- a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php +++ b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php @@ -106,7 +106,21 @@ class UpdatePluginSettingsRequest extends FormRequest $rules[$field] = $fieldRules; } - return HookManager::applyFilters('core.plugin_settings.update_rules', $rules, $identifier); + // 표준 이름(`core.{대상}.{동작}_validation_rules`)으로 발행하되, 이미 공개돼 구독 중일 수 있는 + // 구 이름(`core.plugin_settings.update_rules`)도 함께 발행한다 — 구 이름을 구독하는 제3자 + // 확장이 조용히 멈추지 않도록 한다. 구 이름은 구독자가 있을 때만 1회 경고가 남는다. + // + // 3번째 인자로 이번 요청의 입력값을 함께 넘긴다. 확장이 "현재 입력한 모드에 따라 + // 다른 필드를 필수로 만드는" 조건부 규칙을 만들려면 입력값이 필요한데, 그것이 없어 + // 확장이 request() 를 직접 들여다보던 것을 없애기 위함이다. + // 기존 2인자 구독자는 그대로 동작한다(초과 인자는 무시된다). + return HookManager::applyFiltersWithLegacyName( + 'core.plugin_settings.update_validation_rules', + 'core.plugin_settings.update_rules', + $rules, + $identifier, + $this->all(), + ); } /** diff --git a/app/Http/Requests/Attachment/UploadAttachmentRequest.php b/app/Http/Requests/Attachment/UploadAttachmentRequest.php index 88bdc844..0acb70f7 100644 --- a/app/Http/Requests/Attachment/UploadAttachmentRequest.php +++ b/app/Http/Requests/Attachment/UploadAttachmentRequest.php @@ -29,10 +29,18 @@ class UploadAttachmentRequest extends FormRequest */ public function rules(): array { - $maxSize = config('attachment.max_size', 10) * 1024; // MB to KB + // config/attachment.max_file_size 는 KB 단위 (관리자 설정 MB → KB 변환은 SettingsServiceProvider 담당) + $maxSize = (int) config('attachment.max_file_size', 10240); + $extensions = $this->allowedExtensions(); + + $fileRules = ['required', 'file', 'max:'.$maxSize]; + // 목록이 비면 확장자 제한 없음 (탈출구) — 빈 규칙을 붙이면 전 파일이 거부된다 + if ($extensions !== []) { + $fileRules[] = 'extensions:'.implode(',', $extensions); + } $rules = [ - 'file' => ['required', 'file', 'max:' . $maxSize], + 'file' => $fileRules, 'attachmentable_type' => ['nullable', 'string', 'max:255'], 'attachmentable_id' => ['nullable', 'integer', 'min:1'], 'collection' => ['sometimes', 'string', 'max:100'], @@ -54,7 +62,8 @@ class UploadAttachmentRequest extends FormRequest return [ 'file.required' => __('attachment.validation.file_required'), 'file.file' => __('attachment.validation.file_invalid'), - 'file.max' => __('attachment.validation.file_max', ['max' => config('attachment.max_size', 10)]), + 'file.max' => __('attachment.validation.file_max', ['max' => (int) config('attachment.max_file_size', 10240) / 1024]), + 'file.extensions' => __('attachment.validation.file_extension_invalid', ['extensions' => implode(', ', $this->allowedExtensions())]), 'attachmentable_type.string' => __('attachment.validation.type_invalid'), 'attachmentable_id.integer' => __('attachment.validation.id_invalid'), ]; @@ -62,8 +71,6 @@ class UploadAttachmentRequest extends FormRequest /** * 검증 전 데이터 준비 - * - * @return void */ protected function prepareForValidation(): void { @@ -72,4 +79,25 @@ class UploadAttachmentRequest extends FormRequest 'source_type' => $this->source_type ?? AttachmentSourceType::Core->value, ]); } + + /** + * 허용 확장자 목록을 반환합니다 (소문자 정규화 + 확장 훅 적용). + * + * 빈 배열은 "확장자 제한 없음" 을 의미합니다. + * + * @return array 허용 확장자 목록 + */ + protected function allowedExtensions(): array + { + $extensions = (array) config('attachment.allowed_extensions', []); + + $extensions = array_values(array_filter(array_map( + static fn ($ext) => strtolower(trim(ltrim((string) $ext, '.'))), + $extensions + ))); + + return array_values(array_unique( + (array) HookManager::applyFilters('core.attachment.allowed_extensions', $extensions, $this) + )); + } } diff --git a/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php b/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php index 32af6a4b..ef985e99 100644 --- a/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php +++ b/app/Http/Requests/Attachment/UploadBatchAttachmentRequest.php @@ -29,11 +29,19 @@ class UploadBatchAttachmentRequest extends FormRequest */ public function rules(): array { - $maxSize = config('attachment.max_size', 10) * 1024; // MB to KB + // config/attachment.max_file_size 는 KB 단위 (관리자 설정 MB → KB 변환은 SettingsServiceProvider 담당) + $maxSize = (int) config('attachment.max_file_size', 10240); + $extensions = $this->allowedExtensions(); + + $fileRules = ['file', 'max:'.$maxSize]; + // 목록이 비면 확장자 제한 없음 (탈출구) — 빈 규칙을 붙이면 전 파일이 거부된다 + if ($extensions !== []) { + $fileRules[] = 'extensions:'.implode(',', $extensions); + } $rules = [ 'files' => ['required', 'array', 'min:1'], - 'files.*' => ['file', 'max:' . $maxSize], + 'files.*' => $fileRules, 'attachmentable_type' => ['nullable', 'string', 'max:255'], 'attachmentable_id' => ['nullable', 'integer', 'min:1'], 'collection' => ['sometimes', 'string', 'max:100'], @@ -57,7 +65,8 @@ class UploadBatchAttachmentRequest extends FormRequest 'files.array' => __('attachment.validation.files_array'), 'files.min' => __('attachment.validation.files_min'), 'files.*.file' => __('attachment.validation.file_invalid'), - 'files.*.max' => __('attachment.validation.file_max', ['max' => config('attachment.max_size', 10)]), + 'files.*.max' => __('attachment.validation.file_max', ['max' => (int) config('attachment.max_file_size', 10240) / 1024]), + 'files.*.extensions' => __('attachment.validation.file_extension_invalid', ['extensions' => implode(', ', $this->allowedExtensions())]), 'attachmentable_type.string' => __('attachment.validation.type_invalid'), 'attachmentable_id.integer' => __('attachment.validation.id_invalid'), ]; @@ -65,8 +74,6 @@ class UploadBatchAttachmentRequest extends FormRequest /** * 검증 전 데이터 준비 - * - * @return void */ protected function prepareForValidation(): void { @@ -75,4 +82,25 @@ class UploadBatchAttachmentRequest extends FormRequest 'source_type' => $this->source_type ?? AttachmentSourceType::Core->value, ]); } + + /** + * 허용 확장자 목록을 반환합니다 (소문자 정규화 + 확장 훅 적용). + * + * 빈 배열은 "확장자 제한 없음" 을 의미합니다. + * + * @return array 허용 확장자 목록 + */ + protected function allowedExtensions(): array + { + $extensions = (array) config('attachment.allowed_extensions', []); + + $extensions = array_values(array_filter(array_map( + static fn ($ext) => strtolower(trim(ltrim((string) $ext, '.'))), + $extensions + ))); + + return array_values(array_unique( + (array) HookManager::applyFilters('core.attachment.allowed_extensions', $extensions, $this) + )); + } } diff --git a/app/Http/Requests/Auth/AuthenticatedRequest.php b/app/Http/Requests/Auth/AuthenticatedRequest.php new file mode 100644 index 00000000..6d67f246 --- /dev/null +++ b/app/Http/Requests/Auth/AuthenticatedRequest.php @@ -0,0 +1,38 @@ + 빈 배열 (본문 입력 없음) + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 2926d4a8..aed4f22b 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -3,12 +3,15 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; class LoginRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -18,13 +21,15 @@ class LoginRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'email' => 'required|email', - 'password' => 'required|string|min:6', + // 로그인은 기존 비밀번호를 대조하는 행위이지 새 정책을 강제하는 지점이 아니다. + // 길이 규칙을 두면 정책 미달 비밀번호에 422 를 주어 미인증 공격자에게 그 사실을 누설한다. + 'password' => 'required|string', ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 @@ -42,7 +47,6 @@ class LoginRequest extends FormRequest 'email.required' => __('validation.auth.email.required'), 'email.email' => __('validation.auth.email.email'), 'password.required' => __('validation.auth.password.required'), - 'password.min' => __('validation.auth.password.min'), ]; } } diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php index 31e8e6bb..5e119ba9 100644 --- a/app/Http/Requests/Auth/RegisterRequest.php +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,6 +13,8 @@ class RegisterRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어 체인에서 처리) */ public function authorize(): bool { @@ -20,7 +24,7 @@ class RegisterRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -28,8 +32,10 @@ class RegisterRequest extends FormRequest 'name' => 'required|string|max:255', 'nickname' => 'nullable|string|max:50', 'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)], - 'password' => 'required|string|min:8|confirmed', - 'language' => 'nullable|string|in:' . implode(',', config('app.supported_locales', ['ko', 'en'])), + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], + 'mobile' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'], + 'phone' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'], + 'language' => 'nullable|string|in:'.implode(',', config('app.supported_locales', ['ko', 'en'])), 'agree_terms' => 'accepted', 'agree_privacy' => 'accepted', ]; @@ -55,6 +61,10 @@ class RegisterRequest extends FormRequest 'password.required' => __('validation.auth.password.required'), 'password.min' => __('validation.auth.password.min'), 'password.confirmed' => __('validation.auth.password.confirmed'), + 'mobile.max' => __('validation.auth.mobile.max'), + 'mobile.regex' => __('validation.auth.mobile.regex'), + 'phone.max' => __('validation.auth.phone.max'), + 'phone.regex' => __('validation.auth.phone.regex'), 'agree_terms.accepted' => __('validation.auth.agree_terms.accepted'), 'agree_privacy.accepted' => __('validation.auth.agree_privacy.accepted'), ]; diff --git a/app/Http/Requests/Auth/ResetPasswordRequest.php b/app/Http/Requests/Auth/ResetPasswordRequest.php index 8975ec1a..4f83b208 100644 --- a/app/Http/Requests/Auth/ResetPasswordRequest.php +++ b/app/Http/Requests/Auth/ResetPasswordRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,6 +13,8 @@ class ResetPasswordRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -20,14 +24,14 @@ class ResetPasswordRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'token' => 'required|string', 'email' => ['required', 'email', Rule::exists(User::class, 'email')], - 'password' => 'required|string|min:8|confirmed', + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 diff --git a/app/Http/Requests/Auth/TwoFactorChallengeRequest.php b/app/Http/Requests/Auth/TwoFactorChallengeRequest.php new file mode 100644 index 00000000..f1043207 --- /dev/null +++ b/app/Http/Requests/Auth/TwoFactorChallengeRequest.php @@ -0,0 +1,58 @@ + 검증 규칙 + */ + public function rules(): array + { + $rules = [ + 'challenge_id' => ['required', 'string', 'uuid'], + // 코드 자리수는 프로바이더 설정에 따라 달라지므로 상·하한만 느슨하게 두고 + // 정확한 일치 판정은 프로바이더가 수행한다. + 'code' => ['required', 'string', 'min:4', 'max:16'], + ]; + + // 확장이 자체 2단계 수단(앱 OTP·SMS 등)을 붙일 때 입력 필드를 추가할 수 있도록 개방한다 + return HookManager::applyFilters('core.auth.two_factor_validation_rules', $rules, $this); + } + + /** + * 검증 실패 메시지를 반환합니다. + * + * @return array 검증 메시지 + */ + public function messages(): array + { + return [ + 'challenge_id.required' => __('validation.auth.two_factor.challenge_required'), + 'challenge_id.uuid' => __('validation.auth.two_factor.challenge_invalid'), + 'code.required' => __('validation.auth.two_factor.code_required'), + ]; + } +} diff --git a/app/Http/Requests/Auth/ValidateResetTokenRequest.php b/app/Http/Requests/Auth/ValidateResetTokenRequest.php index 7a4cd8a9..531799eb 100644 --- a/app/Http/Requests/Auth/ValidateResetTokenRequest.php +++ b/app/Http/Requests/Auth/ValidateResetTokenRequest.php @@ -13,11 +13,13 @@ class ValidateResetTokenRequest extends FormRequest /** * Determine if the user is authorized to make this request. * + * 공개 엔드포인트이므로 항상 통과시킨다 — 접근 제어는 미들웨어가 담당한다. + * * @return bool */ public function authorize(): bool { - return true; // 공개 엔드포인트 + return true; } /** @@ -32,7 +34,8 @@ class ValidateResetTokenRequest extends FormRequest 'email' => 'required|email', ]; - return HookManager::applyFilters( + return HookManager::applyFiltersWithLegacyName( + 'core.auth.validate_reset_token_validation_rules', 'core.auth.validate_reset_token_rules', $rules, $this diff --git a/app/Http/Requests/Auth/VerifyPasswordRequest.php b/app/Http/Requests/Auth/VerifyPasswordRequest.php index 578b4470..fb30c3fc 100644 --- a/app/Http/Requests/Auth/VerifyPasswordRequest.php +++ b/app/Http/Requests/Auth/VerifyPasswordRequest.php @@ -9,6 +9,8 @@ class VerifyPasswordRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool */ public function authorize(): bool { @@ -27,6 +29,9 @@ class VerifyPasswordRequest extends FormRequest ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 - return HookManager::applyFilters('core.auth.verify_password_rules', $rules); + return HookManager::applyFiltersWithLegacyName( + 'core.auth.verify_password_validation_rules', + 'core.auth.verify_password_rules', + $rules); } } diff --git a/app/Http/Requests/Extension/ChangelogRequest.php b/app/Http/Requests/Extension/ChangelogRequest.php index 4bf6da1e..89cd72e1 100644 --- a/app/Http/Requests/Extension/ChangelogRequest.php +++ b/app/Http/Requests/Extension/ChangelogRequest.php @@ -14,6 +14,8 @@ class ChangelogRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool */ public function authorize(): bool { @@ -33,7 +35,10 @@ class ChangelogRequest extends FormRequest 'to_version' => 'nullable|string|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$/|required_with:from_version', ]; - return HookManager::applyFilters('core.extension.changelog_rules', $rules); + return HookManager::applyFiltersWithLegacyName( + 'core.extension.changelog_validation_rules', + 'core.extension.changelog_rules', + $rules); } /** diff --git a/app/Http/Requests/Identity/AdminIdentityProviderIndexRequest.php b/app/Http/Requests/Identity/AdminIdentityProviderIndexRequest.php new file mode 100644 index 00000000..514f1788 --- /dev/null +++ b/app/Http/Requests/Identity/AdminIdentityProviderIndexRequest.php @@ -0,0 +1,38 @@ +> 검증 규칙 (요청 파라미터 없음) + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Identity/PurposesIndexRequest.php b/app/Http/Requests/Identity/PurposesIndexRequest.php index f17ff486..1d937e55 100644 --- a/app/Http/Requests/Identity/PurposesIndexRequest.php +++ b/app/Http/Requests/Identity/PurposesIndexRequest.php @@ -7,14 +7,14 @@ use Illuminate\Foundation\Http\FormRequest; /** * 등록된 IDV purpose 목록 조회 요청 (GET /api/identity/purposes). * - * 코어 4종 + 활성 모듈/플러그인 declarative purpose + filter 훅 확장 결과를 반환. + * 코어 5종 + 활성 모듈/플러그인 declarative purpose + filter 훅 확장 결과를 반환. */ class PurposesIndexRequest extends FormRequest { /** * 요청 권한 — 공개 엔드포인트. * - * @return bool + * @return bool 항상 true (인증 불필요) */ public function authorize(): bool { diff --git a/app/Http/Requests/LanguagePack/InstallFromBundledRequest.php b/app/Http/Requests/LanguagePack/InstallFromBundledRequest.php index 73ce0a83..bdee367c 100644 --- a/app/Http/Requests/LanguagePack/InstallFromBundledRequest.php +++ b/app/Http/Requests/LanguagePack/InstallFromBundledRequest.php @@ -2,6 +2,8 @@ namespace App\Http\Requests\LanguagePack; +use App\Extension\HookManager; +use App\Rules\LanguagePack\RequiresActivationPermission; use Illuminate\Foundation\Http\FormRequest; /** @@ -29,15 +31,27 @@ class InstallFromBundledRequest extends FormRequest */ public function rules(): array { - return [ + $rules = [ 'identifier' => [ 'required', 'string', 'max:200', 'regex:/^[a-zA-Z0-9._\-]+$/', ], - 'auto_activate' => ['nullable', 'boolean'], + /* + * 외부 소스(파일·URL·GitHub)와 동일하게 활성화 권한 게이트를 건다 (PO 결정, 2026-08-04). + * 설치(install)와 활성화(manage)를 별도 권한으로 두는 정책을 네 경로 전부에 같은 강도로 + * 적용해, "어느 경로로 들어오느냐" 가 권한 경계를 바꾸지 않게 한다. + * + * 부수 효과: 관리자 화면에서 auto_activate 를 보내는 곳이 번들 재설치 모달 하나뿐이므로, + * 설치 권한만 가진 운영자가 활성 팩을 재설치하면 그 팩이 installed 로 내려간다. + * 다시 켜려면 언어팩 관리 권한이 필요하다 — 의도된 동작이다. + */ + 'auto_activate' => ['nullable', 'boolean', new RequiresActivationPermission], ]; + + // 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공 + return HookManager::applyFilters('core.language_packs.install_from_bundled_validation_rules', $rules, $this); } /** diff --git a/app/Http/Requests/LanguagePack/InstallFromFileRequest.php b/app/Http/Requests/LanguagePack/InstallFromFileRequest.php index 3545275f..38d282da 100644 --- a/app/Http/Requests/LanguagePack/InstallFromFileRequest.php +++ b/app/Http/Requests/LanguagePack/InstallFromFileRequest.php @@ -2,6 +2,8 @@ namespace App\Http\Requests\LanguagePack; +use App\Extension\HookManager; +use App\Rules\LanguagePack\RequiresActivationPermission; use Illuminate\Foundation\Http\FormRequest; /** @@ -26,10 +28,14 @@ class InstallFromFileRequest extends FormRequest */ public function rules(): array { - return [ + $rules = [ 'file' => ['required', 'file', 'max:10240', 'mimetypes:application/zip,application/x-zip-compressed,application/octet-stream'], - 'auto_activate' => ['nullable', 'boolean'], + // 설치 권한만으로 활성화까지 수행하지 못하게 한다 — 활성 팩만 require 경로에 배선된다. + 'auto_activate' => ['nullable', 'boolean', new RequiresActivationPermission], ]; + + // 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공 + return HookManager::applyFilters('core.language_packs.install_from_file_validation_rules', $rules, $this); } /** diff --git a/app/Http/Requests/LanguagePack/InstallFromGithubRequest.php b/app/Http/Requests/LanguagePack/InstallFromGithubRequest.php index fd0b3b1e..2e5595cd 100644 --- a/app/Http/Requests/LanguagePack/InstallFromGithubRequest.php +++ b/app/Http/Requests/LanguagePack/InstallFromGithubRequest.php @@ -2,6 +2,8 @@ namespace App\Http\Requests\LanguagePack; +use App\Extension\HookManager; +use App\Rules\LanguagePack\RequiresActivationPermission; use Illuminate\Foundation\Http\FormRequest; /** @@ -26,14 +28,18 @@ class InstallFromGithubRequest extends FormRequest */ public function rules(): array { - return [ + $rules = [ 'github_url' => [ 'required', 'url', 'regex:/^https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+\/?$/', ], - 'auto_activate' => ['nullable', 'boolean'], + // 설치 권한만으로 활성화까지 수행하지 못하게 한다 — 활성 팩만 require 경로에 배선된다. + 'auto_activate' => ['nullable', 'boolean', new RequiresActivationPermission], ]; + + // 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공 + return HookManager::applyFilters('core.language_packs.install_from_github_validation_rules', $rules, $this); } /** diff --git a/app/Http/Requests/LanguagePack/InstallFromUrlRequest.php b/app/Http/Requests/LanguagePack/InstallFromUrlRequest.php index 47549fdb..c0bde012 100644 --- a/app/Http/Requests/LanguagePack/InstallFromUrlRequest.php +++ b/app/Http/Requests/LanguagePack/InstallFromUrlRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\LanguagePack; use App\Extension\HookManager; +use App\Rules\LanguagePack\RequiresActivationPermission; use App\Rules\PublicOutboundUrl; use Illuminate\Foundation\Http\FormRequest; @@ -32,7 +33,8 @@ class InstallFromUrlRequest extends FormRequest // 원격 코드를 내려받는 지점이므로 https 고정 + 내부 주소 허용 설정과 무관하게 항상 차단 'url' => ['required', 'url', 'max:500', new PublicOutboundUrl(schemes: ['https'], allowInternalOptIn: false)], 'checksum' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], - 'auto_activate' => ['nullable', 'boolean'], + // 설치 권한만으로 활성화까지 수행하지 못하게 한다 — 활성 팩만 require 경로에 배선된다. + 'auto_activate' => ['nullable', 'boolean', new RequiresActivationPermission], ]; // 모듈/플러그인이 validation rules 를 동적으로 추가할 수 있도록 훅 제공 diff --git a/app/Http/Requests/Menu/UpdateMenuRequest.php b/app/Http/Requests/Menu/UpdateMenuRequest.php index 050dec58..46de4417 100644 --- a/app/Http/Requests/Menu/UpdateMenuRequest.php +++ b/app/Http/Requests/Menu/UpdateMenuRequest.php @@ -4,10 +4,10 @@ namespace App\Http\Requests\Menu; use App\Extension\HookManager; use App\Models\Menu; -use App\Models\Module; use App\Models\Role; use App\Rules\LocaleRequiredTranslatable; -use App\Rules\NotSelfParent; +use App\Rules\NotCircularParent; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -17,6 +17,8 @@ class UpdateMenuRequest extends FormRequest * 사용자가 이 요청을 수행할 권한이 있는지 확인 * * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다. + * + * @return bool 권한 검증 결과 (항상 true) */ public function authorize(): bool { @@ -26,7 +28,7 @@ class UpdateMenuRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -38,7 +40,9 @@ class UpdateMenuRequest extends FormRequest 'slug' => ['required', 'string', 'max:255', Rule::unique(Menu::class, 'slug')->ignore($menuId)], 'url' => 'nullable|string|max:500', 'icon' => 'nullable|string|max:100', - 'parent_id' => ['nullable', 'integer', Rule::exists(Menu::class, 'id'), new NotSelfParent($menuId)], + // 순서 변경 엔드포인트(UpdateMenuOrderRequest)와 동일하게 자손 전체를 검사한다. + // NotCircularParent 생성자는 int non-nullable 이므로 $menuId 가 있을 때만 부착한다. + 'parent_id' => ['nullable', 'integer', Rule::exists(Menu::class, 'id')], 'order' => 'nullable|integer|min:0', 'is_active' => 'nullable|boolean', 'extension_type' => 'nullable|string|in:core,module,plugin', @@ -47,6 +51,10 @@ class UpdateMenuRequest extends FormRequest 'roles.*' => ['integer', Rule::exists(Role::class, 'id')], ]; + if ($menuId !== null) { + $rules['parent_id'][] = new NotCircularParent((int) $menuId); + } + // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 return HookManager::applyFilters('core.menu.update_validation_rules', $rules, $this); } diff --git a/app/Http/Requests/Module/PerformModuleUpdateRequest.php b/app/Http/Requests/Module/PerformModuleUpdateRequest.php index 72dba137..f51e27a5 100644 --- a/app/Http/Requests/Module/PerformModuleUpdateRequest.php +++ b/app/Http/Requests/Module/PerformModuleUpdateRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Module; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; /** @@ -28,7 +29,7 @@ class PerformModuleUpdateRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -37,6 +38,8 @@ class PerformModuleUpdateRequest extends FormRequest 'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'], // 코어 버전 비호환 강제 우회 플래그 'force' => ['nullable', 'boolean'], + // 업데이트 후 검색 인덱스 재생성 여부 — 인덱스 잠금·재색인 비용이 있어 기본은 미수행 + 'rebuild_search_index' => ['nullable', 'boolean'], ]; return HookManager::applyFilters('core.module.perform_update_validation_rules', $rules, $this); diff --git a/app/Http/Requests/Notification/NotificationBatchReadRequest.php b/app/Http/Requests/Notification/NotificationBatchReadRequest.php index 4ac40f8f..3acd0159 100644 --- a/app/Http/Requests/Notification/NotificationBatchReadRequest.php +++ b/app/Http/Requests/Notification/NotificationBatchReadRequest.php @@ -9,6 +9,8 @@ class NotificationBatchReadRequest extends FormRequest { /** * 권한 확인 (미들웨어에서 처리). + * + * @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당) */ public function authorize(): bool { @@ -17,6 +19,8 @@ class NotificationBatchReadRequest extends FormRequest /** * 검증 규칙을 반환합니다. + * + * @return array 검증 규칙 배열 */ public function rules(): array { @@ -26,7 +30,7 @@ class NotificationBatchReadRequest extends FormRequest ]; return HookManager::applyFilters( - 'core.notification.filter_batch_read_rules', + 'core.notification.batch_read_validation_rules', $rules ); } diff --git a/app/Http/Requests/Notification/NotificationIndexRequest.php b/app/Http/Requests/Notification/NotificationIndexRequest.php index 69b0d15d..3ce47956 100644 --- a/app/Http/Requests/Notification/NotificationIndexRequest.php +++ b/app/Http/Requests/Notification/NotificationIndexRequest.php @@ -9,6 +9,8 @@ class NotificationIndexRequest extends FormRequest { /** * 권한 확인 (미들웨어에서 처리). + * + * @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당) */ public function authorize(): bool { @@ -17,6 +19,8 @@ class NotificationIndexRequest extends FormRequest /** * 검증 규칙을 반환합니다. + * + * @return array 검증 규칙 배열 */ public function rules(): array { @@ -28,7 +32,7 @@ class NotificationIndexRequest extends FormRequest ]; return HookManager::applyFilters( - 'core.notification.filter_index_rules', + 'core.notification.index_validation_rules', $rules ); } diff --git a/app/Http/Requests/NotificationDefinition/NotificationDefinitionIndexRequest.php b/app/Http/Requests/NotificationDefinition/NotificationDefinitionIndexRequest.php index 6a761835..c7b5e095 100644 --- a/app/Http/Requests/NotificationDefinition/NotificationDefinitionIndexRequest.php +++ b/app/Http/Requests/NotificationDefinition/NotificationDefinitionIndexRequest.php @@ -10,7 +10,7 @@ class NotificationDefinitionIndexRequest extends FormRequest /** * 권한 확인 (미들웨어에서 처리). * - * @return bool + * @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당) */ public function authorize(): bool { @@ -20,7 +20,7 @@ class NotificationDefinitionIndexRequest extends FormRequest /** * 검증 규칙을 반환합니다. * - * @return array + * @return array 검증 규칙 배열 */ public function rules(): array { @@ -29,6 +29,11 @@ class NotificationDefinitionIndexRequest extends FormRequest 'extension_type' => ['nullable', 'string', 'in:core,module,plugin'], 'extension_identifier' => ['nullable', 'string', 'max:100'], 'channel' => ['nullable', 'string', 'max:50'], + // 목록에 실을 템플릿을 한 채널로 좁힌다. `channel` 과 다르다 — `channel` 은 정의 행 + // 자체를 거르지만(그 채널을 지원하지 않는 정의는 목록에서 사라진다), 이 값은 행은 + // 그대로 두고 각 행에 딸려오는 템플릿만 좁힌다. 화면이 한 번에 한 채널만 그리므로 + // 나머지 채널의 제목·본문은 전송할 이유가 없다. + 'template_channel' => ['nullable', 'string', 'max:50'], 'is_active' => ['nullable', 'boolean'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], 'sort_by' => ['nullable', 'string', 'in:id,type,extension_type,is_active,created_at,updated_at'], @@ -36,7 +41,7 @@ class NotificationDefinitionIndexRequest extends FormRequest ]; return HookManager::applyFilters( - 'core.notification_definition.filter_index_rules', + 'core.notification_definition.index_validation_rules', $rules ); } @@ -44,7 +49,7 @@ class NotificationDefinitionIndexRequest extends FormRequest /** * 검증 메시지를 반환합니다. * - * @return array + * @return array 검증 오류 메시지 배열 */ public function messages(): array { diff --git a/app/Http/Requests/NotificationDefinition/UpdateNotificationDefinitionRequest.php b/app/Http/Requests/NotificationDefinition/UpdateNotificationDefinitionRequest.php index 4a9d4a5e..5c7815a9 100644 --- a/app/Http/Requests/NotificationDefinition/UpdateNotificationDefinitionRequest.php +++ b/app/Http/Requests/NotificationDefinition/UpdateNotificationDefinitionRequest.php @@ -2,7 +2,10 @@ namespace App\Http\Requests\NotificationDefinition; +use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface; use App\Extension\HookManager; +use App\Models\NotificationDefinition; +use App\Rules\AvailableNotificationChannel; use Illuminate\Foundation\Http\FormRequest; class UpdateNotificationDefinitionRequest extends FormRequest @@ -10,7 +13,7 @@ class UpdateNotificationDefinitionRequest extends FormRequest /** * 권한 확인 (미들웨어에서 처리). * - * @return bool + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -20,22 +23,47 @@ class UpdateNotificationDefinitionRequest extends FormRequest /** * 검증 규칙을 반환합니다. * - * @return array + * @return array 필드별 검증 규칙 */ public function rules(): array { $rules = [ + // audit:allow formrequest-sometimes-array-min-inert reason: 채널 0개는 유효한 상태가 아니다. + // 발송 채널을 모두 해제하면 알림이 사라지므로, 저장을 막고 오류를 노출하는 것이 올바른 동작이다. 'channels' => ['sometimes', 'array', 'min:1'], - 'channels.*' => ['string', 'max:50'], + // 허용 채널 목록은 config + filter_available_channels 훅으로 런타임 결정되므로 + // 요청 클래스에 하드코딩하지 않는다. 이미 저장된 채널은 통과시켜, 채널 제공 + // 플러그인이 비활성화된 뒤에도 기존 레코드 수정이 막히지 않게 한다. + 'channels.*' => ['string', 'max:50', new AvailableNotificationChannel($this->persistedChannels())], 'hooks' => ['sometimes', 'array'], 'hooks.*' => ['string', 'max:255'], 'is_active' => ['sometimes', 'boolean'], ]; return HookManager::applyFilters( - 'core.notification_definition.filter_update_rules', + 'core.notification_definition.update_validation_rules', $rules, $this->route('definition') ); } + + /** + * 수정 대상 알림 정의에 이미 저장되어 있는 채널 목록을 반환합니다. + * + * @return array 저장된 채널 식별자 목록 + */ + private function persistedChannels(): array + { + $definition = $this->route('definition'); + + if (! $definition instanceof NotificationDefinition) { + $definition = is_numeric($definition) + ? app(NotificationDefinitionRepositoryInterface::class)->findById((int) $definition) + : null; + } + + $channels = $definition?->channels; + + return is_array($channels) ? array_values(array_filter($channels, 'is_string')) : []; + } } diff --git a/app/Http/Requests/NotificationLog/NotificationLogIndexRequest.php b/app/Http/Requests/NotificationLog/NotificationLogIndexRequest.php index 020c3070..ac691f3d 100644 --- a/app/Http/Requests/NotificationLog/NotificationLogIndexRequest.php +++ b/app/Http/Requests/NotificationLog/NotificationLogIndexRequest.php @@ -12,6 +12,8 @@ class NotificationLogIndexRequest extends FormRequest { /** * 권한 확인 (미들웨어에서 처리). + * + * @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당) */ public function authorize(): bool { @@ -20,6 +22,8 @@ class NotificationLogIndexRequest extends FormRequest /** * 검증 규칙을 반환합니다. + * + * @return array 검증 규칙 배열 */ public function rules(): array { @@ -32,12 +36,18 @@ class NotificationLogIndexRequest extends FormRequest 'extension_type' => ['nullable', 'string', 'in:core,module,plugin'], 'status' => ['nullable', 'string', Rule::enum(NotificationLogStatus::class)], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], - 'sort_by' => ['nullable', 'string', 'in:id,channel,notification_type,status,sent_at,created_at'], + // 허용 집합은 NotificationLogRepository::SORTABLE_COLUMNS 와 동일해야 한다. + // 게이트가 더 좁으면 화면 정렬 셀렉트가 제공하는 옵션(수신자명순/제목순)이 422 로 막힌다. + 'sort_by' => ['nullable', 'string', 'in:id,channel,notification_type,status,sent_at,created_at,recipient_name,subject'], 'sort_order' => ['nullable', 'string', 'in:asc,desc'], + // 커서를 주면 목록이 키셋 방식으로 응답한다. 로그는 계속 쌓이기만 하므로 깊은 + // 페이지를 OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 한다. + // 형식이 깨진 값은 KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다. + 'cursor' => ['nullable', 'string', 'max:500'], ]; return HookManager::applyFilters( - 'core.notification_log.filter_index_rules', + 'core.notification_log.index_validation_rules', $rules ); } diff --git a/app/Http/Requests/NotificationTemplate/UpdateNotificationTemplateRequest.php b/app/Http/Requests/NotificationTemplate/UpdateNotificationTemplateRequest.php index 286e8dc7..31f5f7f3 100644 --- a/app/Http/Requests/NotificationTemplate/UpdateNotificationTemplateRequest.php +++ b/app/Http/Requests/NotificationTemplate/UpdateNotificationTemplateRequest.php @@ -11,7 +11,7 @@ class UpdateNotificationTemplateRequest extends FormRequest /** * 권한 확인 (미들웨어에서 처리). * - * @return bool + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -21,12 +21,14 @@ class UpdateNotificationTemplateRequest extends FormRequest /** * 검증 규칙을 반환합니다. * - * @return array + * @return array 필드별 검증 규칙 */ public function rules(): array { $rules = [ - 'subject' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 500)], + // subject 컬럼은 nullable 이다 (제목 개념이 없는 채널이 존재). + // 스키마와 정합하도록 미전송/null 을 허용한다. + 'subject' => ['sometimes', 'nullable', 'array', new LocaleRequiredTranslatable(maxLength: 500)], 'body' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 65535)], 'click_url' => ['nullable', 'string', 'max:500'], 'recipients' => ['sometimes', 'nullable', 'array'], @@ -38,7 +40,7 @@ class UpdateNotificationTemplateRequest extends FormRequest ]; return HookManager::applyFilters( - 'core.notification_template.filter_update_rules', + 'core.notification_template.update_validation_rules', $rules, $this->route('template') ); @@ -47,7 +49,7 @@ class UpdateNotificationTemplateRequest extends FormRequest /** * 검증 메시지를 반환합니다. * - * @return array + * @return array 규칙별 오류 메시지 */ public function messages(): array { diff --git a/app/Http/Requests/Plugin/PerformPluginUpdateRequest.php b/app/Http/Requests/Plugin/PerformPluginUpdateRequest.php index 572ef5b4..df7ec969 100644 --- a/app/Http/Requests/Plugin/PerformPluginUpdateRequest.php +++ b/app/Http/Requests/Plugin/PerformPluginUpdateRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Plugin; use App\Extension\HookManager; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; /** @@ -28,7 +29,7 @@ class PerformPluginUpdateRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -37,6 +38,8 @@ class PerformPluginUpdateRequest extends FormRequest 'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'], // 코어 버전 비호환 강제 우회 플래그 (위험 인지 후 사용자 명시 필요) 'force' => ['nullable', 'boolean'], + // 업데이트 후 검색 인덱스 재생성 여부 — 인덱스 잠금·재색인 비용이 있어 기본은 미수행 + 'rebuild_search_index' => ['nullable', 'boolean'], ]; return HookManager::applyFilters('core.plugin.perform_update_validation_rules', $rules, $this); diff --git a/app/Http/Requests/Public/Module/ServeModuleAssetRequest.php b/app/Http/Requests/Public/Module/ServeModuleAssetRequest.php index 411ff198..9d2002cc 100644 --- a/app/Http/Requests/Public/Module/ServeModuleAssetRequest.php +++ b/app/Http/Requests/Public/Module/ServeModuleAssetRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Public\Module; use App\Rules\AllowedModuleFileType; use App\Rules\SafeModulePath; +use App\Support\Routing\DualExtensionRoute; use Illuminate\Foundation\Http\FormRequest; class ServeModuleAssetRequest extends FormRequest @@ -12,6 +13,8 @@ class ServeModuleAssetRequest extends FormRequest * 사용자가 이 요청을 수행할 권한이 있는지 확인 * * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다. + * + * @return bool 항상 true (권한은 미들웨어 책임) */ public function authorize(): bool { @@ -21,8 +24,11 @@ class ServeModuleAssetRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * - * @return array + * @return array 검증 규칙 배열 */ + // audit:allow core-formrequest-hook-filter reason: 자산 서빙 경로 검증은 파일시스템 + // 화이트리스트(SafeModulePath + AllowedModuleFileType)가 유일한 방어선이다. + // 확장이 필터로 규칙을 대체할 수 있으면 경로 탈출·임의 파일 읽기가 열린다. public function rules(): array { // 모듈 식별자로부터 기준 경로 구성 (모듈 루트) @@ -35,7 +41,7 @@ class ServeModuleAssetRequest extends FormRequest 'required', 'string', new SafeModulePath($basePath), - new AllowedModuleFileType(), + new AllowedModuleFileType, ], ]; } @@ -45,10 +51,11 @@ class ServeModuleAssetRequest extends FormRequest */ protected function prepareForValidation(): void { - // 라우트 파라미터를 검증 데이터에 병합 + // 라우트 파라미터를 검증 데이터에 병합. + // 확장자 없는 모드에서는 파일 경로가 `?file=` 쿼리로 온다 (경로 확장자 회피). $this->merge([ 'identifier' => $this->route('identifier'), - 'path' => $this->route('path'), + 'path' => $this->route('path') ?? $this->query(DualExtensionRoute::FILE_QUERY_PARAM), ]); } diff --git a/app/Http/Requests/Public/Plugin/ServePluginAssetRequest.php b/app/Http/Requests/Public/Plugin/ServePluginAssetRequest.php index b2af81a1..c4543607 100644 --- a/app/Http/Requests/Public/Plugin/ServePluginAssetRequest.php +++ b/app/Http/Requests/Public/Plugin/ServePluginAssetRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Public\Plugin; use App\Rules\AllowedPluginFileType; use App\Rules\SafePluginPath; +use App\Support\Routing\DualExtensionRoute; use Illuminate\Foundation\Http\FormRequest; class ServePluginAssetRequest extends FormRequest @@ -12,6 +13,8 @@ class ServePluginAssetRequest extends FormRequest * 사용자가 이 요청을 수행할 권한이 있는지 확인 * * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다. + * + * @return bool 항상 true (권한은 미들웨어 책임) */ public function authorize(): bool { @@ -21,8 +24,11 @@ class ServePluginAssetRequest extends FormRequest /** * 요청에 적용할 검증 규칙 * - * @return array + * @return array 검증 규칙 배열 */ + // audit:allow core-formrequest-hook-filter reason: 자산 서빙 경로 검증은 파일시스템 + // 화이트리스트(SafePluginPath + AllowedPluginFileType)가 유일한 방어선이다. + // 확장이 필터로 규칙을 대체할 수 있으면 경로 탈출·임의 파일 읽기가 열린다. public function rules(): array { // 플러그인 식별자로부터 기준 경로 구성 (플러그인 루트) @@ -35,7 +41,7 @@ class ServePluginAssetRequest extends FormRequest 'required', 'string', new SafePluginPath($basePath), - new AllowedPluginFileType(), + new AllowedPluginFileType, ], ]; } @@ -48,7 +54,7 @@ class ServePluginAssetRequest extends FormRequest // 라우트 파라미터를 검증 데이터에 병합 $this->merge([ 'identifier' => $this->route('identifier'), - 'path' => $this->route('path'), + 'path' => $this->route('path') ?? $this->query(DualExtensionRoute::FILE_QUERY_PARAM), ]); } diff --git a/app/Http/Requests/Public/SearchRequest.php b/app/Http/Requests/Public/SearchRequest.php index 47845067..54582355 100644 --- a/app/Http/Requests/Public/SearchRequest.php +++ b/app/Http/Requests/Public/SearchRequest.php @@ -3,6 +3,8 @@ namespace App\Http\Requests\Public; use App\Extension\HookManager; +use App\Support\Query\PaginationLimits; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; /** @@ -15,16 +17,38 @@ class SearchRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool */ public function authorize(): bool { return true; } + /** + * 검증 전에 쿼리 파라미터를 정규화합니다. + * + * 쿼리스트링은 언제나 문자열로 도착하므로 `with_counts=false` 가 `boolean` 규칙에 + * 걸려 422 가 된다. 해석 가능한 값만 캐스팅하고, 그 외(오타 등)는 손대지 않아 + * 규칙이 그대로 걸러내게 둔다 — null 로 바꾸면 오타가 "미지정" 으로 통과한다. + */ + protected function prepareForValidation(): void + { + if (! $this->has('with_counts')) { + return; + } + + $normalized = filter_var($this->input('with_counts'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($normalized !== null) { + $this->merge(['with_counts' => $normalized]); + } + } + /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -33,13 +57,29 @@ class SearchRequest extends FormRequest 'q' => ['nullable', 'string', 'min:2', 'max:200'], 'type' => ['nullable', 'string'], 'sort' => ['nullable', 'string'], - 'page' => ['nullable', 'integer', 'min:1'], + // 페이지 상한은 남용 차단용이다. 정상 탐색은 has_more_pages 로 계속 열려 있고, + // 임의의 큰 페이지 번호를 직접 던져 초대형 OFFSET 을 만드는 것만 막는다. + 'page' => array_filter([ + 'nullable', + 'integer', + 'min:1', + ($maxPage = PaginationLimits::maxPage('search')) !== null ? 'max:'.$maxPage : null, + ]), 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + // 깊은 페이지를 OFFSET 없이 넘기기 위한 커서. 형식이 깨진 값은 + // KeysetPaginator 가 첫 페이지로 되돌리므로 여기서는 길이만 본다. + 'cursor' => ['nullable', 'string', 'max:500'], + // 특정 탭만 볼 때 다른 탭의 배지 건수를 세지 않게 하는 스위치. + // 배지를 그리지 않는 화면에서 그 집계 비용을 없앤다 (기본값은 세는 쪽). + 'with_counts' => ['nullable', 'boolean'], ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 // 예: sort의 in 규칙, board_slug 등 모듈별 파라미터 - return HookManager::applyFilters('core.search.validation_rules', $rules, $this); + return HookManager::applyFiltersWithLegacyName( + 'core.search.index_validation_rules', + 'core.search.validation_rules', + $rules, $this); } /** @@ -54,6 +94,7 @@ class SearchRequest extends FormRequest 'q.max' => __('search.validation.q_max'), 'page.integer' => __('search.validation.page_integer'), 'page.min' => __('search.validation.page_min'), + 'page.max' => __('search.validation.page_max'), 'per_page.integer' => __('search.validation.per_page_integer'), 'per_page.min' => __('search.validation.per_page_min'), 'per_page.max' => __('search.validation.per_page_max'), diff --git a/app/Http/Requests/Public/Template/ServeTemplateAssetRequest.php b/app/Http/Requests/Public/Template/ServeTemplateAssetRequest.php index a8f21904..8902303e 100644 --- a/app/Http/Requests/Public/Template/ServeTemplateAssetRequest.php +++ b/app/Http/Requests/Public/Template/ServeTemplateAssetRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Public\Template; use App\Rules\AllowedTemplateFileType; use App\Rules\SafeTemplatePath; +use App\Support\Routing\DualExtensionRoute; use Illuminate\Foundation\Http\FormRequest; class ServeTemplateAssetRequest extends FormRequest @@ -12,6 +13,8 @@ class ServeTemplateAssetRequest extends FormRequest * 사용자가 이 요청을 수행할 권한이 있는지 확인 * * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다. + * + * @return bool 항상 true (권한은 미들웨어 책임) */ public function authorize(): bool { @@ -20,7 +23,13 @@ class ServeTemplateAssetRequest extends FormRequest /** * 요청에 적용할 검증 규칙 + * + * @return array 검증 규칙 배열 */ + // audit:allow core-formrequest-hook-filter reason: 자산 서빙 경로 검증은 파일시스템 + // 화이트리스트(SafeTemplatePath + AllowedTemplateFileType)가 유일한 방어선이다. + // 확장이 필터로 규칙을 대체할 수 있으면 경로 탈출·임의 파일 읽기가 열린다. + // 확장 가능한 "동적 필드" 가 없는 요청이라 룰의 취지(필드 확장)도 해당하지 않는다. public function rules(): array { // 템플릿 식별자로부터 기준 경로 구성 @@ -33,7 +42,7 @@ class ServeTemplateAssetRequest extends FormRequest 'required', 'string', new SafeTemplatePath($basePath), - new AllowedTemplateFileType(), + new AllowedTemplateFileType, ], ]; } @@ -43,10 +52,13 @@ class ServeTemplateAssetRequest extends FormRequest */ protected function prepareForValidation(): void { - // 라우트 파라미터를 검증 데이터에 병합 + // 라우트 파라미터를 검증 데이터에 병합. + // 확장자 없는 모드에서는 파일 경로가 경로 세그먼트가 아니라 `?file=` 쿼리로 온다 + // (nginx 정적 최적화 블록이 URL 경로의 확장자만 보고 가로채는 것을 회피). + // 어느 형태로 오든 컨트롤러는 동일하게 `path` 만 본다. $this->merge([ 'identifier' => $this->route('identifier'), - 'path' => $this->route('path'), + 'path' => $this->route('path') ?? $this->query(DualExtensionRoute::FILE_QUERY_PARAM), ]); } @@ -64,4 +76,4 @@ class ServeTemplateAssetRequest extends FormRequest 'path.string' => __('validation.asset.path.string'), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index 7708710b..ddcd5384 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -5,6 +5,8 @@ namespace App\Http\Requests\Settings; use App\Extension\HookManager; use App\Models\Attachment; use App\Search\Engines\DatabaseFulltextEngine; +use App\Support\AllowedExtensions; +use App\Support\AssetUrl; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; @@ -105,6 +107,28 @@ class SaveSettingsRequest extends FormRequest 'advanced.geoip_enabled', 'advanced.geoip_auto_update_enabled', ]); + + $this->normalizeAllowedExtensions(); + } + + /** + * 허용 확장자를 저장 형태(소문자 확장자 배열)로 정규화합니다. + * + * 화면 입력 칸은 콤마로 구분한 한 줄 텍스트라 이 값은 문자열로 들어온다. 문자열 그대로 + * 저장하면 업로드 제한을 적용하는 지점이 그 값을 쓰지 못해, 저장은 성공하는데 제한은 + * 종전 목록으로 남는다. 형태 판정은 저장 경로 진입 전 이 한 곳에서만 수행한다. + */ + private function normalizeAllowedExtensions(): void + { + $upload = $this->input('upload', []); + + if (! is_array($upload) || ! array_key_exists('allowed_extensions', $upload)) { + return; + } + + $upload['allowed_extensions'] = AllowedExtensions::normalize($upload['allowed_extensions']); + + $this->merge(['upload' => $upload]); } /** @@ -174,6 +198,13 @@ class SaveSettingsRequest extends FormRequest 'general.language' => $this->getTabRules($tab, 'general', [Rule::in(config('app.supported_locales', ['ko', 'en']))]), 'general.currency' => ['nullable', 'string', 'max:10'], 'general.maintenance_mode' => ['nullable', 'boolean'], + // 자산 URL 방식 — 정적 최적화 서버 대응 (이슈 #486). + // 임의 문자열이 들어오면 AssetUrl::mode() 가 기본값으로 폴백하지만, + // 저장 단계에서 막아 설정 파일에 뜻 모를 값이 남지 않게 한다. + 'general.asset_url_mode' => ['nullable', 'string', Rule::in([ + AssetUrl::MODE_EXTENSION, + AssetUrl::MODE_EXTENSIONLESS, + ])], 'general.site_logo' => ['nullable', 'array'], 'general.site_logo.*' => ['integer', Rule::exists(Attachment::class, 'id')], @@ -194,11 +225,11 @@ class SaveSettingsRequest extends FormRequest 'mail.from_name' => $this->getTabRules($tab, 'mail', 'string|max:255'), // 업로드 설정 - 'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:1|max:1024'), + 'upload.max_file_size' => $this->getTabRules($tab, 'upload', 'integer|min:'.config('core.settings_limits.upload_max_file_size_min', 1).'|max:'.config('core.settings_limits.upload_max_file_size_max', 1024)), 'upload.allowed_extensions' => $this->getAllowedExtensionsRules($tab), - 'upload.image_max_width' => ['nullable', 'integer', 'min:100', 'max:10000'], - 'upload.image_max_height' => ['nullable', 'integer', 'min:100', 'max:10000'], - 'upload.image_quality' => ['nullable', 'integer', 'min:1', 'max:100'], + 'upload.image_max_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_width_min', 100), 'max:'.config('core.settings_limits.upload_image_max_width_max', 10000)], + 'upload.image_max_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_max_height_min', 100), 'max:'.config('core.settings_limits.upload_image_max_height_max', 10000)], + 'upload.image_quality' => ['nullable', 'integer', 'min:'.config('core.settings_limits.upload_image_quality_min', 1), 'max:'.config('core.settings_limits.upload_image_quality_max', 100)], // SEO 설정 'seo.meta_title_suffix' => ['nullable', 'string', 'max:100'], @@ -212,14 +243,20 @@ class SaveSettingsRequest extends FormRequest 'seo.bot_detection_enabled' => ['nullable', 'boolean'], 'seo.bot_detection_library_enabled' => ['nullable', 'boolean'], 'seo.og_default_site_name' => ['nullable', 'string', 'max:200'], - 'seo.og_image_default_width' => ['nullable', 'integer', 'min:0', 'max:8000'], - 'seo.og_image_default_height' => ['nullable', 'integer', 'min:0', 'max:8000'], + 'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)], + 'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)], 'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])], 'seo.twitter_default_site' => ['nullable', 'string', 'max:50'], 'seo.cache_enabled' => ['nullable', 'boolean'], - 'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'], + 'seo.cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_cache_ttl_min', 60), 'max:'.config('core.settings_limits.seo_cache_ttl_max', 86400)], 'seo.sitemap_enabled' => ['nullable', 'boolean'], - 'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'], + 'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_sitemap_cache_ttl_min', 3600), 'max:'.config('core.settings_limits.seo_sitemap_cache_ttl_max', 604800)], + // 분할 기준. 상한 50000 은 sitemaps.org 프로토콜 제한이며 SitemapWriter 가 동일 값으로 클램프합니다. + 'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_sitemap_urls_per_file_min', 1000), 'max:'.config('core.settings_limits.seo_sitemap_urls_per_file_max', 50000)], + 'seo.sitemap_gzip' => ['nullable', 'boolean'], + 'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'], + 'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'], + 'seo.sitemap_hreflang_enabled' => ['nullable', 'boolean'], 'seo.sitemap_schedule' => ['nullable', 'string', Rule::in(['hourly', 'daily', 'weekly'])], 'seo.sitemap_schedule_time' => ['nullable', 'string', 'regex:/^\d{2}:\d{2}$/'], 'seo.generator_enabled' => ['nullable', 'boolean'], @@ -229,8 +266,18 @@ class SaveSettingsRequest extends FormRequest 'security.force_https' => $this->getTabRules($tab, 'security', 'boolean'), 'security.login_attempt_enabled' => $this->getTabRules($tab, 'security', 'boolean'), 'security.auth_token_lifetime' => $this->getSecurityTabAuthTokenRules($tab), - 'security.max_login_attempts' => ['nullable', 'integer', 'min:0', 'max:100'], - 'security.login_lockout_time' => ['nullable', 'integer', 'min:0', 'max:1440'], + 'security.max_login_attempts' => ['nullable', 'integer', 'min:'.config('core.settings_limits.security_max_login_attempts_min', 0), 'max:'.config('core.settings_limits.security_max_login_attempts_max', 100)], + 'security.login_lockout_time' => ['nullable', 'integer', 'min:'.config('core.settings_limits.security_login_lockout_time_min', 0), 'max:'.config('core.settings_limits.security_login_lockout_time_max', 1440)], + // 비밀번호 정책 — 저장 규칙이 없어 whitelist 에서 탈락하던 고아 설정을 배선. + // 경계값은 config('core.settings_limits') 가 SSoT — 설정 화면도 같은 값을 받아 쓴다. + 'security.password_min_length' => [ + 'nullable', + 'integer', + 'min:'.config('core.settings_limits.security_password_min_length_min', 6), + 'max:'.config('core.settings_limits.security_password_min_length_max', 64), + ], + 'security.require_password_special_char' => ['nullable', 'boolean'], + 'security.two_factor_auth' => ['nullable', 'boolean'], // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송 시 기본값 false 유지 'security.allow_internal_outbound_urls' => ['nullable', 'boolean'], @@ -242,6 +289,9 @@ class SaveSettingsRequest extends FormRequest 'advanced.stats_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab), 'advanced.seo_cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'), 'advanced.seo_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab), + // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송/미지정 시 코드 기본값 유지. + // 범위는 SEO 탭의 오버라이드 칸(seo.sitemap_cache_ttl)과 동일하게 맞춘다. + 'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_seo_sitemap_cache_ttl_min', 3600), 'max:'.config('core.settings_limits.advanced_seo_sitemap_cache_ttl_max', 604800)], // 디버그 설정 (advanced 탭) 'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'), @@ -258,6 +308,10 @@ class SaveSettingsRequest extends FormRequest 'advanced.geoip_license_key' => ['nullable', 'string', 'max:200', 'regex:/^[A-Za-z0-9_]+$/'], 'advanced.geoip_auto_update_enabled' => ['nullable', 'boolean'], + // 목록 한계값 (advanced 탭) — 0 은 무제한 + 'advanced.pagination_result_cap' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_pagination_result_cap_min', 0), 'max:'.config('core.settings_limits.advanced_pagination_result_cap_max', 1000000)], + 'advanced.pagination_max_page' => ['nullable', 'integer', 'min:'.config('core.settings_limits.advanced_pagination_max_page_min', 0), 'max:'.config('core.settings_limits.advanced_pagination_max_page_max', 100000)], + // 드라이버 설정 (drivers 탭) 'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS)]), 'drivers.s3_bucket' => ['nullable', 'string', 'max:255'], @@ -267,35 +321,35 @@ class SaveSettingsRequest extends FormRequest 'drivers.s3_url' => ['nullable', 'url', 'max:500'], 'drivers.cache_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_CACHE_DRIVERS)]), 'drivers.redis_host' => ['nullable', 'string', 'max:255'], - 'drivers.redis_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.redis_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_port_min', 1), 'max:'.config('core.settings_limits.drivers_redis_port_max', 65535)], 'drivers.redis_password' => ['nullable', 'string', 'max:255'], - 'drivers.redis_database' => ['nullable', 'integer', 'min:0', 'max:15'], + 'drivers.redis_database' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_redis_database_min', 0), 'max:'.config('core.settings_limits.drivers_redis_database_max', 15)], 'drivers.memcached_host' => ['nullable', 'string', 'max:255'], - 'drivers.memcached_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.memcached_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_memcached_port_min', 1), 'max:'.config('core.settings_limits.drivers_memcached_port_max', 65535)], 'drivers.session_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_SESSION_DRIVERS)]), - 'drivers.session_lifetime' => ['nullable', 'integer', 'min:1', 'max:43200'], // 1분 ~ 30일 + 'drivers.session_lifetime' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_session_lifetime_min', 1), 'max:'.config('core.settings_limits.drivers_session_lifetime_max', 43200)], // 1분 ~ 30일 'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS)]), 'drivers.websocket_enabled' => ['nullable', 'boolean'], 'drivers.websocket_app_id' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'], 'drivers.websocket_app_key' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'], 'drivers.websocket_app_secret' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'], 'drivers.websocket_host' => ['nullable', 'string', 'max:255'], - 'drivers.websocket_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.websocket_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_websocket_port_min', 1), 'max:'.config('core.settings_limits.drivers_websocket_port_max', 65535)], 'drivers.websocket_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)], 'drivers.websocket_verify_ssl' => ['nullable', 'boolean'], 'drivers.websocket_server_host' => ['nullable', 'string', 'max:255'], - 'drivers.websocket_server_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'drivers.websocket_server_port' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_websocket_server_port_min', 1), 'max:'.config('core.settings_limits.drivers_websocket_server_port_max', 65535)], 'drivers.websocket_server_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)], 'drivers.search_engine_driver' => ['nullable', Rule::in($this->getSupportedSearchEngineDrivers())], 'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]), 'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]), - 'drivers.log_days' => ['nullable', 'integer', 'min:1', 'max:365'], + 'drivers.log_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_log_days_min', 1), 'max:'.config('core.settings_limits.drivers_log_days_max', 365)], // 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨 'identity.default_provider' => ['nullable', 'string', 'max:100'], 'identity.purpose_providers' => ['sometimes', 'array'], - 'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:1440'), - 'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:20'), + 'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:'.config('core.settings_limits.identity_challenge_ttl_minutes_min', 1).'|max:'.config('core.settings_limits.identity_challenge_ttl_minutes_max', 1440)), + 'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:'.config('core.settings_limits.identity_max_attempts_min', 1).'|max:'.config('core.settings_limits.identity_max_attempts_max', 20)), ]; // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 @@ -357,8 +411,8 @@ class SaveSettingsRequest extends FormRequest return [ 'nullable', 'integer', - 'min:0', - 'max:3600', + 'min:'.config('core.settings_limits.security_auth_token_lifetime_min', 0), + 'max:'.config('core.settings_limits.security_auth_token_lifetime_max', 3600), function ($attribute, $value, $fail) { // 0은 무한대를 의미하므로 허용, 그 외에는 30 이상이어야 함 if ($value !== null && $value !== 0 && $value < 30) { @@ -419,8 +473,8 @@ class SaveSettingsRequest extends FormRequest return array_merge($baseRules, [ 'integer', - 'min:0', - 'max:14400', + 'min:'.config('core.settings_limits.advanced_cache_ttl_min', 0), + 'max:'.config('core.settings_limits.advanced_cache_ttl_max', 14400), ]); } @@ -605,6 +659,14 @@ class SaveSettingsRequest extends FormRequest 'seo.sitemap_cache_ttl.integer' => __('validation.settings.sitemap_cache_ttl_integer'), 'seo.sitemap_cache_ttl.min' => __('validation.settings.sitemap_cache_ttl_min'), 'seo.sitemap_cache_ttl.max' => __('validation.settings.sitemap_cache_ttl_max'), + 'seo.sitemap_urls_per_file.integer' => __('validation.settings.sitemap_urls_per_file_integer'), + 'seo.sitemap_urls_per_file.min' => __('validation.settings.sitemap_urls_per_file_min'), + 'seo.sitemap_urls_per_file.max' => __('validation.settings.sitemap_urls_per_file_max'), + 'seo.sitemap_gzip.boolean' => __('validation.settings.sitemap_gzip_boolean'), + 'seo.sitemap_serve_stale_on_miss.boolean' => __('validation.settings.sitemap_serve_stale_on_miss_boolean'), + 'seo.sitemap_max_urls_per_contributor.integer' => __('validation.settings.sitemap_max_urls_per_contributor_integer'), + 'seo.sitemap_max_urls_per_contributor.min' => __('validation.settings.sitemap_max_urls_per_contributor_min'), + 'seo.sitemap_hreflang_enabled.boolean' => __('validation.settings.sitemap_hreflang_enabled_boolean'), 'seo.sitemap_schedule.in' => __('validation.settings.sitemap_schedule_invalid'), 'seo.sitemap_schedule_time.regex' => __('validation.settings.sitemap_schedule_time_invalid'), @@ -639,12 +701,15 @@ class SaveSettingsRequest extends FormRequest 'advanced.stats_cache_ttl.integer' => __('validation.settings.stats_cache_ttl_integer'), 'advanced.stats_cache_ttl.min' => __('validation.settings.stats_cache_ttl_min'), 'advanced.stats_cache_ttl.max' => __('validation.settings.stats_cache_ttl_max'), - 'advanced.seo_cache_enabled.required' => __('validation.settings.seo_cache_enabled_required'), - 'advanced.seo_cache_enabled.boolean' => __('validation.settings.seo_cache_enabled_boolean'), - 'advanced.seo_cache_ttl.required' => __('validation.settings.seo_cache_ttl_required'), - 'advanced.seo_cache_ttl.integer' => __('validation.settings.seo_cache_ttl_integer'), - 'advanced.seo_cache_ttl.min' => __('validation.settings.seo_cache_ttl_min'), - 'advanced.seo_cache_ttl.max' => __('validation.settings.seo_cache_ttl_max'), + 'advanced.seo_cache_enabled.required' => __('validation.settings.advanced_seo_cache_enabled_required'), + 'advanced.seo_cache_enabled.boolean' => __('validation.settings.advanced_seo_cache_enabled_boolean'), + 'advanced.seo_cache_ttl.required' => __('validation.settings.advanced_seo_cache_ttl_required'), + 'advanced.seo_cache_ttl.integer' => __('validation.settings.advanced_seo_cache_ttl_integer'), + 'advanced.seo_cache_ttl.min' => __('validation.settings.advanced_seo_cache_ttl_min'), + 'advanced.seo_cache_ttl.max' => __('validation.settings.advanced_seo_cache_ttl_max'), + 'advanced.seo_sitemap_cache_ttl.integer' => __('validation.settings.seo_sitemap_cache_ttl_integer'), + 'advanced.seo_sitemap_cache_ttl.min' => __('validation.settings.seo_sitemap_cache_ttl_min'), + 'advanced.seo_sitemap_cache_ttl.max' => __('validation.settings.seo_sitemap_cache_ttl_max'), // 디버그 설정 'advanced.debug_mode.required' => __('validation.settings.debug_mode_required'), @@ -652,6 +717,14 @@ class SaveSettingsRequest extends FormRequest 'advanced.sql_query_log.required' => __('validation.settings.sql_query_log_required'), 'advanced.sql_query_log.boolean' => __('validation.settings.sql_query_log_boolean'), + // 목록 한계값 + 'advanced.pagination_result_cap.integer' => __('validation.settings.pagination_result_cap_integer'), + 'advanced.pagination_result_cap.min' => __('validation.settings.pagination_result_cap_min'), + 'advanced.pagination_result_cap.max' => __('validation.settings.pagination_result_cap_max'), + 'advanced.pagination_max_page.integer' => __('validation.settings.pagination_max_page_integer'), + 'advanced.pagination_max_page.min' => __('validation.settings.pagination_max_page_min'), + 'advanced.pagination_max_page.max' => __('validation.settings.pagination_max_page_max'), + // 코어 업데이트 설정 'advanced.core_update_github_url.url' => __('validation.settings.core_update_github_url_invalid'), 'advanced.core_update_github_url.max' => __('validation.settings.core_update_github_url_max'), @@ -752,6 +825,122 @@ class SaveSettingsRequest extends FormRequest 'identity.purpose_providers' => __('validation.attributes.identity_purpose_providers'), 'identity.challenge_ttl_minutes' => __('validation.attributes.identity_challenge_ttl_minutes'), 'identity.max_attempts' => __('validation.attributes.identity_max_attempts'), + // notifications + 'notifications.channels' => __('validation.attributes.channels'), + // general + 'general.currency' => __('validation.attributes.currency'), + 'general.maintenance_mode' => __('validation.attributes.maintenance_mode'), + 'general.asset_url_mode' => __('validation.attributes.asset_url_mode'), + 'general.site_logo' => __('validation.attributes.site_logo'), + // mail + 'mail.mailer' => __('validation.attributes.mailer'), + 'mail.host' => __('validation.attributes.host'), + 'mail.port' => __('validation.attributes.port'), + 'mail.username' => __('validation.attributes.username'), + 'mail.password' => __('validation.attributes.password'), + 'mail.encryption' => __('validation.attributes.encryption'), + 'mail.mailgun_domain' => __('validation.attributes.mailgun_domain'), + 'mail.mailgun_secret' => __('validation.attributes.mailgun_secret'), + 'mail.mailgun_endpoint' => __('validation.attributes.mailgun_endpoint'), + 'mail.ses_key' => __('validation.attributes.ses_key'), + 'mail.ses_secret' => __('validation.attributes.ses_secret'), + 'mail.ses_region' => __('validation.attributes.ses_region'), + 'mail.from_address' => __('validation.attributes.from_address'), + 'mail.from_name' => __('validation.attributes.from_name'), + // upload + 'upload.max_file_size' => __('validation.attributes.max_file_size'), + 'upload.allowed_extensions' => __('validation.attributes.allowed_extensions'), + 'upload.image_max_width' => __('validation.attributes.image_max_width'), + 'upload.image_max_height' => __('validation.attributes.image_max_height'), + 'upload.image_quality' => __('validation.attributes.image_quality'), + // seo + 'seo.meta_title_suffix' => __('validation.attributes.meta_title_suffix'), + 'seo.meta_description' => __('validation.attributes.meta_description'), + 'seo.meta_keywords' => __('validation.attributes.meta_keywords'), + 'seo.google_analytics_id' => __('validation.attributes.google_analytics_id'), + 'seo.google_site_verification' => __('validation.attributes.google_site_verification'), + 'seo.naver_site_verification' => __('validation.attributes.naver_site_verification'), + 'seo.bot_user_agents' => __('validation.attributes.bot_user_agents'), + 'seo.bot_detection_enabled' => __('validation.attributes.bot_detection_enabled'), + 'seo.bot_detection_library_enabled' => __('validation.attributes.bot_detection_library_enabled'), + 'seo.og_default_site_name' => __('validation.attributes.og_default_site_name'), + 'seo.og_image_default_width' => __('validation.attributes.og_image_default_width'), + 'seo.og_image_default_height' => __('validation.attributes.og_image_default_height'), + 'seo.twitter_default_card' => __('validation.attributes.twitter_default_card'), + 'seo.twitter_default_site' => __('validation.attributes.twitter_default_site'), + 'seo.cache_enabled' => __('validation.attributes.seo_page_cache_enabled'), + 'seo.cache_ttl' => __('validation.attributes.cache_ttl'), + 'seo.sitemap_enabled' => __('validation.attributes.sitemap_enabled'), + 'seo.sitemap_cache_ttl' => __('validation.attributes.sitemap_cache_ttl'), + 'seo.sitemap_urls_per_file' => __('validation.attributes.sitemap_urls_per_file'), + 'seo.sitemap_gzip' => __('validation.attributes.sitemap_gzip'), + 'seo.sitemap_serve_stale_on_miss' => __('validation.attributes.sitemap_serve_stale_on_miss'), + 'seo.sitemap_max_urls_per_contributor' => __('validation.attributes.sitemap_max_urls_per_contributor'), + 'seo.sitemap_hreflang_enabled' => __('validation.attributes.sitemap_hreflang_enabled'), + 'seo.sitemap_schedule' => __('validation.attributes.sitemap_schedule'), + 'seo.sitemap_schedule_time' => __('validation.attributes.sitemap_schedule_time'), + 'seo.generator_enabled' => __('validation.attributes.generator_enabled'), + 'seo.generator_content' => __('validation.attributes.generator_content'), + // security + 'security.force_https' => __('validation.attributes.force_https'), + 'security.login_attempt_enabled' => __('validation.attributes.login_attempt_enabled'), + 'security.auth_token_lifetime' => __('validation.attributes.auth_token_lifetime'), + 'security.max_login_attempts' => __('validation.attributes.max_login_attempts'), + 'security.login_lockout_time' => __('validation.attributes.login_lockout_time'), + 'security.password_min_length' => __('validation.attributes.password_min_length'), + 'security.require_password_special_char' => __('validation.attributes.require_password_special_char'), + 'security.two_factor_auth' => __('validation.attributes.two_factor_auth'), + 'security.allow_internal_outbound_urls' => __('validation.attributes.allow_internal_outbound_urls'), + // advanced + 'advanced.cache_enabled' => __('validation.attributes.advanced_cache_enabled'), + 'advanced.layout_cache_enabled' => __('validation.attributes.layout_cache_enabled'), + 'advanced.layout_cache_ttl' => __('validation.attributes.layout_cache_ttl'), + 'advanced.stats_cache_enabled' => __('validation.attributes.stats_cache_enabled'), + 'advanced.stats_cache_ttl' => __('validation.attributes.stats_cache_ttl'), + 'advanced.seo_cache_enabled' => __('validation.attributes.seo_cache_enabled'), + 'advanced.seo_cache_ttl' => __('validation.attributes.seo_cache_ttl'), + 'advanced.seo_sitemap_cache_ttl' => __('validation.attributes.seo_sitemap_cache_ttl'), + 'advanced.debug_mode' => __('validation.attributes.debug_mode'), + 'advanced.sql_query_log' => __('validation.attributes.sql_query_log'), + 'advanced.core_update_github_url' => __('validation.attributes.core_update_github_url'), + 'advanced.core_update_github_token' => __('validation.attributes.core_update_github_token'), + 'advanced.geoip_enabled' => __('validation.attributes.geoip_enabled'), + 'advanced.geoip_license_key' => __('validation.attributes.geoip_license_key'), + 'advanced.geoip_auto_update_enabled' => __('validation.attributes.geoip_auto_update_enabled'), + 'advanced.pagination_result_cap' => __('validation.attributes.pagination_result_cap'), + 'advanced.pagination_max_page' => __('validation.attributes.pagination_max_page'), + // drivers + 'drivers.storage_driver' => __('validation.attributes.storage_driver'), + 'drivers.s3_bucket' => __('validation.attributes.s3_bucket'), + 'drivers.s3_region' => __('validation.attributes.s3_region'), + 'drivers.s3_access_key' => __('validation.attributes.s3_access_key'), + 'drivers.s3_secret_key' => __('validation.attributes.s3_secret_key'), + 'drivers.s3_url' => __('validation.attributes.s3_url'), + 'drivers.cache_driver' => __('validation.attributes.cache_driver'), + 'drivers.redis_host' => __('validation.attributes.redis_host'), + 'drivers.redis_port' => __('validation.attributes.redis_port'), + 'drivers.redis_password' => __('validation.attributes.redis_password'), + 'drivers.redis_database' => __('validation.attributes.redis_database'), + 'drivers.memcached_host' => __('validation.attributes.memcached_host'), + 'drivers.memcached_port' => __('validation.attributes.memcached_port'), + 'drivers.session_driver' => __('validation.attributes.session_driver'), + 'drivers.session_lifetime' => __('validation.attributes.session_lifetime'), + 'drivers.queue_driver' => __('validation.attributes.queue_driver'), + 'drivers.websocket_enabled' => __('validation.attributes.websocket_enabled'), + 'drivers.websocket_app_id' => __('validation.attributes.websocket_app_id'), + 'drivers.websocket_app_key' => __('validation.attributes.websocket_app_key'), + 'drivers.websocket_app_secret' => __('validation.attributes.websocket_app_secret'), + 'drivers.websocket_host' => __('validation.attributes.websocket_host'), + 'drivers.websocket_port' => __('validation.attributes.websocket_port'), + 'drivers.websocket_scheme' => __('validation.attributes.websocket_scheme'), + 'drivers.websocket_verify_ssl' => __('validation.attributes.websocket_verify_ssl'), + 'drivers.websocket_server_host' => __('validation.attributes.websocket_server_host'), + 'drivers.websocket_server_port' => __('validation.attributes.websocket_server_port'), + 'drivers.websocket_server_scheme' => __('validation.attributes.websocket_server_scheme'), + 'drivers.search_engine_driver' => __('validation.attributes.search_engine_driver'), + 'drivers.log_driver' => __('validation.attributes.log_driver'), + 'drivers.log_level' => __('validation.attributes.log_level'), + 'drivers.log_days' => __('validation.attributes.log_days'), ]; } } diff --git a/app/Http/Requests/User/ChangePasswordRequest.php b/app/Http/Requests/User/ChangePasswordRequest.php index ad81dcdc..af3bd322 100644 --- a/app/Http/Requests/User/ChangePasswordRequest.php +++ b/app/Http/Requests/User/ChangePasswordRequest.php @@ -3,8 +3,9 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rules\Password; /** * 비밀번호 변경 요청 클래스 @@ -15,6 +16,8 @@ class ChangePasswordRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -24,13 +27,13 @@ class ChangePasswordRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { $rules = [ 'current_password' => ['required', 'string', 'current_password:sanctum'], - 'password' => ['required', 'string', 'confirmed', Password::min(8)], + 'password' => ['required', 'string', 'confirmed', PasswordPolicy::rule()], 'password_confirmation' => ['required', 'string'], ]; @@ -54,4 +57,4 @@ class ChangePasswordRequest extends FormRequest 'password_confirmation.required' => __('user.validation.password_confirmation_required'), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/User/CreateUserRequest.php b/app/Http/Requests/User/CreateUserRequest.php index e0d5f595..e59cac4d 100644 --- a/app/Http/Requests/User/CreateUserRequest.php +++ b/app/Http/Requests/User/CreateUserRequest.php @@ -5,14 +5,17 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\Role; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; class CreateUserRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -22,7 +25,7 @@ class CreateUserRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -33,7 +36,7 @@ class CreateUserRequest extends FormRequest 'name' => 'required|string|max:255', 'nickname' => 'nullable|string|max:50', 'email' => ['required', 'email', Rule::unique(User::class, 'email'), 'max:255'], - 'password' => ['required', 'confirmed', Password::min(8)], + 'password' => ['required', 'confirmed', PasswordPolicy::rule()], 'language' => 'nullable|string|in:'.$supportedLocales, 'country' => 'nullable|string|size:2|alpha', 'timezone' => ['nullable', 'string', 'timezone'], @@ -122,8 +125,6 @@ class CreateUserRequest extends FormRequest * 유효성 검사를 위해 데이터를 준비합니다. * * role_ids와 roles가 동시에 전송될 경우 role_ids를 우선하여 roles를 제거합니다. - * - * @return void */ protected function prepareForValidation(): void { diff --git a/app/Http/Requests/User/UpdateProfileRequest.php b/app/Http/Requests/User/UpdateProfileRequest.php index 3db5feee..fd7cdbf2 100644 --- a/app/Http/Requests/User/UpdateProfileRequest.php +++ b/app/Http/Requests/User/UpdateProfileRequest.php @@ -4,6 +4,8 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; @@ -19,6 +21,8 @@ class UpdateProfileRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어에서 검증) */ public function authorize(): bool { @@ -28,7 +32,7 @@ class UpdateProfileRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -40,7 +44,7 @@ class UpdateProfileRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'nickname' => ['nullable', 'string', 'max:50'], 'email' => ['required', 'email', 'max:255', Rule::unique(User::class, 'email')->ignore($userId)], - 'password' => ['nullable', 'confirmed', Password::min(8)], + 'password' => ['nullable', 'confirmed', PasswordPolicy::rule()], 'current_password' => ['required_with:password', 'current_password'], 'language' => ['nullable', 'string', Rule::in($supportedLocales)], 'country' => ['nullable', 'string', 'size:2', 'alpha'], @@ -124,5 +128,4 @@ class UpdateProfileRequest extends FormRequest ]); } } - } diff --git a/app/Http/Requests/User/UpdateUserRequest.php b/app/Http/Requests/User/UpdateUserRequest.php index 677e2fae..ff4b5f4d 100644 --- a/app/Http/Requests/User/UpdateUserRequest.php +++ b/app/Http/Requests/User/UpdateUserRequest.php @@ -5,14 +5,17 @@ namespace App\Http\Requests\User; use App\Extension\HookManager; use App\Models\Role; use App\Models\User; +use App\Rules\PasswordPolicy; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; class UpdateUserRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 permission 미들웨어에서 검증) */ public function authorize(): bool { @@ -24,8 +27,6 @@ class UpdateUserRequest extends FormRequest * * API 응답에서 로드된 roles(객체 배열)와 사용자가 선택한 role_ids가 * 동시에 전송될 수 있으므로, role_ids가 있으면 roles를 제거합니다. - * - * @return void */ protected function prepareForValidation(): void { @@ -37,7 +38,7 @@ class UpdateUserRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -54,7 +55,7 @@ class UpdateUserRequest extends FormRequest 'max:255', Rule::unique(User::class, 'email')->ignore($userId), ], - 'password' => ['nullable', 'confirmed', Password::min(8)], + 'password' => ['nullable', 'confirmed', PasswordPolicy::rule()], 'language' => 'nullable|string|in:'.$supportedLocales, 'country' => 'nullable|string|size:2|alpha', 'timezone' => ['nullable', 'string', 'timezone'], diff --git a/app/Http/Requests/User/UploadAvatarRequest.php b/app/Http/Requests/User/UploadAvatarRequest.php index 11d9413d..9c55a20a 100644 --- a/app/Http/Requests/User/UploadAvatarRequest.php +++ b/app/Http/Requests/User/UploadAvatarRequest.php @@ -24,6 +24,8 @@ class UploadAvatarRequest extends FormRequest /** * Determine if the user is authorized to make this request. + * + * @return bool */ public function authorize(): bool { @@ -46,7 +48,10 @@ class UploadAvatarRequest extends FormRequest ], ]; - return HookManager::applyFilters('core.user.upload_avatar_rules', $rules); + return HookManager::applyFiltersWithLegacyName( + 'core.user.upload_avatar_validation_rules', + 'core.user.upload_avatar_rules', + $rules); } /** diff --git a/app/Http/Resources/ActivityLogCollection.php b/app/Http/Resources/ActivityLogCollection.php index d17d5014..79930efa 100644 --- a/app/Http/Resources/ActivityLogCollection.php +++ b/app/Http/Resources/ActivityLogCollection.php @@ -3,7 +3,6 @@ namespace App\Http\Resources; use Illuminate\Http\Request; -use Illuminate\Pagination\LengthAwarePaginator; /** * 활동 로그 컬렉션 리소스 @@ -36,15 +35,10 @@ class ActivityLogCollection extends BaseApiCollection 'data' => $this->mapWithRowNumber(function ($activityLog) { return (new ActivityLogResource($activityLog))->toArray(request()); }), - 'pagination' => $this->resource instanceof LengthAwarePaginator ? [ - 'current_page' => $this->resource->currentPage(), - 'last_page' => $this->resource->lastPage(), - 'per_page' => $this->resource->perPage(), - 'total' => $this->resource->total(), - 'from' => $this->resource->firstItem(), - 'to' => $this->resource->lastItem(), - 'has_more_pages' => $this->resource->hasMorePages(), - ] : null, + // 메타를 손으로 조립하면 정확도 필드(total_relation·total_is_exact·result_cap)가 + // 빠진다. 그러면 상한에 걸려 잘린 건수가 화면에서 정확한 값처럼 읽힌다 + // (실측: 실제 88,792 건이 "10000건" 으로 표기). 표준 메타 한 곳만 쓴다. + ...$this->paginationMeta(), ...($abilities ? ['abilities' => $abilities] : []), ]; } diff --git a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php index 55e43ca4..4afb8622 100644 --- a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php +++ b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php @@ -36,15 +36,8 @@ class IdentityMessageDefinitionCollection extends BaseApiCollection 'data' => $this->mapWithRowNumber(function ($definition) use ($request) { return (new IdentityMessageDefinitionResource($definition))->toArray($request); }, $sortOrder), - 'pagination' => [ - 'current_page' => $this->currentPage(), - 'last_page' => $this->lastPage(), - 'per_page' => $this->perPage(), - 'total' => $this->total(), - 'from' => $this->firstItem(), - 'to' => $this->lastItem(), - 'has_more_pages' => $this->hasMorePages(), - ], + // 표준 메타를 쓴다 — 페이지 결과의 형태를 스스로 판정해 그 형태가 아는 값만 낸다. + ...$this->paginationMeta(), ...($abilities ? ['abilities' => $abilities] : []), ]; } diff --git a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionResource.php b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionResource.php index e82e5469..9b44bb60 100644 --- a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionResource.php +++ b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionResource.php @@ -4,6 +4,7 @@ namespace App\Http\Resources\Admin\Identity; use App\Http\Resources\BaseApiResource; use Illuminate\Http\Request; +use Illuminate\Http\Resources\MissingValue; /** * IDV 메시지 정의 리소스. @@ -60,9 +61,25 @@ class IdentityMessageDefinitionResource extends BaseApiResource 'is_active' => (bool) $this->getValue('is_active'), 'is_default' => (bool) $this->getValue('is_default'), 'user_overrides' => $this->getValue('user_overrides'), - 'templates' => $this->relationLoaded('templates') - ? IdentityMessageTemplateResource::collection($this->templates) - : null, + // 목록은 대표 템플릿 1건만 로드하고(firstTemplate), 단건은 전체를 로드한다(templates). + // 화면 계약은 양쪽 모두 `templates` 배열이라 여기서 하나로 합류시킨다 — + // 로드된 쪽을 쓰고, 어느 쪽도 로드되지 않았으면 키 자체를 내보내지 않는다. + // + // 대표 템플릿 분기는 `whenLoaded` 로 쓸 수 없다. `whenLoaded($relation, $value)` 는 + // 관계가 로드됐어도 값이 null 이면 $value 를 평가하지 않고 그대로 null 을 돌려준다 + // (HasOne 이 0건이면 null). 그러면 "로드하지 않음"(null)과 "로드했더니 0건"(빈 배열)이 + // 같은 값이 되어 소비자가 둘을 구분할 수 없다 — 관계 로드 여부는 직접 판정한다. + 'templates' => $this->whenLoaded( + 'templates', + fn () => IdentityMessageTemplateResource::collection($this->templates), + fn () => $this->resource->relationLoaded('firstTemplate') + ? ($this->firstTemplate + ? IdentityMessageTemplateResource::collection(collect([$this->firstTemplate])) + : []) + : new MissingValue, + ), + // 목록이 대표 1건만 싣기 때문에, "템플릿이 더 있는가" 는 배열 길이로 알 수 없다. + 'templates_count' => $this->whenHas('templates_count', fn () => (int) $this->templates_count), ...$this->formatTimestamps(), ...$this->resourceMeta($request), ]; diff --git a/app/Http/Resources/BaseApiCollection.php b/app/Http/Resources/BaseApiCollection.php index 132a15b2..bc57f662 100644 --- a/app/Http/Resources/BaseApiCollection.php +++ b/app/Http/Resources/BaseApiCollection.php @@ -2,10 +2,15 @@ namespace App\Http\Resources; +use App\Contracts\Pagination\BoundedTotalAware; use App\Helpers\PermissionHelper; use App\Http\Resources\Traits\HasRowNumber; +use App\Support\Query\BoundedPage; +use App\Support\Query\KeysetPaginator; +use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; +use Illuminate\Pagination\CursorPaginator; /** * API 컬렉션 리소스 기본 클래스 @@ -61,24 +66,74 @@ abstract class BaseApiCollection extends ResourceCollection * 정확히 판정할 수 있도록 노출합니다. 전체 조회(get) 등 paginator 가 아닌 * 경우에는 빈 배열을 반환하므로 toArray 에서 array_merge 로 안전하게 합칠 수 있습니다. * + * 페이지 결과는 세 형태 중 하나이며, 형태를 스스로 판정해 그 형태가 실제로 아는 + * 값만 내보냅니다. 모르는 값을 0 이나 1 로 채우면 화면이 그것을 사실로 읽습니다. + * + * | 형태 | 예 | total | last_page | 추가 | + * |---|---|---|---|---| + * | 전체건수형 | `paginate()` | 정확 | 정확 | — | + * | 상한형 | {@see BoundedPage} | 정확 또는 하한 | 하한이면 null | `total_relation` `total_is_exact` `result_cap` | + * | 단순형 | `simplePaginate()` / `cursorPaginate()` | 없음 | 없음 | 커서면 `next_cursor` `prev_cursor` | + * + * 기존 필드는 하나도 제거하지 않으므로, 표준 `paginate()` 를 쓰는 기존 컬렉션의 + * 응답은 이전과 완전히 동일합니다. + * * @return array ['pagination' => [...]] 또는 빈 배열 */ protected function paginationMeta(): array { - if (! method_exists($this->resource, 'currentPage')) { + $resource = $this->resource; + + if ($resource instanceof CursorPaginator) { + return ['pagination' => $this->cursorPaginationMeta($resource)]; + } + + if (! method_exists($resource, 'currentPage')) { return []; } + $meta = [ + 'current_page' => $resource->currentPage(), + 'per_page' => $resource->perPage(), + 'from' => $resource->firstItem(), + 'to' => $resource->lastItem(), + 'has_more_pages' => $resource->hasMorePages(), + ]; + + // 단순형(simplePaginate)은 총 건수를 아예 세지 않으므로 total/last_page 를 내보내지 않는다. + if ($resource instanceof LengthAwarePaginatorContract) { + $meta['last_page'] = $resource->lastPage(); + $meta['total'] = $resource->total(); + } + + if ($resource instanceof BoundedTotalAware) { + $meta['total_relation'] = $resource->totalRelation()->value; + $meta['total_is_exact'] = $resource->totalRelation()->isExact(); + $meta['result_cap'] = $resource->resultCap(); + } + + return ['pagination' => $meta]; + } + + /** + * 커서 페이지의 pagination 메타를 만듭니다. + * + * 커서 방식은 총 건수와 페이지 번호를 계산하지 않습니다. 대신 앞뒤 이동 커서를 + * 그대로 실어 보내며, 화면은 이 값으로 이전/다음 버튼을 만듭니다. + * + * 인코딩은 {@see KeysetPaginator} 를 거칩니다. 커서 문자열의 형식은 디코딩과 짝을 + * 이뤄야 하므로, 한쪽만 여기서 직접 만들면 표준이 두 벌이 됩니다. + * + * @param CursorPaginator $resource 커서 페이지 결과 + * @return array pagination 메타 + */ + private function cursorPaginationMeta(CursorPaginator $resource): array + { return [ - 'pagination' => [ - 'current_page' => $this->resource->currentPage(), - 'last_page' => $this->resource->lastPage(), - 'per_page' => $this->resource->perPage(), - 'total' => $this->resource->total(), - 'from' => $this->resource->firstItem(), - 'to' => $this->resource->lastItem(), - 'has_more_pages' => $this->resource->hasMorePages(), - ], + 'per_page' => $resource->perPage(), + 'has_more_pages' => $resource->hasMorePages(), + 'next_cursor' => KeysetPaginator::nextCursor($resource), + 'prev_cursor' => KeysetPaginator::previousCursor($resource), ]; } } diff --git a/app/Http/Resources/BaseApiResource.php b/app/Http/Resources/BaseApiResource.php index 4217539f..b4b4a765 100644 --- a/app/Http/Resources/BaseApiResource.php +++ b/app/Http/Resources/BaseApiResource.php @@ -5,13 +5,16 @@ namespace App\Http\Resources; use App\Helpers\TimezoneHelper; use App\Http\Resources\Traits\HasAbilityCheck; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Http\Resources\MissingValue; use Illuminate\Support\Facades\App; class BaseApiResource extends JsonResource { use HasAbilityCheck; + /** * 리소스를 배열로 변환합니다. * @@ -122,6 +125,76 @@ class BaseApiResource extends JsonResource ]; } + /** + * 조건부 필드(`when()`)가 걸러지지 않은 배열에서 미충족 항목을 제거합니다. + * + * `toArray()` 결과는 Laravel 이 응답 직전에 `filter()` 로 `MissingValue` 를 걷어내지만, + * `toListArray()` 처럼 **배열을 그대로 컨트롤러에 넘기는 경로**는 그 단계를 거치지 않는다. + * 그대로 두면 조건 미충족 필드가 `"updated_at": {}` 같은 빈 객체로 응답에 실린다 + * (예: `UPDATED_AT = null` 인 모델의 타임스탬프). + * + * @param array $data 직렬화 결과 + * @return array 미충족 항목이 제거된 배열 + */ + protected function withoutMissing(array $data): array + { + return array_filter($data, fn ($value) => ! $value instanceof MissingValue); + } + + /** + * 쿼리에서 계산된 집계 별칭(`withCount`/`withSum` 등)이 실제로 실려 있는지 판정합니다. + * + * 값 검사(`!== null`, `> 0`)로는 판정할 수 없다. SUM 은 대상 0건에서 NULL 을 돌려주고 + * COUNT 는 0 을 돌려주므로, "집계하지 않았다" 와 "집계했더니 비어 있다" 가 구분되지 않는다. + * 속성 키의 존재 여부만이 두 상황을 가른다. + * + * @param string $alias 집계 별칭 (예: `options_count`) + * @return bool 집계가 실려 있는지 여부 + */ + protected function hasAggregate(string $alias): bool + { + $model = $this->unwrapResource(); + + return $model instanceof Model && array_key_exists($alias, $model->getAttributes()); + } + + /** + * 집계 별칭 값을 반환합니다. 집계가 실려 있지 않으면 기본값을 돌려줍니다. + * + * @param string $alias 집계 별칭 + * @param mixed $default 집계 부재 시 사용할 값 + * @return mixed 집계 값 또는 기본값 + */ + protected function aggregate(string $alias, mixed $default = null): mixed + { + $model = $this->unwrapResource(); + + if (! $model instanceof Model || ! array_key_exists($alias, $model->getAttributes())) { + return $default; + } + + return $model->getAttributes()[$alias]; + } + + /** + * 리소스 래핑을 벗겨 원본 모델을 반환합니다. + * + * Resource 가 Resource 를 감싸는 경우(컬렉션 매핑 등)가 있어 한 겹만 벗기면 모델에 + * 닿지 못한다. + * + * @return mixed 래핑이 제거된 리소스 본체 + */ + private function unwrapResource(): mixed + { + $model = $this->resource; + + while ($model instanceof JsonResource) { + $model = $model->resource; + } + + return $model; + } + /** * datetime 값을 사용자 timezone으로 변환하여 ISO8601 문자열로 반환합니다. * diff --git a/app/Http/Resources/LanguagePackResource.php b/app/Http/Resources/LanguagePackResource.php index 08826f3a..77e6ee05 100644 --- a/app/Http/Resources/LanguagePackResource.php +++ b/app/Http/Resources/LanguagePackResource.php @@ -3,9 +3,12 @@ namespace App\Http\Resources; use App\Enums\LanguagePackOrigin; +use App\Extension\Helpers\ChangelogParser; use App\Helpers\TimezoneHelper; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\DB; /** * 언어팩 단건 API 리소스. @@ -46,6 +49,8 @@ class LanguagePackResource extends BaseApiResource 'github_changelog_url' => $this->resolveManifestField('github_changelog_url'), 'bundled_identifier' => $this->getValue('bundled_identifier'), 'install_blocked_reason' => $this->getValue('install_blocked_reason'), + 'files_missing' => (bool) $this->getValue('files_missing', false), + 'bundled_source_available' => (bool) $this->getValue('bundled_source_available', false), 'target_name' => $this->resolveTargetName(), 'installed_at' => $this->formatTimestamp('installed_at'), 'activated_at' => $this->formatTimestamp('activated_at'), @@ -82,7 +87,7 @@ class LanguagePackResource extends BaseApiResource return null; } - $locale = \Illuminate\Support\Facades\App::getLocale(); + $locale = App::getLocale(); $fallback = (string) config('app.fallback_locale', 'ko'); foreach ([$locale, $fallback, 'ko', 'en'] as $candidate) { @@ -153,7 +158,7 @@ class LanguagePackResource extends BaseApiResource $directoryPath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $directory); $changelogPath = $directory.DIRECTORY_SEPARATOR.'CHANGELOG.md'; if (is_file($changelogPath)) { - $changelogEntries = \App\Extension\Helpers\ChangelogParser::parse($changelogPath); + $changelogEntries = ChangelogParser::parse($changelogPath); } } catch (\Throwable $e) { $directoryPath = null; @@ -185,6 +190,10 @@ class LanguagePackResource extends BaseApiResource * 미설치 번들 가상 행(`status === uninstalled`)은 활성/비활성/제거 액션이 * 무의미하므로 `can_install` 만 노출합니다. 모듈/플러그인 행 액션과 동일 패턴. * + * 드리프트 행(설치본 파일 부재 + 번들 소스 실재)은 설치 행이지만 "재설치로 복구" 가 + * 유효한 액션이므로 `can_install` 을 함께 노출합니다. 이 플래그가 없으면 화면이 + * 복구 버튼을 켤 근거가 없어, 드리프트가 보이기만 하고 고칠 수 없는 상태로 남습니다. + * * @return array */ protected function abilityMap(): array @@ -195,11 +204,17 @@ class LanguagePackResource extends BaseApiResource ]; } - return [ + $map = [ 'can_activate' => 'core.language_packs.manage', 'can_deactivate' => 'core.language_packs.manage', 'can_uninstall' => 'core.language_packs.manage', ]; + + if ($this->getValue('files_missing') && $this->getValue('bundled_source_available')) { + $map['can_install'] = 'core.language_packs.install'; + } + + return $map; } /** @@ -248,7 +263,7 @@ class LanguagePackResource extends BaseApiResource return null; } - $row = \Illuminate\Support\Facades\DB::table($table) + $row = DB::table($table) ->where('identifier', $target) ->first(['name']); if (! $row || ! $row->name) { @@ -260,7 +275,7 @@ class LanguagePackResource extends BaseApiResource return is_string($row->name) ? $row->name : null; } - $locale = \Illuminate\Support\Facades\App::getLocale(); + $locale = App::getLocale(); return $decoded[$locale] ?? $decoded[config('app.fallback_locale', 'ko')] ?? (reset($decoded) ?: null); } diff --git a/app/Http/Resources/LayoutExtensionResource.php b/app/Http/Resources/LayoutExtensionResource.php index 36a3dde6..fc8deb79 100644 --- a/app/Http/Resources/LayoutExtensionResource.php +++ b/app/Http/Resources/LayoutExtensionResource.php @@ -69,6 +69,56 @@ class LayoutExtensionResource extends BaseApiResource ]; } + /** + * 목록용 경량 배열로 변환합니다. + * + * 확장 목록은 라우트 트리에 "어느 화면에 어떤 확장이 붙어 있는가" 를 그리는 데 쓰입니다. + * 편집 본문(`content`)은 그 화면이 쓰지 않고 단건 조회가 공급하는데, 목록에 실리면 템플릿에 + * 등록된 **모든 확장의 JSON 전문**이 한 응답에 담깁니다. + * + * 수정 여부(`is_modified`)와 크기(`size`/`size_formatted`)는 본문에서 계산하므로 값은 그대로 + * 유지합니다 — 본문을 직렬화하지 않을 뿐, 계산은 서버에서 수행해 결과만 내려줍니다. + * (목록 화면이 확장마다 크기를 표시하므로 빼면 전 행이 `0 B` 로 보인다.) + * + * @param Request $request 요청 객체 + * @return array 목록용 직렬화 결과 + */ + public function toListArray(Request $request): array + { + $content = $this->getValue('content', []); + $contentJson = is_array($content) ? json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '{}'; + + $extensionType = $this->getValue('extension_type'); + $sourceType = $this->getValue('source_type'); + + // 이 배열은 컨트롤러가 그대로 응답에 싣는다 — Laravel 의 MissingValue 제거 단계를 거치지 + // 않으므로 조건부 필드를 직접 걸러낸다. + return $this->withoutMissing([ + 'id' => $this->getValue('id'), + 'extension_type' => $extensionType?->value ?? $extensionType, + 'target_name' => $this->getValue('target_name'), + 'source_type' => $sourceType?->value ?? $sourceType, + 'source_identifier' => $this->getValue('source_identifier'), + 'source_label' => $this->getValue('source_label', $this->getValue('source_identifier')), + 'is_override' => (bool) $this->getValue( + 'is_override', + ($sourceType?->value ?? $sourceType) === 'template' + ), + 'priority' => $this->getValue('priority'), + 'is_active' => (bool) $this->getValue('is_active'), + 'is_modified' => $this->resolveIsModified($content), + 'size' => strlen($contentJson), + 'size_formatted' => $this->formatFileSize(strlen($contentJson)), + 'host_layouts' => $this->getValue('host_layouts', []), + 'current_version' => $this->getValue('current_version') !== null + ? (int) $this->getValue('current_version') + : null, + + ...$this->formatTimestamps(), + ...$this->resourceMeta($request), + ]); + } + /** * 리소스별 권한 매핑을 반환합니다. * diff --git a/app/Http/Resources/LayoutExtensionVersionResource.php b/app/Http/Resources/LayoutExtensionVersionResource.php index b83fb3e0..d8cb79e1 100644 --- a/app/Http/Resources/LayoutExtensionVersionResource.php +++ b/app/Http/Resources/LayoutExtensionVersionResource.php @@ -39,6 +39,33 @@ class LayoutExtensionVersionResource extends BaseApiResource ]; } + /** + * 목록용 경량 배열로 변환합니다. + * + * 버전 히스토리 패널이 쓰는 값은 버전 번호·변경 요약·저장자·저장 시각뿐입니다. 확장 본문 + * (`content`)은 버전 비교 diff 전용이라 단건 조회가 공급합니다. 목록에 실으면 **버전마다** + * 확장 본문 사본이 실려, 저장을 반복한 확장일수록 목록 응답이 선형으로 커집니다. + * (레이아웃 본체 LayoutVersionResource 와 동일 정책) + * + * @param Request $request 요청 객체 + * @return array 목록용 직렬화 결과 + */ + public function toListArray(Request $request): array + { + // 이 배열은 컨트롤러가 그대로 응답에 싣는다 — Laravel 의 MissingValue 제거 단계를 거치지 + // 않으므로 조건부 필드를 직접 걸러낸다 (이 모델은 UPDATED_AT 이 없어 updated_at 이 미충족). + return $this->withoutMissing([ + 'id' => $this->getValue('id'), + 'extension_id' => $this->getValue('extension_id'), + 'version' => $this->getValue('version'), + 'changes_summary' => $this->formatChangesSummary($this->getValue('changes_summary', [])), + 'created_by_name' => $this->resolveCreatorName(), + + ...$this->formatTimestamps(), + ...$this->resourceMeta($request), + ]); + } + /** * 저장자 이름을 해석합니다. * diff --git a/app/Http/Resources/LayoutListResource.php b/app/Http/Resources/LayoutListResource.php new file mode 100644 index 00000000..bf8ddb71 --- /dev/null +++ b/app/Http/Resources/LayoutListResource.php @@ -0,0 +1,130 @@ + 레이아웃 이름 → 라우트 path 매핑 */ + private array $routePathMap = []; + + /** @var array 소유 템플릿의 프론트엔드 다국어 사전 (점 표기 중첩) */ + private array $translations = []; + + /** + * 라우트 path 매핑을 주입합니다. + * + * @param array $routePathMap 레이아웃 이름 → 라우트 path + * @return $this + */ + public function withRoutePathMap(array $routePathMap): self + { + $this->routePathMap = $routePathMap; + + return $this; + } + + /** + * 설명 해석에 쓸 소유 템플릿 사전을 주입합니다. + * + * @param array $translations 템플릿 프론트엔드 다국어 데이터 + * @return $this + */ + public function withTranslations(array $translations): self + { + $this->translations = $translations; + + return $this; + } + + /** + * 목록 행을 배열로 변환합니다. + * + * @param Request $request 요청 객체 + * @return array 목록 행 데이터 + */ + public function toArray(Request $request): array + { + $name = $this->getValue('name'); + $size = (int) $this->getValue('size', 0); + + return [ + 'id' => $this->getValue('id'), + 'template_id' => $this->getValue('template_id'), + 'name' => $name, + 'description' => LayoutDescription::resolve($this->getValue('description'), $name, $this->translations), + + // 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 파일 선택 시 ?route= + // 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용. + 'route_path' => $this->routePathMap[$name] ?? null, + + 'size' => $size, + 'size_formatted' => $this->formatFileSize($size), + 'has_update' => false, + + // 낙관적 잠금 — 편집 진입 후 저장 요청에 expected_lock_version 으로 전달 + 'lock_version' => (int) $this->getValue('lock_version', 0), + + // 상세 응답(LayoutResource)의 formatTimestamps() 와 같은 규약 — 사용자 timezone + // 기준 문자열이다. 원시 ISO 를 그대로 내보내면 목록의 수정일 표기만 형식이 달라진다. + 'created_at' => $this->formatDateTimeStringForUser($this->toCarbon($this->getValue('created_at'))), + 'updated_at' => $this->formatDateTimeStringForUser($this->toCarbon($this->getValue('updated_at'))), + ]; + } + + /** + * 값을 Carbon 인스턴스로 변환합니다. + * + * 경량 조회는 배열 행을 넘기므로 값이 Carbon 일 수도, 문자열일 수도, null 일 수도 있다. + * + * @param mixed $value 변환할 값 + * @return Carbon|null 변환된 Carbon 또는 null + */ + private function toCarbon(mixed $value): ?Carbon + { + if ($value === null || $value === '') { + return null; + } + + return $value instanceof Carbon ? $value : Carbon::parse($value); + } + + /** + * 파일 크기를 사람이 읽는 단위로 변환합니다. + * + * @param int $bytes 바이트 수 + * @return string 변환된 크기 문자열 + */ + private function formatFileSize(int $bytes): string + { + if ($bytes < 1024) { + return $bytes.' B'; + } + + if ($bytes < 1048576) { + return round($bytes / 1024, 1).' KB'; + } + + return round($bytes / 1048576, 1).' MB'; + } +} diff --git a/app/Http/Resources/LayoutResource.php b/app/Http/Resources/LayoutResource.php index c7c2e53c..c8be3f80 100644 --- a/app/Http/Resources/LayoutResource.php +++ b/app/Http/Resources/LayoutResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Support\LayoutDescription; use Illuminate\Http\Request; class LayoutResource extends BaseApiResource @@ -16,6 +17,22 @@ class LayoutResource extends BaseApiResource */ protected array $routePathMap = []; + /** @var array 소유 템플릿의 프론트엔드 다국어 사전 */ + protected array $translations = []; + + /** + * 설명 해석에 쓸 소유 템플릿 사전을 주입합니다. + * + * @param array $translations 템플릿 프론트엔드 다국어 데이터 + * @return $this + */ + public function withTranslations(array $translations): static + { + $this->translations = $translations; + + return $this; + } + /** * 레이아웃 이름 → 라우트 path 매핑을 주입합니다. * @@ -45,7 +62,9 @@ class LayoutResource extends BaseApiResource 'id' => $this->getValue('id'), 'template_id' => $this->getValue('template_id'), 'name' => $name, - 'description' => $content['meta']['description'] ?? $name, + // 설명은 소유 템플릿 사전 키를 쓰므로 서버가 해석한다 — 목록(LayoutListResource)과 + // 같은 규칙이어야 파일 목록과 선택 파일 헤더의 표기가 어긋나지 않는다. + 'description' => LayoutDescription::resolve($content['meta']['description'] ?? null, $name, $this->translations), 'endpoint' => $content['endpoint'] ?? null, // 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 코드 편집기가 // 파일 선택 시 ?route= 동기화 / 위지윅에서 넘어온 ?route= 복원에 사용. diff --git a/app/Http/Resources/LayoutVersionResource.php b/app/Http/Resources/LayoutVersionResource.php index c5554efc..98b3298a 100644 --- a/app/Http/Resources/LayoutVersionResource.php +++ b/app/Http/Resources/LayoutVersionResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Models\User; use Illuminate\Http\Request; class LayoutVersionResource extends BaseApiResource @@ -64,6 +65,36 @@ class LayoutVersionResource extends BaseApiResource return $payload; } + /** + * 목록용 경량 배열로 변환합니다. + * + * 버전 히스토리 패널이 쓰는 값은 버전 번호·변경 요약·저장자·저장 시각뿐입니다 + * (`LayoutVersionSummary`). 분해된 본문(`components`/`data_sources`/`metadata`/`endpoint`)은 + * 버전 비교 diff 전용이라 단건 조회가 공급합니다. 목록에 실으면 **버전마다** 레이아웃 본문 + * 사본이 실려, 저장을 반복한 레이아웃일수록 목록 응답이 선형으로 커집니다. + * + * 종전 주석은 "목록에는 content 를 제외한다" 고 했지만 실제로 제외된 것은 `full_content` + * 하나뿐이었습니다 — 분해된 키들이 그대로 남아 사실상 본문이 다 실렸습니다. + * + * @param Request $request 현재 요청 + * @return array 목록용 직렬화 결과 + */ + public function toListArray(Request $request): array + { + // 이 배열은 컨트롤러가 그대로 응답에 싣는다 — Laravel 의 MissingValue 제거 단계를 거치지 + // 않으므로 조건부 필드를 직접 걸러낸다 (이 모델은 UPDATED_AT 이 없어 updated_at 이 미충족). + return $this->withoutMissing([ + 'id' => $this->getValue('id'), + 'layout_id' => $this->getValue('layout_id'), + 'version' => $this->getValue('version'), + 'changes_summary' => $this->formatChangesSummary($this->getValue('changes_summary', [])), + 'created_by_name' => $this->resolveCreatorName(), + + ...$this->formatTimestamps(), + ...$this->resourceMeta($request), + ]); + } + /** * 저장자 이름을 해석합니다. * @@ -77,7 +108,7 @@ class LayoutVersionResource extends BaseApiResource $creator = $this->whenLoaded('creator'); // whenLoaded 미로딩 시 MissingValue 반환 → 삼항으로 null 처리 (MissingValue 누출 방지). - return $creator instanceof \App\Models\User ? $creator->name : null; + return $creator instanceof User ? $creator->name : null; } /** diff --git a/app/Http/Resources/MenuResource.php b/app/Http/Resources/MenuResource.php index cc78b0ff..069933ce 100644 --- a/app/Http/Resources/MenuResource.php +++ b/app/Http/Resources/MenuResource.php @@ -38,7 +38,7 @@ class MenuResource extends BaseApiResource 'children' => $this->whenLoaded('children', function () { return $this->children->map(function ($child) { - return [ + $data = [ 'id' => $child->id, 'name' => $child->name, // 다국어 객체 그대로 반환 'slug' => $child->slug, @@ -49,14 +49,23 @@ class MenuResource extends BaseApiResource 'parent_id' => $child->parent_id, 'extension_type' => $child->extension_type?->value, 'extension_identifier' => $child->extension_identifier, - 'roles' => $child->relationLoaded('roles') ? $child->roles->map(function ($role) { + ]; + + // 역할은 로드된 경우에만 싣는다. 미로드 시 `[]` 를 돌려주면 "역할 제한이 + // 없다" 는 사실이 아닌 단언이 되어, 화면이 그 값을 그대로 저장에 실어 + // 보내면 해당 메뉴의 역할 제한이 통째로 지워진다. 키 자체를 생략해 + // 소비 측이 "모른다" 를 구분할 수 있게 한다 (단건 조회로 보강). + if ($child->relationLoaded('roles')) { + $data['roles'] = $child->roles->map(function ($role) { return [ 'id' => $role->id, 'name' => $role->name, 'permission_type' => $role->pivot->permission_type ?? null, ]; - }) : [], - ]; + })->values(); + } + + return $data; })->sortBy('order')->values(); }), diff --git a/app/Http/Resources/NotificationDefinitionCollection.php b/app/Http/Resources/NotificationDefinitionCollection.php index 95868d79..aa65961f 100644 --- a/app/Http/Resources/NotificationDefinitionCollection.php +++ b/app/Http/Resources/NotificationDefinitionCollection.php @@ -19,7 +19,7 @@ class NotificationDefinitionCollection extends BaseApiCollection /** * 컬렉션을 배열로 변환합니다. * - * @param Request $request + * @param Request $request * @return array */ public function toArray(Request $request): array @@ -31,15 +31,8 @@ class NotificationDefinitionCollection extends BaseApiCollection 'data' => $this->mapWithRowNumber(function ($definition) use ($request) { return (new NotificationDefinitionResource($definition))->toArray($request); }, $sortOrder), - 'pagination' => [ - 'current_page' => $this->currentPage(), - 'last_page' => $this->lastPage(), - 'per_page' => $this->perPage(), - 'total' => $this->total(), - 'from' => $this->firstItem(), - 'to' => $this->lastItem(), - 'has_more_pages' => $this->hasMorePages(), - ], + // 표준 메타를 쓴다 — 페이지 결과의 형태를 스스로 판정해 그 형태가 아는 값만 낸다. + ...$this->paginationMeta(), ...($abilities ? ['abilities' => $abilities] : []), ]; } diff --git a/app/Http/Resources/NotificationDefinitionResource.php b/app/Http/Resources/NotificationDefinitionResource.php index 2e967444..591fd062 100644 --- a/app/Http/Resources/NotificationDefinitionResource.php +++ b/app/Http/Resources/NotificationDefinitionResource.php @@ -20,12 +20,15 @@ class NotificationDefinitionResource extends BaseApiResource /** * 리소스를 배열로 변환합니다. * - * @param Request $request + * @param Request $request * @return array */ public function toArray(Request $request): array { - return [ + // 컬렉션(NotificationDefinitionCollection)이 이 배열을 그대로 응답에 실어 Laravel 의 + // MissingValue 제거 단계를 거치지 않는다 — 미충족 조건부 필드를 직접 걸러낸다. + // (걸러내지 않으면 `"templates": {}` 같은 빈 객체가 응답에 남는다) + return $this->withoutMissing([ 'id' => $this->getValue('id'), 'type' => $this->getValue('type'), 'hook_prefix' => $this->getValue('hook_prefix'), @@ -38,11 +41,19 @@ class NotificationDefinitionResource extends BaseApiResource 'hooks' => $this->getValue('hooks'), 'is_active' => (bool) $this->getValue('is_active'), 'is_default' => (bool) $this->getValue('is_default'), - 'templates' => $this->relationLoaded('templates') - ? NotificationTemplateResource::collection($this->templates) - : null, + 'templates' => $this->whenLoaded( + 'templates', + fn () => NotificationTemplateResource::collection($this->templates) + ), + // 목록이 템플릿을 한 채널로 좁혀 싣기 때문에, "커스터마이즈된 템플릿이 있는가" 는 + // 배열을 훑어서는 알 수 없다 (다른 채널만 수정한 경우를 놓친다). 되돌리기 버튼 + // 노출 조건이 이 값에 의존하므로 집계로 내려보낸다. + 'has_customized_templates' => $this->whenHas( + 'customized_templates_count', + fn () => (int) $this->customized_templates_count > 0 + ), ...$this->formatTimestamps(), ...$this->resourceMeta($request), - ]; + ]); } } diff --git a/app/Http/Resources/NotificationLogCollection.php b/app/Http/Resources/NotificationLogCollection.php index c0047cc3..651fed46 100644 --- a/app/Http/Resources/NotificationLogCollection.php +++ b/app/Http/Resources/NotificationLogCollection.php @@ -19,7 +19,7 @@ class NotificationLogCollection extends BaseApiCollection /** * 컬렉션을 배열로 변환합니다. * - * @param Request $request + * @param Request $request * @return array */ public function toArray(Request $request): array @@ -31,15 +31,10 @@ class NotificationLogCollection extends BaseApiCollection 'data' => $this->mapWithRowNumber(function ($log) use ($request) { return (new NotificationLogResource($log))->toArray($request); }, $sortOrder), - 'pagination' => [ - 'current_page' => $this->resource->currentPage(), - 'last_page' => $this->resource->lastPage(), - 'per_page' => $this->resource->perPage(), - 'total' => $this->resource->total(), - 'from' => $this->resource->firstItem(), - 'to' => $this->resource->lastItem(), - 'has_more_pages' => $this->resource->hasMorePages(), - ], + // 표준 메타를 쓴다. 이 목록은 커서 요청이면 CursorPaginator 로, 그 외에는 상한형 + // 페이지로 온다. 손으로 조립하면 커서 결과에 없는 total()/lastPage() 를 불러 + // 그 요청만 500 이 되고, 상한형에서는 정확도 메타가 빠진다. + ...$this->paginationMeta(), ...($abilities ? ['abilities' => $abilities] : []), ]; } diff --git a/app/Http/Resources/RoleResource.php b/app/Http/Resources/RoleResource.php index 6f844e8d..b3758dc7 100644 --- a/app/Http/Resources/RoleResource.php +++ b/app/Http/Resources/RoleResource.php @@ -37,6 +37,14 @@ class RoleResource extends BaseApiResource $this->resource->users_count !== null, fn () => $this->resource->users_count ), + // 목록은 권한 배열 3종을 싣지 않는다. 그 대신 "몇 개가 할당됐는가" 는 집계로 남긴다 + // — 목록 화면이 권한 규모를 보여주면서도 권한 트리를 전송하지 않게 하는 대체 경로다. + // 값 검사(`!== null`)가 아니라 별칭 존재 여부로 판정한다: COUNT 는 0건에서도 0 을 + // 돌려주므로 "집계하지 않았다" 와 "집계했더니 0" 이 구분되지 않는다. + 'permissions_count' => $this->whenHas( + 'permissions_count', + fn () => (int) $this->resource->permissions_count + ), 'permission_ids' => $this->when( $this->resource->relationLoaded('permissions'), fn () => $this->resource->permissions->pluck('id')->toArray() diff --git a/app/Http/Resources/Traits/HasRowNumber.php b/app/Http/Resources/Traits/HasRowNumber.php index e6ad272b..fd9fd304 100644 --- a/app/Http/Resources/Traits/HasRowNumber.php +++ b/app/Http/Resources/Traits/HasRowNumber.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Traits; +use App\Contracts\Pagination\BoundedTotalAware; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -57,7 +58,7 @@ trait HasRowNumber * @param int $total 전체 항목 수 * @param int $perPage 페이지당 항목 수 * @param int $currentPage 현재 페이지 번호 - * @return int 계산된 순번 + * @return int|null 계산된 순번 (총 건수가 잘린 내림차순이면 null) */ protected function calculateRowNumber( int $index, @@ -65,13 +66,22 @@ trait HasRowNumber int $total, int $perPage, int $currentPage - ): int { + ): ?int { if ($sortOrder === 'asc') { // 오름차순: 1, 2, 3, ... (페이지별 연속) // 예: 2페이지 → ((2-1) * 10) + 0 + 1 = 11, 12, 13, ... + // offset 만으로 정해지므로 총 건수가 잘려도 그대로 정확하다. return (($currentPage - 1) * $perPage) + $index + 1; } + // 내림차순 순번은 "전체 몇 건 중 몇 번째" 라 총 건수를 알아야 나온다. 총 건수가 + // 상한에 걸려 잘렸으면 그 값으로 역산한 순번은 첫 페이지부터 이미 틀리고, 상한을 + // 넘어선 페이지에서는 0 과 음수까지 내려간다. 틀린 숫자를 내보내는 것보다 + // 내보내지 않는 편이 낫으므로 `last_page` 와 같은 원칙으로 null 을 돌려준다. + if ($this->resource instanceof BoundedTotalAware && ! $this->resource->totalRelation()->isExact()) { + return null; + } + // 내림차순: total, total-1, ... (역순) // 예: 총 30개, 2페이지 → 30 - ((2-1) * 10) - 0 = 20, 19, 18, ... return $total - (($currentPage - 1) * $perPage) - $index; diff --git a/app/Http/Resources/UserNotificationCollection.php b/app/Http/Resources/UserNotificationCollection.php index ecd1da1c..6da48dcd 100644 --- a/app/Http/Resources/UserNotificationCollection.php +++ b/app/Http/Resources/UserNotificationCollection.php @@ -5,14 +5,16 @@ namespace App\Http\Resources; use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface; use Illuminate\Http\Request; +/** + * 알림함 컬렉션 + */ class UserNotificationCollection extends BaseApiCollection { - /** * 컬렉션을 배열로 변환합니다. * - * @param Request $request - * @return array + * @param Request $request HTTP 요청 + * @return array 변환된 배열 */ public function toArray(Request $request): array { @@ -29,15 +31,10 @@ class UserNotificationCollection extends BaseApiCollection ->withTypeLabelMap($typeLabelMap) ->toArray($request); }, $sortOrder), - 'pagination' => [ - 'current_page' => $this->resource->currentPage(), - 'last_page' => $this->resource->lastPage(), - 'per_page' => $this->resource->perPage(), - 'total' => $this->resource->total(), - 'from' => $this->resource->firstItem(), - 'to' => $this->resource->lastItem(), - 'has_more_pages' => $this->resource->hasMorePages(), - ], + // 표준 메타를 쓴다. 손으로 조립하면 페이지 결과의 형태(전체건수형/상한형/단순형)를 + // 구분하지 못해, 상한에 걸려 잘린 총 건수가 정확한 값처럼 나가고 마지막 페이지도 + // 계산할 수 없는데 숫자가 채워진다. + ...$this->paginationMeta(), ]; } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 6abc8929..3c397b3b 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -260,6 +260,14 @@ class UserResource extends BaseApiResource 'blocked_at' => $this->getValue('blocked_at') ? $this->formatDateTimeStringForUser($this->getValue('blocked_at')) : null, + // 로그인 실패 누적 잠금 상태 — 관리자만 확인·해제할 수 있어야 하므로 관리자 응답에만 노출 + 'failed_login_attempts' => (int) ($this->getValue('failed_login_attempts') ?? 0), + 'locked_permanently' => (bool) $this->getValue('locked_permanently'), + 'locked_until' => $this->getValue('locked_until') + ? $this->formatDateTimeStringForUser($this->getValue('locked_until')) + : null, + 'is_locked' => (bool) $this->getValue('locked_permanently') + || ($this->getValue('locked_until') !== null && $this->resource->locked_until?->isFuture()), ]); // Filter 훅: 모듈이 자신의 데이터를 응답에 병합 diff --git a/app/Http/View/Composers/TemplateComposer.php b/app/Http/View/Composers/TemplateComposer.php index 5d565fb3..716dfbd1 100644 --- a/app/Http/View/Composers/TemplateComposer.php +++ b/app/Http/View/Composers/TemplateComposer.php @@ -8,17 +8,18 @@ use App\Extension\PluginManager; use App\Extension\TemplateManager; use App\Extension\Traits\ClearsTemplateCaches; use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta; +use App\Http\View\Composers\Traits\CollectsExtensionAssets; use App\Http\View\Composers\Traits\CollectsTemplateExternals; use App\Services\ModuleSettingsService; use App\Services\PluginSettingsService; use App\Services\SettingsService; use App\Services\TemplateService; -use Illuminate\Support\Facades\Log; use Illuminate\View\View; class TemplateComposer { use CollectsActiveExtensionMeta; + use CollectsExtensionAssets; use CollectsTemplateExternals; /** @@ -115,156 +116,4 @@ class TemplateComposer $view->with('appConfig', $appConfig); $view->with('templateExternals', $templateExternals); } - - /** - * 확장 프론트엔드 병합 번들 URL을 계산합니다. - * - * 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고, - * 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin - * (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지 - * 않는다(CDN 호스팅 금지). - * - * @param array $moduleAssets 수집된 모듈 개별 에셋 맵 - * @param array $pluginAssets 수집된 플러그인 개별 에셋 맵 - * @param int $version 확장 캐시 버전 - * @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string} - */ - private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array - { - $hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js']))); - $hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css']))); - - return [ - 'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null, - 'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null, - 'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null, - 'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null, - ]; - } - - /** - * 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다. - * - * @return array - */ - private function collectModuleAssets(): array - { - $assets = []; - - try { - // ModuleManager에서 활성화된 모듈 목록 조회 - $activeModules = $this->moduleManager->getActiveModules(); - - // 캐시 버전 조회 (브라우저 캐시 무효화용) - $cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); - - foreach ($activeModules as $identifier => $module) { - // 모듈에 에셋이 있는지 확인 - if (! $module->hasAssets()) { - continue; - } - - $builtPaths = $module->getBuiltAssetPaths(); - $loadingConfig = $module->getAssetLoadingConfig(); - $assetConfig = $module->getAssets(); - - // global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리) - if ($loadingConfig['strategy'] !== 'global') { - continue; - } - - $moduleAsset = [ - 'priority' => $loadingConfig['priority'], - ]; - - // JS 빌드 경로 (캐시 버전 파라미터 추가) - if (! empty($builtPaths['js'])) { - $moduleAsset['js'] = "/api/modules/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}"; - } - - // CSS 빌드 경로 (캐시 버전 파라미터 추가) - if (! empty($builtPaths['css'])) { - $moduleAsset['css'] = "/api/modules/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}"; - } - - // 외부 스크립트 (조건부 로드용) - if (! empty($assetConfig['external'])) { - $moduleAsset['external'] = $assetConfig['external']; - } - - $assets[$identifier] = $moduleAsset; - } - - // 우선순위 기준 정렬 (낮을수록 먼저) - uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']); - } catch (\Exception $e) { - // 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행) - Log::warning('Failed to collect module assets: '.$e->getMessage()); - } - - return $assets; - } - - /** - * 활성화된 플러그인의 프론트엔드 에셋 정보를 수집합니다. - * - * @return array - */ - private function collectPluginAssets(): array - { - $assets = []; - - try { - // PluginManager에서 활성화된 플러그인 목록 조회 - $activePlugins = $this->pluginManager->getActivePlugins(); - - // 캐시 버전 조회 (브라우저 캐시 무효화용) - $cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); - - foreach ($activePlugins as $identifier => $plugin) { - // 플러그인에 에셋이 있는지 확인 - if (! $plugin->hasAssets()) { - continue; - } - - $builtPaths = $plugin->getBuiltAssetPaths(); - $loadingConfig = $plugin->getAssetLoadingConfig(); - $assetConfig = $plugin->getAssets(); - - // global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리) - if ($loadingConfig['strategy'] !== 'global') { - continue; - } - - $pluginAsset = [ - 'priority' => $loadingConfig['priority'], - ]; - - // JS 빌드 경로 (캐시 버전 파라미터 추가) - if (! empty($builtPaths['js'])) { - $pluginAsset['js'] = "/api/plugins/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}"; - } - - // CSS 빌드 경로 (캐시 버전 파라미터 추가) - if (! empty($builtPaths['css'])) { - $pluginAsset['css'] = "/api/plugins/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}"; - } - - // 외부 스크립트 (조건부 로드용) - if (! empty($assetConfig['external'])) { - $pluginAsset['external'] = $assetConfig['external']; - } - - $assets[$identifier] = $pluginAsset; - } - - // 우선순위 기준 정렬 (낮을수록 먼저) - uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']); - } catch (\Exception $e) { - // 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행) - Log::warning('Failed to collect plugin assets: '.$e->getMessage()); - } - - return $assets; - } } diff --git a/app/Http/View/Composers/Traits/CollectsExtensionAssets.php b/app/Http/View/Composers/Traits/CollectsExtensionAssets.php new file mode 100644 index 00000000..bb009985 --- /dev/null +++ b/app/Http/View/Composers/Traits/CollectsExtensionAssets.php @@ -0,0 +1,143 @@ + $moduleAssets 수집된 모듈 개별 에셋 맵 + * @param array $pluginAssets 수집된 플러그인 개별 에셋 맵 + * @param int $version 확장 캐시 버전 + * @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string} 번들 URL 맵 + */ + private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array + { + $hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js']))); + $hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css']))); + + return [ + 'moduleJs' => $hasJs($moduleAssets) ? AssetUrl::extensionBundle('modules', 'js', $version) : null, + 'moduleCss' => $hasCss($moduleAssets) ? AssetUrl::extensionBundle('modules', 'css', $version) : null, + 'pluginJs' => $hasJs($pluginAssets) ? AssetUrl::extensionBundle('plugins', 'js', $version) : null, + 'pluginCss' => $hasCss($pluginAssets) ? AssetUrl::extensionBundle('plugins', 'css', $version) : null, + ]; + } + + /** + * 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다. + * + * @return array 식별자별 에셋 맵 + */ + private function collectModuleAssets(): array + { + return $this->collectExtensionAssets( + 'modules', + fn () => $this->moduleManager->getActiveModules(), + 'Failed to collect module assets: ' + ); + } + + /** + * 활성화된 플러그인의 프론트엔드 에셋 정보를 수집합니다. + * + * @return array 식별자별 에셋 맵 + */ + private function collectPluginAssets(): array + { + return $this->collectExtensionAssets( + 'plugins', + fn () => $this->pluginManager->getActivePlugins(), + 'Failed to collect plugin assets: ' + ); + } + + /** + * 확장 에셋 수집 공통 구현. + * + * 모듈/플러그인은 수집 대상 조회 방법과 로그 문구만 다르고 나머지 로직이 동일하다. + * + * @param string $type `modules` 또는 `plugins` + * @param callable $activeResolver 활성 확장 목록을 반환하는 콜백 + * @param string $logPrefix 실패 시 경고 로그 접두사 + * @return array 식별자별 에셋 맵 + */ + private function collectExtensionAssets(string $type, callable $activeResolver, string $logPrefix): array + { + $assets = []; + + try { + $active = $activeResolver(); + + // 캐시 버전 조회 (브라우저 캐시 무효화용) + $cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); + + foreach ($active as $identifier => $extension) { + // 확장에 에셋이 있는지 확인 + if (! $extension->hasAssets()) { + continue; + } + + $builtPaths = $extension->getBuiltAssetPaths(); + $loadingConfig = $extension->getAssetLoadingConfig(); + $assetConfig = $extension->getAssets(); + + // global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리) + if ($loadingConfig['strategy'] !== 'global') { + continue; + } + + $asset = [ + 'priority' => $loadingConfig['priority'], + ]; + + // JS 빌드 경로 (캐시 버전 파라미터 추가) + if (! empty($builtPaths['js'])) { + $asset['js'] = AssetUrl::extensionAsset($type, $identifier, $builtPaths['js'], $cacheVersion); + } + + // CSS 빌드 경로 (캐시 버전 파라미터 추가) + if (! empty($builtPaths['css'])) { + $asset['css'] = AssetUrl::extensionAsset($type, $identifier, $builtPaths['css'], $cacheVersion); + } + + // 외부 스크립트 (조건부 로드용) + if (! empty($assetConfig['external'])) { + $asset['external'] = $assetConfig['external']; + } + + $assets[$identifier] = $asset; + } + + // 우선순위 기준 정렬 (낮을수록 먼저) + uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']); + } catch (\Exception $e) { + // 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행) + Log::warning($logPrefix.$e->getMessage()); + } + + return $assets; + } +} diff --git a/app/Http/View/Composers/UserTemplateComposer.php b/app/Http/View/Composers/UserTemplateComposer.php index d145b0b1..5188e100 100644 --- a/app/Http/View/Composers/UserTemplateComposer.php +++ b/app/Http/View/Composers/UserTemplateComposer.php @@ -8,12 +8,12 @@ use App\Extension\PluginManager; use App\Extension\TemplateManager; use App\Extension\Traits\ClearsTemplateCaches; use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta; +use App\Http\View\Composers\Traits\CollectsExtensionAssets; use App\Http\View\Composers\Traits\CollectsTemplateExternals; use App\Services\ModuleSettingsService; use App\Services\PluginSettingsService; use App\Services\SettingsService; use App\Services\TemplateService; -use Illuminate\Support\Facades\Log; use Illuminate\View\View; /** @@ -24,6 +24,7 @@ use Illuminate\View\View; class UserTemplateComposer { use CollectsActiveExtensionMeta; + use CollectsExtensionAssets; use CollectsTemplateExternals; /** @@ -121,156 +122,4 @@ class UserTemplateComposer $view->with('appConfig', $appConfig); $view->with('templateExternals', $templateExternals); } - - /** - * 확장 프론트엔드 병합 번들 URL을 계산합니다. - * - * 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고, - * 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin - * (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지 - * 않는다(CDN 호스팅 금지). - * - * @param array $moduleAssets 수집된 모듈 개별 에셋 맵 - * @param array $pluginAssets 수집된 플러그인 개별 에셋 맵 - * @param int $version 확장 캐시 버전 - * @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string} - */ - private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array - { - $hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js']))); - $hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css']))); - - return [ - 'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null, - 'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null, - 'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null, - 'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null, - ]; - } - - /** - * 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다. - * - * @return array - */ - private function collectModuleAssets(): array - { - $assets = []; - - try { - // ModuleManager에서 활성화된 모듈 목록 조회 - $activeModules = $this->moduleManager->getActiveModules(); - - // 캐시 버전 조회 (브라우저 캐시 무효화용) - $cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); - - foreach ($activeModules as $identifier => $module) { - // 모듈에 에셋이 있는지 확인 - if (! $module->hasAssets()) { - continue; - } - - $builtPaths = $module->getBuiltAssetPaths(); - $loadingConfig = $module->getAssetLoadingConfig(); - $assetConfig = $module->getAssets(); - - // global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리) - if ($loadingConfig['strategy'] !== 'global') { - continue; - } - - $moduleAsset = [ - 'priority' => $loadingConfig['priority'], - ]; - - // JS 빌드 경로 (캐시 버전 파라미터 추가) - if (! empty($builtPaths['js'])) { - $moduleAsset['js'] = "/api/modules/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}"; - } - - // CSS 빌드 경로 (캐시 버전 파라미터 추가) - if (! empty($builtPaths['css'])) { - $moduleAsset['css'] = "/api/modules/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}"; - } - - // 외부 스크립트 (조건부 로드용) - if (! empty($assetConfig['external'])) { - $moduleAsset['external'] = $assetConfig['external']; - } - - $assets[$identifier] = $moduleAsset; - } - - // 우선순위 기준 정렬 (낮을수록 먼저) - uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']); - } catch (\Exception $e) { - // 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행) - Log::warning('Failed to collect module assets: '.$e->getMessage()); - } - - return $assets; - } - - /** - * 활성화된 플러그인의 프론트엔드 에셋 정보를 수집합니다. - * - * @return array - */ - private function collectPluginAssets(): array - { - $assets = []; - - try { - // PluginManager에서 활성화된 플러그인 목록 조회 - $activePlugins = $this->pluginManager->getActivePlugins(); - - // 캐시 버전 조회 (브라우저 캐시 무효화용) - $cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); - - foreach ($activePlugins as $identifier => $plugin) { - // 플러그인에 에셋이 있는지 확인 - if (! $plugin->hasAssets()) { - continue; - } - - $builtPaths = $plugin->getBuiltAssetPaths(); - $loadingConfig = $plugin->getAssetLoadingConfig(); - $assetConfig = $plugin->getAssets(); - - // global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리) - if ($loadingConfig['strategy'] !== 'global') { - continue; - } - - $pluginAsset = [ - 'priority' => $loadingConfig['priority'], - ]; - - // JS 빌드 경로 (캐시 버전 파라미터 추가) - if (! empty($builtPaths['js'])) { - $pluginAsset['js'] = "/api/plugins/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}"; - } - - // CSS 빌드 경로 (캐시 버전 파라미터 추가) - if (! empty($builtPaths['css'])) { - $pluginAsset['css'] = "/api/plugins/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}"; - } - - // 외부 스크립트 (조건부 로드용) - if (! empty($assetConfig['external'])) { - $pluginAsset['external'] = $assetConfig['external']; - } - - $assets[$identifier] = $pluginAsset; - } - - // 우선순위 기준 정렬 (낮을수록 먼저) - uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']); - } catch (\Exception $e) { - // 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행) - Log::warning('Failed to collect plugin assets: '.$e->getMessage()); - } - - return $assets; - } } diff --git a/app/Jobs/DispatchHookListenerJob.php b/app/Jobs/DispatchHookListenerJob.php index 3bcee7a6..9371090f 100644 --- a/app/Jobs/DispatchHookListenerJob.php +++ b/app/Jobs/DispatchHookListenerJob.php @@ -1,10 +1,14 @@ context); + // 큐 드라이버가 sync 면 이 Job 은 원 HTTP 요청과 **같은 컨테이너**에서 실행된다. + // restore() 가 재구성 Request 를 바인딩하면 그 뒤로 이어지는 같은 요청의 코드는 + // 헤더·입력·메서드가 전부 지워진 가짜 GET 요청을 보게 된다 — 뒤 순위 훅 리스너가 + // 전송 헤더를 읽지 못해 조용히 건너뛰는 사고가 여기서 난다. + // 워커 프로세스에서는 원래 바인딩이 없어 아래 원복이 아무 일도 하지 않는다. + $previousRequest = app()->bound('request') ? app('request') : null; - $args = HookArgumentSerializer::deserialize($this->serializedArgs); - app($this->listenerClass)->{$this->method}(...$args); + try { + HookContextCapture::restore($this->context); + + $args = HookArgumentSerializer::deserialize($this->serializedArgs); + + try { + $listener = app($this->listenerClass); + } catch (BindingResolutionException $e) { + // 리스너를 만들 수 없다는 것은 그 확장이 이 런타임에 배선되지 않았다는 뜻이다. + // Action 훅은 부가 작업이므로 호출자의 작업까지 함께 실패시키지 않고 기록만 남긴다 + // (큐 드라이버가 sync 인 환경에서 원 요청이 500 이 되는 것을 막는다). + Log::warning('훅 리스너를 해석할 수 없어 건너뜁니다 (확장 미배선)', [ + 'listener' => $this->listenerClass, + 'method' => $this->method, + 'exception' => $e->getMessage(), + ]); + + return; + } + + $listener->{$this->method}(...$args); + } finally { + if ($previousRequest !== null) { + app()->instance('request', $previousRequest); + } + } } /** * Job 실패 시 로그를 기록합니다. * * @param \Throwable $exception 발생한 예외 - * @return void */ public function failed(\Throwable $exception): void { @@ -76,8 +102,6 @@ class DispatchHookListenerJob implements ShouldQueue /** * Job의 표시 이름을 반환합니다. - * - * @return string */ public function displayName(): string { diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php index df419daf..be491038 100644 --- a/app/Jobs/GenerateSitemapJob.php +++ b/app/Jobs/GenerateSitemapJob.php @@ -2,8 +2,12 @@ namespace App\Jobs; +use App\Enums\SitemapGenerationMode; +use App\Extension\HookManager; use App\Seo\SitemapManager; +use App\Seo\SitemapProgress; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -13,22 +17,82 @@ use Illuminate\Support\Facades\Log; /** * Sitemap XML 생성 큐 잡 * - * 스케줄러 또는 Artisan 커맨드에서 디스패치되며, + * 스케줄러, Artisan 커맨드, 관리자 수동 재생성, 봇 요청 캐시 미스에서 디스패치되며, * 실제 생성 로직은 SitemapManager 서비스에 위임합니다. + * + * 대용량(수백만 URL) 생성은 수 분~수십 분이 걸리므로 동시 실행을 막고(ShouldBeUnique) + * 타임아웃/재시도 한계를 넉넉히 잡되, 재시도가 무한히 누적되지 않도록 retryUntil 로 제한합니다. */ -class GenerateSitemapJob implements ShouldQueue +class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * @var string 전용 큐 이름 — 대용량·장시간 사이트맵 생성을 default 큐에서 분리한다. + * + * 이 큐에는 반드시 워커를 1개만 배치한다(동시 중복 생성 방지). 그러면 + * 긴 생성 작업이 default 큐의 방송/알림을 막지 않아 진행상황이 실시간으로 흐르고, + * 워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않는다. + */ + public const QUEUE = 'sitemap'; + /** * @var int 최대 재시도 횟수 */ public int $tries = 3; /** - * @var int 타임아웃 (초) + * @var int 타임아웃 (초) — 대용량 스트리밍 생성 대응 */ - public int $timeout = 300; + public int $timeout = 1800; + + /** + * @var int 재시도 대기 시간 (초) + */ + public int $backoff = 120; + + /** + * @var int 유니크 락 유지 시간 (초) — 잡이 비정상 종료해도 이 시간 후 자동 해제 + */ + public int $uniqueFor = 900; + + /** + * GenerateSitemapJob 생성자 + * + * @param SitemapGenerationMode $mode 재생성 모드 — SitemapManager 로 전달 + * @param int|null $triggeredBy 관리자 수동 재생성을 실행한 사용자 ID(스케줄러/증분/봇 = null). + * 완료/실패 알림의 수신자(수동 실행자) 판정에 사용됩니다. + */ + public function __construct( + public SitemapGenerationMode $mode = SitemapGenerationMode::Auto, + public ?int $triggeredBy = null, + ) { + $this->onQueue(self::QUEUE); + } + + /** + * 유니크 락 식별자를 반환합니다. + * + * 봇 요청 캐시 미스마다 디스패치되어도 큐에는 한 건만 남도록 고정 키를 사용합니다. + * + * @return string 유니크 락 키 + */ + public function uniqueId(): string + { + return 'seo-sitemap'; + } + + /** + * 재시도 마감 시각을 반환합니다. + * + * timeout(1800초) × tries(3) 만큼 재시도가 누적되는 것을 막습니다. + * + * @return \DateTime 재시도 마감 시각 + */ + public function retryUntil(): \DateTime + { + return now()->addMinutes(30)->toDateTime(); + } /** * Sitemap 을 생성하고 캐시에 저장합니다. @@ -37,7 +101,7 @@ class GenerateSitemapJob implements ShouldQueue */ public function handle(SitemapManager $manager): void { - $result = $manager->regenerate(); + $result = $manager->regenerate($this->mode, $this->triggeredBy); if (($result['status'] ?? null) === 'disabled') { Log::info('[SEO] Sitemap generation skipped (disabled)'); @@ -62,6 +126,18 @@ class GenerateSitemapJob implements ShouldQueue */ public function failed(\Throwable $exception): void { + // 잡 최종 실패(재시도 소진/타임아웃) 시 진행상황을 'failed' 로 고정 — 무한 running 방지 + app(SitemapProgress::class)->fail($exception->getMessage()); + + // 최종 실패 전용 훅 — 매 시도마다 발화하는 core.seo.sitemap.after_regenerate_failed 와 달리 + // 재시도가 모두 소진된 뒤 딱 한 번만 발화한다. 재시도 중 성공하면 발화하지 않으므로 + // 실패 알림이 중복되거나(각 시도) 결국 성공했는데도 발송되는 일이 없다. + HookManager::doAction('core.seo.sitemap.regenerate_failed_final', [ + 'success' => false, + 'status' => 'failed', + 'message' => $exception->getMessage(), + ], $this->triggeredBy); + Log::error('[SEO] Sitemap generation failed', [ 'error' => $exception->getMessage(), ]); diff --git a/app/Listeners/CoreActivityLogListener.php b/app/Listeners/CoreActivityLogListener.php index 4717b492..ef1fbdcf 100644 --- a/app/Listeners/CoreActivityLogListener.php +++ b/app/Listeners/CoreActivityLogListener.php @@ -8,8 +8,10 @@ use App\Contracts\Extension\HookListenerInterface; use App\Contracts\Repositories\ActivityLogRepositoryInterface; use App\Contracts\Repositories\ScheduleRepositoryInterface; use App\Contracts\Repositories\UserRepositoryInterface; -use App\Models\ActivityLog; +use App\Extension\IdentityVerification\DTO\VerificationChallenge; +use App\Extension\IdentityVerification\DTO\VerificationResult; use App\Models\Attachment; +use App\Models\IdentityVerificationLog; use App\Models\Schedule; use App\Models\User; use Illuminate\Database\Eloquent\Model; @@ -65,6 +67,7 @@ class CoreActivityLogListener implements HookListenerInterface 'core.auth.record_consents' => ['method' => 'handleAuthRecordConsents', 'priority' => 20], 'core.auth.login_failed' => ['method' => 'handleAuthLoginFailed', 'priority' => 20], 'core.auth.account_locked' => ['method' => 'handleAuthAccountLocked', 'priority' => 20], + 'core.auth.account_unlocked' => ['method' => 'handleAuthAccountUnlocked', 'priority' => 20], // ─── IdentityVerification (IDV) ─── // DTO(VerificationChallenge/VerificationResult) 를 인자로 받으므로 sync 실행 필수 @@ -145,7 +148,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 훅 이벤트 처리 (기본 핸들러) * - * @param mixed ...$args 훅에서 전달된 인수들 + * @param mixed ...$args 훅에서 전달된 인수들 */ public function handle(...$args): void { @@ -159,8 +162,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 생성 후 로그 기록 * - * @param User $user 생성된 사용자 - * @param array $originalData 원본 요청 데이터 + * @param User $user 생성된 사용자 + * @param array $originalData 원본 요청 데이터 */ public function handleUserAfterCreate(User $user, array $originalData): void { @@ -175,9 +178,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 수정 후 로그 기록 * - * @param User $user 수정된 사용자 - * @param array $originalData 원본 요청 데이터 - * @param array|null $snapshot 수정 전 스냅샷 + * @param User $user 수정된 사용자 + * @param array $originalData 원본 요청 데이터 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleUserAfterUpdate(User $user, array $originalData, ?array $snapshot = null): void { @@ -194,7 +197,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 삭제 후 로그 기록 * - * @param array $userData 삭제된 사용자 데이터 + * @param array $userData 삭제된 사용자 데이터 */ public function handleUserAfterDelete(array $userData): void { @@ -213,7 +216,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 탈퇴 후 로그 기록 * - * @param User $user 탈퇴한 사용자 + * @param User $user 탈퇴한 사용자 */ public function handleUserAfterWithdraw(User $user): void { @@ -227,7 +230,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 상세 조회 후 로그 기록 * - * @param User $user 조회된 사용자 + * @param User $user 조회된 사용자 */ public function handleUserAfterShow(User $user): void { @@ -241,7 +244,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 목록 조회 후 로그 기록 * - * @param int $count 조회된 총 사용자 수 + * @param int $count 조회된 총 사용자 수 */ public function handleUserAfterList(int $count): void { @@ -254,8 +257,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 검색 후 로그 기록 * - * @param string $query 검색어 - * @param int $count 검색 결과 수 + * @param string $query 검색어 + * @param int $count 검색 결과 수 */ public function handleUserAfterSearch(string $query, int $count): void { @@ -268,10 +271,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 사용자 일괄 상태 변경 후 로그 기록 * - * @param array $uuids 대상 UUID 목록 - * @param string $status 변경된 상태 - * @param int $updatedCount 변경된 수 - * @param array $snapshots 변경 전 스냅샷 (uuid => snapshot) + * @param array $uuids 대상 UUID 목록 + * @param string $status 변경된 상태 + * @param int $updatedCount 변경된 수 + * @param array $snapshots 변경 전 스냅샷 (uuid => snapshot) */ public function handleUserAfterBulkUpdate(array $uuids, string $status, int $updatedCount, array $snapshots = []): void { @@ -303,8 +306,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 관리자 로그인 후 로그 기록 * - * @param User $user 로그인한 사용자 - * @param array $loginData 로그인 데이터 + * @param User $user 로그인한 사용자 + * @param array $loginData 로그인 데이터 */ public function handleAuthAfterLogin(User $user, array $loginData): void { @@ -318,7 +321,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 로그아웃 로그 기록 * - * @param User $user 로그아웃한 사용자 + * @param User $user 로그아웃한 사용자 */ public function handleAuthLogout(User $user): void { @@ -332,8 +335,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 회원가입 후 로그 기록 * - * @param User $user 등록된 사용자 - * @param array $registrationData 등록 데이터 + * @param User $user 등록된 사용자 + * @param array $registrationData 등록 데이터 */ public function handleAuthRegister(User $user, array $registrationData): void { @@ -347,7 +350,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 비밀번호 찾기 요청 로그 기록 * - * @param User $user 요청한 사용자 + * @param User $user 요청한 사용자 */ public function handleAuthForgotPassword(User $user): void { @@ -361,7 +364,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 비밀번호 재설정 로그 기록 * - * @param User $user 재설정한 사용자 + * @param User $user 재설정한 사용자 */ public function handleAuthResetPassword(User $user): void { @@ -375,10 +378,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 이용약관 동의 기록 로그 * - * @param User $user 동의한 사용자 - * @param array $data 동의 데이터 - * @param string|null $agreedAt 동의 시각 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) - * @param string|null $ip IP 주소 + * @param User $user 동의한 사용자 + * @param array $data 동의 데이터 + * @param string|null $agreedAt 동의 시각 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) + * @param string|null $ip IP 주소 */ public function handleAuthRecordConsents(User $user, array $data, ?string $agreedAt = null, ?string $ip = null): void { @@ -393,8 +396,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 로그인 실패 로그 기록 (사용자 존재 여부와 무관) * - * @param string $email 시도된 이메일 - * @param array $context IP/UA/시각 등 부가 정보 + * @param string $email 시도된 이메일 + * @param array $context IP/UA/시각 등 부가 정보 */ public function handleAuthLoginFailed(string $email, array $context = []): void { @@ -408,14 +411,19 @@ class CoreActivityLogListener implements HookListenerInterface /** * 계정 잠금 로그 기록 * - * @param User $user 잠긴 사용자 - * @param array $context attempts/locked_until/lockout_minutes/IP + * @param User $user 잠긴 사용자 + * @param array $context attempts/locked_until/lockout_minutes/IP */ public function handleAuthAccountLocked(User $user, array $context = []): void { + // 영구 잠금(무한대)은 차단 분(minutes)이 없으므로 별도 문구를 사용한다. + $permanent = ($context['permanent'] ?? false) || ($context['locked_until'] ?? null) === null; + $this->logActivity('auth.account_locked', [ 'loggable' => $user, - 'description_key' => 'activity_log.description.auth_account_locked', + 'description_key' => $permanent + ? 'activity_log.description.auth_account_locked_permanently' + : 'activity_log.description.auth_account_locked', 'description_params' => [ 'attempts' => $context['attempts'] ?? null, 'minutes' => $context['lockout_minutes'] ?? null, @@ -425,6 +433,22 @@ class CoreActivityLogListener implements HookListenerInterface ]); } + /** + * 계정 잠금 해제 로그 기록 (관리자 수동 해제) + * + * @param User $user 잠금이 해제된 사용자 + * @param array $context IP 등 부가 정보 + */ + public function handleAuthAccountUnlocked(User $user, array $context = []): void + { + $this->logActivity('auth.account_unlocked', [ + 'loggable' => $user, + 'description_key' => 'activity_log.description.auth_account_unlocked', + 'description_params' => ['email' => $user->email], + 'ip_address' => $context['ip_address'] ?? null, + ]); + } + // ═══════════════════════════════════════════ // Role 핸들러 // ═══════════════════════════════════════════ @@ -432,7 +456,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 생성 후 로그 기록 * - * @param Model $role 생성된 역할 + * @param Model $role 생성된 역할 */ public function handleRoleAfterCreate(Model $role): void { @@ -447,8 +471,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 수정 후 로그 기록 * - * @param Model $role 수정된 역할 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $role 수정된 역할 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleRoleAfterUpdate(Model $role, ?array $snapshot = null): void { @@ -465,7 +489,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 삭제 후 로그 기록 * - * @param int $roleId 삭제된 역할 ID + * @param int $roleId 삭제된 역할 ID */ public function handleRoleAfterDelete(int $roleId): void { @@ -479,9 +503,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 권한 동기화 후 로그 기록 * - * @param Model $role 역할 - * @param array $previousPermissions 이전 권한 목록 - * @param array $currentPermissions 현재 권한 목록 + * @param Model $role 역할 + * @param array $previousPermissions 이전 권한 목록 + * @param array $currentPermissions 현재 권한 목록 */ public function handleRoleAfterSyncPermissions(Model $role, array $previousPermissions, array $currentPermissions): void { @@ -499,7 +523,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 역할 상태 전환 후 로그 기록 * - * @param Model $role 역할 + * @param Model $role 역할 */ public function handleRoleAfterToggleStatus(Model $role): void { @@ -517,7 +541,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 생성 후 로그 기록 * - * @param Model $menu 생성된 메뉴 + * @param Model $menu 생성된 메뉴 */ public function handleMenuAfterCreate(Model $menu): void { @@ -532,8 +556,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 수정 후 로그 기록 * - * @param Model $menu 수정된 메뉴 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $menu 수정된 메뉴 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleMenuAfterUpdate(Model $menu, ?array $snapshot = null): void { @@ -550,7 +574,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 삭제 후 로그 기록 * - * @param int $menuId 삭제된 메뉴 ID + * @param int $menuId 삭제된 메뉴 ID */ public function handleMenuAfterDelete(int $menuId): void { @@ -564,7 +588,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 순서 변경 후 로그 기록 * - * @param array $orderData 순서 데이터 + * @param array $orderData 순서 데이터 */ public function handleMenuAfterUpdateOrder(array $orderData): void { @@ -576,7 +600,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 상태 전환 후 로그 기록 * - * @param Model $menu 메뉴 + * @param Model $menu 메뉴 */ public function handleMenuAfterToggleStatus(Model $menu): void { @@ -590,9 +614,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 메뉴 역할 동기화 후 로그 기록 * - * @param Model $menu 메뉴 - * @param array $previousRoles 이전 역할 목록 - * @param array $currentRoles 현재 역할 목록 + * @param Model $menu 메뉴 + * @param array $previousRoles 이전 역할 목록 + * @param array $currentRoles 현재 역할 목록 */ public function handleMenuAfterSyncRoles(Model $menu, array $previousRoles, array $currentRoles): void { @@ -614,9 +638,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 설정 저장 후 로그 기록 * - * @param string $tab 설정 탭 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $tab 설정 탭 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handleSettingsAfterSave(string $tab, array $settings, bool $result): void { @@ -629,9 +653,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 개별 설정 변경 후 로그 기록 * - * @param string $key 설정 키 - * @param mixed $value 설정 값 - * @param bool $result 저장 결과 + * @param string $key 설정 키 + * @param mixed $value 설정 값 + * @param bool $result 저장 결과 */ public function handleSettingsAfterSet(string $key, mixed $value, bool $result): void { @@ -648,7 +672,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 생성 후 로그 기록 * - * @param Model $schedule 생성된 스케줄 + * @param Model $schedule 생성된 스케줄 */ public function handleScheduleAfterCreate(Model $schedule): void { @@ -662,8 +686,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 수정 후 로그 기록 * - * @param Model $schedule 수정된 스케줄 - * @param array|null $snapshot 수정 전 스냅샷 + * @param Model $schedule 수정된 스케줄 + * @param array|null $snapshot 수정 전 스냅샷 */ public function handleScheduleAfterUpdate(Model $schedule, ?array $snapshot = null): void { @@ -680,7 +704,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 삭제 후 로그 기록 * - * @param int $scheduleId 삭제된 스케줄 ID + * @param int $scheduleId 삭제된 스케줄 ID */ public function handleScheduleAfterDelete(int $scheduleId): void { @@ -694,8 +718,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 수동 실행 후 로그 기록 * - * @param Model $schedule 실행된 스케줄 - * @param Model $history 실행 이력 + * @param Model $schedule 실행된 스케줄 + * @param Model $history 실행 이력 */ public function handleScheduleAfterRun(Model $schedule, Model $history): void { @@ -709,10 +733,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 일괄 상태 변경 후 로그 기록 * - * @param array $ids 대상 ID 목록 - * @param bool $isActive 활성화 여부 - * @param int $updatedCount 변경된 수 - * @param array $snapshots 변경 전 스냅샷 (id => snapshot) + * @param array $ids 대상 ID 목록 + * @param bool $isActive 활성화 여부 + * @param int $updatedCount 변경된 수 + * @param array $snapshots 변경 전 스냅샷 (id => snapshot) */ public function handleScheduleAfterBulkUpdate(array $ids, bool $isActive, int $updatedCount, array $snapshots = []): void { @@ -740,9 +764,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 스케줄 일괄 삭제 후 로그 기록 * - * @param array $ids 대상 ID 목록 - * @param int $deletedCount 삭제된 수 - * @param array $snapshots 삭제 전 스냅샷 (id => snapshot) + * @param array $ids 대상 ID 목록 + * @param int $deletedCount 삭제된 수 + * @param array $snapshots 삭제 전 스냅샷 (id => snapshot) */ public function handleScheduleAfterBulkDelete(array $ids, int $deletedCount, array $snapshots = []): void { @@ -769,7 +793,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 업로드 후 로그 기록 * - * @param Model $attachment 업로드된 첨부파일 + * @param Model $attachment 업로드된 첨부파일 */ public function handleAttachmentAfterUpload(Model $attachment): void { @@ -782,7 +806,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 삭제 후 로그 기록 * - * @param Model|null $attachment 삭제된 첨부파일 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) + * @param Model|null $attachment 삭제된 첨부파일 (DispatchHookListenerJob 역직렬화 과정에서 null 가능) */ public function handleAttachmentAfterDelete(?Model $attachment = null): void { @@ -794,10 +818,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 첨부파일 일괄 삭제 후 로그 기록 (per-item) * - * @param string $identifier 식별자 - * @param int $count 삭제된 수 - * @param array $ids 삭제된 첨부파일 ID 목록 - * @param array $snapshots 삭제 전 스냅샷 (keyBy id) + * @param string $identifier 식별자 + * @param int $count 삭제된 수 + * @param array $ids 삭제된 첨부파일 ID 목록 + * @param array $snapshots 삭제 전 스냅샷 (keyBy id) */ public function handleAttachmentAfterBulkDelete(string $identifier, int $count, array $ids = [], array $snapshots = []): void { @@ -825,8 +849,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설치 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterInstall(string $moduleName, array $moduleInfo): void { @@ -840,8 +864,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 활성화 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterActivate(string $moduleName, array $moduleInfo): void { @@ -854,7 +878,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 비활성화 후 로그 기록 * - * @param string $moduleName 모듈 식별자 + * @param string $moduleName 모듈 식별자 */ public function handleModuleAfterDeactivate(string $moduleName): void { @@ -867,9 +891,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 제거 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $moduleInfo 모듈 정보 - * @param bool $deleteData 데이터 삭제 여부 + * @param string $moduleName 모듈 식별자 + * @param array $moduleInfo 모듈 정보 + * @param bool $deleteData 데이터 삭제 여부 */ public function handleModuleAfterUninstall(string $moduleName, array $moduleInfo, bool $deleteData): void { @@ -883,9 +907,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 업데이트 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) - * @param array $moduleInfo 모듈 정보 + * @param string $moduleName 모듈 식별자 + * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) + * @param array $moduleInfo 모듈 정보 */ public function handleModuleAfterUpdate(string $moduleName, array $result, array $moduleInfo): void { @@ -899,8 +923,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 레이아웃 갱신 후 로그 기록 * - * @param string $moduleName 모듈 식별자 - * @param array $result 갱신 결과 + * @param string $moduleName 모듈 식별자 + * @param array $result 갱신 결과 */ public function handleModuleAfterRefreshLayouts(string $moduleName, array $result): void { @@ -917,8 +941,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설치 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterInstall(string $pluginName, array $pluginInfo): void { @@ -932,8 +956,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 활성화 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterActivate(string $pluginName, array $pluginInfo): void { @@ -946,7 +970,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 비활성화 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 + * @param string $pluginName 플러그인 식별자 */ public function handlePluginAfterDeactivate(string $pluginName): void { @@ -959,9 +983,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 제거 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param bool $deleteData 데이터 삭제 여부 - * @param bool $result 제거 결과 + * @param string $pluginName 플러그인 식별자 + * @param bool $deleteData 데이터 삭제 여부 + * @param bool $result 제거 결과 */ public function handlePluginAfterUninstall(string $pluginName, bool $deleteData, bool $result): void { @@ -975,9 +999,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 업데이트 후 로그 기록 * - * @param string $pluginName 플러그인 식별자 - * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) - * @param array $pluginInfo 플러그인 정보 + * @param string $pluginName 플러그인 식별자 + * @param array $result 업데이트 결과 배열 (['success' => bool, ...]) + * @param array $pluginInfo 플러그인 정보 */ public function handlePluginAfterUpdate(string $pluginName, array $result, array $pluginInfo): void { @@ -995,8 +1019,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 설치 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $templateInfo 템플릿 정보 + * @param string $identifier 템플릿 식별자 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterInstall(string $identifier, array $templateInfo): void { @@ -1010,7 +1034,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 활성화 후 로그 기록 * - * @param array $templateInfo 템플릿 정보 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterActivate(array $templateInfo): void { @@ -1023,7 +1047,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 비활성화 후 로그 기록 * - * @param string $identifier 템플릿 식별자 + * @param string $identifier 템플릿 식별자 */ public function handleTemplateAfterDeactivate(string $identifier): void { @@ -1036,9 +1060,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 제거 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $templateInfo 템플릿 정보 - * @param bool $deleteData 데이터 삭제 여부 + * @param string $identifier 템플릿 식별자 + * @param array $templateInfo 템플릿 정보 + * @param bool $deleteData 데이터 삭제 여부 */ public function handleTemplateAfterUninstall(string $identifier, array $templateInfo, bool $deleteData): void { @@ -1052,9 +1076,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 버전 업데이트 후 로그 기록 * - * @param string $templateName 템플릿 식별자 - * @param array $result 업데이트 결과 - * @param array $templateInfo 템플릿 정보 + * @param string $templateName 템플릿 식별자 + * @param array $result 업데이트 결과 + * @param array $templateInfo 템플릿 정보 */ public function handleTemplateAfterVersionUpdate(string $templateName, array $result, array $templateInfo): void { @@ -1068,8 +1092,8 @@ class CoreActivityLogListener implements HookListenerInterface /** * 템플릿 레이아웃 갱신 후 로그 기록 * - * @param string $identifier 템플릿 식별자 - * @param array $result 갱신 결과 + * @param string $identifier 템플릿 식별자 + * @param array $result 갱신 결과 */ public function handleTemplateAfterRefreshLayouts(string $identifier, array $result): void { @@ -1086,10 +1110,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 레이아웃 수정 후 로그 기록 * - * @param Model $layout 수정된 레이아웃 - * @param int $templateId 템플릿 ID - * @param string $name 레이아웃 이름 - * @param array $data 수정 데이터 + * @param Model $layout 수정된 레이아웃 + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @param array $data 수정 데이터 */ public function handleLayoutAfterUpdate(Model $layout, int $templateId, string $name, array $data): void { @@ -1103,10 +1127,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 레이아웃 버전 복원 후 로그 기록 * - * @param Model $newVersion 복원된 새 버전 - * @param int $templateId 템플릿 ID - * @param string $name 레이아웃 이름 - * @param int $versionId 복원 대상 버전 ID + * @param Model $newVersion 복원된 새 버전 + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @param int $versionId 복원 대상 버전 ID */ public function handleLayoutAfterVersionRestore(Model $newVersion, int $templateId, string $name, int $versionId): void { @@ -1124,9 +1148,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설정 저장 후 로그 기록 * - * @param string $identifier 모듈 식별자 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $identifier 모듈 식별자 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handleModuleSettingsAfterSave(string $identifier, array $settings, bool $result): void { @@ -1140,7 +1164,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 모듈 설정 초기화 후 로그 기록 * - * @param string $identifier 모듈 식별자 + * @param string $identifier 모듈 식별자 */ public function handleModuleSettingsAfterReset(string $identifier): void { @@ -1157,9 +1181,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설정 저장 후 로그 기록 * - * @param string $identifier 플러그인 식별자 - * @param array $settings 저장된 설정 - * @param bool $result 저장 결과 + * @param string $identifier 플러그인 식별자 + * @param array $settings 저장된 설정 + * @param bool $result 저장 결과 */ public function handlePluginSettingsAfterSave(string $identifier, array $settings, bool $result): void { @@ -1173,7 +1197,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 플러그인 설정 초기화 후 로그 기록 * - * @param string $identifier 플러그인 식별자 + * @param string $identifier 플러그인 식별자 */ public function handlePluginSettingsAfterReset(string $identifier): void { @@ -1190,10 +1214,10 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 challenge 요청 후 로그 기록. * - * @param \App\Extension\IdentityVerification\DTO\VerificationChallenge $challenge - * @param string $purpose - * @param \App\Models\User|array $target - * @param array $context + * @param VerificationChallenge $challenge 발급된 본인인증 challenge + * @param string $purpose 인증 목적 (IdentityVerificationPurpose 값) + * @param User|array $target 인증 대상 (회원 모델 또는 이메일/전화 배열) + * @param array $context 부가 컨텍스트 (IP 등) */ public function handleIdentityRequested($challenge, string $purpose, $target, array $context = []): void { @@ -1213,9 +1237,9 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 검증 후 로그 기록 (성공·실패 공통 디스패치). * - * @param \App\Extension\IdentityVerification\DTO\VerificationResult $result - * @param \App\Models\IdentityVerificationLog|null $log - * @param array $context + * @param VerificationResult $result 검증 결과 DTO + * @param IdentityVerificationLog|null $log 검증 로그 (없을 수 있음) + * @param array $context 부가 컨텍스트 (IP 등) */ public function handleIdentityVerified($result, $log, array $context = []): void { @@ -1238,7 +1262,7 @@ class CoreActivityLogListener implements HookListenerInterface /** * 본인인증 challenge 만료 로그 기록. * - * @param \App\Models\IdentityVerificationLog $log + * @param IdentityVerificationLog $log */ public function handleIdentityExpired($log): void { @@ -1251,5 +1275,4 @@ class CoreActivityLogListener implements HookListenerInterface ], ]); } - } diff --git a/app/Listeners/Identity/EnforceIdentityPolicyListener.php b/app/Listeners/Identity/EnforceIdentityPolicyListener.php index e40317cc..b8adb26f 100644 --- a/app/Listeners/Identity/EnforceIdentityPolicyListener.php +++ b/app/Listeners/Identity/EnforceIdentityPolicyListener.php @@ -90,9 +90,13 @@ class EnforceIdentityPolicyListener implements HookListenerInterface // 2) 코어 hook 은 getSubscribedHooks() 가 소유하므로 동적 등록 대상에서 제외 $coreHooks = array_fill_keys(static::coreHookTargets(), true); - // 3) 현재 hook scope 정책 target 으로 재구독 + // 3) 전담 리스너가 소유하는 target 은 일반 enforce 대상에서 제외 + $delegated = array_fill_keys(static::delegatedHookTargets(), true); + + // 4) 현재 hook scope 정책 target 으로 재구독 foreach (static::loadDynamicHookTargets() as $hookName) { - if (! is_string($hookName) || $hookName === '' || isset($coreHooks[$hookName])) { + if (! is_string($hookName) || $hookName === '' + || isset($coreHooks[$hookName]) || isset($delegated[$hookName])) { continue; } if (isset(self::$dynamicCallbacks[$hookName])) { @@ -145,6 +149,27 @@ class EnforceIdentityPolicyListener implements HookListenerInterface ]; } + /** + * 전담 리스너가 강제를 소유하는 hook target 목록. + * + * 이 리스너는 "행위 직전(before_*)" 훅을 막아 세우는 가드다. 행위가 이미 끝난 뒤 발화하는 + * 훅에서 예외를 던지면 되돌릴 것이 없는 상태에서 응답만 깨진다. + * + * `core.auth.after_register` 가 그 경우다. 이 훅은 `InitiateIdentityChallengeAfterRegister` + * 가 소유하며, `core.auth.signup_after_create` 정책이 켜져 있으면 계정을 '인증 대기' 로 두고 + * 본인확인 challenge 를 발행한다(가입 자체는 성립). 여기서 다시 enforce 하면 가입이 428 로 + * 막혀 '가입 전 본인확인'(`core.auth.signup_before_submit`) 정책과 동작이 같아지고, + * '가입 후' 정책은 켜는 순간 아무도 가입할 수 없게 된다. + * + * @return list 전담 소유 hook target 목록 + */ + protected static function delegatedHookTargets(): array + { + return [ + 'core.auth.after_register', + ]; + } + /** * identity_policies 테이블에서 scope='hook' 정책의 target 목록을 추출합니다. * @@ -175,6 +200,12 @@ class EnforceIdentityPolicyListener implements HookListenerInterface return; } + // 전담 리스너가 소유하는 훅은 구독 단계에서 제외되지만, 다른 경로로 호출되더라도 + // 이중 강제가 생기지 않도록 여기서도 막는다 (delegatedHookTargets 주석 참조). + if (in_array($hookName, static::delegatedHookTargets(), true)) { + return; + } + $policies = $this->policyRepository->resolveByScopeTarget('hook', $hookName); if ($policies->isEmpty()) { return; diff --git a/app/Listeners/SeoNotificationDataListener.php b/app/Listeners/SeoNotificationDataListener.php new file mode 100644 index 00000000..ea39ad4b --- /dev/null +++ b/app/Listeners/SeoNotificationDataListener.php @@ -0,0 +1,133 @@ + [ + 'method' => 'extractData', + 'priority' => 20, + 'type' => 'filter', + ], + ]; + } + + /** + * 훅 이벤트를 처리합니다(HookListenerInterface 필수 메서드). + * + * @param mixed ...$args 훅에서 전달된 인수들 + */ + public function handle(...$args): void {} + + /** + * 알림 유형에 따라 데이터와 컨텍스트를 추출합니다. + * + * @param array $default 기본 extract_data 구조 + * @param string $type 알림 정의 유형 + * @param array $args 훅에서 전달된 원본 인수 [$result, $triggeredBy] + * @return array{notifiable: null, notifiables: null, data: array, context: array} + */ + public function extractData(array $default, string $type, array $args): array + { + return match ($type) { + 'sitemap_regenerated' => $this->extractRegenerated($args), + 'sitemap_regenerate_failed' => $this->extractRegenerateFailed($args), + default => $default, + }; + } + + /** + * 사이트맵 재생성 완료 알림 데이터를 추출합니다. + * + * @param array $args 훅 인수 [$result, $triggeredBy] + * @return array + */ + private function extractRegenerated(array $args): array + { + $triggeredBy = $args[1] ?? null; + if (! is_int($triggeredBy)) { + return $this->skip(); + } + + $result = is_array($args[0] ?? null) ? $args[0] : []; + $data = $result['data'] ?? []; + $baseUrl = config('app.url'); + + return [ + 'notifiable' => null, + 'notifiables' => null, + 'data' => [ + 'app_name' => config('app.name'), + 'url_count' => (string) ($data['url_count'] ?? 0), + 'child_count' => (string) ($data['child_count'] ?? 1), + 'action_url' => "{$baseUrl}/admin/settings?tab=seo", + 'site_url' => $baseUrl, + ], + 'context' => [ + 'trigger_user_id' => $triggeredBy, + ], + ]; + } + + /** + * 사이트맵 재생성 실패 알림 데이터를 추출합니다. + * + * @param array $args 훅 인수 [$result, $triggeredBy] + * @return array + */ + private function extractRegenerateFailed(array $args): array + { + $triggeredBy = $args[1] ?? null; + if (! is_int($triggeredBy)) { + return $this->skip(); + } + + $result = is_array($args[0] ?? null) ? $args[0] : []; + $baseUrl = config('app.url'); + + return [ + 'notifiable' => null, + 'notifiables' => null, + 'data' => [ + 'app_name' => config('app.name'), + 'error' => (string) ($result['message'] ?? ''), + 'action_url' => "{$baseUrl}/admin/settings?tab=seo", + 'site_url' => $baseUrl, + ], + 'context' => [ + 'trigger_user_id' => $triggeredBy, + ], + ]; + } + + /** + * 발송을 건너뛰도록 지시하는 결과를 반환합니다(비수동 재생성 = 유발 관리자 없음). + * + * @return array + */ + private function skip(): array + { + return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => ['skip' => true]]; + } +} diff --git a/app/Listeners/UserLogin/HandleFailedLoginListener.php b/app/Listeners/UserLogin/HandleFailedLoginListener.php index d761a733..02a98d2d 100644 --- a/app/Listeners/UserLogin/HandleFailedLoginListener.php +++ b/app/Listeners/UserLogin/HandleFailedLoginListener.php @@ -47,7 +47,7 @@ class HandleFailedLoginListener implements HookListenerInterface * 로그인 실패 시 카운트 증가 및 임계 도달 시 잠금 처리. * * @param string $email 실패한 로그인 이메일 - * @param array $context IP/UA/시각 등 부가 정보 + * @param array $context IP/UA/시각 등 부가 정보 */ public function handleFailed(string $email, array $context = []): void { @@ -79,12 +79,14 @@ class HandleFailedLoginListener implements HookListenerInterface $user ); + // 잠금 시간 0(= 무한대) 은 영구 잠금이라 해제 시각이 null 이다. $lockedUntil = $this->userRepository->lockAccount($user, $lockoutMinutes); HookManager::doAction('core.auth.account_locked', $user, array_merge($context, [ 'attempts' => $newCount, 'locked_until' => $lockedUntil, 'lockout_minutes' => $lockoutMinutes, + 'permanent' => $lockedUntil === null, ])); } } diff --git a/app/Models/IdentityMessageDefinition.php b/app/Models/IdentityMessageDefinition.php index d3a25213..84ea6ef5 100644 --- a/app/Models/IdentityMessageDefinition.php +++ b/app/Models/IdentityMessageDefinition.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; /** * 본인인증 메시지 정의 모델. @@ -125,11 +126,31 @@ class IdentityMessageDefinition extends Model /** * 메시지 템플릿 관계. * + * 등록 순서(기본키)로 정렬을 고정한다. 목록은 대표 1건만(`firstTemplate` = MIN(id)) 싣고 + * 단건은 전체를 싣는데, 화면 계약상 양쪽 모두 `templates[0]` 을 대표로 읽는다. 정렬이 없으면 + * 단건의 첫 항목이 DB 반환 순서(`(definition_id, channel)` 유니크 인덱스 순)를 타서, 목록에서 + * 본 제목과 다른 채널의 템플릿이 열린다. + * * @return HasMany */ public function templates(): HasMany { - return $this->hasMany(IdentityMessageTemplate::class, 'definition_id'); + return $this->hasMany(IdentityMessageTemplate::class, 'definition_id')->orderBy('id'); + } + + /** + * 대표 메시지 템플릿 관계 (가장 먼저 등록된 1건 = `templates` 정렬의 첫 항목). + * + * 정의 목록 화면은 대표 템플릿 1건의 제목/본문만 그린다. `templates` 를 통째로 eager load + * 하면 한 페이지를 여는 것만으로 그 페이지 전 정의의 모든 채널 템플릿 본문이 응답에 실린다. + * eager load 의 `limit(1)` 은 부모별이 아니라 배치 쿼리 전체에 걸리므로 쓸 수 없고, + * 관계 자체를 "가장 오래된 1건" 으로 좁혀야 정의별 1건이 보장된다. + * + * @return HasOne 대표 메시지 템플릿과의 관계 + */ + public function firstTemplate(): HasOne + { + return $this->hasOne(IdentityMessageTemplate::class, 'definition_id')->oldestOfMany(); } /** diff --git a/app/Models/SitemapUrl.php b/app/Models/SitemapUrl.php new file mode 100644 index 00000000..72318add --- /dev/null +++ b/app/Models/SitemapUrl.php @@ -0,0 +1,61 @@ + 대량 할당 가능 컬럼 + */ + protected $fillable = [ + 'resource_type', + 'resource_id', + 'loc', + 'loc_hash', + 'lastmod', + 'changefreq', + 'priority', + 'contributor', + 'is_visible', + ]; + + /** + * 속성 캐스팅을 반환합니다. + * + * @return array 캐스팅 맵 + */ + protected function casts(): array + { + return [ + 'lastmod' => 'datetime', + 'priority' => 'float', + 'is_visible' => 'boolean', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0e5832ee..6e365a0e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,24 +3,25 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Contracts\UniqueIdServiceInterface; use App\Enums\MenuPermissionType; use App\Enums\PermissionType; use App\Enums\ScopeType; use App\Enums\UserStatus; +use Database\Factories\UserFactory; +use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphOne; -use App\Contracts\UniqueIdServiceInterface; -use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements HasLocalePreference { - /** @use HasFactory<\Database\Factories\UserFactory> */ + /** @use HasFactory */ use HasApiTokens, HasFactory, Notifiable; /** @var array 활동 로그 추적 필드 */ @@ -31,7 +32,7 @@ class User extends Authenticatable implements HasLocalePreference 'language' => ['label_key' => 'activity_log.fields.language', 'type' => 'text'], 'timezone' => ['label_key' => 'activity_log.fields.timezone', 'type' => 'text'], 'country' => ['label_key' => 'activity_log.fields.country', 'type' => 'text'], - 'status' => ['label_key' => 'activity_log.fields.status', 'type' => 'enum', 'enum' => \App\Enums\UserStatus::class], + 'status' => ['label_key' => 'activity_log.fields.status', 'type' => 'enum', 'enum' => UserStatus::class], 'is_super' => ['label_key' => 'activity_log.fields.is_super', 'type' => 'boolean'], 'homepage' => ['label_key' => 'activity_log.fields.homepage', 'type' => 'text'], 'mobile' => ['label_key' => 'activity_log.fields.mobile', 'type' => 'text'], @@ -50,6 +51,22 @@ class User extends Authenticatable implements HasLocalePreference */ protected array $effectiveScopeCache = []; + /** + * 보유 권한 부여 내역 캐시 (인스턴스 레벨) + * + * 한 요청에서 권한 판정은 수십~수백 번 일어난다 — 레이아웃 노드마다, 목록 행마다, + * 리소스 필드마다 부른다. 판정마다 DB 에 물으면 노드/행 수에 비례해 쿼리가 늘어난다. + * 첫 판정에서 `roles.permissions` 를 한 번 적재해 두고 이후로는 배열만 본다. + * + * 캐시 수명은 **모델 인스턴스**, 즉 요청 스코프다. 크로스 요청 캐시를 두지 않으므로 + * 권한을 바꾸면 다음 요청부터 즉시 반영된다. + * + * 구조: 권한 식별자 ⇒ [{type, scope_type}, ...] (같은 권한을 여러 역할이 주면 여러 건) + * + * @var array>|null + */ + protected ?array $permissionGrantsCache = null; + /** * 테이블명 * @@ -108,6 +125,7 @@ class User extends Authenticatable implements HasLocalePreference 'mobile_verified_at', 'failed_login_attempts', 'locked_until', + 'locked_permanently', 'last_failed_login_at', ]; @@ -140,6 +158,7 @@ class User extends Authenticatable implements HasLocalePreference 'is_super' => 'boolean', 'failed_login_attempts' => 'integer', 'locked_until' => 'datetime', + 'locked_permanently' => 'boolean', 'last_failed_login_at' => 'datetime', ]; } @@ -196,7 +215,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 모듈들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function modules() { @@ -206,7 +225,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 플러그인들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function plugins() { @@ -241,14 +260,79 @@ class User extends Authenticatable implements HasLocalePreference */ public function hasPermission(string $permission, ?PermissionType $type = null): bool { - return $this->roles() - ->whereHas('permissions', function ($query) use ($permission, $type) { - $query->where('identifier', $permission); - if ($type !== null) { - $query->where('type', $type); - } - }) - ->exists(); + $grants = $this->permissionGrants()[$permission] ?? null; + + if ($grants === null) { + return false; + } + + if ($type === null) { + return true; + } + + foreach ($grants as $grant) { + if ($grant['type'] === $type->value) { + return true; + } + } + + return false; + } + + /** + * 보유 권한 부여 내역을 반환합니다 (인스턴스 캐시). + * + * 역할 → 권한을 한 번만 적재하고, 이후 판정은 전부 이 배열에서 이루어집니다. + * + * @return array> 권한 식별자 ⇒ 부여 내역 + */ + protected function permissionGrants(): array + { + if ($this->permissionGrantsCache !== null) { + return $this->permissionGrantsCache; + } + + $grants = []; + + foreach ($this->roles()->with('permissions')->get() as $role) { + foreach ($role->permissions as $permission) { + $grants[$permission->identifier][] = [ + 'type' => $this->enumValue($permission->type), + 'scope_type' => $this->enumValue($permission->pivot->scope_type ?? null), + ]; + } + } + + return $this->permissionGrantsCache = $grants; + } + + /** + * Enum 또는 원시 값을 문자열로 정규화합니다. + * + * 캐스팅 설정 유무에 따라 Enum 인스턴스와 문자열이 섞여 들어오므로 한 형태로 맞춥니다. + * + * @param mixed $value Enum 인스턴스 · 문자열 · null + * @return string|null 정규화된 문자열 (부재 시 null) + */ + private function enumValue(mixed $value): ?string + { + if ($value instanceof \BackedEnum) { + return (string) $value->value; + } + + return $value === null ? null : (string) $value; + } + + /** + * 권한 판정 캐시를 비웁니다. + * + * 같은 인스턴스에서 역할을 바꾼 직후 다시 판정해야 하는 경우에 호출합니다. + * (역할 동기화 서비스가 호출하며, 일반 조회 경로에서는 필요하지 않습니다) + */ + public function flushPermissionCaches(): void + { + $this->permissionGrantsCache = null; + $this->effectiveScopeCache = []; } /** @@ -260,27 +344,17 @@ class User extends Authenticatable implements HasLocalePreference */ public function hasPermissions(array $permissions, bool $requireAll = true, ?PermissionType $type = null): bool { - $userPermissions = $this->roles() - ->whereHas('permissions', function ($query) use ($permissions, $type) { - $query->whereIn('identifier', $permissions); - if ($type !== null) { - $query->where('type', $type); - } - }) - ->with(['permissions' => function ($query) use ($permissions, $type) { - $query->whereIn('identifier', $permissions); - if ($type !== null) { - $query->where('type', $type); - } - }]) - ->get() - ->pluck('permissions') - ->flatten() - ->pluck('identifier') - ->unique() - ->count(); + // 같은 권한 집합을 hasPermission 과 공유한다 — 권한 개수만큼 쿼리가 늘지 않는다. + // 비교 기준(고유 매칭 수 vs 인자 개수)은 종전과 동일하게 유지한다. + $matched = 0; - return $requireAll ? $userPermissions === count($permissions) : $userPermissions > 0; + foreach (array_unique($permissions) as $permission) { + if ($this->hasPermission($permission, $type)) { + $matched++; + } + } + + return $requireAll ? $matched === count($permissions) : $matched > 0; } /** @@ -300,33 +374,21 @@ class User extends Authenticatable implements HasLocalePreference return $this->effectiveScopeCache[$identifier]; } - $scopeTypes = $this->roles() - ->whereHas('permissions', function ($query) use ($identifier) { - $query->where('identifier', $identifier); - }) - ->with(['permissions' => function ($query) use ($identifier) { - $query->where('identifier', $identifier); - }]) - ->get() - ->pluck('permissions') - ->flatten() - ->pluck('pivot.scope_type'); + // 같은 권한 집합에서 scope_type 만 뽑는다 — 권한마다 쿼리를 다시 내지 않는다. + $values = array_column($this->permissionGrants()[$identifier] ?? [], 'scope_type'); // 권한 미보유 시 null 반환 (기본값: 전체 접근) - if ($scopeTypes->isEmpty()) { + if ($values === []) { return $this->effectiveScopeCache[$identifier] = null; } // union 정책: 하나라도 null → 전체 접근 - if ($scopeTypes->contains(null)) { + if (in_array(null, $values, true)) { return $this->effectiveScopeCache[$identifier] = null; } - // ScopeType Enum 값을 문자열로 변환하여 비교 - $values = $scopeTypes->map(fn ($scope) => $scope instanceof ScopeType ? $scope->value : $scope); - // 하나라도 'role' → role 적용 - if ($values->contains('role')) { + if (in_array(ScopeType::Role->value, $values, true)) { return $this->effectiveScopeCache[$identifier] = 'role'; } @@ -366,11 +428,16 @@ class User extends Authenticatable implements HasLocalePreference */ public function isAdmin(): bool { - return $this->roles() - ->whereHas('permissions', function ($query) { - $query->where('type', PermissionType::Admin); - }) - ->exists(); + // 같은 권한 집합을 재사용한다 — 관리자 판정은 미들웨어·리소스·레이아웃에서 반복 호출된다. + foreach ($this->permissionGrants() as $grants) { + foreach ($grants as $grant) { + if ($grant['type'] === PermissionType::Admin->value) { + return true; + } + } + } + + return false; } /** @@ -388,8 +455,8 @@ class User extends Authenticatable implements HasLocalePreference /** * 슈퍼 관리자만 조회합니다. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param Builder $query + * @return Builder */ public function scopeSuperAdmins($query) { @@ -445,7 +512,7 @@ class User extends Authenticatable implements HasLocalePreference /** * 사용자가 생성한 메뉴들과의 관계를 정의합니다. * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function menus() { diff --git a/app/Notifications/GenericNotification.php b/app/Notifications/GenericNotification.php index b16d5c33..9e6ad309 100644 --- a/app/Notifications/GenericNotification.php +++ b/app/Notifications/GenericNotification.php @@ -67,6 +67,16 @@ class GenericNotification extends BaseNotification // 대상 채널이 지정된 경우 (template별 발송) — 게스트 게이트 + 확장 토글 + readiness 확인 if ($this->channel !== null) { try { + $channelService = app(NotificationChannelService::class); + + // 사용 가능한 채널 목록에 없는 채널은 발송 후보에서 제외한다 (로그도 남기지 않음). + // 채널을 제공하던 확장이 비활성/삭제되면 그 채널이 getAvailableChannels() 에서 빠지지만, + // 저장된 채널 설정·템플릿은 남아 있어 죽은 채널로 발송·"건너뜀" 로깅이 발생한다. 살아 있지 + // 않은 채널은 "존재하지 않는 것"으로 취급해 조용히 제외한다(skipped 기록 대상 아님). + if (! $channelService->isChannelAvailable($this->channel)) { + return []; + } + // 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외 if ($this->isChannelBlockedForGuest($notifiable, $this->channel)) { $this->logSkippedChannels([ @@ -77,7 +87,6 @@ class GenericNotification extends BaseNotification } // 0단계: 확장 단위 채널 전역 활성 여부 확인 - $channelService = app(NotificationChannelService::class); if (! $channelService->isChannelEnabledForExtension( $this->extensionType, $this->extensionIdentifier, @@ -126,6 +135,12 @@ class GenericNotification extends BaseNotification $skippedChannels = []; foreach ($channels as $channel) { + // 사용 가능한 채널 목록에 없는 채널(비활성/삭제된 확장이 제공하던 채널)은 로그 없이 + // 조용히 제외한다 — 죽은 채널을 "건너뜀"으로 기록하지 않는다(채널 지정 경로와 동일 정책). + if (! $channelService->isChannelAvailable($channel)) { + continue; + } + // 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외 if ($this->isChannelBlockedForGuest($notifiable, $channel)) { $skippedChannels[] = [ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 625fb2c0..3a22a476 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,19 +2,31 @@ namespace App\Providers; +use App\Contracts\Extension\HookManagerInterface; +use App\Contracts\Extension\ModuleManagerInterface; +use App\Contracts\Extension\PluginManagerInterface; +use App\Contracts\Notifications\ChannelReadinessCheckerInterface; use App\Extension\HookManager; +use App\Extension\ModuleManager; +use App\Extension\PluginManager; use App\Http\View\Composers\TemplateComposer; use App\Http\View\Composers\UserTemplateComposer; use App\Listeners\ExtensionCompatibilityAlertListener; +use App\Notifications\NotificationChannelManager; +use App\Services\ChannelReadinessService; +use App\Services\GeoIpService; +use App\Support\Routing\DualExtensionRoute; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Events\QueryExecuted; use Illuminate\Http\Request; +use Illuminate\Notifications\ChannelManager; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; +use Laravel\Boost\BoostServiceProvider; class AppServiceProvider extends ServiceProvider { @@ -23,48 +35,55 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + // 자산 URL 이중 모드 Route 매크로 (dualSuffix / dualAsset). + // boot() 가 아니라 register() 에서 등록하는 이유: 라우트 파일은 프레임워크의 + // 라우팅 부트스트랩(boot 단계)에서 로드되므로, 프로바이더 간 boot 순서에 + // 의존하면 매크로 미정의 시점에 라우트가 로드될 수 있다. 모든 프로바이더의 + // register() 는 어떤 boot() 보다 먼저 실행되므로 여기가 유일하게 안전한 지점이다. + DualExtensionRoute::register(); + // NOTE: Faker 부재 시 FakerShim 대체는 app/Support/SampleData/bootstrap.php 에서 처리 // (composer autoload.files 진입점 — vendor/autoload.php 로드 직후 실행되어 // Laravel 의 fake() 헬퍼 정의 시점에 \Faker\Factory 가 이미 alias 되어 있음) // 알림 발송 공통 디스패처 — 채널 독립 발송 + 발송 전후 G7 훅 실행 $this->app->singleton( - \Illuminate\Notifications\ChannelManager::class, - fn ($app) => new \App\Notifications\NotificationChannelManager($app) + ChannelManager::class, + fn ($app) => new NotificationChannelManager($app) ); // 채널 Readiness 검증 — 미설정 채널 발송 사전 차단 $this->app->singleton( - \App\Contracts\Notifications\ChannelReadinessCheckerInterface::class, - \App\Services\ChannelReadinessService::class + ChannelReadinessCheckerInterface::class, + ChannelReadinessService::class ); // TODO: TemplateManagerInterface 바인딩을 추가해야 함 // PluginManagerInterface 바인딩 $this->app->bind( - \App\Contracts\Extension\PluginManagerInterface::class, - \App\Extension\PluginManager::class + PluginManagerInterface::class, + PluginManager::class ); // ModuleManagerInterface 바인딩 $this->app->bind( - \App\Contracts\Extension\ModuleManagerInterface::class, - \App\Extension\ModuleManager::class + ModuleManagerInterface::class, + ModuleManager::class ); // HookManagerInterface 바인딩 $this->app->bind( - \App\Contracts\Extension\HookManagerInterface::class, - \App\Extension\HookManager::class + HookManagerInterface::class, + HookManager::class ); // GeoIpService 싱글톤 등록 - $this->app->singleton(\App\Services\GeoIpService::class); + $this->app->singleton(GeoIpService::class); // Laravel Boost (개발 전용 - dont-discover 대상, 클래스 존재 시에만 등록) - if (class_exists(\Laravel\Boost\BoostServiceProvider::class)) { - $this->app->register(\Laravel\Boost\BoostServiceProvider::class); + if (class_exists(BoostServiceProvider::class)) { + $this->app->register(BoostServiceProvider::class); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 29867b2b..452bd1b5 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Models\Role; use App\Models\User; +use App\Support\GuestRoleResolver; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; @@ -11,11 +12,6 @@ use Illuminate\Support\Facades\Log; class AuthServiceProvider extends ServiceProvider { - /** - * guest role 캐시 - */ - protected static ?Role $guestRoleCache = null; - /** * Register any authentication / authorization services. */ @@ -236,9 +232,9 @@ class AuthServiceProvider extends ServiceProvider return null; // guest role 없으면 다음 Gate 정의로 위임 } - return $guestRole->permissions() - ->where('identifier', $ability) - ->exists() ? true : null; + // 적재해 둔 권한 컬렉션에서 판정한다. 빌더를 다시 열면 캐시가 무의미해져 + // Gate 호출마다 쿼리가 나간다. + return GuestRoleResolver::hasPermission($ability) ? true : null; } /** @@ -246,13 +242,7 @@ class AuthServiceProvider extends ServiceProvider */ protected static function getGuestRole(): ?Role { - if (self::$guestRoleCache === null) { - self::$guestRoleCache = Role::where('identifier', 'guest') - ->with('permissions') - ->first(); - } - - return self::$guestRoleCache; + return GuestRoleResolver::resolve(); } /** @@ -260,7 +250,7 @@ class AuthServiceProvider extends ServiceProvider */ public static function clearGuestRoleCache(): void { - self::$guestRoleCache = null; + GuestRoleResolver::flush(); } /** @@ -275,16 +265,14 @@ class AuthServiceProvider extends ServiceProvider $hasPermission = false; if ($guestRole) { - $hasPermission = $guestRole->permissions() - ->where('identifier', $ability) - ->exists(); + $hasPermission = GuestRoleResolver::hasPermission($ability); } return [ 'result' => self::checkGuestPermission($ability), 'guestRole' => $guestRole, 'hasPermission' => $hasPermission, - 'cachedRoleId' => self::$guestRoleCache?->id, + 'cachedRoleId' => $guestRole?->id, ]; } } diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index 00cbac47..a7064352 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -2,6 +2,11 @@ namespace App\Providers; +use App\Benchmark\Axes\BatchAxisRunner; +use App\Benchmark\Axes\ListAxisRunner; +use App\Benchmark\Axes\ScreenAxisRunner; +use App\Benchmark\Axes\WriteAxisRunner; +use App\Console\Commands\BenchCommand; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\ExtensionMiddlewareRegistryInterface; use App\Contracts\Extension\HookListenerInterface; @@ -32,6 +37,7 @@ use App\Contracts\Repositories\PluginRepositoryInterface; use App\Contracts\Repositories\RoleRepositoryInterface; use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface; use App\Contracts\Repositories\ScheduleRepositoryInterface; +use App\Contracts\Repositories\SeoCacheStatRepositoryInterface; use App\Contracts\Repositories\SystemConfigRepositoryInterface; use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface; use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface; @@ -79,6 +85,7 @@ use App\Repositories\PluginRepository; use App\Repositories\RoleRepository; use App\Repositories\ScheduleHistoryRepository; use App\Repositories\ScheduleRepository; +use App\Repositories\SeoCacheStatRepository; use App\Repositories\SystemConfigRepository; use App\Repositories\TemplateCustomTranslationRepository; use App\Repositories\TemplateLayoutAttachmentRepository; @@ -90,6 +97,7 @@ use App\Services\DriverRegistryService; use App\Services\LayoutExtensionService; use App\Services\TemplateLayoutAttachmentService; use App\Services\UniqueIdService; +use App\Support\PrivilegedDatabaseAccounts; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -116,9 +124,30 @@ class CoreServiceProvider extends ServiceProvider $this->registerRepositoryBindings(); $this->registerExtensionManagers(); + $this->registerBenchmarkAxes(); // ActivityLogManager 제거됨 — Monolog 채널(config/logging.php 'activity')로 대체 } + /** + * 성능 계측 축 실행기를 등록합니다. + * + * 축이 늘어날 때 `g7:bench` 커맨드를 고치지 않고 여기에 실행기만 추가하면 되도록 + * 태그로 묶어 주입합니다. 실행기는 CLI 계측 시점에만 해석되므로 웹 요청 비용은 없습니다. + */ + private function registerBenchmarkAxes(): void + { + $this->app->tag([ + ListAxisRunner::class, + ScreenAxisRunner::class, + WriteAxisRunner::class, + BatchAxisRunner::class, + ], 'benchmark.axes'); + + $this->app->when(BenchCommand::class) + ->needs('$runners') + ->giveTagged('benchmark.axes'); + } + /** * Extension(모듈/플러그인) PSR-4 오토로드를 등록합니다. * @@ -216,6 +245,9 @@ class CoreServiceProvider extends ServiceProvider $this->app->bind(NotificationRepositoryInterface::class, NotificationRepository::class); $this->app->bind(NotificationTemplateRepositoryInterface::class, NotificationTemplateRepository::class); + // SEO Repository 바인딩 + $this->app->bind(SeoCacheStatRepositoryInterface::class, SeoCacheStatRepository::class); + // IdentityVerification Repository 바인딩 $this->app->bind(IdentityVerificationLogRepositoryInterface::class, IdentityVerificationLogRepository::class); $this->app->bind(IdentityPolicyRepositoryInterface::class, IdentityPolicyRepository::class); @@ -333,9 +365,9 @@ class CoreServiceProvider extends ServiceProvider return; } - // DB 연결 유효성 검증 (root 사용자 접속 방지) + // DB 연결 유효성 검증 (최고권한 계정 접속 방지) if (! $this->isDatabaseConnectionValid()) { - Log::error('Database connection invalid: using root user or missing credentials. Skipping extension loading.'); + Log::error('Database connection invalid: using a privileged database account or missing credentials. Skipping extension loading.'); return; } @@ -439,30 +471,20 @@ class CoreServiceProvider extends ServiceProvider /** * 데이터베이스 연결이 유효한지 검증합니다. * - * root 사용자로 접속하거나 비밀번호가 없는 경우를 감지하여 + * DB 최고권한 계정으로 접속하거나 사용자명이 비어 있는 경우를 감지하여 * 잘못된 설정으로 인한 접속 오류를 방지합니다. + * 계정 판정은 `PrivilegedDatabaseAccounts` 가 SSoT 입니다. * * @return bool 연결이 유효하면 true */ protected function isDatabaseConnectionValid(): bool { try { - $config = DB::connection()->getConfig(); + // read/write 분리 설정의 우선순위 규칙까지 PrivilegedDatabaseAccounts 가 소유한다. + // 호출처마다 규칙이 달라지면 같은 설정에 서로 다른 판정이 나온다. + $username = PrivilegedDatabaseAccounts::resolveUsername(DB::connection()->getConfig()); - // read/write 분리 설정인 경우 read 설정 확인 - $username = $config['username'] ?? null; - - // read 설정이 배열로 있는 경우 - if (isset($config['read']['username'])) { - $username = $config['read']['username']; - } - - // root 사용자 또는 빈 username인 경우 무효 - if (empty($username) || $username === 'root') { - return false; - } - - return true; + return PrivilegedDatabaseAccounts::isUsable($username); } catch (\Throwable $e) { Log::error('Database configuration check failed: '.$e->getMessage()); @@ -997,7 +1019,7 @@ class CoreServiceProvider extends ServiceProvider $listener = app($listenerClass); $listener->registerDynamicHooks(); - Log::info('동적 훅 리스너 등록 완료', ['listener' => $listenerClass]); + // 등록 성공은 로그로 남기지 않는다 — 요청마다 부팅되는 경로다. 실패만 아래에 남긴다. } catch (\Throwable $e) { Log::warning('동적 훅 리스너 등록 실패', [ 'listener' => $listenerClass, diff --git a/app/Providers/LanguagePackServiceProvider.php b/app/Providers/LanguagePackServiceProvider.php index 3454ef02..985d3add 100644 --- a/app/Providers/LanguagePackServiceProvider.php +++ b/app/Providers/LanguagePackServiceProvider.php @@ -304,10 +304,7 @@ class LanguagePackServiceProvider extends ServiceProvider $syncFor = function (string $scope, string $identifier, ?array $info, string $relativeBase) use ($registrar) { $vendor = $this->resolveVendor($identifier, $info); $version = (string) ($info['version'] ?? '1.0.0'); - $langDir = $relativeBase.'/'.$identifier.'/lang'; - if (! File::isDirectory(base_path($langDir))) { - $langDir = $relativeBase.'/'.$identifier.'/resources/lang'; - } + $langDir = $this->resolveExtensionLangDir($scope, $identifier, $relativeBase); $registrar->syncFromExtension($scope, $identifier, $vendor, $version, $langDir); }; @@ -359,6 +356,38 @@ class LanguagePackServiceProvider extends ServiceProvider }); } + /** + * 확장의 백엔드 다국어 디렉토리를 반환합니다. + * + * **런타임이 실제로 로드하는 경로와 같아야 한다** (`TranslationServiceProvider`). + * 모듈은 `src/lang`, 플러그인은 `lang` 이며, 코어의 확장 설치 검증 + * (`ValidatesTranslationPath`)도 같은 규칙을 강제한다. 여기서 다른 경로를 잡으면 + * 언어팩에 등록된 키와 화면에 나오는 문구의 출처가 어긋난다. + * + * 존재하지 않으면 관례 경로들을 순서대로 확인해 실제 있는 디렉토리를 돌려준다 + * (제3자 확장이 다른 배치를 쓰는 경우 대비). + * + * @param string $scope 확장 스코프 (module|plugin|template) + * @param string $identifier 확장 식별자 + * @param string $relativeBase base_path 기준 상위 디렉토리 (modules|plugins|templates) + * @return string base_path 기준 상대 lang 디렉토리 + */ + private function resolveExtensionLangDir(string $scope, string $identifier, string $relativeBase): string + { + $primary = $scope === 'module' ? 'src/lang' : 'lang'; + $candidates = array_unique([$primary, 'lang', 'src/lang', 'resources/lang']); + + foreach ($candidates as $candidate) { + $dir = $relativeBase.'/'.$identifier.'/'.$candidate; + + if (File::isDirectory(base_path($dir))) { + return $dir; + } + } + + return $relativeBase.'/'.$identifier.'/'.$primary; + } + /** * 확장 manifest 또는 identifier 로부터 vendor 를 추출합니다. * diff --git a/app/Providers/ModuleRouteServiceProvider.php b/app/Providers/ModuleRouteServiceProvider.php index 767ebf09..d134a558 100644 --- a/app/Providers/ModuleRouteServiceProvider.php +++ b/app/Providers/ModuleRouteServiceProvider.php @@ -2,10 +2,9 @@ namespace App\Providers; -use App\Enums\ExtensionStatus; use App\Extension\ExtensionManager; use App\Extension\Testing\ExtensionTestAllowlist; -use App\Models\Module; +use App\Extension\Traits\CachesModuleStatus; use App\Support\InstallerContext; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\File; @@ -14,6 +13,8 @@ use Illuminate\Support\Facades\Schema; class ModuleRouteServiceProvider extends ServiceProvider { + use CachesModuleStatus; + /** * The path to the "home" route for your application. * @@ -74,10 +75,10 @@ class ModuleRouteServiceProvider extends ServiceProvider } } - // 활성화된 모듈 identifier 목록 가져오기 - $activeModuleIdentifiers = Module::where('status', ExtensionStatus::Active->value) - ->pluck('identifier') - ->toArray(); + // 활성화된 모듈 identifier 목록 가져오기. + // 같은 목록을 ModuleManager·ModuleServiceProvider 가 이미 캐시(TTL 기본 하루)해 두므로 + // 여기서 다시 조회하지 않고 그 캐시를 공유한다. 상태 변경 시 무효화도 같이 따라온다. + $activeModuleIdentifiers = self::getActiveModuleIdentifiers(); $modules = File::directories($modulesPath); $allowlistActive = ExtensionTestAllowlist::isActive(); diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 4a9ac0b4..678f4846 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Repositories\JsonConfigRepository; +use App\Support\AllowedExtensions; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; @@ -29,6 +30,7 @@ class SettingsServiceProvider extends ServiceProvider 'geoip', 'seo', 'identity', + 'pagination', ]; /** @@ -652,12 +654,27 @@ class SettingsServiceProvider extends ServiceProvider Config::set('filesystems.default', $uploadSettings['disk']); } - if (! empty($uploadSettings['max_size'])) { - Config::set('g7.upload.max_size', (int) $uploadSettings['max_size']); + // 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다. + // (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다) + if (! empty($uploadSettings['max_file_size'])) { + Config::set('attachment.max_file_size', (int) $uploadSettings['max_file_size'] * 1024); } - if (! empty($uploadSettings['allowed_extensions'])) { - Config::set('g7.upload.allowed_extensions', $uploadSettings['allowed_extensions']); + // 화면은 콤마 문자열을 보내고 API 직접 호출은 배열을 보낸다. 배열만 받아들이면 + // 문자열로 저장된 설정이 조용히 버려져 관리자가 확장자를 바꿔도 반영되지 않는다. + if (isset($uploadSettings['allowed_extensions'])) { + Config::set( + 'attachment.allowed_extensions', + AllowedExtensions::normalize($uploadSettings['allowed_extensions']) + ); + } + + // 이미지 축소 한계값·품질 — 픽셀/퍼센트라 단위 변환이 없다. + // 미설정(빈 값)은 "축소하지 않음" 이므로 그대로 넘겨 ImageResizer 가 판정하게 둔다. + foreach (['image_max_width', 'image_max_height', 'image_quality'] as $key) { + if (isset($uploadSettings[$key]) && $uploadSettings[$key] !== '') { + Config::set("attachment.{$key}", (int) $uploadSettings[$key]); + } } } } diff --git a/app/Repositories/ActivityLogRepository.php b/app/Repositories/ActivityLogRepository.php index 513573b7..15418f77 100644 --- a/app/Repositories/ActivityLogRepository.php +++ b/app/Repositories/ActivityLogRepository.php @@ -5,9 +5,16 @@ namespace App\Repositories; use App\Contracts\Repositories\ActivityLogRepositoryInterface; use App\Helpers\PermissionHelper; use App\Helpers\TimezoneHelper; +use App\Http\Resources\BaseApiCollection; use App\Models\ActivityLog; use App\Repositories\Concerns\HasMultipleSearchFilters; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; +use App\Support\Query\KeysetPaginator; +use App\Support\Query\PaginationLimits; +use Illuminate\Contracts\Pagination\CursorPaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -17,6 +24,25 @@ use Illuminate\Database\Eloquent\Model; class ActivityLogRepository implements ActivityLogRepositoryInterface { use HasMultipleSearchFilters; + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** + * 활동 로그 목록 정렬 허용 컬럼 + * + * 요청 값을 그대로 orderBy 에 넘기면 없는 컬럼으로 SQL 오류가 나거나 인덱스 없는 컬럼 + * 정렬을 강제할 수 있다. + * + * @var array + */ + private const SORTABLE_COLUMNS = [ + 'id', + 'created_at', + 'action', + 'log_type', + 'user_id', + ]; + /** * 특정 모델의 활동 로그를 페이지네이션하여 조회합니다. * @@ -26,8 +52,8 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface */ public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator { - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query = ActivityLog::forModel($model)->orderBy('created_at', $sortOrder); + $sortOrder = $this->normalizeSortDirection($filters['sort_order'] ?? null); + $query = ActivityLog::forModel($model); if (isset($filters['action'])) { $query->action($filters['action']); @@ -37,18 +63,31 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface $query->byUser($filters['user_id']); } - return $query->paginate($filters['per_page'] ?? 15); + // 컬럼을 좁히지 않는 이유는 getPaginated() 와 같다 — 리소스가 변경 내역을 그대로 노출한다 + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => 'created_at', 'direction' => $sortOrder]], + perPage: (int) ($filters['per_page'] ?? 15), + // 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은 + // per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐). + resultCap: PaginationLimits::resultCap('admin.activity_logs'), + ); } /** * 활동 로그 목록을 페이지네이션하여 조회합니다. * + * 요청에 `cursor` 가 있으면 키셋(커서) 방식으로, 없으면 페이지 번호 방식으로 응답합니다. + * 두 방식의 응답 봉투 차이는 {@see BaseApiCollection} 이 흡수합니다. + * * @param array $filters 필터 조건 - * @return LengthAwarePaginator 페이지네이션된 로그 목록 + * @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록 */ - public function getPaginated(array $filters = []): LengthAwarePaginator + public function getPaginated(array $filters = []): LengthAwarePaginator|CursorPaginator { - $query = ActivityLog::query()->with('user:id,uuid,name,email'); + // 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다) + $query = ActivityLog::query(); // 스코프 기반 권한 필터링 (self: 본인 로그만, role: 소유역할 로그, null: 전체) PermissionHelper::applyPermissionScope($query, 'core.activities.read'); @@ -92,11 +131,41 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface $query->where('created_at', '<=', TimezoneHelper::fromSiteDateTime($filters['date_to'])); } - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at'); + $perPage = (int) ($filters['per_page'] ?? 15); - return $query->paginate($filters['per_page'] ?? 15); + // 커서를 받은 요청은 키셋으로 응답한다. 로그는 계속 쌓이기만 해서 깊은 페이지를 + // OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 하지만, 커서는 직전 페이지의 정렬 + // 키를 WHERE 경계로 삼아 깊이와 무관하게 일정하다. + // 커서 모드에서는 OFFSET 자체가 없으므로 지연 조인이 해결하려던 문제도 함께 사라진다. + $sortKeys = array_map( + static fn (array $spec): array => [$spec['column'], $spec['direction']], + $sort + ); + + if (! empty($filters['cursor']) && KeysetPaginator::supports($sortKeys, self::SORTABLE_COLUMNS)) { + return KeysetPaginator::paginate( + query: $query->with('user:id,uuid,name,email'), + perPage: $perPage, + sortKeys: $sortKeys, + uniqueKey: 'id', + cursor: (string) $filters['cursor'], + ); + } + + // 목록 컬럼을 좁히지 않는 이유: 활동 로그 리소스는 변경 내역(mediumText `changes`)과 + // 부가 정보(`properties`)까지 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다. + // 지연 조인만으로도 이 넓은 컬럼들을 읽는 행 수가 이번 페이지 분량으로 고정된다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + relations: ['user:id,uuid,name,email'], + // 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은 + // per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐). + resultCap: PaginationLimits::resultCap('admin.activity_logs'), + ); } /** @@ -105,10 +174,9 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface * action/description 필드는 영어 키를 저장하므로, * 검색어가 번역된 라벨과 일치하는 원본 키도 함께 검색합니다. * - * @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더 - * @param string $search 검색어 - * @param string $searchType 검색 유형 (all, action, description, ip_address) - * @return void + * @param Builder $query 쿼리 빌더 + * @param string $search 검색어 + * @param string $searchType 검색 유형 (all, action, description, ip_address) */ private function applyBilingualSearch($query, string $search, string $searchType): void { @@ -146,7 +214,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface /** * 번역된 액션 라벨로 원본 action 값을 역추적합니다. * - * @param string $searchLower 소문자 검색어 + * @param string $searchLower 소문자 검색어 * @return array 일치하는 action 값 목록 */ private function findActionsByTranslatedLabel(string $searchLower): array @@ -179,7 +247,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface /** * 번역된 설명 텍스트로 원본 description_key를 역추적합니다. * - * @param string $searchLower 소문자 검색어 + * @param string $searchLower 소문자 검색어 * @return array 일치하는 description_key 목록 */ private function findDescriptionKeysByTranslatedText(string $searchLower): array @@ -200,7 +268,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface /** * 활동 로그를 삭제합니다. * - * @param int $id 삭제할 활동 로그 ID + * @param int $id 삭제할 활동 로그 ID * @return bool 삭제 성공 여부 */ public function delete(int $id): bool @@ -211,7 +279,7 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface /** * 여러 활동 로그를 일괄 삭제합니다. * - * @param array $ids 삭제할 활동 로그 ID 목록 + * @param array $ids 삭제할 활동 로그 ID 목록 * @return int 삭제된 건수 */ public function deleteMany(array $ids): int diff --git a/app/Repositories/AttachmentRepository.php b/app/Repositories/AttachmentRepository.php index 79533f0b..bc9b2669 100644 --- a/app/Repositories/AttachmentRepository.php +++ b/app/Repositories/AttachmentRepository.php @@ -32,7 +32,8 @@ class AttachmentRepository implements AttachmentRepositoryInterface public function findByIds(array $ids): Collection { if (empty($ids)) { - return collect(); + // `collect()` 는 Support 라 선언과 어긋난다 — 빈 결과도 모델 컬렉션이어야 한다. + return new Collection; } return Attachment::whereIn('id', $ids) diff --git a/app/Repositories/Concerns/FiltersByDateRange.php b/app/Repositories/Concerns/FiltersByDateRange.php new file mode 100644 index 00000000..c5e48108 --- /dev/null +++ b/app/Repositories/Concerns/FiltersByDateRange.php @@ -0,0 +1,72 @@ += 하루 시작` / `<= 하루 끝` 범위 조건으로 바꾸면 인덱스가 그대로 쓰인다. + * + * 경계 처리를 각 저장소가 따로 적으면 한쪽이 종료일 하루를 통째로 빠뜨리기 쉽다 + * (`<= '2026-08-02'` 는 그날 00:00:00 까지만 포함한다). 경계는 이 Trait 한 곳에서만 정한다. + */ +trait FiltersByDateRange +{ + /** + * 기간(시작일~종료일) 필터를 범위 조건으로 적용합니다. + * + * 종료일은 그날 23:59:59.999999 까지 포함하므로 `whereDate(..., '<=', ...)` 와 + * 같은 결과를 냅니다. + * + * @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리 + * @param string $column 날짜 컬럼 (테이블 한정자 포함 가능) + * @param string|null $startDate 시작일 (빈 값이면 미적용) + * @param string|null $endDate 종료일 (빈 값이면 미적용) + */ + protected function applyDateRangeFilter($query, string $column, ?string $startDate, ?string $endDate): void + { + if (! empty($startDate)) { + $query->where($column, '>=', CarbonImmutable::parse($startDate)->startOfDay()); + } + + if (! empty($endDate)) { + $query->where($column, '<=', CarbonImmutable::parse($endDate)->endOfDay()); + } + } + + /** + * 특정 하루에 해당하는 행만 남기는 필터를 범위 조건으로 적용합니다. + * + * @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리 + * @param string $column 날짜 컬럼 (테이블 한정자 포함 가능) + * @param \DateTimeInterface|string $day 대상 날짜 + */ + protected function applyDayFilter($query, string $column, \DateTimeInterface|string $day): void + { + $target = CarbonImmutable::parse($day instanceof \DateTimeInterface ? $day->format('Y-m-d H:i:s') : $day); + + $query->whereBetween($column, [$target->startOfDay(), $target->endOfDay()]); + } + + /** + * 특정 연·월에 해당하는 행만 남기는 필터를 범위 조건으로 적용합니다. + * + * `whereYear` + `whereMonth` 조합은 컬럼에 함수를 두 번 씌워 인덱스를 완전히 막습니다. + * + * @param Builder|\Illuminate\Database\Query\Builder $query 대상 쿼리 + * @param string $column 날짜 컬럼 (테이블 한정자 포함 가능) + * @param int $year 연도 + * @param int $month 월 (1~12) + */ + protected function applyMonthFilter($query, string $column, int $year, int $month): void + { + $start = CarbonImmutable::create($year, $month, 1)->startOfDay(); + + $query->whereBetween($column, [$start, $start->endOfMonth()]); + } +} diff --git a/app/Repositories/Concerns/PaginatesWithDeferredJoin.php b/app/Repositories/Concerns/PaginatesWithDeferredJoin.php new file mode 100644 index 00000000..45f1b696 --- /dev/null +++ b/app/Repositories/Concerns/PaginatesWithDeferredJoin.php @@ -0,0 +1,325 @@ +with()` 만 하고 인자를 생략하면 관계가 로드되지 않는다. + * 이 실패는 예외도 쿼리 오류도 내지 않고 응답에서 관계 필드만 사라진다 + * - 정렬은 닫힌 집합이어야 한다 (ResolvesSortSpec 참조). 인덱스 없는 컬럼 정렬은 inner 도 + * 전체 스캔하므로 개선 폭이 보장되지 않는다 + * - 키 컬럼은 정렬 마지막에 자동으로 덧붙는다 (동률 시 페이지 경계가 흔들리는 것을 방지) + * + * 목록 컬럼에 `SUBSTRING(content, 1, N)` 을 두는 것은 프루닝으로 인정하지 않는다. 오버플로 + * 페이지 읽기가 그대로 발생하므로, 잘라내기는 outer(`$columns`)에서만 수행한다. + * + * @see ResolvesSortSpec 정렬 스펙 해석 + */ +trait PaginatesWithDeferredJoin +{ + /** + * 지연 조인으로 목록을 페이지네이션합니다. + * + * @param Builder $query 필터/where 만 적용된 쿼리 (orderBy/with/select 미적용) + * @param array $columns outer select 목록 (DB::raw 허용) + * @param array $sort 정렬 스펙 + * @param int $perPage 페이지당 건수 + * @param int|null $page 현재 페이지 (null = 요청에서 해석) + * @param array $relations outer 전용 eager load 관계 + * @param array $withCount outer 전용 관계 카운트. `withCount()` 로 + * 그대로 forward 되므로 Laravel 의 두 형태를 + * 모두 받는다 — 목록 형태(`['options']`) 와 + * 제약 클로저를 붙인 연관 형태 + * (`['options as active_count' => fn ($q) => ...]`). + * 별칭 없이 같은 관계를 두 번 세면 기본 별칭이 + * 충돌해 뒤엣것이 앞엣것을 덮으므로 `as` 로 구분한다 + * @param string $keyName 키 컬럼명 (기본 id) + * @param int|null $total 전체 건수 (null = COUNT 수행, 값 지정 시 캐시 total 사용) + * @param bool $simple true = COUNT 없이 다음 페이지 유무만 판정 + * @param bool $preserveIdOrder true = inner 가 정한 ID 순서를 outer 에서 그대로 복원 + * @param string $pageName 페이지 쿼리 파라미터명 + * @param Closure|null $outerUsing outer 에만 적용할 조인/집계 (inner 에서는 실행되지 않는다) + * @param int|null $resultCap 총 건수 집계 상한 (null = 항상 정확한 COUNT) + * @return PaginatorContract 페이지네이터 ($simple=true 면 Paginator, $resultCap 지정 시 BoundedPage, 아니면 LengthAwarePaginator) + */ + protected function paginateWithDeferredJoin( + Builder|Relation $query, + array $columns, + array $sort, + int $perPage, + ?int $page = null, + array $relations = [], + array $withCount = [], + string $keyName = 'id', + ?int $total = null, + bool $simple = false, + bool $preserveIdOrder = false, + string $pageName = 'page', + ?Closure $outerUsing = null, + ?int $resultCap = null, + ): PaginatorContract { + // 관계(`$model->items()`)도 받는다 — 표준 `paginate()` 가 되는 자리는 이 계약으로 + // 바꿔도 되어야 한다. 관계의 소속 조건은 그 밑 빌더에 이미 들어가 있어 보존된다. + // (쿼리 빌더는 받지 않는다. 이 계약은 모델의 키 컬럼·eager load 를 다루므로 + // Eloquent 가 전제다 — 넓힐 수 있는 범위와 없는 범위를 구분한다.) + $query = $query instanceof Relation ? $query->getQuery() : $query; + + $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = max(1, $page); + + $sort = $this->normalizeSortSpecForKey($sort, $keyName); + + // inner — 키 컬럼만 조회한다. with() 를 비우는 이유 두 가지: + // (a) 성능 — 관계 쿼리가 inner/outer 두 번 실행된다 + // (b) 정합성 — inner 는 키 컬럼만 select 하므로 관계 FK 가 없어 eager load 가 깨진다 + // whereHas 는 결과 집합을 결정하는 조건이라 setEagerLoads 로 지워지지 않는다 (의도된 구분). + $inner = (clone $query)->setEagerLoads([]); + + // 상한이 지정되면 총 건수를 상한까지만 센다. 다음 페이지 판정은 총 건수와 무관하게 + // per_page + 1 실측으로 하므로, 마지막 페이지 번호 하나만 계산 불가가 된다. + $bounded = ! $simple && $resultCap !== null && $resultCap > 0; + $relation = null; + + if ($bounded && $total === null) { + // Eloquent 빌더를 그대로 넘긴다 — countWithCap 이 내부에서 toBase() 로 + // 글로벌 스코프까지 적용한 기반 쿼리를 만든다. + [$total, $relation] = BoundedPaginator::countWithCap(clone $inner, $resultCap); + } elseif (! $simple && $total === null) { + // Laravel 의 paginate() 와 같은 집계 경로를 쓴다. count() 는 groupBy/having 이 있는 + // 쿼리에서 `select count(*) ... group by ...` 를 그대로 실행해 **첫 그룹의 행 수**를 + // 총 건수로 돌려준다. getCountForPagination() 은 그룹 쿼리를 서브쿼리로 감싸므로 + // 그룹 수가 정확히 나온다. toBase() 는 글로벌 스코프를 적용한 뒤 기반 쿼리를 주므로 + // 소프트 삭제/필터 조건이 집계에서 누락되지 않는다. + $total = (clone $inner)->toBase()->getCountForPagination(); + } + + $this->applySortSpec($inner, $sort); + + // simple/bounded 모드는 다음 페이지 유무 판정을 위해 한 건을 더 읽는다. + // forPage() 를 쓰면 offset 이 (page-1) * (perPage+1) 로 계산돼 페이지가 넘어갈수록 + // 건너뛰는 행이 어긋나므로, offset 은 perPage 기준으로 직접 지정한다. + $probesNextPage = $simple || $bounded; + $fetchCount = $probesNextPage ? $perPage + 1 : $perPage; + + $ids = $inner + ->select($inner->getModel()->qualifyColumn($keyName)) + ->offset(($page - 1) * $perPage) + ->limit($fetchCount) + ->pluck($keyName); + + $hasMorePages = $probesNextPage && $ids->count() > $perPage; + + if ($bounded && $hasMorePages) { + $ids = $ids->slice(0, $perPage)->values(); + } + + $items = $ids->isEmpty() + ? new Collection + : $this->fetchDeferredJoinRows($query, $columns, $sort, $ids->all(), $relations, $withCount, $keyName, $preserveIdOrder, $outerUsing); + + $options = [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ]; + + if ($simple) { + return new Paginator($items, $perPage, $page, $options); + } + + if ($bounded) { + // 동시 삽입으로 집계 시점과 조회 시점의 모수가 달라질 수 있다 + $seenSoFar = ($page - 1) * $perPage + $items->count(); + + return new BoundedPage( + items: $items, + total: max((int) $total, $seenSoFar), + perPage: $perPage, + currentPage: $page, + totalRelation: $relation ?? TotalRelation::Exact, + resultCap: $resultCap, + hasMorePages: $hasMorePages, + options: $options, + ); + } + + return new LengthAwarePaginator($items, $total ?? $items->count(), $perPage, $page, $options); + } + + /** + * outer 쿼리를 실행해 실제 행을 조회합니다. + * + * @param Builder $query 필터가 적용된 원본 쿼리 (soft delete 스코프 보존을 위해 재사용) + * @param array $columns select 목록 + * @param array $sort 정렬 스펙 + * @param array $ids 이번 페이지의 키 값 목록 + * @param array $relations eager load 관계 + * @param array $withCount 관계 카운트 (목록/연관 두 형태 모두 허용) + * @param string $keyName 키 컬럼명 + * @param bool $preserveIdOrder ID 순서 그대로 복원 여부 + * @param Closure|null $outerUsing outer 전용 조인/집계 + * @return Collection 조회된 행 컬렉션 + */ + private function fetchDeferredJoinRows( + Builder $query, + array $columns, + array $sort, + array $ids, + array $relations, + array $withCount, + string $keyName, + bool $preserveIdOrder, + ?Closure $outerUsing = null, + ): Collection { + $outer = (clone $query)->setEagerLoads([]); + $qualifiedKey = $outer->getModel()->qualifyColumn($keyName); + + $outer->whereIn($qualifiedKey, $ids); + + // 결과 집합을 좁히지 않는 조인/집계(최근 댓글 서브쿼리, 상관 COUNT 등)는 여기서만 + // 붙인다. inner 에 두면 건너뛸 행 전체에 대해 실행되므로 OFFSET 이 깊어질수록 그 + // 비용이 그대로 누적된다. whereIn 뒤에 적용하므로 이번 페이지 건수에만 실행된다. + if ($outerUsing !== null) { + $outerUsing($outer); + } + + if (! empty($relations)) { + $outer->with($relations); + } + + if (! empty($withCount)) { + $outer->withCount($withCount); + } + + if ($preserveIdOrder) { + $this->applyIdOrder($outer, $qualifiedKey, $ids); + } else { + // 정렬 스펙에 키 컬럼이 포함돼 전순서가 보장되므로, 같은 스펙을 다시 적용하면 + // inner 가 정한 순서와 결과가 동일하다 (MySQL 전용 FIELD() 가 필요 없다). + $this->applySortSpec($outer, $sort); + } + + return $outer->get($columns); + } + + /** + * 정렬 스펙을 쿼리에 적용합니다. + * + * 컬럼이 상관 서브쿼리(Builder)면 `orderBy()` 가 서브 select 로 전개한다. 서브쿼리는 + * 원 행의 키에 상관되므로 inner/outer 어느 쪽에 적용해도 같은 순서를 만든다 — + * outer 에서는 이번 페이지 건수만큼만 실행된다. + * + * @param Builder $query 대상 쿼리 + * @param array $sort 정렬 스펙 + */ + private function applySortSpec(Builder $query, array $sort): void + { + foreach ($sort as $spec) { + $query->orderBy($spec['column'], $spec['direction']); + } + } + + /** + * 정렬 스펙 끝에 키 컬럼을 덧붙여 전순서를 만듭니다. + * + * 정렬 컬럼에 중복이 많으면(상태/조회수/평점 등) 동률 행의 순서가 실행마다 달라져 페이지 + * 경계에서 행이 중복되거나 누락된다. inner 와 outer 가 같은 순서를 갖도록 하는 전제이기도 하다. + * + * 정렬 컬럼은 문자열 외에 **상관 서브쿼리(Builder)** 나 Expression 도 올 수 있다 + * (관계 테이블 컬럼 기준 정렬 — `SortsByRelatedColumn`). 이 경우 키 컬럼일 수 없으므로 + * 전순서 보장을 위해 키 컬럼이 항상 덧붙는다. + * + * @param array $sort 정렬 스펙 + * @param string $keyName 키 컬럼명 + * @return array 키 컬럼이 보장된 정렬 스펙 + */ + private function normalizeSortSpecForKey(array $sort, string $keyName): array + { + $normalized = []; + + foreach ($sort as $spec) { + $column = $spec['column'] ?? null; + + if (is_string($column) ? $column === '' : ! ($column instanceof Builder || $column instanceof QueryBuilder || $column instanceof Expression)) { + continue; + } + + $normalized[] = [ + 'column' => $column, + 'direction' => strtolower($spec['direction'] ?? 'desc') === 'asc' ? 'asc' : 'desc', + ]; + } + + foreach ($normalized as $spec) { + // 이미 키 컬럼으로 정렬한다면 그것으로 전순서가 성립한다 + if (! is_string($spec['column'])) { + continue; + } + + if ($spec['column'] === $keyName || str_ends_with($spec['column'], '.'.$keyName)) { + return $normalized; + } + } + + $lastDirection = $normalized === [] ? 'desc' : end($normalized)['direction']; + + $normalized[] = ['column' => $keyName, 'direction' => $lastDirection]; + + return $normalized; + } + + /** + * inner 가 정한 ID 순서를 outer 에서 그대로 복원합니다. + * + * outer 에 존재하지 않는 표현식(집계/조인 서브쿼리 등)으로 정렬한 경우에만 사용한다. + * MySQL 전용 `FIELD()` 대신 표준 SQL `CASE WHEN` 을 쓴다 (DB 비의존 규정). + * + * @param Builder $query 대상 쿼리 + * @param string $qualifiedKey 테이블명이 붙은 키 컬럼 + * @param array $ids 복원할 순서의 키 값 목록 + */ + private function applyIdOrder(Builder $query, string $qualifiedKey, array $ids): void + { + $wrapped = $query->getQuery()->getGrammar()->wrap($qualifiedKey); + + $cases = []; + $bindings = []; + + foreach (array_values($ids) as $index => $id) { + $cases[] = "WHEN {$wrapped} = ? THEN {$index}"; + $bindings[] = $id; + } + + $query->orderByRaw('CASE '.implode(' ', $cases).' ELSE '.count($ids).' END', $bindings); + } +} diff --git a/app/Repositories/Concerns/ResolvesSortSpec.php b/app/Repositories/Concerns/ResolvesSortSpec.php new file mode 100644 index 00000000..91639093 --- /dev/null +++ b/app/Repositories/Concerns/ResolvesSortSpec.php @@ -0,0 +1,105 @@ + $allowedColumns 허용 정렬 컬럼 화이트리스트 + * @param string $defaultColumn 화이트리스트에 없거나 미전송일 때 사용할 컬럼 + * @param string $defaultDirection 기본 정렬 방향 (asc|desc) + * @param array $columnAliases 요청 값 → 실제 컬럼 매핑 (예: ['author' => 'author_name']) + * @param string $columnKey 정렬 컬럼이 담긴 필터 키 + * @param string $directionKey 정렬 방향이 담긴 필터 키 + * @return array 정렬 스펙 배열 + */ + protected function resolveSortSpec( + array $filters, + array $allowedColumns, + string $defaultColumn, + string $defaultDirection = 'desc', + array $columnAliases = [], + string $columnKey = 'sort_by', + string $directionKey = 'sort_order', + ): array { + $column = $this->normalizeSortValue($filters[$columnKey] ?? null); + $direction = $this->normalizeSortValue($filters[$directionKey] ?? null); + + if ($column !== null && isset($columnAliases[$column])) { + $column = $columnAliases[$column]; + } + + if ($column === null || ! in_array($column, $allowedColumns, true)) { + $column = $defaultColumn; + } + + return [[ + 'column' => $column, + 'direction' => $this->normalizeSortDirection($direction, $defaultDirection), + ]]; + } + + /** + * 정렬 방향 문자열을 asc/desc 로 정규화합니다. + * + * @param string|null $direction 요청 값 + * @param string $default 허용 값이 아닐 때 사용할 기본 방향 + * @return string asc 또는 desc + */ + protected function normalizeSortDirection(?string $direction, string $default = 'desc'): string + { + $normalized = strtolower((string) $direction); + + if (in_array($normalized, ['asc', 'desc'], true)) { + return $normalized; + } + + $fallback = strtolower($default); + + return in_array($fallback, ['asc', 'desc'], true) ? $fallback : 'desc'; + } + + /** + * 정렬 관련 요청 값을 문자열로 정규화합니다. (Enum/빈 문자열 흡수) + * + * @param mixed $value 요청 값 (문자열 또는 BackedEnum) + * @return string|null 정규화된 문자열 (값이 없으면 null) + */ + private function normalizeSortValue(mixed $value): ?string + { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (! is_string($value) && ! is_int($value)) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } +} diff --git a/app/Repositories/Concerns/SortsByRelatedColumn.php b/app/Repositories/Concerns/SortsByRelatedColumn.php new file mode 100644 index 00000000..93552f3e --- /dev/null +++ b/app/Repositories/Concerns/SortsByRelatedColumn.php @@ -0,0 +1,111 @@ + $allowedColumns 원 테이블 허용 정렬 컬럼 + * @param array, foreign_key: string, column: string}> $relatedMap 정렬 키 → 관계 스펙 + * @param Model $parent 정렬 대상 원 모델 (상관 조건의 좌변) + * @param string $defaultColumn 기본 정렬 컬럼 + * @param string $defaultDirection 기본 정렬 방향 + * @param string $columnKey 정렬 컬럼이 담긴 필터 키 + * @param string $directionKey 정렬 방향이 담긴 필터 키 + * @return array 정렬 스펙 배열 + */ + protected function resolveSortSpecWithRelated( + array $filters, + array $allowedColumns, + array $relatedMap, + Model $parent, + string $defaultColumn, + string $defaultDirection = 'desc', + string $columnKey = 'sort_by', + string $directionKey = 'sort_order', + ): array { + $requested = $filters[$columnKey] ?? null; + $requested = is_string($requested) ? trim($requested) : ''; + + if ($requested === '' || ! isset($relatedMap[$requested])) { + return $this->resolveSortSpec( + $filters, + $allowedColumns, + $defaultColumn, + $defaultDirection, + columnKey: $columnKey, + directionKey: $directionKey, + ); + } + + $direction = $this->normalizeSortDirection($filters[$directionKey] ?? null, $defaultDirection); + + return [[ + 'column' => $this->relatedSortSubquery($relatedMap[$requested], $parent, $direction), + 'direction' => $direction, + ]]; + } + + /** + * 관계 테이블에서 정렬 기준값 한 건을 뽑는 상관 서브쿼리를 만듭니다. + * + * 정렬 방향과 같은 방향으로 관계 행을 정렬해 첫 건만 취한다 — + * `desc` 면 가장 늦은 값, `asc` 면 가장 이른 값이 기준이 된다. + * + * @param array{model: class-string, foreign_key: string, column: string} $spec 관계 스펙 + * @param Model $parent 원 모델 + * @param string $direction 정렬 방향 (asc|desc) + * @return Builder 상관 서브쿼리 + */ + protected function relatedSortSubquery(array $spec, Model $parent, string $direction): Builder + { + /** @var Model $related */ + $related = new $spec['model']; + + return $related->newQuery() + ->select($related->qualifyColumn($spec['column'])) + ->whereColumn($related->qualifyColumn($spec['foreign_key']), $parent->qualifyColumn($parent->getKeyName())) + ->orderBy($related->qualifyColumn($spec['column']), $direction) + ->limit(1); + } +} diff --git a/app/Repositories/IdentityMessageDefinitionRepository.php b/app/Repositories/IdentityMessageDefinitionRepository.php index da177b78..d3f02c6e 100644 --- a/app/Repositories/IdentityMessageDefinitionRepository.php +++ b/app/Repositories/IdentityMessageDefinitionRepository.php @@ -4,11 +4,25 @@ namespace App\Repositories; use App\Contracts\Repositories\IdentityMessageDefinitionRepositoryInterface; use App\Models\IdentityMessageDefinition; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; class IdentityMessageDefinitionRepository implements IdentityMessageDefinitionRepositoryInterface { + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (AdminIdentityMessageDefinitionIndexRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = [ + 'id', + 'provider_id', + 'scope_type', + 'scope_value', + 'is_active', + 'created_at', + 'updated_at', + ]; + /** * ID로 메시지 정의 조회. * @@ -137,7 +151,11 @@ class IdentityMessageDefinitionRepository implements IdentityMessageDefinitionRe */ public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator { - $query = IdentityMessageDefinition::with('templates'); + // 목록은 대표 템플릿 1건만 싣는다 — 화면이 그리는 것은 `templates[0]` 의 제목/본문뿐이다. + // `templates` 를 통째로 로드하면 한 페이지를 여는 것만으로 그 페이지 전 정의의 모든 채널 + // 템플릿 본문이 응답에 실린다. 전체 템플릿은 단건 조회(show)가 공급한다. + $query = IdentityMessageDefinition::with('firstTemplate') + ->withCount('templates as templates_count'); if (! empty($filters['provider_id'])) { $query->where('provider_id', $filters['provider_id']); @@ -180,10 +198,11 @@ class IdentityMessageDefinitionRepository implements IdentityMessageDefinitionRe }); } - $sortBy = $filters['sort_by'] ?? 'id'; - $sortOrder = $filters['sort_order'] ?? 'asc'; - $query->orderBy($sortBy, $sortOrder); + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'id', 'asc') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); + } + // audit:allow repository-paginate-column-pruning reason: 본인인증 메시지 정의 테이블 — 정의 수가 고정이고 넓은 컬럼이 없다 return $query->paginate($perPage); } } diff --git a/app/Repositories/IdentityPolicyRepository.php b/app/Repositories/IdentityPolicyRepository.php index a6b7611b..54889b7f 100644 --- a/app/Repositories/IdentityPolicyRepository.php +++ b/app/Repositories/IdentityPolicyRepository.php @@ -252,6 +252,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface }); } + // audit:allow repository-paginate-column-pruning reason: 본인인증 정책 정의 테이블 — 정책 수가 고정이고 넓은 컬럼이 없다 return $query->orderByDesc('created_at')->orderByDesc('id')->paginate($perPage); } @@ -262,6 +263,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface */ public function allEnabled(): Collection { + // audit:allow query-unbounded-get reason: 본인인증 정책은 운영자가 등록한 수만큼만 존재한다 (사용량과 무관) return IdentityPolicy::query()->where('enabled', true)->get(); } @@ -346,6 +348,7 @@ class IdentityPolicyRepository implements IdentityPolicyRepositoryInterface return []; } + // audit:allow query-unbounded-get reason: 본인인증 정책은 운영자가 등록한 수만큼만 존재한다 (사용량과 무관) return IdentityPolicy::query() ->where('scope', 'hook') ->distinct() diff --git a/app/Repositories/IdentityVerificationLogRepository.php b/app/Repositories/IdentityVerificationLogRepository.php index 325222c4..d62ec2f3 100644 --- a/app/Repositories/IdentityVerificationLogRepository.php +++ b/app/Repositories/IdentityVerificationLogRepository.php @@ -4,7 +4,12 @@ namespace App\Repositories; use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface; use App\Enums\IdentityVerificationStatus; +use App\Helpers\TimezoneHelper; +use App\Models\IdentityPolicy; use App\Models\IdentityVerificationLog; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Support\Query\PaginationLimits; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; /** @@ -14,6 +19,8 @@ use Illuminate\Support\Carbon; */ class IdentityVerificationLogRepository implements IdentityVerificationLogRepositoryInterface { + use PaginatesWithDeferredJoin; + /** * 검증 로그를 생성합니다. * @@ -130,7 +137,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi * * @param array $filters 검색 필터 * @param int $perPage 페이지당 항목 수 - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 페이지네이터 + * @return LengthAwarePaginator 페이지네이터 */ public function search(array $filters, int $perPage = 20) { @@ -158,7 +165,7 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi if (! empty($filters['source_type'])) { $query->whereIn('origin_policy_key', function ($q) use ($filters) { $q->select('key') - ->from('identity_policies') + ->from((new IdentityPolicy)->getTable()) ->where('source_type', $filters['source_type']); if (! empty($filters['source_identifier'])) { $q->where('source_identifier', $filters['source_identifier']); @@ -190,21 +197,33 @@ class IdentityVerificationLogRepository implements IdentityVerificationLogReposi } } + // 기간 필터는 사이트 타임존 기준으로 해석한다 — created_at 은 UTC 로 저장되고 + // 화면은 사이트 타임존으로 보여주므로, 입력 문자열을 그대로 비교하면 하루가 어긋난다. + // 종료값은 시각 없이 날짜만 오는 경우(``) 그날 끝까지 포함한다. if (! empty($filters['date_from'])) { - $query->where('created_at', '>=', $filters['date_from']); + $query->where('created_at', '>=', TimezoneHelper::fromSiteDateTime($filters['date_from'])); } if (! empty($filters['date_to'])) { - $query->where('created_at', '<=', $filters['date_to']); + $query->where('created_at', '<=', TimezoneHelper::fromSiteRangeEnd($filters['date_to'])); } $sortBy = in_array($filters['sort_by'] ?? null, ['created_at', 'attempts'], true) ? $filters['sort_by'] : 'created_at'; $sortOrder = ($filters['sort_order'] ?? 'desc') === 'asc' ? 'asc' : 'desc'; - $query->orderBy($sortBy, $sortOrder); - return $query->paginate($perPage); + // 본인인증 로그는 계속 쌓이는 이력이라 뒤쪽 페이지에서 OFFSET 비용이 커진다. + // 응답이 요청/응답 payload 를 그대로 노출하므로 컬럼은 좁히지 않고 지연 조인만 적용한다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: [['column' => $sortBy, 'direction' => $sortOrder]], + perPage: $perPage, + // 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은 + // per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐). + resultCap: PaginationLimits::resultCap('admin.identity_logs'), + ); } /** diff --git a/app/Repositories/JsonConfigRepository.php b/app/Repositories/JsonConfigRepository.php index ed63319d..3087f26e 100644 --- a/app/Repositories/JsonConfigRepository.php +++ b/app/Repositories/JsonConfigRepository.php @@ -43,6 +43,17 @@ class JsonConfigRepository implements ConfigRepositoryInterface */ private ?array $cache = null; + /** + * 카테고리별 메모리 캐시 + * + * 부팅 한 번에 같은 카테고리를 여러 곳에서 읽는다 — SettingsServiceProvider 의 + * apply*Config 9종과 loadCoreSettingsToConfig 가 각각 조회하므로, 캐시가 없으면 + * 요청마다 카테고리 수만큼 곱한 횟수로 파일 존재 확인·읽기·JSON 파싱이 반복된다. + * + * @var array> + */ + private array $categoryCache = []; + /** * 모든 카테고리의 설정을 조회합니다. * @@ -67,9 +78,27 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 특정 카테고리의 설정을 조회합니다. * - * @return array + * @param string $category 카테고리명 + * @return array 기본값과 병합된 설정 */ public function getCategory(string $category): array + { + if (array_key_exists($category, $this->categoryCache)) { + return $this->categoryCache[$category]; + } + + return $this->categoryCache[$category] = $this->readCategory($category); + } + + /** + * 카테고리 설정을 파일에서 읽어 기본값과 병합합니다. + * + * 캐시를 거치지 않는 실제 읽기 경로입니다. + * + * @param string $category 카테고리명 + * @return array + */ + private function readCategory(string $category): array { if (! $this->categoryExists($category)) { return $this->getDefaultsForCategory($category); @@ -77,11 +106,17 @@ class JsonConfigRepository implements ConfigRepositoryInterface $path = $this->getCategoryPath($category); + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. if (! Storage::disk(self::STORAGE_DISK)->exists($path)) { return $this->getDefaultsForCategory($category); } try { + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. $content = Storage::disk(self::STORAGE_DISK)->get($path); $data = json_decode($content, true); @@ -113,6 +148,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface * 도트 노테이션으로 특정 설정값을 조회합니다. * * @param string $key 예: 'mail.host', 'general.site_name' + * @param mixed $default 값이 없을 때 돌려줄 기본값 + * @return mixed 설정값 (없으면 $default) */ public function get(string $key, mixed $default = null): mixed { @@ -131,6 +168,10 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 도트 노테이션으로 특정 설정값을 저장합니다. + * + * @param string $key 설정 키 (카테고리.항목) + * @param mixed $value 저장할 값 + * @return bool 저장 성공 여부 */ public function set(string $key, mixed $value): bool { @@ -150,7 +191,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 여러 설정을 일괄 저장합니다. * - * @param array $settings + * @param array $settings 도트 노테이션 키 ⇒ 값 + * @return bool 저장 성공 여부 */ public function setMany(array $settings): bool { @@ -171,7 +213,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 특정 카테고리의 설정을 저장합니다. * - * @param array $settings + * @param string $category 카테고리명 + * @param array $settings 저장할 설정 + * @return bool 저장 성공 여부 */ public function saveCategory(string $category, array $settings): bool { @@ -195,6 +239,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface try { // 파일 잠금으로 동시 쓰기 방지 + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. $fullPath = Storage::disk(self::STORAGE_DISK)->path($path); $handle = fopen($fullPath, 'c'); @@ -211,8 +258,10 @@ class JsonConfigRepository implements ConfigRepositoryInterface fclose($handle); - // 캐시 무효화 + // 캐시 무효화 — 저장한 카테고리와 전체 맵 양쪽을 비운다. + // 한쪽만 비우면 저장 직후 조회가 이전 값을 돌려준다. $this->cache = null; + unset($this->categoryCache[$category]); return true; } catch (\Exception $e) { @@ -226,6 +275,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 설정 키 존재 여부를 확인합니다. + * + * @param string $key 설정 키 (카테고리.항목) + * @return bool 존재 여부 */ public function has(string $key): bool { @@ -234,6 +286,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 특정 설정을 삭제합니다. + * + * @param string $key 설정 키 (카테고리.항목) + * @return bool 삭제 성공 여부 */ public function delete(string $key): bool { @@ -269,6 +324,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 카테고리 존재 여부를 확인합니다. + * + * @param string $category 카테고리명 + * @return bool 존재 여부 */ public function categoryExists(string $category): bool { @@ -278,7 +336,8 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 설정 파일을 초기화합니다. * - * @param array> $settings + * @param array> $settings 기본값 위에 덮어쓸 설정 + * @return bool 초기화 성공 여부 */ public function initialize(array $settings = []): bool { @@ -310,6 +369,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface $backupPath = self::BACKUP_DIR.'/'.$backupName; $zip = new \ZipArchive; + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. $fullBackupPath = Storage::disk(self::STORAGE_DISK)->path($backupPath); if ($zip->open($fullBackupPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { @@ -318,6 +380,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface foreach ($this->getCategories() as $category) { $categoryPath = $this->getCategoryPath($category); + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. $fullCategoryPath = Storage::disk(self::STORAGE_DISK)->path($categoryPath); if (file_exists($fullCategoryPath)) { @@ -332,9 +397,15 @@ class JsonConfigRepository implements ConfigRepositoryInterface /** * 백업에서 설정을 복원합니다. + * + * @param string $backupPath 백업 파일 경로 (disk 기준) + * @return bool 복원 성공 여부 */ public function restore(string $backupPath): bool { + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. $fullBackupPath = Storage::disk(self::STORAGE_DISK)->path($backupPath); if (! file_exists($fullBackupPath)) { @@ -356,12 +427,16 @@ class JsonConfigRepository implements ConfigRepositoryInterface if ($this->categoryExists($category)) { $content = $zip->getFromIndex($i); $categoryPath = $this->getCategoryPath($category); + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. Storage::disk(self::STORAGE_DISK)->put($categoryPath, $content); } } $zip->close(); $this->cache = null; + $this->categoryCache = []; return true; } @@ -457,6 +532,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface */ private function ensureDirectoryExists(): void { + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. $disk = Storage::disk(self::STORAGE_DISK); if (! $disk->exists('.')) { @@ -469,6 +547,9 @@ class JsonConfigRepository implements ConfigRepositoryInterface */ private function ensureBackupDirectoryExists(): void { + // audit:allow no-storage-disk-direct reason: 이 저장소는 SettingsServiceProvider::register() 에서 + // `new` 로 직접 생성된다 — 컨테이너 바인딩 이전이라 StorageInterface 를 해석할 수 없다. + // 설정 JSON 은 부팅 자체가 의존하는 자료라 드라이버 추상화보다 부팅 순서가 우선한다. $disk = Storage::disk(self::STORAGE_DISK); if (! $disk->exists(self::BACKUP_DIR)) { diff --git a/app/Repositories/LanguagePackRepository.php b/app/Repositories/LanguagePackRepository.php index 1ee83b3d..426408b1 100644 --- a/app/Repositories/LanguagePackRepository.php +++ b/app/Repositories/LanguagePackRepository.php @@ -4,10 +4,13 @@ namespace App\Repositories; use App\Contracts\Repositories\LanguagePackRepositoryInterface; use App\Enums\LanguagePackScope; +use App\Enums\LanguagePackSourceType; use App\Enums\LanguagePackStatus; +use App\Enums\TextDirection; use App\Models\LanguagePack; use App\Repositories\Concerns\HasMultipleSearchFilters; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -47,6 +50,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface */ public function getActivePacks(): Collection { + // audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관) return LanguagePack::query() ->where('status', LanguagePackStatus::Active->value) ->get(); @@ -93,6 +97,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface ?string $targetIdentifier, string $locale ): Collection { + // audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관) return LanguagePack::query() ->where('scope', $scope) ->where('target_identifier', $targetIdentifier) @@ -109,6 +114,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface */ public function getActiveCoreLocales(): array { + // audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관) return LanguagePack::query() ->where('scope', LanguagePackScope::Core->value) ->where('status', LanguagePackStatus::Active->value) @@ -127,6 +133,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface */ public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator { + // audit:allow repository-paginate-column-pruning reason: 언어팩 정의 테이블 — 설치된 팩 수만큼만 존재하고 넓은 컬럼이 없다 return $this->buildFilteredQuery($filters)->paginate($perPage); } @@ -148,9 +155,9 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface * (실제 미설치 번들은 Service 계층에서 합쳐집니다). * * @param array $filters 필터 조건 - * @return \Illuminate\Database\Eloquent\Builder 정렬까지 적용된 쿼리 빌더 + * @return Builder 정렬까지 적용된 쿼리 빌더 */ - private function buildFilteredQuery(array $filters): \Illuminate\Database\Eloquent\Builder + private function buildFilteredQuery(array $filters): Builder { $query = LanguagePack::query(); @@ -165,7 +172,8 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface } if (! empty($filters['status'])) { if ($filters['status'] === LanguagePackStatus::Uninstalled->value) { - $query->whereRaw('1 = 0'); + // 결과 없음 — 빈 whereIn 은 `0 = 1` 로 컴파일된다(raw 불필요) + $query->whereIn($query->getModel()->getKeyName(), []); } else { $query->where('status', $filters['status']); } @@ -190,7 +198,9 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface return $query ->orderBy('scope') ->orderBy('target_identifier') - ->orderBy('locale'); + ->orderBy('locale') + // 전순서 보장 — 위 세 컬럼 조합이 유니크하더라도 정렬 계약을 키로 닫아 둔다 + ->orderBy('id'); } /** @@ -239,6 +249,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface */ public function getPacksForTarget(string $scope, string $targetIdentifier): Collection { + // audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관) return LanguagePack::query() ->where('scope', $scope) ->where('target_identifier', $targetIdentifier) @@ -253,6 +264,7 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface */ public function getPacksForLocale(string $locale): Collection { + // audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관) return LanguagePack::query()->where('locale', $locale)->get(); } @@ -282,10 +294,10 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface $pack->version = (string) ($manifest['version'] ?? '0.0.0'); $pack->license = $manifest['license'] ?? null; $pack->description = is_array($manifest['description'] ?? null) ? $manifest['description'] : null; - $pack->status = \App\Enums\LanguagePackStatus::Uninstalled->value; + $pack->status = LanguagePackStatus::Uninstalled->value; $pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (요구사항 #3) $pack->manifest = $manifest; - $pack->source_type = \App\Enums\LanguagePackSourceType::Bundled->value; + $pack->source_type = LanguagePackSourceType::Bundled->value; $pack->source_url = $bundledIdentifier; $pack->setAttribute('bundled_identifier', $bundledIdentifier); @@ -335,11 +347,11 @@ class LanguagePackRepository implements LanguagePackRepositoryInterface $pack->locale = $locale; $pack->locale_name = strtoupper($locale); $pack->locale_native_name = $nativeName; - $pack->text_direction = \App\Enums\TextDirection::Ltr->value; + $pack->text_direction = TextDirection::Ltr->value; $pack->version = $version; - $pack->status = \App\Enums\LanguagePackStatus::Active->value; + $pack->status = LanguagePackStatus::Active->value; $pack->is_protected = true; - $pack->source_type = \App\Enums\LanguagePackSourceType::BuiltIn->value; + $pack->source_type = LanguagePackSourceType::BuiltIn->value; $pack->source_url = $langPathRelative; $pack->manifest = [ 'identifier' => $identifier, diff --git a/app/Repositories/LayoutExtensionVersionRepository.php b/app/Repositories/LayoutExtensionVersionRepository.php index 3c9b9dab..04f82095 100644 --- a/app/Repositories/LayoutExtensionVersionRepository.php +++ b/app/Repositories/LayoutExtensionVersionRepository.php @@ -48,20 +48,49 @@ class LayoutExtensionVersionRepository implements LayoutExtensionVersionReposito } /** - * 특정 확장의 모든 버전 조회 (최신순) + * 특정 확장의 최근 버전 목록 조회 (최신순) * * @param int $extensionId 레이아웃 확장 ID + * @param int $limit 조회할 최대 버전 수 (버전 행은 정리되지 않고 쌓이므로 상한 필수) * @return Collection 버전 컬렉션 */ - public function getVersions(int $extensionId): Collection + public function getVersions(int $extensionId, int $limit = 100): Collection { // creator eager load — 버전 목록에 저장자 이름(created_by_name) 노출용 (N+1 회피, // 레이아웃 본체 getVersionsByLayoutId 와 동일 정책). + // + // 버전 행은 저장할 때마다 쌓이고 정리되지 않으므로 조회 건수에 상한을 둔다. 목록이 쓰지 + // 않는 `content`(버전마다 확장 본문 사본)도 조회하지 않는다 — 본문은 버전 비교 diff + // 전용이며 단건 조회가 공급한다. (레이아웃 본체와 동일 정책) return $this->model ->with('creator:id,name') ->where('extension_id', $extensionId) ->orderBy('version', 'desc') - ->get(); + ->limit($limit) + ->get(['id', 'extension_id', 'version', 'changes_summary', 'created_by', 'created_at']); + } + + /** + * 확장의 특정 버전 번호 조회 (본문 포함) + * + * 목록(getVersions)에서 뺀 `content` 의 대체 경로다. 목록 조회를 재사용하면 두 가지가 + * 동시에 깨진다 — ① 목록이 조회하지 않는 `content` 가 빈 값이 되어 버전 비교 diff 가 + * "전 항목 삭제"로 표시되고 ② 목록 상한(기본 100건) 밖의 오래된 버전이 404 가 된다. + * (레이아웃 본체 LayoutRepository::findVersionByNumber 와 동일 정책) + * + * @param int $extensionId 레이아웃 확장 ID + * @param int $version 버전 번호 + * @return TemplateLayoutExtensionVersion|null 찾은 버전 모델 또는 null + */ + public function findVersionByNumber(int $extensionId, int $version): ?TemplateLayoutExtensionVersion + { + // creator eager load — 단건 버전 조회도 저장자 이름을 목록과 일관 노출. + return $this->model + ->newQuery() + ->with('creator:id,name') + ->where('extension_id', $extensionId) + ->where('version', $version) + ->first(); } /** diff --git a/app/Repositories/LayoutRepository.php b/app/Repositories/LayoutRepository.php index 46eba571..388a6e17 100644 --- a/app/Repositories/LayoutRepository.php +++ b/app/Repositories/LayoutRepository.php @@ -7,22 +7,88 @@ use App\Enums\LayoutSourceType; use App\Models\TemplateLayout; use App\Models\TemplateLayoutVersion; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as SupportCollection; class LayoutRepository implements LayoutRepositoryInterface { + /** @var int 목록 조회 시 본문을 흘려 읽는 청크 크기 */ + private const LIST_CHUNK_SIZE = 25; + /** * 특정 템플릿의 모든 레이아웃 조회 * + * 편집 본문(`content`)은 읽지 않는다. 이 메서드의 남은 소비처는 캐시 무효화 + * (`InvalidatesLayoutCache` 트레이트)뿐이고 그 경로는 캐시 키를 만드는 식별 컬럼만 쓰는데, + * 템플릿 하나에 수십~수백 레이아웃이 있으면 본문 JSON 전량이 메모리에 올라온다. + * + * 목록 응답은 이 메서드가 아니라 {@see getListByTemplateId()} 가 공급하고(본문에서 파생되는 + * 설명·크기만 남기고 본문은 즉시 버린다), 본문 자체가 필요한 경로는 단건 조회 + * (`findByName`)가 공급한다. + * * @param int $templateId 템플릿 ID * @return Collection 레이아웃 컬렉션 */ public function getByTemplateId(int $templateId): Collection { return TemplateLayout::where('template_id', $templateId) + ->select([ + 'id', 'template_id', 'name', 'original_content_hash', 'original_content_size', + 'extends', 'source_type', 'source_identifier', 'created_by', 'updated_by', + 'lock_version', 'created_at', 'updated_at', + ]) ->orderBy('name') ->get(); } + /** + * 목록 표시용 경량 레이아웃 행을 조회합니다. + * + * 목록 화면은 이름·설명·크기·수정일만 쓰고 본문은 상세 엔드포인트가 따로 제공한다. + * 그런데 `getByTemplateId()` 로 전체 컬럼을 읽으면 `content`(레이아웃 본문)까지 전부 + * 메모리에 올라간다 — sirsoft-admin_basic 기준 102행 합계 17MB 이상이다. + * + * 여기서는 chunk 로 흘려 읽으며 본문에서 파생되는 값(설명·크기)만 뽑고 본문 자체는 + * 즉시 버린다. 결과적으로 반환 컬렉션에 본문이 남지 않고, PHP 피크 메모리도 청크 + * 단위로 묶인다. + * + * @param int $templateId 대상 템플릿 ID + * @return Collection 목록 행 배열 (id, template_id, name, description, size, lock_version, created_at, updated_at) + */ + public function getListByTemplateId(int $templateId): SupportCollection + { + $rows = collect(); + + // 순회 커서는 기본키다 — `chunkById` 는 `where id > lastId` 로 다음 페이지를 잡으므로 + // 여기에 `orderBy('name')` 을 함께 걸면 정렬 순서와 커서 기준이 어긋나 행이 조용히 + // 누락된다(실측: 102행 중 58행만 반환). 표시 정렬은 순회를 마친 뒤에 적용한다. + TemplateLayout::query() + ->where('template_id', $templateId) + ->select(['id', 'template_id', 'name', 'content', 'lock_version', 'created_at', 'updated_at']) + ->chunkById(self::LIST_CHUNK_SIZE, function (Collection $chunk) use ($rows) { + foreach ($chunk as $layout) { + $content = $layout->content; + $contentJson = is_array($content) + ? json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : '{}'; + + $rows->push([ + 'id' => $layout->id, + 'template_id' => $layout->template_id, + 'name' => $layout->name, + // 본문에서 파생 — 이 값만 남기고 본문은 버린다 + 'description' => $content['meta']['description'] ?? null, + 'size' => strlen($contentJson), + 'lock_version' => $layout->lock_version, + 'created_at' => $layout->created_at, + 'updated_at' => $layout->updated_at, + ]); + } + }); + + // 표시 정렬은 순회와 분리 — 커서 기준(id)과 섞으면 행이 누락된다 + return $rows->sortBy('name', SORT_STRING)->values(); + } + /** * 특정 레이아웃 조회 (템플릿 ID와 이름으로) * @@ -37,6 +103,28 @@ class LayoutRepository implements LayoutRepositoryInterface ->first(); } + /** + * 특정 레이아웃을 영구 삭제 (템플릿 ID와 이름으로) + * + * @param int $templateId 템플릿 ID + * @param string $name 레이아웃 이름 + * @return bool 삭제 여부 (대상 부재 시 false) + */ + public function deleteByName(int $templateId, string $name): bool + { + $layout = TemplateLayout::where('template_id', $templateId) + ->where('name', $name) + ->first(); + + if (! $layout) { + return false; + } + + $layout->forceDelete(); + + return true; + } + /** * ID로 레이아웃 조회 * @@ -261,15 +349,24 @@ class LayoutRepository implements LayoutRepositoryInterface * 특정 레이아웃의 모든 버전 조회 * * @param int $layoutId 레이아웃 ID + * @param int $limit 조회할 최대 버전 수 (버전 행은 정리되지 않고 쌓이므로 상한 필수) * @return Collection 버전 컬렉션 */ - public function getVersionsByLayoutId(int $layoutId): Collection + public function getVersionsByLayoutId(int $layoutId, int $limit = 100): Collection { // creator eager load — 버전 목록에 저장자 이름(created_by_name) 노출용 (N+1 회피). + // + // 목록이 쓰는 컬럼만 조회한다. `content` 는 버전마다 레이아웃 본문 사본이라, 상한이 있어도 + // 100건이면 본문 100벌을 읽는다. 본문은 버전 비교 diff 전용이며 단건 조회가 공급한다. + // 정렬 기준은 `version` — 레이아웃 안에서 유일하다(uk_layout_version). 종전의 + // `latest()`(created_at)는 같은 초에 저장된 버전들의 순서를 정하지 못해, 상한이 걸린 + // 지금은 "어느 100건이 오는가" 까지 실행마다 달라질 수 있다. return TemplateLayoutVersion::with('creator:id,name') ->where('layout_id', $layoutId) - ->latest() - ->get(); + ->orderBy('version', 'desc') + ->limit($limit) + // 이 테이블에는 updated_at 이 없다 (버전 행은 불변 스냅샷이라 created_at 만 둔다) + ->get(['id', 'layout_id', 'version', 'changes_summary', 'created_by', 'created_at']); } /** @@ -323,9 +420,9 @@ class LayoutRepository implements LayoutRepositoryInterface * 특정 템플릿의 모든 레이아웃 이름 조회 * * @param int $templateId 템플릿 ID - * @return \Illuminate\Support\Collection 레이아웃 이름 컬렉션 + * @return SupportCollection 레이아웃 이름 컬렉션 */ - public function getLayoutNamesByTemplateId(int $templateId): \Illuminate\Support\Collection + public function getLayoutNamesByTemplateId(int $templateId): SupportCollection { return TemplateLayout::where('template_id', $templateId) ->pluck('name') diff --git a/app/Repositories/MenuRepository.php b/app/Repositories/MenuRepository.php index 126ae382..b9d5b17b 100644 --- a/app/Repositories/MenuRepository.php +++ b/app/Repositories/MenuRepository.php @@ -3,15 +3,31 @@ namespace App\Repositories; use App\Contracts\Repositories\MenuRepositoryInterface; -use App\Helpers\PermissionHelper; use App\Enums\ExtensionOwnerType; +use App\Helpers\PermissionHelper; use App\Models\Menu; +use App\Models\Module; +use App\Models\Plugin; use App\Models\User; +use App\Repositories\Concerns\ResolvesSortSpec; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; class MenuRepository implements MenuRepositoryInterface { + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (MenuListRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = ['created_at', 'name', 'slug', 'order']; + + /** + * 다국어 JSON 컬럼 목록 (검색·정렬 시 로케일 경로로 풀어야 하는 컬럼) + * + * @var array + */ + private const TRANSLATABLE_COLUMNS = ['name']; + /** * 모든 메뉴를 조회합니다. * @@ -37,6 +53,14 @@ class MenuRepository implements MenuRepositoryInterface ->get(); } + /** + * 관리용 최상위 메뉴 목록을 조회합니다. (비활성 확장 메뉴 제외) + * + * @param array $activeModuleIdentifiers 활성 모듈 식별자 목록 + * @param array $activePluginIdentifiers 활성 플러그인 식별자 목록 + * @param User|null $user 접근 권한 판정 대상 사용자 (null 이면 역할 필터 미적용) + * @return Collection 최상위 메뉴 컬렉션 (자식 메뉴 포함) + */ public function getTopLevelMenusForManagement(array $activeModuleIdentifiers = [], array $activePluginIdentifiers = [], ?User $user = null): Collection { $extensionFilter = function ($query) use ($activeModuleIdentifiers, $activePluginIdentifiers) { @@ -67,13 +91,18 @@ class MenuRepository implements MenuRepositoryInterface } return $query->with(['creator', 'roles', 'children' => function ($query) use ($extensionFilter, $user) { - $query->where($extensionFilter); - if ($user) { - $query->accessibleBy($user); - } - $query->with('roles') - ->orderBy('order'); - }]) + $query->where($extensionFilter); + if ($user) { + $query->accessibleBy($user); + } + // 하위 메뉴의 역할은 목록에서 로드하지 않는다 — 화면이 쓰는 것은 하위 메뉴의 + // 개수와 표시 정보뿐이고, 역할 배지는 선택한(상위) 메뉴에만 나온다. 로드하면 + // 상위 메뉴마다 하위 전체의 역할 피벗까지 함께 실려 응답이 배가 된다. + // Resource 는 로드되지 않은 하위 메뉴의 `roles` 키를 **생략**한다(빈 배열이 아니다) + // — 빈 배열은 "역할 제한 없음" 이라는 사실 아닌 단언이 되어, 화면이 그 값을 그대로 + // 저장 요청에 실으면 역할 제한이 통째로 해제된다. + $query->orderBy('order'); + }]) ->orderBy('order') ->get(); } @@ -260,7 +289,7 @@ class MenuRepository implements MenuRepositoryInterface $q->where('extension_type', ExtensionOwnerType::Module) ->whereExists(function ($subQuery) { $subQuery->select(DB::raw(1)) - ->from('modules') + ->from((new Module)->getTable()) ->whereColumn('modules.identifier', 'menus.extension_identifier') ->where('modules.status', 'active'); }); @@ -270,7 +299,7 @@ class MenuRepository implements MenuRepositoryInterface $q->where('extension_type', ExtensionOwnerType::Plugin) ->whereExists(function ($subQuery) { $subQuery->select(DB::raw(1)) - ->from('plugins') + ->from((new Plugin)->getTable()) ->whereColumn('plugins.identifier', 'menus.extension_identifier') ->where('plugins.status', 'active'); }); @@ -359,6 +388,15 @@ class MenuRepository implements MenuRepositoryInterface return (int) Menu::where('parent_id', $parentId)->max('order'); } + /** + * 필터 조건이 적용된 최상위 메뉴 목록을 조회합니다. + * + * @param array $filters 필터 조건 배열 (sort_by/sort_order/검색 필드) + * @param array $activeModuleIdentifiers 활성 모듈 식별자 목록 + * @param array $activePluginIdentifiers 활성 플러그인 식별자 목록 + * @param User|null $user 접근 권한 판정 대상 사용자 (null 이면 역할 필터 미적용) + * @return Collection 최상위 메뉴 컬렉션 (자식 메뉴 포함) + */ public function getFilteredTopLevelMenus(array $filters, array $activeModuleIdentifiers = [], array $activePluginIdentifiers = [], ?User $user = null): Collection { $extensionFilter = function ($query) use ($activeModuleIdentifiers, $activePluginIdentifiers) { @@ -408,20 +446,20 @@ class MenuRepository implements MenuRepositoryInterface $query->where(function ($q) use ($searchableFields, $value, $operator) { foreach ($searchableFields as $searchField) { $q->orWhere(function ($subQ) use ($searchField, $value, $operator) { - $this->applyFilterOperator($subQ, $searchField, $value, $operator); + $this->applySearchableField($subQ, $searchField, $value, $operator); }); } }); } elseif (in_array($field, $searchableFields)) { - $this->applyFilterOperator($query, $field, $value, $operator); + $this->applySearchableField($query, $field, $value, $operator); } } } - // 정렬 - $sortBy = $filters['sort_by'] ?? 'order'; - $sortOrder = $filters['sort_order'] ?? 'asc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 (허용 컬럼 화이트리스트로 해석) + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'order', 'asc') as $sort) { + $query->orderBy($this->qualifySortColumn($sort['column']), $sort['direction']); + } // 자식 메뉴도 동일한 조건 적용하여 로드 return $query->with(['creator', 'roles', 'children' => function ($childQuery) use ($extensionFilter, $filters, $user) { @@ -437,18 +475,86 @@ class MenuRepository implements MenuRepositoryInterface $childQuery->where('is_active', $filters['is_active']); } - $childQuery->with('roles')->orderBy('order'); + // 하위 메뉴의 역할은 목록에서 로드하지 않는다 — 화면이 쓰는 것은 하위 메뉴의 + // 개수와 표시 정보뿐이고, 역할 배지는 선택한(상위) 메뉴에만 나온다. 로드하면 + // 상위 메뉴마다 하위 전체의 역할 피벗까지 함께 실려 응답이 배가 된다. + // 접근 제어(accessibleBy)는 whereHas 로 결과 집합을 좁히므로 그대로 유지된다. + // Resource 는 로드되지 않은 하위 메뉴의 `roles` 키를 생략한다 — 위 메서드와 동일. + $childQuery->orderBy('order'); }])->get(); } /** * 필터 연산자를 쿼리에 적용합니다. * - * @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더 + * @param Builder $query 쿼리 빌더 * @param string $field 필드명 * @param string $value 검색 값 * @param string $operator 연산자 (like, eq, starts_with, ends_with) */ + /** + * 검색 대상 필드에 조건을 적용합니다. (다국어 JSON 컬럼 인지) + * + * `name` 은 다국어 JSON 컬럼이다. JSON 은 유니코드 이스케이프(`\uXXXX`)로 저장되므로 + * 컬럼 전체에 LIKE 를 걸면 한글·일본어 검색어가 **절대 매칭되지 않는다** + * (저장된 바이트는 `{"ko":"대시보드"}` 이다). + * 지원 로케일별 JSON 경로(`name->ko`)로 나눠 검색한다 — 저장소 공통 관례다. + * + * @param Builder $query 쿼리 빌더 + * @param string $field 필드명 + * @param string $value 검색 값 + * @param string $operator 연산자 (like, eq, starts_with, ends_with) + */ + private function applySearchableField($query, string $field, string $value, string $operator): void + { + if (! in_array($field, self::TRANSLATABLE_COLUMNS, true)) { + $this->applyFilterOperator($query, $field, $value, $operator); + + return; + } + + $query->where(function ($q) use ($field, $value, $operator) { + foreach ($this->translatableLocales() as $locale) { + $q->orWhere(function ($subQ) use ($field, $locale, $value, $operator) { + $this->applyFilterOperator($subQ, "{$field}->{$locale}", $value, $operator); + }); + } + }); + } + + /** + * 정렬 컬럼이 다국어 JSON 컬럼이면 현재 로케일 경로로 바꿉니다. + * + * JSON 원문으로 정렬하면 `{"ko":"\uXXXX...` 문자열 순서가 되어 사람이 읽는 순서와 + * 무관해진다. 화면에 보이는 값(현재 로케일)으로 정렬해야 목록 순서가 납득 가능해진다. + * + * @param string $column 정렬 컬럼 + * @return string 실제 정렬에 쓸 컬럼 표현 + */ + private function qualifySortColumn(string $column): string + { + if (! in_array($column, self::TRANSLATABLE_COLUMNS, true)) { + return $column; + } + + return $column.'->'.app()->getLocale(); + } + + /** + * 다국어 필드가 값을 가질 수 있는 로케일 목록을 반환합니다. + * + * 다국어 JSON 필드의 언어 집합은 `translatable_locales` 다 — 번역 파일이 없어도 + * 데이터는 저장될 수 있으므로 UI 표시 언어(`supported_locales`)보다 넓다. + * + * @return array 로케일 코드 목록 + */ + private function translatableLocales(): array + { + $locales = config('app.translatable_locales', config('app.supported_locales', [])); + + return empty($locales) ? [app()->getLocale()] : array_values($locales); + } + private function applyFilterOperator($query, string $field, string $value, string $operator): void { switch ($operator) { diff --git a/app/Repositories/NotificationDefinitionRepository.php b/app/Repositories/NotificationDefinitionRepository.php index a652e898..482fac38 100644 --- a/app/Repositories/NotificationDefinitionRepository.php +++ b/app/Repositories/NotificationDefinitionRepository.php @@ -4,15 +4,28 @@ namespace App\Repositories; use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface; use App\Models\NotificationDefinition; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Database\Eloquent\Collection; use Illuminate\Pagination\LengthAwarePaginator; class NotificationDefinitionRepository implements NotificationDefinitionRepositoryInterface { + use ResolvesSortSpec; + + /** 허용 정렬 컬럼 (NotificationDefinitionIndexRequest 와 동일 집합) */ + private const SORTABLE_COLUMNS = [ + 'id', + 'type', + 'extension_type', + 'is_active', + 'created_at', + 'updated_at', + ]; + /** * ID로 알림 정의 조회. * - * @param int $id + * @param int $id * @return NotificationDefinition|null */ public function findById(int $id): ?NotificationDefinition @@ -23,7 +36,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 타입으로 알림 정의 조회. * - * @param string $type + * @param string $type * @return NotificationDefinition|null */ public function findByType(string $type): ?NotificationDefinition @@ -34,7 +47,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 활성 상태인 특정 타입 알림 정의 조회. * - * @param string $type + * @param string $type * @return NotificationDefinition|null */ public function getActiveByType(string $type): ?NotificationDefinition @@ -58,7 +71,7 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito * 알림 목록 응답에서 N+1 회피 목적으로 한 번 호출하여 [type => label] 맵을 생성합니다. * 라벨 fallback 우선순위: 사용자 로케일 → ko → en → type 식별자 * - * @param string|null $locale 사용자 로케일 (null이면 app()->getLocale()) + * @param string|null $locale 사용자 로케일 (null이면 app()->getLocale()) * @return array */ public function getLabelMap(?string $locale = null): array @@ -86,8 +99,8 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 특정 확장의 알림 정의 목록 조회. * - * @param string $extensionType - * @param string $extensionIdentifier + * @param string $extensionType + * @param string $extensionIdentifier * @return Collection */ public function getByExtension(string $extensionType, string $extensionIdentifier): Collection @@ -98,8 +111,8 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 알림 정의 수정. * - * @param NotificationDefinition $definition - * @param array $data + * @param NotificationDefinition $definition + * @param array $data * @return NotificationDefinition */ public function update(NotificationDefinition $definition, array $data): NotificationDefinition @@ -112,13 +125,34 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito /** * 페이지네이션 목록 조회. * - * @param array $filters - * @param int $perPage + * @param array $filters + * @param int $perPage * @return LengthAwarePaginator */ public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator { - $query = NotificationDefinition::with('templates'); + // 목록은 템플릿 본문을 싣지 않는다. + // + // 좁히지 않으면 한 페이지를 여는 것만으로 그 페이지 전 정의의 **모든 채널** 제목·본문이 + // 응답에 실린다(정의 하나당 채널 × 로케일). 화면은 한 번에 한 채널만 그리므로 그 채널을 + // `template_channel` 로 지정해 요청한다 — 지정하지 않은 호출자는 템플릿을 받지 않고, + // 필요하면 단건 조회(`GET .../notification-definitions/{id}`)가 전 채널을 제공한다. + $templateChannel = $filters['template_channel'] ?? null; + + $query = NotificationDefinition::query() + // "되돌리기" 버튼 노출 조건은 채널을 가리지 않고 "커스터마이즈된 템플릿이 하나라도 + // 있는가" 다. 템플릿을 한 채널로 좁히면 배열만으로는 알 수 없으므로 집계로 낸다. + ->withCount([ + 'templates as customized_templates_count' => function ($q) { + $q->where('is_default', false); + }, + ]); + + if (! empty($templateChannel)) { + $query->with([ + 'templates' => fn ($q) => $q->where('channel', $templateChannel), + ]); + } if (! empty($filters['extension_type'])) { $query->where('extension_type', $filters['extension_type']); @@ -148,10 +182,11 @@ class NotificationDefinitionRepository implements NotificationDefinitionReposito }); } - $sortBy = $filters['sort_by'] ?? 'id'; - $sortOrder = $filters['sort_order'] ?? 'asc'; - $query->orderBy($sortBy, $sortOrder); + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'id', 'asc') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); + } + // audit:allow repository-paginate-column-pruning reason: 알림 정의 테이블 — 정의 수가 고정이고 넓은 컬럼이 없다 return $query->paginate($perPage); } } diff --git a/app/Repositories/NotificationLogRepository.php b/app/Repositories/NotificationLogRepository.php index f92cf1a4..52415dc6 100644 --- a/app/Repositories/NotificationLogRepository.php +++ b/app/Repositories/NotificationLogRepository.php @@ -6,12 +6,39 @@ use App\Contracts\Repositories\NotificationLogRepositoryInterface; use App\Enums\NotificationLogStatus; use App\Models\NotificationLog; use App\Models\User; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; +use App\Support\Query\KeysetPaginator; +use App\Support\Query\PaginationLimits; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Pagination\LengthAwarePaginator; class NotificationLogRepository implements NotificationLogRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + + /** + * 발송 이력 목록 정렬 허용 컬럼 + * + * 요청 값을 그대로 orderBy 에 넘기면 없는 컬럼으로 SQL 오류가 나거나 인덱스 없는 컬럼 + * 정렬을 강제할 수 있다. + * + * @var array + */ + private const SORTABLE_COLUMNS = [ + 'id', + 'sent_at', + 'created_at', + 'status', + 'channel', + 'notification_type', + 'recipient_name', + 'subject', + ]; + /** * 최근 발송된 알림 로그를 발송 시각 최신순으로 조회합니다 (대시보드 최근 알림). * @@ -77,11 +104,12 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface * @param array $filters 필터 조건 * @param int $perPage 페이지당 건수 * @param User|null $scopeUser 스코프 적용 대상 사용자 (null이면 스코프 미적용) - * @return LengthAwarePaginator 페이지네이션 결과 + * @return LengthAwarePaginator|CursorPaginator 페이지네이션 결과 (커서 요청 시 키셋) */ - 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 { - $query = NotificationLog::with(['senderUser', 'recipientUser']); + // 관계는 지연 조인의 outer 에서만 로드한다 (inner 는 키 컬럼만 조회한다) + $query = NotificationLog::query(); // notification-logs scope: 전달된 사용자의 권한 스코프 적용 if ($scopeUser) { @@ -125,11 +153,39 @@ class NotificationLogRepository implements NotificationLogRepositoryInterface }); } - $sortBy = $filters['sort_by'] ?? 'sent_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'sent_at'); - return $query->paginate($perPage); + // 커서를 받은 요청은 키셋으로 응답한다. 로그는 계속 쌓이기만 해서 깊은 페이지를 + // OFFSET 으로 훑으면 건너뛸 행을 실제로 읽어야 하지만, 커서는 직전 페이지의 정렬 + // 키를 WHERE 경계로 삼아 깊이와 무관하게 일정하다. + $sortKeys = array_map( + static fn (array $spec): array => [$spec['column'], $spec['direction']], + $sort + ); + + if (! empty($filters['cursor']) && KeysetPaginator::supports($sortKeys, self::SORTABLE_COLUMNS)) { + return KeysetPaginator::paginate( + query: $query->with(['senderUser', 'recipientUser']), + perPage: $perPage, + sortKeys: $sortKeys, + uniqueKey: 'id', + cursor: (string) $filters['cursor'], + ); + } + + // 목록 컬럼을 좁히지 않는 이유: 이 목록의 리소스는 렌더링된 본문(longText `body`)까지 + // 그대로 노출하므로 컬럼을 빼면 응답 계약이 바뀐다. 지연 조인만으로도 본문을 읽는 + // 행 수가 OFFSET 과 무관하게 이번 페이지 분량으로 고정된다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + relations: ['senderUser', 'recipientUser'], + // 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은 + // per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐). + resultCap: PaginationLimits::resultCap('admin.notification_logs'), + ); } /** diff --git a/app/Repositories/NotificationRepository.php b/app/Repositories/NotificationRepository.php index bc23df76..07a4cb10 100644 --- a/app/Repositories/NotificationRepository.php +++ b/app/Repositories/NotificationRepository.php @@ -4,6 +4,8 @@ namespace App\Repositories; use App\Contracts\Repositories\NotificationRepositoryInterface; use App\Models\User; +use App\Support\Query\BoundedPaginator; +use App\Support\Query\PaginationLimits; use Illuminate\Notifications\DatabaseNotification; use Illuminate\Pagination\LengthAwarePaginator; @@ -11,6 +13,11 @@ class NotificationRepository implements NotificationRepositoryInterface { /** * 사용자의 알림 목록을 페이지네이션으로 조회합니다. + * + * @param User $user 대상 사용자 + * @param array $filters 필터 조건 (read: unread|read) + * @param int $perPage 페이지당 항목 수 + * @return LengthAwarePaginator 페이지네이션된 알림 목록 */ public function getByUser(User $user, array $filters = [], int $perPage = 20): LengthAwarePaginator { @@ -22,11 +29,26 @@ class NotificationRepository implements NotificationRepositoryInterface $query->whereNotNull('read_at'); } - return $query->orderBy('created_at', 'desc')->paginate($perPage); + // audit:allow repository-paginate-column-pruning reason: 사용자 1명에 종속된 알림함 — + // $user->notifications() 로 이미 좁혀지고, 목록이 표시하는 본문 자체가 data(JSON) 라 + // 걷어낼 넓은 컬럼이 없다 + // 정렬 마지막의 기본키는 전순서 보장용이다 — created_at 동률에서 페이지 경계가 + // 흔들려 인접 페이지가 같은 알림을 중복 노출하고 다른 알림을 누락하는 것을 막는다. + // + // 알림함은 한 사용자에 묶이지만 시간이 지날수록 계속 쌓인다(설정성 테이블이 아니다). + // 총 건수는 상한까지만 세고, 뒤쪽 페이지 이동은 실측으로 끝까지 열어 둔다. + return BoundedPaginator::paginate( + $query->orderBy('created_at', 'desc')->orderBy('id', 'desc'), + perPage: $perPage, + resultCap: PaginationLimits::resultCap('user.notifications'), + ); } /** * 사용자의 미읽음 알림 수를 반환합니다. + * + * @param User $user 대상 사용자 + * @return int 미읽음 알림 수 */ public function getUnreadCount(User $user): int { @@ -35,6 +57,10 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 특정 알림을 읽음 처리합니다. + * + * @param User $user 대상 사용자 + * @param string $notificationId 알림 ID + * @return DatabaseNotification|null 읽음 처리된 알림 (없으면 null) */ public function markAsRead(User $user, string $notificationId): ?DatabaseNotification { @@ -51,6 +77,10 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 지정된 알림들을 일괄 읽음 처리합니다. + * + * @param User $user 대상 사용자 + * @param array $ids 알림 ID 배열 + * @return int 읽음 처리된 알림 수 */ public function markBatchAsRead(User $user, array $ids): int { @@ -61,6 +91,9 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 사용자의 모든 미읽음 알림을 읽음 처리합니다. + * + * @param User $user 대상 사용자 + * @return int 읽음 처리된 알림 수 */ public function markAllAsRead(User $user): int { @@ -73,6 +106,10 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 알림을 삭제합니다. + * + * @param User $user 대상 사용자 + * @param string $notificationId 알림 ID + * @return bool 삭제 성공 여부 (알림이 없으면 false) */ public function delete(User $user, string $notificationId): bool { @@ -88,7 +125,7 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 사용자의 모든 알림을 삭제합니다. * - * @param User $user + * @param User $user * @return int 삭제된 알림 수 */ public function deleteAll(User $user): int @@ -99,6 +136,8 @@ class NotificationRepository implements NotificationRepositoryInterface /** * 오래된 알림을 정리합니다. * + * @param int $readRetentionDays 읽은 알림 보관 일수 (0 이하면 정리하지 않음) + * @param int $unreadRetentionDays 미읽음 알림 보관 일수 (0 이하면 정리하지 않음) * @return array{deleted_read: int, deleted_unread: int} */ public function cleanup(int $readRetentionDays, int $unreadRetentionDays): array diff --git a/app/Repositories/RoleRepository.php b/app/Repositories/RoleRepository.php index 0b960311..05275a46 100644 --- a/app/Repositories/RoleRepository.php +++ b/app/Repositories/RoleRepository.php @@ -17,6 +17,7 @@ class RoleRepository implements RoleRepositoryInterface */ public function getAll(): Collection { + // audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관) return Role::with(['permissions']) ->orderBy('id') ->get(); @@ -29,6 +30,7 @@ class RoleRepository implements RoleRepositoryInterface */ public function getActiveRoles(): Collection { + // audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관) return Role::where('is_active', true) ->orderBy('id') ->get(); @@ -142,6 +144,7 @@ class RoleRepository implements RoleRepositoryInterface */ public function getByExtension(ExtensionOwnerType $extensionType, string $extensionIdentifier): Collection { + // audit:allow query-unbounded-get reason: 역할은 운영자가 정의한 수만큼만 존재한다 (회원 수와 무관) return Role::where('extension_type', $extensionType) ->where('extension_identifier', $extensionIdentifier) ->get(); @@ -195,9 +198,19 @@ class RoleRepository implements RoleRepositoryInterface */ public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator { + // 권한 관계는 목록에서 로드하지 않는다. 목록 화면이 쓰는 것은 이름·설명·사용자 수·활성 + // 여부뿐인데, 로드하면 Resource 가 같은 권한 집합을 `permissions`(계층 트리)· + // `permission_ids`·`permission_values` 세 형태로 중복 직렬화하고, 계층 트리를 만드느라 + // 행마다 상위/카테고리 권한을 다시 조회한다(행 수만큼 추가 쿼리). + // + // Resource 의 `relationLoaded('permissions')` 가드는 그대로 두면 여기서 로드하지 않는 것만으로 + // 세 필드가 함께 빠진다 — 권한 편집은 단건 조회(`GET /admin/roles/{id}`)가 공급한다. + // + // 권한 **개수**는 집계로 남긴다. 목록에서 뺀 값의 대체 경로 — 권한 트리를 전송하지 않고도 + // "이 역할에 권한이 몇 개 걸려 있는가" 를 화면이 보여줄 수 있다. 값 검사가 아니라 별칭 + // 존재 여부로 판정하므로(RoleResource) 0건인 역할도 0 으로 정확히 표시된다. $query = Role::query() - ->withCount('users') - ->with('permissions'); + ->withCount(['users', 'permissions']); // 검색 필터 if (! empty($filters['search'])) { @@ -215,9 +228,14 @@ class RoleRepository implements RoleRepositoryInterface } // 정렬 (코어/확장 소유 역할 먼저, 그 다음 사용자 생성 역할) + // 정렬 마지막의 기본키는 전순서 보장용이다. 시더가 일괄 생성한 역할은 created_at 이 + // 한 타임스탬프에 몰려 있어(실측: 한 값에 최대 10행) 키가 없으면 동률 구간의 순서가 + // 페이지마다 달라지고, 인접 페이지가 같은 역할을 중복 노출하면서 다른 역할을 누락한다. $query->orderByRaw('CASE WHEN extension_type IS NOT NULL THEN 0 ELSE 1 END') - ->orderBy('created_at', 'desc'); + ->orderBy('created_at', 'desc') + ->orderBy('id', 'desc'); + // audit:allow repository-paginate-column-pruning reason: 역할 정의 테이블 — 행 수가 운영상 고정(수십 건)이고 넓은 컬럼이 없다 return $query->paginate($perPage); } diff --git a/app/Repositories/ScheduleHistoryRepository.php b/app/Repositories/ScheduleHistoryRepository.php index 2f93cdc0..10cc4909 100644 --- a/app/Repositories/ScheduleHistoryRepository.php +++ b/app/Repositories/ScheduleHistoryRepository.php @@ -4,15 +4,22 @@ namespace App\Repositories; use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface; use App\Models\ScheduleHistory; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; +use App\Support\Query\PaginationLimits; +use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface { + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; + /** * ID로 실행 이력을 찾습니다. * - * @param int $id 이력 ID + * @param int $id 이력 ID * @return ScheduleHistory|null 찾은 이력 모델 또는 null */ public function findById(int $id): ?ScheduleHistory @@ -23,7 +30,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 새로운 실행 이력을 생성합니다. * - * @param array $data 이력 생성 데이터 + * @param array $data 이력 생성 데이터 * @return ScheduleHistory 생성된 이력 모델 */ public function create(array $data): ScheduleHistory @@ -34,8 +41,8 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 기존 실행 이력을 업데이트합니다. * - * @param ScheduleHistory $history 업데이트할 이력 모델 - * @param array $data 업데이트할 데이터 + * @param ScheduleHistory $history 업데이트할 이력 모델 + * @param array $data 업데이트할 데이터 * @return bool 업데이트 성공 여부 */ public function update(ScheduleHistory $history, array $data): bool @@ -46,7 +53,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 실행 이력을 삭제합니다. * - * @param ScheduleHistory $history 삭제할 이력 모델 + * @param ScheduleHistory $history 삭제할 이력 모델 * @return bool 삭제 성공 여부 */ public function delete(ScheduleHistory $history): bool @@ -57,50 +64,70 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 특정 스케줄의 실행 이력을 페이지네이션하여 조회합니다. * - * @param int $scheduleId 스케줄 ID - * @param array $filters 필터 조건 배열 + * @param int $scheduleId 스케줄 ID + * @param array $filters 필터 조건 배열 * @return LengthAwarePaginator 페이지네이션된 이력 목록 */ public function getPaginatedByScheduleId(int $scheduleId, array $filters = []): LengthAwarePaginator { - $query = ScheduleHistory::with('triggeredBy') + // 관계는 쿼리에 미리 붙이지 않는다 — 지연 조인 트레이트가 inner/outer 양쪽에서 + // setEagerLoads([]) 로 지우므로, 여기서 with() 하면 outer 에서도 사라져 + // ScheduleHistoryResource 의 whenLoaded('triggeredBy') 가 항상 비게 된다. + // 로드는 relations: 인자로 넘긴다. + $query = ScheduleHistory::query() ->where('schedule_id', $scheduleId); // 상태 필터 - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { $query->where('status', $filters['status']); } // 트리거 유형 필터 - if (!empty($filters['trigger_type'])) { + if (! empty($filters['trigger_type'])) { $query->where('trigger_type', $filters['trigger_type']); } - // 날짜 필터 - if (!empty($filters['started_from'])) { - $query->whereDate('started_at', '>=', $filters['started_from']); + // 기간 필터 — whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 못 쓰게 만든다. + // 같은 결과를 내는 범위 조건으로 바꿔 started_at 인덱스를 살린다 + // (종료일은 그날 23:59:59.999999 까지 포함해야 whereDate 와 동일한 경계를 갖는다). + if (! empty($filters['started_from'])) { + $query->where('started_at', '>=', Carbon::parse($filters['started_from'])->startOfDay()); } - if (!empty($filters['started_to'])) { - $query->whereDate('started_at', '<=', $filters['started_to']); + if (! empty($filters['started_to'])) { + $query->where('started_at', '<=', Carbon::parse($filters['started_to'])->endOfDay()); } - // 정렬 적용 - $sortBy = $filters['sort_by'] ?? 'started_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 — 요청 값은 허용 목록으로만 해석한다. + // 허용 집합은 ScheduleHistoryListRequest 의 `in:` 규칙과 동일해야 한다. 저장소 목록이 + // 그보다 좁으면 게이트를 통과한 정렬이 조용히 기본값으로 되돌아간다 (종료시각/소요시간). + $sort = $this->resolveSortSpec( + $filters, + ['started_at', 'ended_at', 'duration', 'status'], + 'started_at' + ); - // 페이지네이션 적용 - $perPage = $filters['per_page'] ?? 15; + $perPage = (int) ($filters['per_page'] ?? 15); - return $query->paginate($perPage); + // 실행 이력은 계속 쌓이며 출력/에러 메시지를 그대로 노출하므로, 컬럼은 좁히지 않고 + // 지연 조인으로 넓은 컬럼을 읽는 행 수만 이번 페이지 분량으로 고정한다. + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + relations: ['triggeredBy'], + // 로그 테이블은 계속 쌓이기만 한다. 총 건수는 상한까지만 세고 "다음" 이동은 + // per_page + 1 실측으로 끝까지 열어 둔다 (계산 불가는 마지막 페이지 번호 하나뿐). + resultCap: PaginationLimits::resultCap('admin.schedule_histories'), + ); } /** * 특정 스케줄의 최근 실행 이력을 조회합니다. * - * @param int $scheduleId 스케줄 ID - * @param int $limit 조회 개수 + * @param int $scheduleId 스케줄 ID + * @param int $limit 조회 개수 * @return Collection 최근 이력 컬렉션 */ public function getRecentByScheduleId(int $scheduleId, int $limit = 10): Collection @@ -115,7 +142,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 여러 이력을 일괄 삭제합니다. * - * @param array $ids 이력 ID 배열 + * @param array $ids 이력 ID 배열 * @return int 삭제된 레코드 수 */ public function bulkDelete(array $ids): int @@ -126,7 +153,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 특정 스케줄의 모든 이력을 삭제합니다. * - * @param int $scheduleId 스케줄 ID + * @param int $scheduleId 스케줄 ID * @return int 삭제된 레코드 수 */ public function deleteByScheduleId(int $scheduleId): int @@ -137,7 +164,7 @@ class ScheduleHistoryRepository implements ScheduleHistoryRepositoryInterface /** * 특정 기간 이전의 이력을 삭제합니다. * - * @param int $days 보관 기간 (일) + * @param int $days 보관 기간 (일) * @return int 삭제된 레코드 수 */ public function deleteOlderThan(int $days): int diff --git a/app/Repositories/ScheduleRepository.php b/app/Repositories/ScheduleRepository.php index dfe718b3..88d0e41c 100644 --- a/app/Repositories/ScheduleRepository.php +++ b/app/Repositories/ScheduleRepository.php @@ -3,29 +3,45 @@ namespace App\Repositories; use App\Contracts\Repositories\ScheduleRepositoryInterface; -use App\Helpers\PermissionHelper; use App\Enums\ScheduleFrequency; use App\Enums\ScheduleResultStatus; use App\Enums\ScheduleType; +use App\Helpers\PermissionHelper; use App\Models\Schedule; +use App\Repositories\Concerns\FiltersByDateRange; use App\Repositories\Concerns\HasMultipleSearchFilters; +use App\Repositories\Concerns\ResolvesSortSpec; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; class ScheduleRepository implements ScheduleRepositoryInterface { + use FiltersByDateRange; use HasMultipleSearchFilters; + use ResolvesSortSpec; /** * 검색 가능한 필드 목록 */ private const SEARCHABLE_FIELDS = ['name', 'description', 'command']; + /** + * 허용 정렬 컬럼 (ScheduleListRequest 와 동일 집합) + */ + private const SORTABLE_COLUMNS = [ + 'created_at', + 'name', + 'next_run_at', + 'last_run_at', + 'is_active', + 'last_result', + ]; + /** * ID로 스케줄을 찾습니다. * - * @param int $id 스케줄 ID + * @param int $id 스케줄 ID * @return Schedule|null 찾은 스케줄 모델 또는 null */ public function findById(int $id): ?Schedule @@ -36,7 +52,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 새로운 스케줄을 생성합니다. * - * @param array $data 스케줄 생성 데이터 + * @param array $data 스케줄 생성 데이터 * @return Schedule 생성된 스케줄 모델 */ public function create(array $data): Schedule @@ -47,8 +63,8 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 기존 스케줄을 업데이트합니다. * - * @param Schedule $schedule 업데이트할 스케줄 모델 - * @param array $data 업데이트할 데이터 + * @param Schedule $schedule 업데이트할 스케줄 모델 + * @param array $data 업데이트할 데이터 * @return bool 업데이트 성공 여부 */ public function update(Schedule $schedule, array $data): bool @@ -59,7 +75,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 스케줄을 삭제합니다. * - * @param Schedule $schedule 삭제할 스케줄 모델 + * @param Schedule $schedule 삭제할 스케줄 모델 * @return bool 삭제 성공 여부 */ public function delete(Schedule $schedule): bool @@ -80,7 +96,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 필터링 및 페이지네이션이 적용된 스케줄 목록을 조회합니다. * - * @param array $filters 필터 조건 배열 + * @param array $filters 필터 조건 배열 * @return LengthAwarePaginator 페이지네이션된 스케줄 목록 */ public function getPaginatedSchedules(array $filters = []): LengthAwarePaginator @@ -93,33 +109,34 @@ class ScheduleRepository implements ScheduleRepositoryInterface // 필터 조건 적용 $this->applyFilters($query, $filters); - // 정렬 적용 - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 적용 (허용 컬럼 화이트리스트로 해석) + foreach ($this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at') as $sort) { + $query->orderBy($sort['column'], $sort['direction']); + } // 페이지네이션 적용 $perPage = $filters['per_page'] ?? 15; + // audit:allow repository-paginate-column-pruning reason: 스케줄 정의 테이블 — 등록된 스케줄 수만큼만 존재하고 넓은 컬럼이 없다 return $query->paginate($perPage); } /** * 쿼리에 필터 조건을 적용합니다. * - * @param Builder $query Eloquent 쿼리 빌더 - * @param array $filters 적용할 필터 조건 배열 + * @param Builder $query Eloquent 쿼리 빌더 + * @param array $filters 적용할 필터 조건 배열 * @return void */ private function applyFilters(Builder $query, array $filters): void { // 다중 검색 조건 적용 - if (!empty($filters['filters']) && is_array($filters['filters'])) { + if (! empty($filters['filters']) && is_array($filters['filters'])) { $this->applyMultipleSearchFilters($query, $filters['filters'], self::SEARCHABLE_FIELDS); } // 타입 필터 - if (!empty($filters['type'])) { + if (! empty($filters['type'])) { $type = ScheduleType::tryFrom($filters['type']); if ($type) { $query->where('type', $type); @@ -127,7 +144,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface } // 주기 필터 - if (!empty($filters['frequency'])) { + if (! empty($filters['frequency'])) { $frequency = ScheduleFrequency::tryFrom($filters['frequency']); if ($frequency) { $query->where('frequency', $frequency); @@ -135,7 +152,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface } // 상태 필터 - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { if ($filters['status'] === 'active') { $query->where('is_active', true); } elseif ($filters['status'] === 'inactive') { @@ -144,7 +161,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface } // 마지막 실행 결과 필터 - if (!empty($filters['last_result'])) { + if (! empty($filters['last_result'])) { $result = ScheduleResultStatus::tryFrom($filters['last_result']); if ($result) { $query->where('last_result', $result); @@ -162,10 +179,10 @@ class ScheduleRepository implements ScheduleRepositoryInterface } // 확장 타입 필터 - if (!empty($filters['extension_type'])) { + if (! empty($filters['extension_type'])) { $query->where('extension_type', $filters['extension_type']); - if (!empty($filters['extension_identifier'])) { + if (! empty($filters['extension_identifier'])) { $query->where('extension_identifier', $filters['extension_identifier']); } } @@ -177,19 +194,19 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 날짜 필터를 적용합니다. * - * @param Builder $query Eloquent 쿼리 빌더 - * @param array $filters 필터 조건 배열 + * @param Builder $query Eloquent 쿼리 빌더 + * @param array $filters 필터 조건 배열 * @return void */ private function applyDateFilters(Builder $query, array $filters): void { - if (!empty($filters['created_from'])) { - $query->whereDate('created_at', '>=', $filters['created_from']); - } - - if (!empty($filters['created_to'])) { - $query->whereDate('created_at', '<=', $filters['created_to']); - } + // whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 범위 조건으로 준다. + $this->applyDateRangeFilter( + $query, + 'created_at', + $filters['created_from'] ?? null, + $filters['created_to'] ?? null + ); } /** @@ -233,8 +250,8 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 여러 스케줄의 상태를 일괄 업데이트합니다. * - * @param array $ids 스케줄 ID 배열 - * @param bool $isActive 활성화 여부 + * @param array $ids 스케줄 ID 배열 + * @param bool $isActive 활성화 여부 * @return int 업데이트된 레코드 수 */ public function bulkUpdateStatus(array $ids, bool $isActive): int @@ -245,7 +262,7 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 여러 스케줄을 일괄 삭제합니다. * - * @param array $ids 스케줄 ID 배열 + * @param array $ids 스케줄 ID 배열 * @return int 삭제된 레코드 수 */ public function bulkDelete(array $ids): int @@ -256,13 +273,13 @@ class ScheduleRepository implements ScheduleRepositoryInterface /** * 스케줄을 복제합니다. * - * @param Schedule $schedule 복제할 스케줄 + * @param Schedule $schedule 복제할 스케줄 * @return Schedule 복제된 스케줄 */ public function duplicate(Schedule $schedule): Schedule { $newSchedule = $schedule->replicate(); - $newSchedule->name = $schedule->name . ' (' . __('schedule.copy') . ')'; + $newSchedule->name = $schedule->name.' ('.__('schedule.copy').')'; $newSchedule->is_active = false; $newSchedule->last_result = ScheduleResultStatus::Never; $newSchedule->last_run_at = null; diff --git a/app/Repositories/SeoCacheStatRepository.php b/app/Repositories/SeoCacheStatRepository.php new file mode 100644 index 00000000..a6bb968e --- /dev/null +++ b/app/Repositories/SeoCacheStatRepository.php @@ -0,0 +1,135 @@ + + */ + private const GROUPABLE_COLUMNS = ['layout_name', 'module_identifier']; + + /** + * 캐시 통계 레코드를 기록합니다. + * + * @param array $attributes 기록할 속성 + */ + public function record(array $attributes): void + { + SeoCacheStat::create($attributes); + } + + /** + * 전체 캐시 통계를 집계합니다. + * + * @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 + { + $row = $this->baseQuery($since) + ->select($this->aggregateSelects()) + ->first(); + + return [ + 'total' => (int) ($row->total ?? 0), + 'hits' => (int) ($row->hits ?? 0), + 'misses' => (int) ($row->misses ?? 0), + 'avg_response_time_ms' => $row?->avg_response_time_ms !== null + ? (float) $row->avg_response_time_ms + : null, + ]; + } + + /** + * 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다. + * + * @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier) + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array 그룹별 집계 결과 + * + * @throws \InvalidArgumentException 허용되지 않은 그룹 컬럼인 경우 + */ + public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array + { + if (! in_array($groupBy, self::GROUPABLE_COLUMNS, true)) { + throw new \InvalidArgumentException( + __('exceptions.seo.sitemap_stat_group_column_unsupported', ['column' => $groupBy]) + ); + } + + return $this->baseQuery($since) + ->select(array_merge([$groupBy], $this->aggregateSelects())) + ->groupBy($groupBy) + ->get() + ->map(fn ($row): array => [ + 'group' => $row->{$groupBy}, + 'total' => (int) $row->total, + 'hits' => (int) $row->hits, + 'misses' => (int) $row->misses, + 'avg_response_time_ms' => $row->avg_response_time_ms !== null + ? (float) $row->avg_response_time_ms + : null, + ]) + ->all(); + } + + /** + * 기준 시점보다 오래된 통계 레코드를 삭제합니다. + * + * @param Carbon $cutoff 기준 시점 + * @return int 삭제된 레코드 수 + */ + public function deleteOlderThan(Carbon $cutoff): int + { + return SeoCacheStat::where('created_at', '<', $cutoff)->delete(); + } + + /** + * 기간 필터가 적용된 기본 쿼리를 반환합니다. + * + * @param Carbon|null $since 집계 시작 시점 + * @return Builder 쿼리 빌더 + */ + private function baseQuery(?Carbon $since): Builder + { + $query = SeoCacheStat::query(); + + if ($since) { + $query->since($since); + } + + return $query; + } + + /** + * 집계 select 절을 반환합니다. + * + * hit/miss 는 단일 순회로 집계하기 위해 조건부 합계를 사용합니다. + * + * @return array select 표현식 배열 + */ + private function aggregateSelects(): array + { + return [ + DB::raw('COUNT(*) as total'), + DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"), + DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"), + DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms"), + ]; + } +} diff --git a/app/Repositories/SitemapUrlRepository.php b/app/Repositories/SitemapUrlRepository.php new file mode 100644 index 00000000..e2e1025d --- /dev/null +++ b/app/Repositories/SitemapUrlRepository.php @@ -0,0 +1,179 @@ +where('resource_type', $type) + ->where('resource_id', $id) + ->delete(); + + $now = now(); + $rows = []; + foreach ($entries as $entry) { + $rows[] = $this->toRow( + $type, + $id, + (string) ($entry['contributor'] ?? ''), + $entry, + $now, + ); + } + + if ($rows !== []) { + SitemapUrl::insert($rows); + } + }); + } + + /** + * {@inheritDoc} + */ + public function removeForResource(string $type, string $id): void + { + SitemapUrl::query() + ->where('resource_type', $type) + ->where('resource_id', $id) + ->delete(); + } + + /** + * {@inheritDoc} + */ + public function streamVisible(?string $contributor = null): iterable + { + $query = SitemapUrl::query()->where('is_visible', true); + + if ($contributor !== null) { + $query->where('contributor', $contributor); + } + + // lazyById 는 id 키셋 페이징이라 결과셋 전체를 메모리/드라이버 버퍼에 적재하지 않습니다. + return $query->lazyById(self::STREAM_CHUNK); + } + + /** + * {@inheritDoc} + */ + public function countVisible(): int + { + return SitemapUrl::query()->where('is_visible', true)->count(); + } + + /** + * {@inheritDoc} + */ + public function replaceAllForContributor(string $contributor, iterable $entries): int + { + // 기여자 스코프 전량 제거 후 청크 삽입. 대용량(수백만) 삽입을 단일 트랜잭션으로 + // 감싸면 락/메모리 부담이 크므로, 삭제 후 청크별 삽입으로 유계 메모리를 유지합니다. + SitemapUrl::query()->where('contributor', $contributor)->delete(); + + $now = now(); + $batch = []; + $inserted = 0; + foreach ($entries as $entry) { + $batch[] = $this->toRow( + (string) ($entry['resource_type'] ?? 'unknown'), + isset($entry['resource_id']) && $entry['resource_id'] !== null + ? (string) $entry['resource_id'] + : null, + $contributor, + $entry, + $now, + ); + + if (count($batch) >= self::INSERT_CHUNK) { + SitemapUrl::insert($batch); + $inserted += count($batch); + $batch = []; + } + } + + if ($batch !== []) { + SitemapUrl::insert($batch); + $inserted += count($batch); + } + + return $inserted; + } + + /** + * URL 항목을 sitemap_urls 삽입용 행으로 변환합니다. + * + * @param string $type 리소스 유형 + * @param string|null $id 리소스 PK (문자열, 없으면 null) + * @param string $contributor 기여자 식별자 + * @param array $entry URL 항목 (loc/lastmod/changefreq/priority) + * @param Carbon $now 타임스탬프 기준값 + * @return array 삽입용 행 + */ + private function toRow(string $type, ?string $id, string $contributor, array $entry, Carbon $now): array + { + $loc = (string) ($entry['loc'] ?? ''); + + return [ + 'resource_type' => $type, + 'resource_id' => $id, + 'loc' => $loc, + 'loc_hash' => hash('sha256', $loc), + 'lastmod' => $this->normalizeLastmod($entry['lastmod'] ?? null), + 'changefreq' => $entry['changefreq'] ?? null, + 'priority' => $entry['priority'] ?? null, + 'contributor' => $contributor, + 'is_visible' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + /** + * lastmod 값을 DB 저장용 타임스탬프 문자열로 정규화합니다. + * + * insert() 는 모델 캐스팅을 우회하므로 W3C 문자열/Carbon 을 직접 파싱합니다. + * + * @param mixed $lastmod 원본 lastmod (문자열/Carbon/null) + * @return string|null 정규화된 'Y-m-d H:i:s' 또는 null + */ + private function normalizeLastmod(mixed $lastmod): ?string + { + if ($lastmod === null || $lastmod === '') { + return null; + } + + try { + return Carbon::parse($lastmod)->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php index cf24e644..f76f248c 100644 --- a/app/Repositories/UserRepository.php +++ b/app/Repositories/UserRepository.php @@ -5,21 +5,34 @@ namespace App\Repositories; use App\Contracts\Repositories\UserRepositoryInterface; use App\Helpers\PermissionHelper; use App\Models\User; +use App\Repositories\Concerns\FiltersByDateRange; use App\Repositories\Concerns\HasMultipleSearchFilters; +use App\Repositories\Concerns\PaginatesWithDeferredJoin; +use App\Repositories\Concerns\ResolvesSortSpec; +use App\Support\Query\PaginationLimits; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; +use Laravel\Sanctum\PersonalAccessToken; class UserRepository implements UserRepositoryInterface { + use FiltersByDateRange; use HasMultipleSearchFilters; + use PaginatesWithDeferredJoin; + use ResolvesSortSpec; /** * 검색 가능한 필드 목록 */ private const SEARCHABLE_FIELDS = ['name', 'email']; + /** + * 허용 정렬 컬럼 (UserListRequest 와 동일 집합) + */ + private const SORTABLE_COLUMNS = ['created_at', 'name', 'email', 'last_login_at']; + /** * 이메일로 사용자를 찾습니다. * @@ -99,21 +112,25 @@ class UserRepository implements UserRepositoryInterface // 권한 스코프 필터링 PermissionHelper::applyPermissionScope($query, 'core.users.read'); - // roles 관계 eager loading - $query->with('roles'); - // 검색 조건 적용 $this->applyFilters($query, $filters); - // 정렬 적용 - $sortBy = $filters['sort_by'] ?? 'created_at'; - $sortOrder = $filters['sort_order'] ?? 'desc'; - $query->orderBy($sortBy, $sortOrder); + // 정렬 적용 (허용 컬럼 화이트리스트로 해석) + $sort = $this->resolveSortSpec($filters, self::SORTABLE_COLUMNS, 'created_at'); // 페이지네이션 적용 $perPage = $filters['per_page'] ?? 15; - return $query->paginate($perPage); + // 지연 조인: inner 는 id 만 훑고 roles eager loading 은 해당 페이지에만 적용된다 + return $this->paginateWithDeferredJoin( + query: $query, + columns: ['*'], + sort: $sort, + perPage: $perPage, + relations: ['roles'], + // 회원 수가 커져도 목록 첫 화면 비용이 총 건수 COUNT 에 끌려가지 않도록 상한을 건다. + resultCap: PaginationLimits::resultCap('admin.users'), + ); } /** @@ -141,13 +158,13 @@ class UserRepository implements UserRepositoryInterface */ private function applyDateFilters(Builder $query, array $filters): void { - if (! empty($filters['start_date'])) { - $query->whereDate('created_at', '>=', $filters['start_date']); - } - - if (! empty($filters['end_date'])) { - $query->whereDate('created_at', '<=', $filters['end_date']); - } + // whereDate 는 컬럼에 DATE() 를 씌워 인덱스를 무력화한다 — 범위 조건으로 준다. + $this->applyDateRangeFilter( + $query, + 'created_at', + $filters['start_date'] ?? null, + $filters['end_date'] ?? null + ); // 기본 날짜 필터 (전체가 아닌 경우) if (empty($filters['start_date']) && empty($filters['end_date']) && @@ -294,16 +311,22 @@ class UserRepository implements UserRepositoryInterface /** * 사용자의 계정을 지정된 분만큼 잠급니다. * + * `$minutes <= 0` 은 보안 환경설정의 "0 = 무한대" 규약에 따라 영구 잠금으로 처리합니다. + * 영구 잠금은 해제 시각이 없으므로 `locked_until` 을 NULL 로 두고 + * `locked_permanently` 플래그로 상태를 표현합니다 (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 { - $lockedUntil = now()->addMinutes(max(1, $minutes)); + $permanent = $minutes <= 0; + $lockedUntil = $permanent ? null : now()->addMinutes($minutes); $user->forceFill([ 'locked_until' => $lockedUntil, + 'locked_permanently' => $permanent, 'failed_login_attempts' => 0, ])->save(); @@ -321,15 +344,18 @@ class UserRepository implements UserRepositoryInterface { $needsReset = ($user->failed_login_attempts ?? 0) > 0 || $user->locked_until !== null + || (bool) $user->locked_permanently || $user->last_failed_login_at !== null; if (! $needsReset) { return; } + // locked_permanently 를 빠뜨리면 성공 로그인·관리자 해제 후에도 영구 잠금이 잔존한다. $user->forceFill([ 'failed_login_attempts' => 0, 'locked_until' => null, + 'locked_permanently' => false, 'last_failed_login_at' => null, ])->save(); } @@ -342,10 +368,92 @@ class UserRepository implements UserRepositoryInterface */ public function isLocked(User $user): bool { + // 영구 잠금 판정이 먼저 — 해제 시각이 없으므로 NULL 체크에 먼저 걸리면 안 된다. + if ((bool) $user->locked_permanently) { + return true; + } + if ($user->locked_until === null) { return false; } return $user->locked_until->isFuture(); } + + /** + * UUID 로 사용자를 찾습니다. + * + * @param string $uuid 사용자 UUID + * @return User|null 찾은 사용자 모델 또는 null + */ + public function findByUuid(string $uuid): ?User + { + return User::where('uuid', $uuid)->first(); + } + + /** + * UUID 목록에 해당하는 사용자의 정수 ID 배열을 반환합니다. + * + * @param array $uuids 사용자 UUID 배열 + * @return array 사용자 ID 배열 + */ + public function getIdsByUuids(array $uuids): array + { + if (empty($uuids)) { + return []; + } + + return User::whereIn('uuid', $uuids)->pluck('id')->all(); + } + + /** + * 사용자 ID 목록에 해당하는 이름을 ID 로 색인해 반환합니다. + * + * 목록 화면이 작성자 이름을 행마다 조회하면 N+1 이 되므로, 표시에 필요한 + * 이름만 한 번에 모아 옵니다. + * + * @param array $ids 사용자 ID 배열 + * @return array 사용자 ID => 이름 + */ + public function getNamesByIds(array $ids): array + { + if (empty($ids)) { + return []; + } + + return User::whereIn('id', $ids)->pluck('name', 'id')->all(); + } + + /** + * 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다. + * + * @param array $ids 사용자 ID 배열 + * @param array $data 갱신할 컬럼 값 + * @return int 갱신된 행 수 + */ + public function updateManyByIds(array $ids, array $data): int + { + if (empty($ids)) { + return 0; + } + + return User::whereIn('id', $ids)->update($data); + } + + /** + * 사용자 ID 목록의 인증 토큰을 모두 삭제합니다. + * + * @param array $ids 사용자 ID 배열 + * @return int 삭제된 토큰 수 + */ + public function deleteTokensByUserIds(array $ids): int + { + if (empty($ids)) { + return 0; + } + + return PersonalAccessToken::where('tokenable_type', User::class) + ->whereIn('tokenable_id', $ids) + ->delete(); + } } diff --git a/app/Rules/AllowedArtisanCommand.php b/app/Rules/AllowedArtisanCommand.php index d3dae0ef..300241d5 100644 --- a/app/Rules/AllowedArtisanCommand.php +++ b/app/Rules/AllowedArtisanCommand.php @@ -7,14 +7,25 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; /** - * Artisan 타입 스케줄의 command 가 차단목록에 걸리지 않는지 검증하는 Custom Rule. + * Artisan 타입 스케줄의 command 가 실행 허용 목록 안인지 검증하는 Custom Rule. * - * `tinker` 처럼 임의 PHP 코드를 실행하거나 `db:wipe` 처럼 파괴적인 명령을 스케줄로 등록하는 것을 - * 저장 시점에 차단한다. 차단목록은 `config/schedule_security.php` 가 소유하며 관리자 환경설정에 - * 노출되지 않는다. + * 허용목록에 없는 명령, 선언되지 않은 옵션, 위치 인자, 그리고 Symfony 가 다르게 해석할 수 있는 + * 형태(따옴표·백슬래시·단축 옵션)를 저장 시점에 차단한다. 정책은 `config/schedule_security.php` + * 가 소유하며 관리자 환경설정에 노출되지 않는다. + * + * 어느 항목이 왜 막혔는지 드러나도록 거부 사유별로 메시지를 분기한다. */ class AllowedArtisanCommand implements ValidationRule { + /** 거부 사유 코드 → 다국어 키 */ + private const REASON_MESSAGES = [ + ScheduleCommandValidator::ARTISAN_REASON_DENIED => 'validation.schedule_command.artisan_denied', + ScheduleCommandValidator::ARTISAN_REASON_NOT_ALLOWED => 'validation.schedule_command.artisan_not_allowlisted', + ScheduleCommandValidator::ARTISAN_REASON_MALFORMED => 'validation.schedule_command.artisan_malformed', + ScheduleCommandValidator::ARTISAN_REASON_OPTION => 'validation.schedule_command.artisan_option_denied', + ScheduleCommandValidator::ARTISAN_REASON_ARGUMENT => 'validation.schedule_command.artisan_argument_denied', + ]; + /** * 값이 실행 허용된 artisan 명령인지 검증합니다. * @@ -29,13 +40,19 @@ class AllowedArtisanCommand implements ValidationRule } if (! is_string($value)) { - $fail(__('validation.schedule_command.artisan_denied')); + $fail(__('validation.schedule_command.artisan_malformed')); return; } - if (! ScheduleCommandValidator::isArtisanCommandAllowed($value)) { - $fail(__('validation.schedule_command.artisan_denied')); + $verdict = ScheduleCommandValidator::inspectArtisanCommand($value); + + if ($verdict['allowed']) { + return; } + + $key = self::REASON_MESSAGES[$verdict['reason']] ?? 'validation.schedule_command.artisan_not_allowlisted'; + + $fail(__($key)); } } diff --git a/app/Rules/AllowedModuleFileType.php b/app/Rules/AllowedModuleFileType.php index f69fd3bd..a212873c 100644 --- a/app/Rules/AllowedModuleFileType.php +++ b/app/Rules/AllowedModuleFileType.php @@ -39,11 +39,12 @@ class AllowedModuleFileType implements ValidationRule } $extension = strtolower(pathinfo($value, PATHINFO_EXTENSION)); + $allowed = self::getAllowedExtensions(); - if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) { + if (! in_array($extension, $allowed, true)) { $fail(__('validation.module_path.file_type_not_allowed', [ 'extension' => $extension, - 'allowed' => implode(', ', self::ALLOWED_EXTENSIONS), + 'allowed' => implode(', ', $allowed), ])); return; @@ -53,10 +54,20 @@ class AllowedModuleFileType implements ValidationRule /** * 허용된 확장자 목록 반환 * - * @return array + * 로컬 개발 환경에서만 소스맵(`map`)을 덧붙인다. dev 빌드 산출물은 + * `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을 가리키므로 서빙되어야 + * 브라우저 콘솔에 404 가 남지 않는다. 반면 소스맵에는 원본 코드 전문 + * (`sourcesContent`)이 담겨 있고 이 화이트리스트가 에셋 서빙의 유일한 + * 방어선이므로, 운영 환경에서는 어떤 경우에도 서빙하지 않는다. + * + * @return array 허용된 확장자 목록 */ public static function getAllowedExtensions(): array { + if (app()->environment('local')) { + return [...self::ALLOWED_EXTENSIONS, 'map']; + } + return self::ALLOWED_EXTENSIONS; } } diff --git a/app/Rules/AllowedPluginFileType.php b/app/Rules/AllowedPluginFileType.php index 0ced5c11..b4ccab54 100644 --- a/app/Rules/AllowedPluginFileType.php +++ b/app/Rules/AllowedPluginFileType.php @@ -39,11 +39,12 @@ class AllowedPluginFileType implements ValidationRule } $extension = strtolower(pathinfo($value, PATHINFO_EXTENSION)); + $allowed = self::getAllowedExtensions(); - if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) { + if (! in_array($extension, $allowed, true)) { $fail(__('validation.plugin_path.file_type_not_allowed', [ 'extension' => $extension, - 'allowed' => implode(', ', self::ALLOWED_EXTENSIONS), + 'allowed' => implode(', ', $allowed), ])); return; @@ -53,10 +54,20 @@ class AllowedPluginFileType implements ValidationRule /** * 허용된 확장자 목록 반환 * - * @return array + * 로컬 개발 환경에서만 소스맵(`map`)을 덧붙인다. dev 빌드 산출물은 + * `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을 가리키므로 서빙되어야 + * 브라우저 콘솔에 404 가 남지 않는다. 반면 소스맵에는 원본 코드 전문 + * (`sourcesContent`)이 담겨 있고 이 화이트리스트가 에셋 서빙의 유일한 + * 방어선이므로, 운영 환경에서는 어떤 경우에도 서빙하지 않는다. + * + * @return array 허용된 확장자 목록 */ public static function getAllowedExtensions(): array { + if (app()->environment('local')) { + return [...self::ALLOWED_EXTENSIONS, 'map']; + } + return self::ALLOWED_EXTENSIONS; } } diff --git a/app/Rules/AllowedTemplateFileType.php b/app/Rules/AllowedTemplateFileType.php index 391876b6..1614e5bc 100644 --- a/app/Rules/AllowedTemplateFileType.php +++ b/app/Rules/AllowedTemplateFileType.php @@ -32,18 +32,21 @@ class AllowedTemplateFileType implements ValidationRule */ public function validate(string $attribute, mixed $value, Closure $fail): void { - if (!is_string($value)) { + if (! is_string($value)) { $fail(__('validation.template_path.must_be_string')); + return; } $extension = strtolower(pathinfo($value, PATHINFO_EXTENSION)); + $allowed = self::getAllowedExtensions(); - if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) { + if (! in_array($extension, $allowed, true)) { $fail(__('validation.template_path.file_type_not_allowed', [ 'extension' => $extension, - 'allowed' => implode(', ', self::ALLOWED_EXTENSIONS), + 'allowed' => implode(', ', $allowed), ])); + return; } } @@ -51,10 +54,20 @@ class AllowedTemplateFileType implements ValidationRule /** * 허용된 확장자 목록 반환 * - * @return array + * 로컬 개발 환경에서만 소스맵(`map`)을 덧붙인다. dev 빌드 산출물은 + * `//# sourceMappingURL` 이 개별 에셋 서빙 URL 을 가리키므로 서빙되어야 + * 브라우저 콘솔에 404 가 남지 않는다. 반면 소스맵에는 원본 코드 전문 + * (`sourcesContent`)이 담겨 있고 이 화이트리스트가 에셋 서빙의 유일한 + * 방어선이므로, 운영 환경에서는 어떤 경우에도 서빙하지 않는다. + * + * @return array 허용된 확장자 목록 */ public static function getAllowedExtensions(): array { + if (app()->environment('local')) { + return [...self::ALLOWED_EXTENSIONS, 'map']; + } + return self::ALLOWED_EXTENSIONS; } } diff --git a/app/Rules/AvailableNotificationChannel.php b/app/Rules/AvailableNotificationChannel.php new file mode 100644 index 00000000..20c4bc7b --- /dev/null +++ b/app/Rules/AvailableNotificationChannel.php @@ -0,0 +1,55 @@ + $persistedChannels 대상 레코드에 이미 저장된 채널 목록 + */ + public function __construct( + private array $persistedChannels = [] + ) {} + + /** + * 검증 규칙 실행 + * + * @param string $attribute 필드명 + * @param mixed $value 검증 값 + * @param Closure $fail 실패 콜백 + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || $value === '') { + $fail(__('validation.notification_channel.invalid', ['value' => (string) $value])); + + return; + } + + if (in_array($value, $this->persistedChannels, true)) { + return; + } + + if (! app(NotificationChannelService::class)->isChannelAvailable($value)) { + $fail(__('validation.notification_channel.unavailable', ['value' => $value])); + } + } +} diff --git a/app/Rules/LanguagePack/RequiresActivationPermission.php b/app/Rules/LanguagePack/RequiresActivationPermission.php new file mode 100644 index 00000000..ae9a96df --- /dev/null +++ b/app/Rules/LanguagePack/RequiresActivationPermission.php @@ -0,0 +1,49 @@ +hasPermission(self::PERMISSION, PermissionType::Admin)) { + $fail(__('language_packs.errors.auto_activate_requires_manage')); + } + } +} diff --git a/app/Rules/LocaleRequiredTranslatable.php b/app/Rules/LocaleRequiredTranslatable.php index 34249b12..384e6a30 100644 --- a/app/Rules/LocaleRequiredTranslatable.php +++ b/app/Rules/LocaleRequiredTranslatable.php @@ -16,23 +16,24 @@ class LocaleRequiredTranslatable implements ValidationRule /** * LocaleRequiredTranslatable 생성자 * - * @param int $maxLength 각 번역의 최대 길이 - * @param int $minLength 각 번역의 최소 길이 (기본: 0) - * @param string|null $requiredLocale 필수 로케일 (null이면 현재 로케일 사용) + * @param int $maxLength 각 번역의 최대 길이 + * @param int $minLength 각 번역의 최소 길이 (기본: 0) + * @param string|null $requiredLocale 필수 로케일 (null이면 현재 로케일 사용) + * @param bool $strictLocales 지원 목록 밖 로케일 키를 실패로 처리할지 여부 */ public function __construct( private int $maxLength = 255, private int $minLength = 0, - private ?string $requiredLocale = null + private ?string $requiredLocale = null, + private bool $strictLocales = false ) {} /** * 검증 규칙 실행 * - * @param string $attribute 필드명 - * @param mixed $value 검증 값 - * @param Closure $fail 실패 콜백 - * @return void + * @param string $attribute 필드명 + * @param mixed $value 검증 값 + * @param Closure $fail 실패 콜백 */ public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -67,11 +68,20 @@ class LocaleRequiredTranslatable implements ValidationRule // 각 언어별 검증 foreach ($value as $lang => $text) { - // 지원하지 않는 언어 코드 + // 지원 목록 밖 로케일 키 + // + // app.translatable_locales 는 활성 언어팩 기준으로 부팅마다 덮어써지는 가변 런타임 + // 값이다. 언어팩을 비활성화하면 기존 레코드에 남아 있던 그 로케일 키 때문에 수정 + // 자체가 차단되므로, 기본값은 "길이 검증 대상에서 제외하고 통과"로 둔다. + // 데이터를 지우지 않으므로 언어팩을 다시 켜면 번역이 그대로 복구된다. if (! in_array($lang, $allowedLanguages)) { - $fail(__('validation.translatable.unsupported_language', ['lang' => $lang])); + if ($this->strictLocales) { + $fail(__('validation.translatable.unsupported_language', ['lang' => $lang])); - return; + return; + } + + continue; } // null이거나 빈 문자열이 아닌 경우에만 검증 diff --git a/app/Rules/NotSelfParent.php b/app/Rules/NotSelfParent.php index daf3ec65..54866803 100644 --- a/app/Rules/NotSelfParent.php +++ b/app/Rules/NotSelfParent.php @@ -8,7 +8,11 @@ use Illuminate\Contracts\Validation\ValidationRule; /** * 자기 자신을 부모로 설정할 수 없도록 검증하는 Custom Rule * - * 메뉴, 카테고리 등 계층 구조에서 순환 참조를 방지합니다. + * 메뉴, 카테고리 등 계층 구조에서 자기참조를 방지합니다. + * + * @deprecated 자기 자신만 검사하므로 자손을 부모로 지정하는 순환은 막지 못합니다. + * 신규 코드는 자손 전체를 검사하는 NotCircularParent 를 사용하세요. + * 확장이 훅으로 이 규칙을 소비할 수 있어 클래스는 제거하지 않습니다. */ class NotSelfParent implements ValidationRule { diff --git a/app/Rules/PasswordPolicy.php b/app/Rules/PasswordPolicy.php new file mode 100644 index 00000000..a6a9e55f --- /dev/null +++ b/app/Rules/PasswordPolicy.php @@ -0,0 +1,46 @@ + 0 ? $minLength : self::DEFAULT_MIN_LENGTH; + + $rule = Password::min($minLength); + + if ((bool) g7_core_settings('security.require_password_special_char', false)) { + $rule = $rule->symbols(); + } + + return $rule; + } +} diff --git a/app/Rules/TranslatableField.php b/app/Rules/TranslatableField.php index 1f11f97e..c615e2a1 100644 --- a/app/Rules/TranslatableField.php +++ b/app/Rules/TranslatableField.php @@ -15,12 +15,14 @@ class TranslatableField implements ValidationRule /** * TranslatableField 생성자 * - * @param int $maxLength 각 번역의 최대 길이 - * @param bool $required 최소 하나의 번역 필수 여부 + * @param int $maxLength 각 번역의 최대 길이 + * @param bool $required 최소 하나의 번역 필수 여부 + * @param bool $strictLocales 지원 목록 밖 로케일 키를 실패로 처리할지 여부 */ public function __construct( private int $maxLength = 255, - private bool $required = false + private bool $required = false, + private bool $strictLocales = false ) {} /** @@ -40,11 +42,20 @@ class TranslatableField implements ValidationRule // 각 언어별 검증 foreach ($value as $lang => $text) { - // 지원하지 않는 언어 코드 + // 지원 목록 밖 로케일 키 + // + // app.translatable_locales 는 활성 언어팩 기준으로 부팅마다 덮어써지는 가변 런타임 + // 값이다. 언어팩을 비활성화하면 기존 레코드에 남아 있던 그 로케일 키 때문에 수정 + // 자체가 차단되므로, 기본값은 "길이 검증 대상에서 제외하고 통과"로 둔다. + // 데이터를 지우지 않으므로 언어팩을 다시 켜면 번역이 그대로 복구된다. if (! in_array($lang, $allowedLanguages)) { - $fail(__('validation.translatable.unsupported_language', ['lang' => $lang])); + if ($this->strictLocales) { + $fail(__('validation.translatable.unsupported_language', ['lang' => $lang])); - return; + return; + } + + continue; } // null이거나 빈 문자열이 아닌 경우에만 검증 @@ -56,8 +67,8 @@ class TranslatableField implements ValidationRule return; } - // 최대 길이 초과 - if (strlen($text) > $this->maxLength) { + // 최대 길이 초과 (바이트가 아닌 문자 단위로 계산) + if (mb_strlen($text) > $this->maxLength) { $fail(__('validation.translatable.max_length', [ 'lang' => $lang, 'max' => $this->maxLength, diff --git a/app/Search/Contracts/KeywordPredicateProvider.php b/app/Search/Contracts/KeywordPredicateProvider.php new file mode 100644 index 00000000..0cf66aa4 --- /dev/null +++ b/app/Search/Contracts/KeywordPredicateProvider.php @@ -0,0 +1,78 @@ +lookupIds($keyword, limit: $context->keyCap); + * $method = $boolean === 'or' ? 'orWhereIn' : 'whereIn'; + * $query->$method($query->getModel()->getQualifiedKeyName(), $ids); + * } + * } + * ``` + */ +interface KeywordPredicateProvider +{ + /** + * 쿼리에 키워드 술어를 붙입니다. + * + * 컬럼 목록은 **하나의 조건으로 함께 평가**해 달라는 요청입니다. 엔진이 그 조합을 + * 다룰 수 없으면(예: MySQL 이 그 컬럼 조합의 복합 FULLTEXT 인덱스를 요구하는 경우) + * 호출자가 {@see KeywordSearch::applyAny()} 로 컬럼별 OR 을 요청합니다. + * + * 매칭이 하나도 없을 수 있는 검색어(예: 연산자만 입력)에는 **항상 거짓인 조건**을 + * 붙여야 합니다. 아무 조건도 붙이지 않으면 필터 없는 전체 목록이 검색 결과로 나갑니다. + * + * **페이지네이션은 호출자(DB)가 담당합니다.** 엔진에게 페이지 번호를 넘기지 않는 이유는, + * 엔진이 자기 순서로 한 페이지 분량만 돌려주면 그 뒤에 적용되는 DB 필터(분류·전시상태· + * 조인)에 일부가 탈락해 페이지가 비고, DB 정렬과 엔진의 관련도 순서가 달라 페이지 경계도 + * 어긋나기 때문입니다. 엔진이 책임지는 것은 **"얼마까지 돌려줄 것인가"** 하나입니다. + * + * 키 집합을 만들어 조건으로 붙이는 구현은 `$context->keyCap` 을 반드시 지켜야 합니다. + * 지키지 않으면 매칭이 큰 검색어에서 그 집합 자체가 메모리 폭발이 됩니다. 상한에 걸려 + * 잘린 경우 그 이상은 도달 불가이며, 총 건수는 "이상" 으로 보고됩니다 + * ({@see PaginationLimits} 와 같은 의미). + * + * @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더 + * @param array $columns 검색 대상 컬럼명 + * @param string $keyword 검색어 원문 (정제는 엔진이 수행) + * @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or') + * @param KeywordSearchContext $context 술어 생성 조건 (키 집합 상한 등) + */ + public function applyKeywordPredicate( + Builder $query, + array $columns, + string $keyword, + string $boolean, + KeywordSearchContext $context + ): void; +} diff --git a/app/Search/Contracts/SearchIndexMaintainer.php b/app/Search/Contracts/SearchIndexMaintainer.php new file mode 100644 index 00000000..60f98e84 --- /dev/null +++ b/app/Search/Contracts/SearchIndexMaintainer.php @@ -0,0 +1,78 @@ + 구현 클래스` 로 합니다. + * + * ```php + * HookManager::addFilter('core.search.index_maintainers', function (array $maintainers) { + * $maintainers['meilisearch'] = MeilisearchIndexMaintainer::class; + * + * return $maintainers; + * }); + * ``` + * + * 구현하지 않은 엔진은 점검 대상에서 제외될 뿐 검색 자체는 정상 동작합니다. + */ +interface SearchIndexMaintainer +{ + /** + * 이 유지보수기가 담당하는 Scout 드라이버명을 반환합니다. + * + * `config('scout.driver')` 값과 대조해 활성 엔진의 유지보수기를 고릅니다. + * + * @return string 드라이버명 (예: mysql-fulltext) + */ + public function driver(): string; + + /** + * 현재 환경에서 점검을 수행할 수 있는지 반환합니다. + * + * 예) FULLTEXT 미지원 DBMS, 외부 검색 서버 미연결 등에서 false. + * + * @return bool 점검 가능 여부 + */ + public function isAvailable(): bool; + + /** + * 점검 불가 사유를 반환합니다. + * + * `isAvailable()` 이 false 일 때 운영자에게 보여 줄 문장입니다. + * 사유를 남기지 않으면 "점검 대상 0" 과 "점검할 수 없었음" 이 구분되지 않습니다. + * + * @return string|null 사유 (사용 가능하면 null) + */ + public function unavailableReason(): ?string; + + /** + * 관리 중인 인덱스의 건강도를 판정합니다. + * + * @param array $filters 엔진별 필터 (예: ['table' => 'pages']) + * @return array 인덱스별 판정 결과 + */ + public function inspect(array $filters = []): array; + + /** + * 재생성이 필요하다고 판정된 인덱스를 재생성합니다. + * + * 재생성은 비용이 크고 서비스에 영향을 줄 수 있으므로, 호출자는 반드시 + * 운영자의 명시적 선택을 받은 뒤에만 호출합니다. + * + * @param SearchIndexHealth $health 재생성 대상 판정 결과 + * @return void + * + * @throws \Throwable 재생성 실패 시 + */ + public function rebuild(SearchIndexHealth $health): void; +} diff --git a/app/Search/DTO/FulltextIndexDefinition.php b/app/Search/DTO/FulltextIndexDefinition.php new file mode 100644 index 00000000..dd1f50d3 --- /dev/null +++ b/app/Search/DTO/FulltextIndexDefinition.php @@ -0,0 +1,65 @@ + $columns 색인 컬럼 목록 + * @param string|null $parser 파서명 (예: ngram). 지정되지 않은 인덱스는 null + */ + public function __construct( + public string $table, + public string $name, + public array $columns, + public ?string $parser = null, + ) {} + + /** + * 백틱을 두른 테이블명을 반환합니다. + * + * @return string 예: `g7_pages` + */ + public function quotedTable(): string + { + return '`'.$this->table.'`'; + } + + /** + * 백틱을 두른 컬럼 목록을 반환합니다. + * + * @return string 예: `title`, `content` + */ + public function quotedColumns(): string + { + return '`'.implode('`, `', $this->columns).'`'; + } + + /** + * `ALTER TABLE ... ADD FULLTEXT` 에 붙일 파서 절을 반환합니다. + * + * @return string 파서가 없으면 빈 문자열 + */ + public function parserClause(): string + { + return $this->parser === null ? '' : ' WITH PARSER '.$this->parser; + } + + /** + * 사람이 읽는 식별자를 반환합니다. + * + * @return string 예: g7_pages.ft_pages_title + */ + public function label(): string + { + return $this->table.'.'.$this->name; + } +} diff --git a/app/Search/DTO/KeywordSearchContext.php b/app/Search/DTO/KeywordSearchContext.php new file mode 100644 index 00000000..f0278fb1 --- /dev/null +++ b/app/Search/DTO/KeywordSearchContext.php @@ -0,0 +1,36 @@ +keyCap !== null && $this->keyCap > 0; + } +} diff --git a/app/Search/DTO/SearchIndexHealth.php b/app/Search/DTO/SearchIndexHealth.php new file mode 100644 index 00000000..cfbc203d --- /dev/null +++ b/app/Search/DTO/SearchIndexHealth.php @@ -0,0 +1,67 @@ + $details 엔진별 부가 정보 (표시 전용) + * @param array $context 재생성에 필요한 엔진 내부 정보 + */ + public function __construct( + public string $driver, + public string $identifier, + public SearchIndexStatus $status, + public string $measurement = '', + public array $details = [], + public array $context = [], + ) {} + + /** + * 재생성 대상인지 여부. + * + * @return bool + */ + public function needsRebuild(): bool + { + return $this->status === SearchIndexStatus::Stale; + } + + /** + * 기계 판독용 배열로 반환합니다. + * + * `context` 는 엔진 내부 정보라 응답에 싣지 않습니다. + * + * @return array + */ + public function toArray(): array + { + return [ + 'driver' => $this->driver, + 'identifier' => $this->identifier, + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'measurement' => $this->measurement, + 'details' => $this->details, + ]; + } +} diff --git a/app/Search/DTO/SearchIndexRepairReport.php b/app/Search/DTO/SearchIndexRepairReport.php new file mode 100644 index 00000000..c3020976 --- /dev/null +++ b/app/Search/DTO/SearchIndexRepairReport.php @@ -0,0 +1,119 @@ + $repaired 재생성 성공 인덱스 식별자 + * @param array $failed 재생성 실패 [식별자 => 사유] + * @param array $remaining 재생성 후에도 색인 누락으로 남은 식별자 + * @param bool $available 이 엔진에서 점검을 수행할 수 있었는지 + * @param string|null $reason 수행 불가 사유 (available=false 일 때) + */ + public function __construct( + public string $driver, + public int $inspected = 0, + public array $repaired = [], + public array $failed = [], + public array $remaining = [], + public bool $available = true, + public ?string $reason = null, + ) {} + + /** + * 점검을 수행할 수 없었던 경우의 보고를 만듭니다. + * + * @param string $driver 드라이버명 + * @param string|null $reason 사유 + * @return self + */ + public static function unavailable(string $driver, ?string $reason): self + { + return new self(driver: $driver, available: false, reason: $reason); + } + + /** + * 재생성 대상이 없었을 때의 보고를 만듭니다. + * + * @param string $driver 드라이버명 + * @param int $inspected 점검한 인덱스 수 + * @return self + */ + public static function nothingToDo(string $driver, int $inspected): self + { + return new self(driver: $driver, inspected: $inspected); + } + + /** + * 재생성을 실제로 수행했는지 여부. + * + * @return bool + */ + public function didRebuild(): bool + { + return $this->repaired !== [] || $this->failed !== []; + } + + /** + * 전부 복구되었는지 여부 (실패·잔존 모두 없음). + * + * @return bool + */ + public function isClean(): bool + { + return $this->failed === [] && $this->remaining === []; + } + + /** + * 한 줄 요약을 반환합니다. + * + * @return string + */ + public function summary(): string + { + if (! $this->available) { + return $this->reason ?? __('search.index.unavailable', ['driver' => $this->driver]); + } + + if (! $this->didRebuild()) { + return __('search.index.report.nothing_to_do', ['count' => $this->inspected]); + } + + return __('search.index.report.rebuilt', [ + 'inspected' => $this->inspected, + 'repaired' => count($this->repaired), + 'failed' => count($this->failed), + 'remaining' => count($this->remaining), + ]); + } + + /** + * 기계 판독용 배열로 반환합니다. + * + * @return array + */ + public function toArray(): array + { + return [ + 'driver' => $this->driver, + 'available' => $this->available, + 'reason' => $this->reason, + 'inspected' => $this->inspected, + 'repaired' => $this->repaired, + 'failed' => $this->failed, + 'remaining' => $this->remaining, + 'summary' => $this->summary(), + ]; + } +} diff --git a/app/Search/Engines/DatabaseFulltextEngine.php b/app/Search/Engines/DatabaseFulltextEngine.php index f77f8cdf..77f7da54 100644 --- a/app/Search/Engines/DatabaseFulltextEngine.php +++ b/app/Search/Engines/DatabaseFulltextEngine.php @@ -2,7 +2,13 @@ namespace App\Search\Engines; +use App\Enums\TotalRelation; use App\Search\Contracts\FulltextSearchable; +use App\Search\Contracts\KeywordPredicateProvider; +use App\Search\DTO\KeywordSearchContext; +use App\Search\KeywordSearch; +use App\Support\Query\BoundedPaginator; +use App\Support\Query\PaginationLimits; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; @@ -18,7 +24,7 @@ use Laravel\Scout\Engines\Engine; * * MySQL 자체가 인덱스 소스이므로 update/delete/flush는 no-op입니다. */ -class DatabaseFulltextEngine extends Engine +class DatabaseFulltextEngine extends Engine implements KeywordPredicateProvider { /** * MariaDB 감지 결과 캐시 (프로세스 수명 동안 유지) @@ -53,20 +59,52 @@ class DatabaseFulltextEngine extends Engine * FULLTEXT 검색을 수행합니다. * * @param Builder $builder Scout 빌더 - * @return array{query: \Illuminate\Database\Eloquent\Builder, total: int} + * @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null} */ public function search(Builder $builder): array { return $this->performSearch($builder); } + /** + * 검색 결과의 키 목록만 조회합니다. + * + * 총 건수를 쓰지 않는 경로이므로 COUNT 를 아예 실행하지 않고, SELECT 도 키 컬럼과 + * 정렬용 스코어로만 좁힙니다. 좁히지 않으면 매칭된 전 행의 모든 컬럼(본문 포함)을 + * 읽고 나서 ID 만 뽑아내게 됩니다. + * + * @param Builder $builder Scout 빌더 + * @return \Illuminate\Support\Collection 키 컬렉션 + */ + public function keys(Builder $builder): \Illuminate\Support\Collection + { + return $this->mapIds($this->performSearch($builder, withTotal: false, keysOnly: true)); + } + + /** + * 검색 결과를 모델 컬렉션으로 조회합니다. + * + * 총 건수를 쓰지 않는 경로이므로 COUNT 를 실행하지 않습니다. + * + * @param Builder $builder Scout 빌더 + * @return Collection 모델 컬렉션 + */ + public function get(Builder $builder): Collection + { + return $this->map( + $builder, + $builder->applyAfterRawSearchCallback($this->performSearch($builder, withTotal: false)), + $builder->model + ); + } + /** * 페이지네이션 적용 FULLTEXT 검색을 수행합니다. * * @param Builder $builder Scout 빌더 * @param int $perPage 페이지당 결과 수 * @param int $page 페이지 번호 - * @return array{query: \Illuminate\Database\Eloquent\Builder, total: int} + * @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null} */ public function paginate(Builder $builder, $perPage, $page): array { @@ -76,6 +114,13 @@ class DatabaseFulltextEngine extends Engine /** * 검색 결과에서 모델 ID를 추출합니다. * + * 넘겨받은 쿼리의 SELECT 는 건드리지 않는다 — ORDER BY 가 `_ft_score`(그리고 소비자 + * `->query()` 콜백이 얹은 별칭)를 참조하므로 SELECT 를 갈아끼우면 정렬 대상이 사라져 + * `Unknown column ... in 'order clause'` 로 죽는다. 키만 읽어야 하는 경로는 + * `applySelect(keysOnly: true)` 가 조립 시점에 이미 좁혀 두었고, `pluck()` 은 기존 + * SELECT 를 보존하므로(`onceWithColumns` 는 columns 가 있으면 덮어쓰지 않는다) + * 여기서 다시 좁힐 이유가 없다. `clone()` 은 호출자 쿼리 불변을 위해 유지한다. + * * @param array $results 검색 결과 */ public function mapIds($results): \Illuminate\Support\Collection @@ -85,7 +130,7 @@ class DatabaseFulltextEngine extends Engine return collect(); } - return $query->pluck($query->getModel()->getKeyName()); + return $query->clone()->pluck($query->getModel()->getKeyName()); } /** @@ -129,7 +174,31 @@ class DatabaseFulltextEngine extends Engine */ public function getTotalCount($results): int { - return $results['total'] ?? 0; + return (int) ($results['total'] ?? 0); + } + + /** + * 검색 결과의 총 건수 정확도를 반환합니다. + * + * 상한을 넘겨 정확히 세지 않은 경우 `AtLeast` 입니다. + * + * @param array $results 검색 결과 + * @return TotalRelation 총 건수 정확도 + */ + public function getTotalRelation($results): TotalRelation + { + return $results['total_relation'] ?? TotalRelation::Exact; + } + + /** + * 총 건수 집계에 적용된 상한을 반환합니다. + * + * @param array $results 검색 결과 + * @return int|null 상한 (무제한이면 null) + */ + public function getResultCap($results): ?int + { + return $results['result_cap'] ?? null; } /** @@ -239,6 +308,14 @@ class DatabaseFulltextEngine extends Engine * MySQL 파싱 오류(ER_PARSE_ERROR)를 유발하므로, 사용자 입력을 일반 검색어로만 * 취급하도록 연산자를 제거하고 남은 토큰을 따옴표 구문으로 묶습니다. * + * 토큰은 공백으로 잇습니다 — BOOLEAN MODE 에서 공백 결합은 OR 입니다. 각 토큰에 `+` 를 + * 붙이면 AND 가 되어 매칭 집합이 줄고 대용량에서 검색 시간이 짧아질 여지가 있지만, + * **의도적으로 OR 을 유지합니다**. 입력한 단어 중 하나만 든 문서가 결과에서 통째로 + * 사라지는 손실이 성능 이득보다 크기 때문입니다. 한글은 ngram 파서가 2글자 단위로 + * 토큰을 쪼개므로 AND 전환의 결과 축소 폭이 특히 큽니다. 성능 축이 문제가 되면 결합 + * 방식이 아니라 전용 검색 엔진(core.search.engine_drivers)으로 해결합니다. + * 배경·실측: docs/backend/search-system.md, docs/backend/pagination.md + * * @param string $keyword 원본 검색어 * @return string 안전하게 정제된 BOOLEAN MODE 검색식 (정제 결과가 없으면 빈 문자열) */ @@ -267,32 +344,114 @@ class DatabaseFulltextEngine extends Engine * * DBMS별로 MATCH...AGAINST 또는 LIKE fallback을 자동 적용합니다. * + * 검색어는 반드시 이 헬퍼를 거쳐야 합니다. BOOLEAN MODE 는 `+ - * " ( )` 등을 + * 연산자로 해석하므로, 원문 키워드를 그대로 바인딩하면 사용자가 `+` 하나만 입력해도 + * 파싱 오류로 500 이 됩니다. 정제는 sanitizeBooleanModeKeyword 한 곳에서만 합니다. + * + * **컬럼을 배열로 넘기면 하나의 `MATCH(a, b)` 가 됩니다.** MySQL 은 이 형태에 정확히 + * 그 컬럼 조합의 **복합 FULLTEXT 인덱스**를 요구하며, 없으면 + * `Can't find FULLTEXT index matching the column list` 오류가 납니다. + * 컬럼별 단일 인덱스만 있는 테이블은 {@see self::whereFulltextAny()} 를 쓰세요. + * * @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더 - * @param string $column 검색 대상 컬럼명 - * @param string $keyword 검색어 + * @param string|array $columns 검색 대상 컬럼명 (배열이면 복합 인덱스 필요) + * @param string $keyword 검색어 (원문 — 정제는 이 메서드가 수행) * @param string $boolean 조건 결합 방식 ('and' 또는 'or') */ public static function whereFulltext( \Illuminate\Database\Eloquent\Builder $query, - string $column, + string|array $columns, string $keyword, string $boolean = 'and' ): void { - if (static::supportsFulltext()) { - $ftKeyword = static::sanitizeBooleanModeKeyword($keyword); - if ($ftKeyword === '') { - // 정제 결과 없음 → 항상 false 조건으로 빈 결과 (void 반환 계약 유지) - $falseMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw'; - $query->$falseMethod('1 = 0'); + // 활성 엔진 해석을 거친다 — 다른 엔진이 켜져 있으면 그 엔진이 조건을 만든다. + // 이 정적 헬퍼를 직접 부르던 코드도 이 경유로 엔진 교체 혜택을 받는다. + KeywordSearch::apply($query, $columns, $keyword, $boolean); + } - return; - } - $method = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw'; - $query->$method("MATCH(`{$column}`) AGAINST(? IN BOOLEAN MODE)", [$ftKeyword]); - } else { - $method = $boolean === 'or' ? 'orWhere' : 'where'; - $query->$method($column, 'LIKE', "%{$keyword}%"); + /** + * {@inheritDoc} + * + * 이 엔진의 술어는 `MATCH ... AGAINST IN BOOLEAN MODE` 입니다. DBMS 가 FULLTEXT 를 + * 지원하지 않으면 같은 자리에서 부분일치로 내려갑니다. + * + * `$context->keyCap` 은 쓰지 않습니다 — 이 엔진의 술어는 SQL 조건 그 자체라 중간 + * 키 집합을 만들지 않으므로 상한을 적용할 대상이 없습니다. 상한은 키 집합을 만들어 + * 조건으로 붙이는 엔진(외부 검색 서버 등)을 위한 것입니다. + */ + public function applyKeywordPredicate( + \Illuminate\Database\Eloquent\Builder $query, + array $columns, + string $keyword, + string $boolean, + KeywordSearchContext $context + ): void { + static::applyFulltextPredicate($query, $columns, $keyword, $boolean); + } + + /** + * FULLTEXT 조건을 실제로 조립합니다. + * + * @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더 + * @param array $columns 검색 대상 컬럼명 + * @param string $keyword 검색어 원문 + * @param string $boolean 조건 결합 방식 ('and' 또는 'or') + */ + protected static function applyFulltextPredicate( + \Illuminate\Database\Eloquent\Builder $query, + array $columns, + string $keyword, + string $boolean = 'and' + ): void { + $columns = array_values($columns); + + if (! static::supportsFulltext()) { + // 전문검색을 제공하지 않는 DBMS 로 설치된 사이트에서는 부분일치가 정상 경로다. + // 연산자 선택과 와일드카드 escape 는 코어 해석기가 단독으로 수행한다 — + // 여기서 다시 조립하면 DBMS 가 늘 때마다 고쳐야 할 곳이 둘이 된다. + KeywordSearch::applyLikeMatch($query, $columns, $keyword, $boolean); + + return; } + + $ftKeyword = static::sanitizeBooleanModeKeyword($keyword); + + if ($ftKeyword === '') { + // 정제 결과 없음(연산자만 입력) → 빈 결과. 빌더가 바인딩까지 처리하도록 + // 빈 whereIn 을 쓴다 (raw '1 = 0' 은 쓰지 않는다). + $method = $boolean === 'or' ? 'orWhereIn' : 'whereIn'; + $query->$method($query->getModel()->getQualifiedKeyName(), []); + + return; + } + + $match = 'MATCH(`'.implode('`, `', $columns).'`)'; + $method = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw'; + $query->$method($match.' AGAINST(? IN BOOLEAN MODE)', [$ftKeyword]); + } + + /** + * 여러 컬럼 중 하나라도 매칭하면 되는 FULLTEXT 조건을 추가합니다. + * + * 컬럼마다 별도의 `MATCH(col)` 를 만들어 OR 로 묶습니다. 컬럼별 단일 FULLTEXT 인덱스만 + * 있는 테이블(대부분의 경우)에서 쓰는 형태이며, 복합 인덱스가 없어도 동작합니다. + * + * 복합 인덱스가 있어 `MATCH(a, b)` 한 번으로 끝내야 하는 테이블은 + * {@see self::whereFulltext()} 에 배열을 넘기세요. + * + * @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더 + * @param array $columns 검색 대상 컬럼명 목록 + * @param string $keyword 검색어 (원문 — 정제는 내부에서 수행) + * @param string $boolean 바깥 쿼리와의 결합 방식 ('and' 또는 'or') + */ + public static function whereFulltextAny( + \Illuminate\Database\Eloquent\Builder $query, + array $columns, + string $keyword, + string $boolean = 'and' + ): void { + // 활성 엔진 해석을 거친다 ({@see self::whereFulltext()} 와 동일한 이유). + KeywordSearch::applyAny($query, $columns, $keyword, $boolean); } /** @@ -329,39 +488,37 @@ class DatabaseFulltextEngine extends Engine * @param Builder $builder Scout 빌더 * @param int|null $perPage 페이지당 결과 수 * @param int|null $page 페이지 번호 - * @return array{query: \Illuminate\Database\Eloquent\Builder, total: int} + * @param bool $withTotal 총 건수를 계산할지 여부 (쓰지 않는 경로에서는 false) + * @param bool $keysOnly 키 컬럼과 정렬용 스코어만 조회할지 여부 + * @return array{query: \Illuminate\Database\Eloquent\Builder|null, total: int|null, total_relation: TotalRelation|null, result_cap: int|null} */ - protected function performSearch(Builder $builder, ?int $perPage = null, ?int $page = null): array - { + protected function performSearch( + Builder $builder, + ?int $perPage = null, + ?int $page = null, + bool $withTotal = true, + bool $keysOnly = false + ): array { $model = $builder->model; $keyword = $builder->query; // 빈 검색어인 경우 빈 결과 반환 if (empty(trim($keyword))) { - return [ - 'query' => null, - 'total' => 0, - ]; + return self::emptyResult(); } $query = $model->newQuery(); // FulltextSearchable 인터페이스 구현 확인 if (! ($model instanceof FulltextSearchable)) { - return [ - 'query' => null, - 'total' => 0, - ]; + return self::emptyResult(); } $columns = $model->searchableColumns(); $weights = $model->searchableWeights(); if (empty($columns)) { - return [ - 'query' => null, - 'total' => 0, - ]; + return self::emptyResult(); } $useFulltext = static::supportsFulltext(); @@ -375,10 +532,7 @@ class DatabaseFulltextEngine extends Engine $ftKeyword = static::sanitizeBooleanModeKeyword($keyword); if ($ftKeyword === '') { // 연산자만 입력 → 500 대신 빈 결과 (빈 검색어와 동일 반환 형태) - return [ - 'query' => null, - 'total' => 0, - ]; + return self::emptyResult(); } // MATCH...AGAINST WHERE 조건 생성 (MySQL, MariaDB) @@ -399,7 +553,7 @@ class DatabaseFulltextEngine extends Engine $scoreBindings[] = $ftKeyword; } $scoreRaw = '('.implode(' + ', $scoreExpressions).') as _ft_score'; - $query->selectRaw($qualifiedTable.'.*, '.$scoreRaw, $scoreBindings); + $this->applySelect($query, $model, $qualifiedTable, $scoreRaw, $scoreBindings, $keysOnly); } else { // LIKE fallback (PostgreSQL, SQLite 등) $query->where(function ($q) use ($columns, $keyword) { @@ -409,7 +563,7 @@ class DatabaseFulltextEngine extends Engine }); // 스코어 고정 0 (관련성 순위 불가) - $query->selectRaw($qualifiedTable.'.*, 0 as _ft_score'); + $this->applySelect($query, $model, $qualifiedTable, '0 as _ft_score', [], $keysOnly); } // Scout Builder 콜백 적용 (추가 where 조건 등) @@ -459,8 +613,16 @@ class DatabaseFulltextEngine extends Engine $query->orderByDesc('_ft_score'); } - // 전체 건수 계산 (페이지네이션 전) - $total = $query->toBase()->getCountForPagination(); + // 전체 건수 계산 (페이지네이션 전). + // 총 건수를 쓰지 않는 경로에서는 아예 세지 않는다 — 대용량 매칭에서 COUNT 한 번이 + // 조회 본체보다 비쌀 수 있다. + $resultCap = PaginationLimits::resultCap('search'); + $total = null; + $relation = null; + + if ($withTotal) { + [$total, $relation] = BoundedPaginator::countWithCap($query, $resultCap); + } // 페이지네이션 적용 if ($perPage !== null) { @@ -468,11 +630,66 @@ class DatabaseFulltextEngine extends Engine $query->limit($perPage)->offset($offset); } elseif ($builder->limit !== null) { $query->limit($builder->limit); + } elseif ($resultCap !== null) { + // perPage 도 limit 도 없으면 LIMIT 이 아예 붙지 않아 매칭 전량을 PHP 로 끌어온다. + // 상한을 걸어 한 요청이 읽는 행 수에 천장을 둔다. + $query->limit($resultCap); } return [ 'query' => $query, 'total' => $total, + 'total_relation' => $relation, + 'result_cap' => $resultCap, + ]; + } + + /** + * 조회 컬럼을 적용합니다. + * + * 키만 필요한 경로에서는 키 컬럼과 정렬용 스코어로 좁힙니다. 스코어는 `ORDER BY` 가 + * 참조하므로 좁힐 때도 함께 남겨야 합니다. + * + * 검색 쿼리의 SELECT 를 재작성하는 유일한 지점입니다. `mapIds()`/`map()`/`lazyMap()` 은 + * 넘겨받은 쿼리의 SELECT·ORDER BY 를 바꾸지 않습니다 — 소비자가 `->query()` 로 얹은 + * 별칭에도 정렬이 걸릴 수 있어, 재작성은 알려진 별칭 하나만 보존해도 안전하지 않습니다. + * + * @param \Illuminate\Database\Eloquent\Builder $query 대상 쿼리 + * @param Model $model 기준 모델 + * @param string $qualifiedTable 프리픽스 포함 테이블명 + * @param string $scoreRaw 스코어 SELECT 표현식 (`... as _ft_score`) + * @param array $scoreBindings 스코어 표현식 바인딩 + * @param bool $keysOnly 키 컬럼만 조회할지 여부 + */ + protected function applySelect( + $query, + Model $model, + string $qualifiedTable, + string $scoreRaw, + array $scoreBindings, + bool $keysOnly + ): void { + if ($keysOnly) { + $query->select($model->getQualifiedKeyName())->selectRaw($scoreRaw, $scoreBindings); + + return; + } + + $query->selectRaw($qualifiedTable.'.*, '.$scoreRaw, $scoreBindings); + } + + /** + * 검색이 성립하지 않을 때의 빈 결과 형태를 반환합니다. + * + * @return array{query: null, total: int, total_relation: TotalRelation, result_cap: null} + */ + protected static function emptyResult(): array + { + return [ + 'query' => null, + 'total' => 0, + 'total_relation' => TotalRelation::Exact, + 'result_cap' => null, ]; } } diff --git a/app/Search/Engines/Maintenance/FulltextIndexMaintainer.php b/app/Search/Engines/Maintenance/FulltextIndexMaintainer.php new file mode 100644 index 00000000..9624ade9 --- /dev/null +++ b/app/Search/Engines/Maintenance/FulltextIndexMaintainer.php @@ -0,0 +1,127 @@ +isAvailable() + ? null + : __('search.index.fulltext.unsupported_driver'); + } + + /** + * {@inheritDoc} + * + * 지원 필터: `table`(테이블명, 프리픽스 생략 가능) · `index`(인덱스명) · `samples`(표본 행 수) + */ + public function inspect(array $filters = []): array + { + $sampleSize = (int) ($filters['samples'] ?? FulltextIndexInspector::DEFAULT_SAMPLE_SIZE); + + $results = $this->inspector->inspectAll( + table: isset($filters['table']) ? (string) $filters['table'] : null, + index: isset($filters['index']) ? (string) $filters['index'] : null, + sampleSize: max(1, $sampleSize), + ); + + return array_map(fn (array $result) => $this->toHealth($result), $results); + } + + /** + * {@inheritDoc} + */ + public function rebuild(SearchIndexHealth $health): void + { + $context = $health->context; + + $this->inspector->rebuild(new FulltextIndexDefinition( + table: (string) ($context['table'] ?? ''), + name: (string) ($context['name'] ?? ''), + columns: (array) ($context['columns'] ?? []), + parser: isset($context['parser']) ? (string) $context['parser'] : null, + )); + } + + /** + * 점검기 결과를 엔진 중립 판정 DTO 로 옮깁니다. + * + * @param array{definition: FulltextIndexDefinition, status: SearchIndexStatus, probed: int, found: int, reason: ?string} $result 점검기 결과 + * @return SearchIndexHealth + */ + private function toHealth(array $result): SearchIndexHealth + { + $definition = $result['definition']; + $status = $result['status']; + + $measurement = $status === SearchIndexStatus::Skipped + ? (string) $result['reason'] + : __('search.index.fulltext.self_match', ['found' => $result['found'], 'probed' => $result['probed']]); + + return new SearchIndexHealth( + driver: self::DRIVER, + identifier: $definition->label(), + status: $status, + measurement: $measurement, + details: [ + 'table' => $definition->table, + 'index' => $definition->name, + 'columns' => $definition->columns, + 'parser' => $definition->parser, + 'probed' => $result['probed'], + 'found' => $result['found'], + ], + context: [ + 'table' => $definition->table, + 'name' => $definition->name, + 'columns' => $definition->columns, + 'parser' => $definition->parser, + ], + ); + } +} diff --git a/app/Search/FulltextIndexInspector.php b/app/Search/FulltextIndexInspector.php new file mode 100644 index 00000000..b62a48ad --- /dev/null +++ b/app/Search/FulltextIndexInspector.php @@ -0,0 +1,367 @@ + + */ + public function discover(?string $table = null, ?string $index = null): array + { + if (! DatabaseFulltextEngine::supportsFulltext()) { + return []; + } + + $rows = DB::select( + 'SELECT TABLE_NAME AS table_name, INDEX_NAME AS index_name, + GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND INDEX_TYPE = ? + GROUP BY TABLE_NAME, INDEX_NAME + ORDER BY TABLE_NAME, INDEX_NAME', + ['FULLTEXT'] + ); + + $prefix = DB::getTablePrefix(); + $wanted = $table === null ? null : [$table, $prefix.$table]; + + $definitions = []; + foreach ($rows as $row) { + if ($wanted !== null && ! in_array($row->table_name, $wanted, true)) { + continue; + } + if ($index !== null && $row->index_name !== $index) { + continue; + } + + $definitions[] = new FulltextIndexDefinition( + table: $row->table_name, + name: $row->index_name, + columns: explode(',', (string) $row->columns), + parser: $this->resolveParser($row->table_name, $row->index_name), + ); + } + + return $definitions; + } + + /** + * 인덱스 하나의 건강도를 판정합니다. + * + * 엔진 중립 DTO 로의 변환은 호출자(`FulltextIndexMaintainer`)가 합니다 — 이 클래스는 + * FULLTEXT 고유의 판정만 담당하고 표현 형식을 알지 않습니다. + * + * @param FulltextIndexDefinition $definition 인덱스 정의 + * @param int $sampleSize 표본 행 수 + * @return array{definition: FulltextIndexDefinition, status: SearchIndexStatus, probed: int, found: int, reason: string|null} + */ + public function inspect(FulltextIndexDefinition $definition, int $sampleSize = self::DEFAULT_SAMPLE_SIZE): array + { + $key = $this->resolvePrimaryKey($definition->table); + + if ($key === null) { + return $this->skipped($definition, __('search.index.fulltext.skip.no_single_pk')); + } + + $samples = $this->sampleRows($definition, $key, $sampleSize); + + if ($samples === []) { + return $this->skipped($definition, __('search.index.fulltext.skip.no_rows')); + } + + $probed = 0; + $found = 0; + + foreach ($samples as $sample) { + $tokens = $this->buildTokens((string) $sample->body); + + if ($tokens === []) { + continue; + } + + $probed++; + + if ($this->rowFindsItself($definition, $key, $sample->pk, $tokens)) { + $found++; + } + } + + if ($probed === 0) { + return $this->skipped($definition, __('search.index.fulltext.skip.no_tokens')); + } + + return [ + 'definition' => $definition, + 'status' => match (true) { + $found === 0 => SearchIndexStatus::Stale, + $found < $probed => SearchIndexStatus::Degraded, + default => SearchIndexStatus::Healthy, + }, + 'probed' => $probed, + 'found' => $found, + 'reason' => null, + ]; + } + + /** + * 전체(또는 필터된) 인덱스의 건강도를 판정합니다. + * + * @param string|null $table 테이블명 필터 + * @param string|null $index 인덱스명 필터 + * @param int $sampleSize 표본 행 수 + * @return array + */ + public function inspectAll(?string $table = null, ?string $index = null, int $sampleSize = self::DEFAULT_SAMPLE_SIZE): array + { + return array_map( + fn (FulltextIndexDefinition $definition) => $this->inspect($definition, $sampleSize), + $this->discover($table, $index) + ); + } + + /** + * 판정 불가 결과를 만듭니다. + * + * @param FulltextIndexDefinition $definition 인덱스 정의 + * @param string $reason 사유 + * @return array{definition: FulltextIndexDefinition, status: SearchIndexStatus, probed: int, found: int, reason: string} + */ + private function skipped(FulltextIndexDefinition $definition, string $reason): array + { + return [ + 'definition' => $definition, + 'status' => SearchIndexStatus::Skipped, + 'probed' => 0, + 'found' => 0, + 'reason' => $reason, + ]; + } + + /** + * 인덱스를 같은 정의로 재생성합니다. + * + * 컬럼 구성과 파서를 그대로 유지하므로 검색 동작이 달라지지 않습니다. + * 재생성 중에는 대상 테이블이 잠기므로 대용량 테이블에서는 시간이 걸립니다. + * + * @param FulltextIndexDefinition $definition 인덱스 정의 + * @return void + * + * @throws Throwable 재생성 실패 시 (드롭까지만 되고 끝나지 않도록 예외를 그대로 전파) + */ + public function rebuild(FulltextIndexDefinition $definition): void + { + $table = $definition->quotedTable(); + $name = $definition->name; + + DB::statement("ALTER TABLE {$table} DROP INDEX `{$name}`"); + + try { + DB::statement( + "ALTER TABLE {$table} ADD FULLTEXT INDEX `{$name}` ({$definition->quotedColumns()})" + .$definition->parserClause() + ); + } catch (Throwable $e) { + // 드롭만 되고 끝나면 검색이 아예 죽는다 — 원 정의로 되돌린 뒤 예외를 전파한다 + $this->restoreQuietly($definition); + + throw $e; + } + } + + /** + * 인덱스의 파서를 조회합니다. + * + * `INFORMATION_SCHEMA` 는 파서를 노출하지 않으므로 `SHOW CREATE TABLE` 정의에서 읽습니다. + * + * @param string $table 테이블명 (프리픽스 포함) + * @param string $index 인덱스명 + * @return string|null 파서명 (없으면 null) + */ + private function resolveParser(string $table, string $index): ?string + { + try { + $create = DB::selectOne('SHOW CREATE TABLE `'.$table.'`'); + } catch (Throwable) { + return null; + } + + $sql = (array) $create; + $ddl = (string) (($sql['Create Table'] ?? $sql['Create View'] ?? '')); + + $pattern = '/FULLTEXT\s+KEY\s+`'.preg_quote($index, '/').'`[^\n]*?WITH\s+PARSER\s+`?(\w+)`?/i'; + + return preg_match($pattern, $ddl, $m) === 1 ? $m[1] : null; + } + + /** + * 테이블의 단일 컬럼 기본키를 조회합니다. + * + * @param string $table 테이블명 (프리픽스 포함) + * @return string|null 기본키 컬럼명 (복합키·부재 시 null) + */ + private function resolvePrimaryKey(string $table): ?string + { + $rows = DB::select( + 'SELECT COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + ORDER BY SEQ_IN_INDEX', + [$table, 'PRIMARY'] + ); + + return count($rows) === 1 ? (string) $rows[0]->column_name : null; + } + + /** + * 색인 컬럼에 내용이 있는 표본 행을 뽑습니다. + * + * @param FulltextIndexDefinition $definition 인덱스 정의 + * @param string $key 기본키 컬럼명 + * @param int $sampleSize 표본 행 수 + * @return array `pk`, `body` 를 가진 행 배열 + */ + private function sampleRows(FulltextIndexDefinition $definition, string $key, int $sampleSize): array + { + $body = "CONCAT_WS(' ', {$definition->quotedColumns()})"; + + return DB::select( + "SELECT `{$key}` AS pk, {$body} AS body + FROM {$definition->quotedTable()} + WHERE {$body} <> '' AND {$body} IS NOT NULL + ORDER BY `{$key}` DESC + LIMIT ".max(1, $sampleSize) + ); + } + + /** + * 행 내용에서 검색 토큰 후보를 만듭니다. + * + * 비-라틴(한글·한자 등) 조각을 먼저 담습니다 — 기본 불용어 목록이 영어라 + * 라틴 토큰보다 토크나이저 영향을 덜 받습니다. + * + * @param string $text 행 내용 + * @return array 토큰 후보 목록 + */ + private function buildTokens(string $text): array + { + // 부울 모드 연산자(+ - > < ( ) ~ * " @)와 구분자를 제거한다 + $normalized = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text) ?? ''; + $pieces = array_values(array_filter( + explode(' ', $normalized), + fn (string $piece) => mb_strlen($piece) >= 2 + )); + + if ($pieces === []) { + return []; + } + + // 길이 내림차순 — 짧은 조각일수록 불용어·잡음일 확률이 높다 + usort($pieces, fn (string $a, string $b) => mb_strlen($b) <=> mb_strlen($a)); + + $nonLatin = []; + $latin = []; + + foreach ($pieces as $piece) { + $token = mb_substr($piece, 0, self::MAX_TOKEN_LENGTH); + + if (preg_match('/^[\x00-\x7F]+$/', $token) === 1) { + $latin[] = $token; + } else { + $nonLatin[] = $token; + } + } + + return array_slice(array_values(array_unique([...$nonLatin, ...$latin])), 0, self::MAX_TOKENS_PER_ROW); + } + + /** + * 한 행이 자기 토큰 중 하나로 자신을 찾을 수 있는지 확인합니다. + * + * @param FulltextIndexDefinition $definition 인덱스 정의 + * @param string $key 기본키 컬럼명 + * @param mixed $pk 행의 기본키 값 + * @param array $tokens 토큰 후보 + * @return bool 하나라도 찾으면 true + */ + private function rowFindsItself(FulltextIndexDefinition $definition, string $key, mixed $pk, array $tokens): bool + { + foreach ($tokens as $token) { + $hit = DB::selectOne( + "SELECT COUNT(*) AS hits FROM {$definition->quotedTable()} + WHERE `{$key}` = ? AND MATCH({$definition->quotedColumns()}) AGAINST(? IN BOOLEAN MODE)", + [$pk, '"'.$token.'"'] + ); + + if ((int) ($hit->hits ?? 0) > 0) { + return true; + } + } + + return false; + } + + /** + * 재생성 실패 시 원 정의로 되돌립니다 (실패해도 원 예외를 가리지 않도록 조용히 처리). + * + * @param FulltextIndexDefinition $definition 인덱스 정의 + * @return void + */ + private function restoreQuietly(FulltextIndexDefinition $definition): void + { + try { + DB::statement( + "ALTER TABLE {$definition->quotedTable()} ADD FULLTEXT INDEX `{$definition->name}` " + ."({$definition->quotedColumns()})".$definition->parserClause() + ); + } catch (Throwable) { + // 되돌리기까지 실패하면 원 예외가 더 중요한 정보다 + } + } +} diff --git a/app/Search/KeywordSearch.php b/app/Search/KeywordSearch.php new file mode 100644 index 00000000..9d958ab9 --- /dev/null +++ b/app/Search/KeywordSearch.php @@ -0,0 +1,280 @@ + + */ + private static array $warnedDrivers = []; + + /** + * 쿼리에 키워드 술어를 붙입니다. + * + * 컬럼을 배열로 넘기면 **하나의 조건으로 함께 평가**해 달라는 뜻입니다. 엔진이 그 조합을 + * 다룰 수 없는 경우(MySQL FULLTEXT 는 그 컬럼 조합의 복합 인덱스를 요구한다)에는 + * {@see self::applyAny()} 로 컬럼별 OR 을 요청하세요. + * + * @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더 + * @param string|array $columns 검색 대상 컬럼명 + * @param string $keyword 검색어 원문 + * @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or') + * @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름 (예: 'search') + */ + public static function apply( + Builder $query, + string|array $columns, + string $keyword, + string $boolean = 'and', + ?string $limitContext = null + ): void { + $columns = array_values((array) $columns); + + if ($columns === []) { + return; + } + + $provider = self::provider(); + + if ($provider !== null) { + $provider->applyKeywordPredicate( + $query, + $columns, + $keyword, + $boolean, + self::context($limitContext) + ); + + return; + } + + self::applyLikeMatch($query, $columns, $keyword, $boolean); + } + + /** + * 엔진에게 넘길 술어 생성 조건을 만듭니다. + * + * 키 집합 상한은 목록 총 건수 상한과 같은 값을 쓴다 — 두 상한이 갈라지면 "엔진이 + * 돌려준 건수" 와 "화면이 보고하는 총 건수" 가 서로 다른 근거를 갖게 된다. + * + * @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름 + * @return KeywordSearchContext 술어 생성 조건 + */ + private static function context(?string $limitContext): KeywordSearchContext + { + return new KeywordSearchContext( + keyCap: PaginationLimits::resultCap($limitContext), + ); + } + + /** + * 컬럼별로 따로 평가해 OR 로 묶은 키워드 술어를 붙입니다. + * + * 컬럼마다 개별 인덱스만 있는 테이블에서 씁니다. 바깥은 하나의 그룹으로 감싸므로 + * 호출자의 기존 조건과 섞이지 않습니다. + * + * @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더 + * @param array $columns 검색 대상 컬럼명 + * @param string $keyword 검색어 원문 + * @param string $boolean 기존 조건과의 결합 방식 ('and' 또는 'or') + * @param string|null $limitContext 상한 해석에 쓸 목록 컨텍스트 이름 (예: 'search') + */ + public static function applyAny( + Builder $query, + array $columns, + string $keyword, + string $boolean = 'and', + ?string $limitContext = null + ): void { + $columns = array_values($columns); + + if ($columns === []) { + return; + } + + $method = $boolean === 'or' ? 'orWhere' : 'where'; + + $query->$method(function ($group) use ($columns, $keyword, $limitContext) { + foreach ($columns as $index => $column) { + self::apply($group, $column, $keyword, $index === 0 ? 'and' : 'or', $limitContext); + } + }); + } + + /** + * 활성 엔진이 키워드 술어를 제공하는지 확인하고 그 엔진을 반환합니다. + * + * 제공하지 않으면 null 을 돌려주고, 그 사실을 드라이버당 한 번 기록합니다. + * 기록하지 않으면 "검색이 느리고 관련도가 이상하다" 는 증상만 남고 원인을 찾을 단서가 + * 없어진다 — 폴백은 정상 동작처럼 보이기 때문이다. + * + * @return KeywordPredicateProvider|null 술어를 제공하는 엔진 (없으면 null) + */ + private static function provider(): ?KeywordPredicateProvider + { + try { + $engine = app(EngineManager::class)->engine(); + } catch (Throwable) { + // 부팅 초기 등 엔진 해석이 불가한 상황 — 경고 없이 폴백한다. + return null; + } + + if ($engine instanceof KeywordPredicateProvider) { + return $engine; + } + + self::warnFallbackOnce(); + + return null; + } + + /** + * 폴백 사용 사실을 드라이버당 한 번 기록합니다. + */ + private static function warnFallbackOnce(): void + { + $driver = (string) config('scout.driver', 'mysql-fulltext'); + + if (isset(self::$warnedDrivers[$driver])) { + return; + } + + self::$warnedDrivers[$driver] = true; + + Log::warning('활성 검색 엔진이 키워드 술어를 제공하지 않아 LIKE 로 검색합니다. 관련도 정렬이 적용되지 않고 전체 스캔이 발생합니다.', [ + 'driver' => $driver, + 'contract' => KeywordPredicateProvider::class, + ]); + } + + /** + * 부분일치(LIKE) 조건을 붙입니다. + * + * 이 경로는 "엔진이 없을 때의 임시방편" 이 아니다. 전문검색을 제공하지 않는 DBMS 로 + * 설치된 사이트에서는 **이것이 정상 검색 경로**이므로 이식성을 갖춰야 한다. + * + * - 대소문자: PostgreSQL 의 `LIKE` 는 대소문자를 구분한다. MySQL 은 기본 collation 이 + * 구분하지 않으므로, 드라이버별로 연산자를 갈라 **같은 검색어가 DBMS 에 따라 다른 + * 결과를 내지 않도록** 한다. + * - 와일드카드: 검색어의 `%` `_` `\` 를 escape 해 이용자가 입력한 글자 그대로 찾는다. + * escape 하지 않으면 `50%` 검색이 `50` 으로 시작하는 모든 행을 반환한다. + * + * @param Builder $query 조건을 붙일 Eloquent 쿼리 빌더 + * @param array $columns 검색 대상 컬럼명 + * @param string $keyword 검색어 원문 + * @param string $boolean 기존 조건과의 결합 방식 + */ + public static function applyLikeMatch( + Builder $query, + array $columns, + string $keyword, + string $boolean = 'and' + ): void { + $columns = array_values($columns); + + if ($columns === []) { + return; + } + + $pattern = '%'.self::escapeLikeWildcards($keyword).'%'; + $operator = self::caseInsensitiveLikeOperator(); + $method = $boolean === 'or' ? 'orWhere' : 'where'; + + $query->$method(function ($nested) use ($columns, $pattern, $operator) { + foreach ($columns as $index => $column) { + $nested->{$index === 0 ? 'where' : 'orWhere'}($column, $operator, $pattern); + } + }); + } + + /** + * 대소문자를 구분하지 않는 부분일치 연산자를 드라이버별로 반환합니다. + * + * 드라이버명을 코드에 적지 않는다 — 앞으로 어떤 DBMS 가 공식 지원될지 정해져 있지 + * 않으므로, 코드에 박으면 DBMS 가 늘 때마다 코어를 고쳐야 한다. 표는 선언형 config + * (`core.search.like_operators`)에 있고, 확장은 필터 훅으로 조정한다. + * + * @return string 사용할 비교 연산자 + */ + private static function caseInsensitiveLikeOperator(): string + { + $default = (string) config('core.search.like_operator_default', 'like'); + + $operators = config('core.search.like_operators', []); + $operators = HookManager::applyFilters(self::LIKE_OPERATORS_FILTER, $operators); + + if (! is_array($operators)) { + return $default; + } + + try { + $driver = DB::getDriverName(); + } catch (Throwable) { + return $default; + } + + $operator = $operators[$driver] ?? $default; + + return is_string($operator) && $operator !== '' ? $operator : $default; + } + + /** + * `LIKE` 패턴에서 특수 의미를 갖는 문자를 escape 합니다. + * + * @param string $keyword 검색어 원문 + * @return string escape 된 검색어 + */ + public static function escapeLikeWildcards(string $keyword): string + { + return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword); + } + + /** + * 폴백 경고 기록을 초기화합니다 (테스트 전용). + */ + public static function forgetFallbackWarnings(): void + { + self::$warnedDrivers = []; + } +} diff --git a/app/Search/SearchCategoryPayload.php b/app/Search/SearchCategoryPayload.php new file mode 100644 index 00000000..2a72fc16 --- /dev/null +++ b/app/Search/SearchCategoryPayload.php @@ -0,0 +1,100 @@ + $items 화면에 실을 항목 (가공 완료 상태) + * @param array $extra 도메인 고유 필드 (available_boards 등) + * @return array 카테고리 페이로드 + */ + public static function fromBounded(BoundedPage $page, array $items, array $extra = []): array + { + return array_merge([ + 'total' => $page->total(), + 'total_relation' => $page->totalRelation()->value, + 'total_is_exact' => $page->totalRelation()->isExact(), + 'result_cap' => $page->resultCap(), + 'last_page' => $page->lastPage(), + 'has_more_pages' => $page->hasMorePages(), + // offset 응답에는 커서가 없다. 키 자체는 남겨 화면이 분기 없이 읽게 한다. + 'next_cursor' => null, + 'prev_cursor' => null, + 'items' => $items, + ], $extra); + } + + /** + * 커서(키셋) 방식 결과로 페이로드를 만듭니다. + * + * 커서 응답에는 총 건수가 없으므로 건수는 별도 집계로 받습니다. 마지막 페이지 번호는 + * 커서 방식에 존재하지 않는 개념이라 언제나 null 이며, 화면은 그때 마지막 페이지 + * 점프만 감추고 "다음" 이동은 그대로 유지합니다. + * + * @param CursorPaginator $page 커서 페이지 결과 + * @param BoundedCount|null $count 총 건수 집계 (배지를 그리지 않으면 null) + * @param array $items 화면에 실을 항목 (가공 완료 상태) + * @param array $extra 도메인 고유 필드 + * @return array 카테고리 페이로드 + */ + public static function fromCursor( + CursorPaginator $page, + ?BoundedCount $count, + array $items, + array $extra = [] + ): array { + $relation = $count?->totalRelation() ?? TotalRelation::AtLeast; + + return array_merge([ + 'total' => $count?->total() ?? count($items), + 'total_relation' => $relation->value, + 'total_is_exact' => $count !== null && $relation->isExact(), + 'result_cap' => $count?->resultCap(), + // 커서 방식에는 마지막 페이지 번호가 없다 (총 건수를 알아도 계산하지 않는다). + 'last_page' => null, + 'has_more_pages' => $page->hasMorePages(), + 'next_cursor' => KeysetPaginator::nextCursor($page), + 'prev_cursor' => KeysetPaginator::previousCursor($page), + 'items' => $items, + ], $extra); + } + + /** + * 목록 없이 건수만 필요한 자리(비활성 탭 배지)의 페이로드를 만듭니다. + * + * @param BoundedCount $count 총 건수 집계 + * @param array $extra 도메인 고유 필드 + * @return array 카테고리 페이로드 + */ + public static function fromCountOnly(BoundedCount $count, array $extra = []): array + { + return array_merge($count->toArray(), [ + 'last_page' => null, + 'has_more_pages' => false, + 'next_cursor' => null, + 'prev_cursor' => null, + 'items' => [], + ], $extra); + } +} diff --git a/app/Search/SearchIndexMaintenanceManager.php b/app/Search/SearchIndexMaintenanceManager.php new file mode 100644 index 00000000..b9c4c0ec --- /dev/null +++ b/app/Search/SearchIndexMaintenanceManager.php @@ -0,0 +1,232 @@ +> 드라이버명 => 구현 클래스 + */ + public function registered(): array + { + $maintainers = [ + 'mysql-fulltext' => FulltextIndexMaintainer::class, + ]; + + $maintainers = HookManager::applyFilters(self::MAINTAINERS_FILTER, $maintainers); + + return is_array($maintainers) ? $maintainers : []; + } + + /** + * 활성 드라이버의 유지보수기를 반환합니다. + * + * @return SearchIndexMaintainer|null 등록되지 않았으면 null + */ + public function maintainer(): ?SearchIndexMaintainer + { + if ($this->resolvedAttempted) { + return $this->resolved; + } + + $this->resolvedAttempted = true; + $class = $this->registered()[$this->driver()] ?? null; + + if ($class === null) { + return $this->resolved = null; + } + + try { + $instance = app($class); + } catch (Throwable $e) { + Log::warning('검색 인덱스 유지보수기 생성 실패', [ + 'driver' => $this->driver(), + 'class' => $class, + 'error' => $e->getMessage(), + ]); + + return $this->resolved = null; + } + + if (! $instance instanceof SearchIndexMaintainer) { + Log::warning('검색 인덱스 유지보수기가 계약을 구현하지 않음', [ + 'driver' => $this->driver(), + 'class' => $class, + ]); + + return $this->resolved = null; + } + + return $this->resolved = $instance; + } + + /** + * 활성 엔진이 점검을 제공하는지 여부. + * + * @return bool + */ + public function hasMaintainer(): bool + { + return $this->maintainer() !== null; + } + + /** + * 활성 엔진의 인덱스를 점검합니다. + * + * @param array $filters 엔진별 필터 + * @return array + */ + public function inspect(array $filters = []): array + { + $maintainer = $this->maintainer(); + + if ($maintainer === null || ! $maintainer->isAvailable()) { + return []; + } + + return $maintainer->inspect($filters); + } + + /** + * 점검 불가 사유를 반환합니다. + * + * @return string|null 점검 가능하면 null + */ + public function unavailableReason(): ?string + { + $maintainer = $this->maintainer(); + + if ($maintainer === null) { + return __('search.index.no_maintainer', ['driver' => $this->driver()]); + } + + return $maintainer->isAvailable() ? null : $maintainer->unavailableReason(); + } + + /** + * 색인이 누락된 인덱스를 재생성합니다. + * + * 재생성 후 **다시 점검해서** 잔존 여부를 담습니다 — 재생성했다는 사실만으로 + * 복구됐다고 단정하지 않습니다. + * + * @param array $filters 엔진별 필터 + * @return SearchIndexRepairReport 실행 보고 + */ + public function repairStale(array $filters = []): SearchIndexRepairReport + { + $maintainer = $this->maintainer(); + + if ($maintainer === null) { + return SearchIndexRepairReport::unavailable($this->driver(), $this->unavailableReason()); + } + + if (! $maintainer->isAvailable()) { + return SearchIndexRepairReport::unavailable($this->driver(), $maintainer->unavailableReason()); + } + + $before = $maintainer->inspect($filters); + $targets = array_values(array_filter($before, fn (SearchIndexHealth $h) => $h->needsRebuild())); + + if ($targets === []) { + return SearchIndexRepairReport::nothingToDo($this->driver(), count($before)); + } + + $repaired = []; + $failed = []; + + foreach ($targets as $target) { + try { + $maintainer->rebuild($target); + $repaired[] = $target->identifier; + } catch (Throwable $e) { + $failed[$target->identifier] = $e->getMessage(); + Log::error('검색 인덱스 재생성 실패', [ + 'driver' => $this->driver(), + 'index' => $target->identifier, + 'error' => $e->getMessage(), + ]); + } + } + + $after = $maintainer->inspect($filters); + $remaining = array_values(array_map( + fn (SearchIndexHealth $h) => $h->identifier, + array_filter($after, fn (SearchIndexHealth $h) => $h->needsRebuild()) + )); + + Log::info('검색 인덱스 재생성 수행', [ + 'driver' => $this->driver(), + 'inspected' => count($after), + 'repaired' => $repaired, + 'failed' => array_keys($failed), + 'remaining' => $remaining, + ]); + + return new SearchIndexRepairReport( + driver: $this->driver(), + inspected: count($after), + repaired: $repaired, + failed: $failed, + remaining: $remaining, + ); + } +} diff --git a/app/Search/SearchPagePolicy.php b/app/Search/SearchPagePolicy.php new file mode 100644 index 00000000..abb6887a --- /dev/null +++ b/app/Search/SearchPagePolicy.php @@ -0,0 +1,68 @@ + $sortKeys [[컬럼, 방향], ...] + * @param array $cursorColumns 이 도메인에서 커서로 허용한 실제 컬럼 + * @param int $page 요청이 지목한 페이지 번호 (없으면 1) + * @return bool 커서로 응답할 수 있으면 true + */ + public static function usesCursor(?string $cursor, array $sortKeys, array $cursorColumns, int $page = 1): bool + { + if (! KeysetPaginator::supports($sortKeys, $cursorColumns)) { + return false; + } + + if ($cursor !== null && $cursor !== '') { + return true; + } + + return $page <= 1; + } + + /** + * 정렬 이름을 정렬 키 배열로 바꿉니다. + * + * 확장이 선언한 `정렬이름 => [컬럼, 방향]` 맵을 코어가 해석합니다. 맵에 없는 이름은 + * 커서로 처리할 수 없는 정렬(관련도순 등)로 보고 빈 배열을 돌려주며, + * {@see self::usesCursor()} 가 이를 "커서 불가" 로 판정합니다. + * + * @param string $sort 요청이 보낸 정렬 이름 + * @param array $sortMap 정렬 이름 → [컬럼, 방향] + * @return array 정렬 키 배열 (해석 불가 시 빈 배열) + */ + public static function sortKeys(string $sort, array $sortMap): array + { + if (! array_key_exists($sort, $sortMap)) { + return []; + } + + return [$sortMap[$sort]]; + } +} diff --git a/app/Seo/AbstractSitemapContributor.php b/app/Seo/AbstractSitemapContributor.php new file mode 100644 index 00000000..a66cd36d --- /dev/null +++ b/app/Seo/AbstractSitemapContributor.php @@ -0,0 +1,57 @@ + + */ + public function getUrls(): array + { + return iterator_to_array($this->getUrlsLazy(), false); + } + + /** + * Sitemap URL 항목을 한 건씩 지연 순회합니다. + * + * 기본 구현은 getUrls() 배열을 순회합니다. 대용량 기여자는 이 메서드를 + * 오버라이드해 Repository 스트림을 직접 yield 하십시오. + * + * @return iterable + */ + public function getUrlsLazy(): iterable + { + yield from $this->getUrls(); + } +} diff --git a/app/Seo/ComponentHtmlMapper.php b/app/Seo/ComponentHtmlMapper.php index b336e875..07a237d5 100644 --- a/app/Seo/ComponentHtmlMapper.php +++ b/app/Seo/ComponentHtmlMapper.php @@ -4,6 +4,25 @@ namespace App\Seo; class ComponentHtmlMapper { + /** + * SEO 렌더링 기준 뷰포트 폭 (px) + * + * 검색 봇은 데스크톱으로 간주하므로 responsive 오버라이드 매칭에 이 값을 사용합니다. + */ + private const DESKTOP_VIEWPORT_WIDTH = 1024; + + /** + * responsive 브레이크포인트 프리셋 → [min, max] 폭 범위 (px) + * + * React ResponsiveManager.ts 의 BREAKPOINT_PRESETS 와 동일한 값입니다. + */ + private const BREAKPOINT_PRESETS = [ + 'mobile' => [0, 767], + 'tablet' => [768, 1023], + 'desktop' => [1024, PHP_INT_MAX], + 'portable' => [0, 1023], + ]; + /** * 템플릿 제공 컴포넌트 매핑 (seo-config.json의 component_map) */ @@ -61,6 +80,21 @@ class ComponentHtmlMapper */ private array $fieldsContext = []; + /** + * 사용자 작성 HTML 정화기 (raw 렌더 모드에서 지연 생성) + */ + private ?HtmlSanitizer $htmlSanitizer = null; + + /** + * `value` prop 을 텍스트로 승격하지 않는 태그 목록 + * + * 폼 컨트롤의 `value` 는 현재 선택값(속성)이지 화면에 보이는 글자가 아닙니다. + * React 는 `created_at_desc`) 두 화면이 어긋나고 + * 색인에도 내부 식별자가 섞입니다. + */ + private const VALUE_NOT_TEXT_TAGS = ['select', 'option', 'input', 'textarea', 'progress', 'meter']; + /** * 템플릿 제공 컴포넌트 매핑을 설정합니다. * @@ -157,6 +191,15 @@ class ComponentHtmlMapper $html = ''; foreach ($components as $component) { + // children 배열 내 문자열 = 텍스트 노드 + // React DynamicRenderer 는 이 문자열을 그대로 반환한다(표현식·번역 미해석). + // SEO 가 여기서 표현식을 해석하면 반대 방향의 패리티 격차가 생기므로, + // 이스케이프만 하고 리터럴로 출력한다. + if (is_string($component)) { + $html .= e($component); + + continue; + } if (! is_array($component)) { continue; } @@ -176,27 +219,30 @@ class ComponentHtmlMapper */ private function renderComponent(array $component, array $context, ExpressionEvaluator $evaluator): string { - // SEO는 데스크톱 뷰로 렌더링 (검색 봇 = 데스크톱) - // responsive.desktop 오버라이드를 기본 속성에 병합 - if (isset($component['responsive']['desktop'])) { - $desktop = $component['responsive']['desktop']; - if (isset($desktop['props'])) { - $component['props'] = array_merge($component['props'] ?? [], $desktop['props']); - } - if (isset($desktop['if'])) { - $component['if'] = $desktop['if']; - } - if (isset($desktop['text'])) { - $component['text'] = $desktop['text']; - } - } + // Extension Point 주입 컴포넌트의 props를 평가 컨텍스트에 주입 + // LayoutExtensionService가 호스트 props를 주입 컴포넌트 최상위 extensionPointProps로 + // 부착하므로, 여기서 해석해 컨텍스트에 넣어야 {{extensionPointProps.xxx}}가 평가된다. + // $context는 값 전달이므로 이 노드의 서브트리에만 상속되고 형제로 새지 않는다 + // (React DynamicRenderer의 extendedDataContext 전파와 동형). + // if/iteration 평가에도 이미 반영되어야 하므로 가장 먼저 처리한다. + $this->injectExtensionPointContext($component, $context, $evaluator); - // 조건부 렌더링 (if) - if (isset($component['if'])) { - $condition = $evaluator->evaluate($component['if'], $context); - if ($condition === '' || $condition === 'false' || $condition === '0') { - return ''; - } + // type: "iterator" → iteration 속성으로 변환 (React DynamicRenderer와 동일 매핑) + $component = $this->normalizeIteratorNode($component); + + // SEO는 데스크톱 뷰로 렌더링 (검색 봇 = 데스크톱) + // 데스크톱 폭에 매칭되는 responsive 오버라이드를 기본 속성에 병합 + $component = $this->applyResponsiveOverrides($component); + + // 조건부 렌더링 (if / condition / conditions) + // + // iteration 이 있으면 여기서 끊지 않는다. 조건이 항목 변수(`{{user.is_active}}` 등)를 + // 참조하면 이 시점 컨텍스트에는 그 변수가 없어 항상 거짓이 되고, 목록 전체가 + // 사라진다. 항목별 컨텍스트에서 renderIteration 이 같은 조건을 다시 평가한다 + // (iteration 만 제거한 정의를 넘기므로 `if` 는 그대로 남는다). + // React DynamicRenderer 와 동일한 순서 — 한쪽만 고치면 봇 화면에서만 목록이 빈다. + if (! isset($component['iteration']) && ! $this->shouldRender($component, $context, $evaluator)) { + return ''; } // 반복 렌더링 (iteration) @@ -260,9 +306,375 @@ class ComponentHtmlMapper return $html; } + /** + * Extension Point 주입 컴포넌트의 props를 평가 컨텍스트에 주입합니다. + * + * LayoutExtensionService는 extension_point 호스트의 props를 주입 컴포넌트 최상위 + * `extensionPointProps` 키로 부착합니다. 주입 컴포넌트는 `{{extensionPointProps.content}}` + * 형태로 그 값을 참조하므로, 렌더링 전에 표현식을 해석해 컨텍스트에 넣어야 합니다. + * + * 해석된 값은 이 노드와 그 자손 전체에 상속됩니다 (React DynamicRenderer가 + * extendedDataContext를 자식에게 전달하는 동작과 동일). + * + * `extensionPointCallbacks`는 의도적으로 주입하지 않습니다 — SEO 파이프라인에는 + * 액션 디스패처가 없고, 액션 배열을 컨텍스트에 넣으면 HTML에 JSON 파편이 + * 노출될 수 있습니다. + * + * @param array $component 컴포넌트 정의 (해석 후 extensionPointProps 키 제거) + * @param array $context 데이터 컨텍스트 (해석된 값이 주입됨) + * @param ExpressionEvaluator $evaluator 표현식 평가기 + */ + private function injectExtensionPointContext(array &$component, array &$context, ExpressionEvaluator $evaluator): void + { + if (! isset($component['extensionPointProps']) || ! is_array($component['extensionPointProps'])) { + return; + } + + $context['extensionPointProps'] = $this->resolveAllProps( + $component['extensionPointProps'], + $context, + $evaluator + ); + + // iteration 재진입 시 항목별로 재해석되지 않도록 제거 + // (React는 바깥 컨텍스트 기준 1회 해석 후 상속) + unset($component['extensionPointProps']); + } + + /** + * `type: "iterator"` 노드를 `iteration` 속성 형태로 정규화합니다. + * + * React DynamicRenderer가 `{ type: "iterator", data, itemName, indexName }` 를 + * `iteration: { source, item_var, index_var }` 로 변환하는 것과 동일합니다. + * + * @param array $component 컴포넌트 정의 + * @return array 정규화된 컴포넌트 정의 + */ + private function normalizeIteratorNode(array $component): array + { + if (($component['type'] ?? null) !== 'iterator' || ! isset($component['data'])) { + return $component; + } + + $component['iteration'] = [ + 'source' => $component['data'], + 'item_var' => $component['itemName'] ?? 'item', + 'index_var' => $component['indexName'] ?? null, + ]; + + unset($component['data'], $component['itemName'], $component['indexName']); + + return $component; + } + + /** + * 데스크톱 폭에 해당하는 responsive 오버라이드를 병합합니다. + * + * SEO는 검색 봇을 데스크톱 뷰포트로 간주하므로 `desktop` 프리셋과 데스크톱 폭 + * (1024px 이상)을 포함하는 숫자 범위 키만 적용합니다. mobile/tablet/portable 은 + * 데스크톱에서 매칭되지 않으므로 무시합니다. + * + * 오버라이드 항목은 React와 동일하게 props/if/text/children/iteration 입니다. + * + * @param array $component 컴포넌트 정의 + * @return array 오버라이드가 병합된 컴포넌트 정의 + */ + private function applyResponsiveOverrides(array $component): array + { + if (empty($component['responsive']) || ! is_array($component['responsive'])) { + return $component; + } + + $breakpoint = $this->matchingBreakpointKey($component['responsive']); + if ($breakpoint === null) { + return $component; + } + + $override = $component['responsive'][$breakpoint]; + + if (isset($override['props'])) { + $component['props'] = array_merge($component['props'] ?? [], $override['props']); + } + foreach (['if', 'text', 'children', 'iteration'] as $key) { + if (isset($override[$key])) { + $component[$key] = $override[$key]; + } + } + + return $component; + } + + /** + * 데스크톱 폭에 매칭되는 responsive 키를 하나만 고릅니다. + * + * React `ResponsiveManager::getMatchingKey()` 와 동일한 우선순위를 따릅니다: + * ① 커스텀 범위가 프리셋보다 우선 ② 좁은 범위가 넓은 범위보다 우선. + * 매칭되는 키를 전부 순차 병합하면 JSON 키 선언 순서에 따라 결과가 달라진다. + * + * @param array $responsive responsive 오버라이드 맵 + * @return string|null 적용할 키 (없으면 null) + */ + private function matchingBreakpointKey(array $responsive): ?string + { + $matched = []; + + foreach ($responsive as $breakpoint => $override) { + if (! is_array($override)) { + continue; + } + + $range = $this->parseBreakpointRange((string) $breakpoint); + if ($range === null) { + continue; + } + + [$min, $max] = $range; + if ($min > self::DESKTOP_VIEWPORT_WIDTH || $max < self::DESKTOP_VIEWPORT_WIDTH) { + continue; + } + + $matched[] = [ + 'key' => (string) $breakpoint, + 'isPreset' => isset(self::BREAKPOINT_PRESETS[$breakpoint]), + 'width' => $max - $min, + ]; + } + + if ($matched === []) { + return null; + } + + usort($matched, function (array $a, array $b): int { + if ($a['isPreset'] !== $b['isPreset']) { + return $a['isPreset'] ? 1 : -1; + } + + return $a['width'] <=> $b['width']; + }); + + return $matched[0]['key']; + } + + /** + * responsive 브레이크포인트 키를 [min, max] 범위로 파싱합니다. + * + * 프리셋(`desktop`/`portable`/`mobile`/`tablet`)과 `1024-`, `1280-1535`, + * `-599` 같은 숫자 범위를 지원합니다. `min > max` 인 잘못된 범위는 무시합니다 + * (React `ResponsiveManager::parseRange()` 와 동일). + * + * @param string $breakpoint 브레이크포인트 키 + * @return array{0: int, 1: int}|null [min, max] (파싱 불가 시 null) + */ + private function parseBreakpointRange(string $breakpoint): ?array + { + if (isset(self::BREAKPOINT_PRESETS[$breakpoint])) { + return self::BREAKPOINT_PRESETS[$breakpoint]; + } + + if (! preg_match('/^(-?\d*)-(-?\d*)$/', $breakpoint, $matches)) { + return null; + } + + $min = $matches[1] === '' ? 0 : (int) $matches[1]; + $max = $matches[2] === '' ? PHP_INT_MAX : (int) $matches[2]; + + return $min <= $max ? [$min, $max] : null; + } + + /** + * 컴포넌트를 렌더링해야 하는지 판정합니다 (if / condition / conditions). + * + * React RenderHelpers.evaluateRenderCondition과 동일한 우선순위를 따릅니다: + * `if` → `condition`(별칭) → `conditions`(AND/OR 그룹 또는 if/else 체인). + * + * @param array $component 컴포넌트 정의 + * @param array $context 데이터 컨텍스트 + * @param ExpressionEvaluator $evaluator 표현식 평가기 + * @return bool 렌더링 대상이면 true + */ + private function shouldRender(array $component, array $context, ExpressionEvaluator $evaluator): bool + { + if (isset($component['if'])) { + return $this->evaluateBooleanExpression($component['if'], $context, $evaluator); + } + + if (isset($component['condition'])) { + return $this->evaluateBooleanExpression($component['condition'], $context, $evaluator); + } + + if (! isset($component['conditions'])) { + return true; + } + + return $this->evaluateConditions($component['conditions'], $context, $evaluator); + } + + /** + * conditions 속성(AND/OR 그룹 또는 if/else 체인)을 평가합니다. + * + * - 문자열 / `{and: [...]}` / `{or: [...]}` → 조건식 평가 + * - `[{if: ...}, {if: ...}, {}]` → 하나라도 매칭되면 true (if 없는 항목은 else 브랜치) + * - 위 어느 형식도 아니면 **렌더링**한다 — React `evaluateRenderCondition()` 이 알 수 없는 + * 형식에 대해 경고 후 true 를 반환하므로, 여기서 false 를 주면 봇 화면에서만 노드가 사라진다. + * + * @param mixed $conditions conditions 속성 값 + * @param array $context 데이터 컨텍스트 + * @param ExpressionEvaluator $evaluator 표현식 평가기 + * @return bool 평가 결과 + */ + private function evaluateConditions(mixed $conditions, array $context, ExpressionEvaluator $evaluator): bool + { + if ($this->isConditionExpression($conditions)) { + return $this->evaluateConditionExpression($conditions, $context, $evaluator); + } + + if ($this->isConditionBranches($conditions)) { + foreach ($conditions as $branch) { + if (! is_array($branch)) { + continue; + } + // if 없는 항목 = else 브랜치 (항상 매칭) + if (! isset($branch['if'])) { + return true; + } + if ($this->evaluateConditionExpression($branch['if'], $context, $evaluator)) { + return true; + } + } + + return false; + } + + // 알 수 없는 형식 → 렌더링 (React 동형) + return true; + } + + /** + * 값이 단일 ConditionExpression(문자열 / `{and:[]}` / `{or:[]}`) 인지 판정합니다. + * + * React `ConditionEvaluator.isConditionExpression()` 과 동일한 판정입니다. + * + * @param mixed $value 판정 대상 + * @return bool ConditionExpression 이면 true + */ + private function isConditionExpression(mixed $value): bool + { + if (is_string($value) || is_bool($value)) { + return true; + } + + if (is_array($value) && ! array_is_list($value)) { + return array_key_exists('and', $value) || array_key_exists('or', $value); + } + + return false; + } + + /** + * 값이 ConditionBranch[] 인지 판정합니다. + * + * 빈 배열은 브랜치 배열로 보지 않으며, 첫 항목에 `if` 또는 `then` 이 있어야 합니다 + * (React `ConditionEvaluator.isConditionBranches()` 와 동일). + * + * @param mixed $value 판정 대상 + * @return bool ConditionBranch[] 이면 true + */ + private function isConditionBranches(mixed $value): bool + { + if (! is_array($value) || ! array_is_list($value) || $value === []) { + return false; + } + + $first = $value[0]; + if (! is_array($first)) { + return false; + } + + return array_key_exists('if', $first) || array_key_exists('then', $first); + } + + /** + * 조건식(문자열 또는 AND/OR 그룹)을 평가합니다. + * + * 빈 AND 그룹은 true, 빈 OR 그룹은 false 를 반환합니다 (React와 동일). + * + * @param mixed $condition 조건식 + * @param array $context 데이터 컨텍스트 + * @param ExpressionEvaluator $evaluator 표현식 평가기 + * @return bool 평가 결과 + */ + private function evaluateConditionExpression(mixed $condition, array $context, ExpressionEvaluator $evaluator): bool + { + if (! is_array($condition)) { + return $this->evaluateBooleanExpression($condition, $context, $evaluator); + } + + if (array_key_exists('and', $condition)) { + if (! is_array($condition['and']) || $condition['and'] === []) { + return true; + } + foreach ($condition['and'] as $sub) { + if (! $this->evaluateConditionExpression($sub, $context, $evaluator)) { + return false; + } + } + + return true; + } + + if (array_key_exists('or', $condition)) { + if (! is_array($condition['or']) || $condition['or'] === []) { + return false; + } + foreach ($condition['or'] as $sub) { + if ($this->evaluateConditionExpression($sub, $context, $evaluator)) { + return true; + } + } + + return false; + } + + // 알 수 없는 조건 형식 + return false; + } + + /** + * 단일 조건 표현식을 boolean으로 평가합니다. + * + * 문자열 결과의 falsy 판정 목록은 React RenderHelpers.evaluateIfCondition과 + * 동일합니다 (`''`, `false`, `0`, `null`, `undefined` — 대소문자·공백 무시). + * + * @param mixed $condition 조건 표현식 (문자열 또는 boolean) + * @param array $context 데이터 컨텍스트 + * @param ExpressionEvaluator $evaluator 표현식 평가기 + * @return bool 평가 결과 + */ + private function evaluateBooleanExpression(mixed $condition, array $context, ExpressionEvaluator $evaluator): bool + { + if (is_bool($condition)) { + return $condition; + } + + // 조건이 비어 있으면 조건 없음으로 간주 (React: `if (!ifCondition) return true`) + if ($condition === null || $condition === '') { + return true; + } + + $resolved = $evaluator->evaluate((string) $condition, $context); + + return ! in_array(strtolower(trim($resolved)), ['', 'false', '0', 'null', 'undefined'], true); + } + /** * 선언적 렌더 모드로 컴포넌트를 HTML로 변환합니다. * + * 우선순위가 renderTag() 와 반대인 것은 의도된 것이다 — renderTag() 는 text 를 + * children 보다 먼저 쓰지만(React DynamicRenderer 동형), 이 경로는 자체 렌더링을 + * 가진 집합 컴포넌트(Select·Pagination·HtmlContent 등) 전용이라 모드 출력이 먼저다. + * React 에서도 Select 는 options prop 으로 스스로 option 을 그리고 text/children 을 + * 무시하므로, 여기서 text 를 우선하면 옵션 목록이 통째로 사라져 오히려 어긋난다. + * * @param string $mode 렌더 모드명 (render_modes 키) * @param string $tag 외부 래퍼 HTML 태그 * @param array $configEntry seo-config.json의 component_map 엔트리 @@ -298,7 +710,7 @@ class ComponentHtmlMapper // 텍스트 콘텐츠 fallback if ($innerHtml === '') { - $innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator); + $innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator, $tag); } return "<{$tag}{$attrs}>{$innerHtml}"; @@ -322,17 +734,26 @@ class ComponentHtmlMapper return "<{$tag}{$attrs}>"; } - $innerHtml = $this->renderChildren($component, $context, $evaluator); + // 노드 레벨 text가 있으면 children보다 우선 (React DynamicRenderer와 동일 우선순위). + // text 키가 있으면 해석 결과가 빈 문자열이어도 그것이 최종 값이다 — React 는 + // text !== undefined 인 순간 children 경로로 내려가지 않는다(DynamicRenderer.tsx:2591,2639). + $nodeText = $this->resolveNodeText($component, $context, $evaluator); - if ($innerHtml === '') { - $innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator); + if ($nodeText !== null) { + $innerHtml = $nodeText; + } else { + $innerHtml = $this->renderChildren($component, $context, $evaluator); + + if ($innerHtml === '') { + $innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator, $tag); + } } - // dangerouslySetInnerHTML 처리 + // dangerouslySetInnerHTML 처리 — 일반 화면과 동일 강도로 정화 후 삽입 if ($innerHtml === '' && isset($props['dangerouslySetInnerHTML'])) { $rawHtml = $evaluator->evaluate((string) $props['dangerouslySetInnerHTML'], $context); if ($rawHtml !== '') { - $innerHtml = $rawHtml; + $innerHtml = $this->htmlSanitizer()->sanitize($rawHtml); } } @@ -387,39 +808,37 @@ class ComponentHtmlMapper $html = ''; foreach ($data as $item) { - if ($itemAttrs) { - // 속성 기반 렌더링 (이미지 갤러리 등) - $attrStr = ''; - foreach ($itemAttrs as $attrName => $fieldPattern) { - $value = $this->resolveFieldPattern($fieldPattern, $item); - if ($value !== '') { - // src 속성의 상대 경로를 절대 경로로 변환 - if ($attrName === 'src' && ! str_starts_with($value, 'http')) { - $value = url($value); - } - $attrStr .= " {$attrName}=\"".e($value).'"'; + $attrStr = $itemAttrs ? $this->buildIterateItemAttributes($itemAttrs, $item) : ''; + + if ($itemContent !== null) { + // 콘텐츠 기반 렌더링 (탭 리스트, 선택 목록 등) + // item_attrs 가 함께 선언되면 속성과 라벨을 모두 그린다 — `` + // 처럼 둘이 다 필요한 요소를 표현하기 위함이다. + $content = $this->resolveFieldPattern($itemContent, $item); + $evaluatedContent = $content === '' + ? '' + : e($evaluator->evaluate((string) $content, $context)); + + if ($evaluatedContent === '' && $attrStr === '') { + continue; + } + + $badge = ''; + if ($badgeField && isset($item[$badgeField])) { + $evaluatedBadge = $evaluator->evaluate((string) $item[$badgeField], $context); + if ($evaluatedBadge !== '' && $evaluatedBadge !== '0') { + $badge = ' ('.e($evaluatedBadge).')'; } } + $html .= "<{$itemTag}{$attrStr}>".$evaluatedContent.$badge.""; + } elseif ($itemAttrs) { + // 속성 기반 렌더링 (이미지 갤러리 등) if (in_array($itemTag, $this->selfClosing)) { $html .= "<{$itemTag}{$attrStr}>"; } else { $html .= "<{$itemTag}{$attrStr}>"; } - } elseif ($itemContent !== null) { - // 콘텐츠 기반 렌더링 (탭 리스트 등) - $content = $this->resolveFieldPattern($itemContent, $item); - if ($content !== '') { - $evaluatedContent = e($evaluator->evaluate((string) $content, $context)); - $badge = ''; - if ($badgeField && isset($item[$badgeField])) { - $evaluatedBadge = $evaluator->evaluate((string) $item[$badgeField], $context); - if ($evaluatedBadge !== '' && $evaluatedBadge !== '0') { - $badge = ' ('.e($evaluatedBadge).')'; - } - } - $html .= "<{$itemTag}>".$evaluatedContent.$badge.""; - } } else { // 단순 아이템 (문자열) $value = is_string($item) ? e($item) : ''; @@ -430,6 +849,34 @@ class ComponentHtmlMapper return $html; } + /** + * iterate 모드 아이템의 속성 문자열을 만듭니다. + * + * @param array $itemAttrs render_modes 의 item_attrs 정의 + * @param mixed $item 현재 아이템 + * @return string 속성 문자열 (앞에 공백 포함, 없으면 빈 문자열) + */ + private function buildIterateItemAttributes(array $itemAttrs, mixed $item): string + { + $attrStr = ''; + + foreach ($itemAttrs as $attrName => $fieldPattern) { + $value = $this->resolveFieldPattern($fieldPattern, $item); + if ($value === '') { + continue; + } + + // src 속성의 상대 경로를 절대 경로로 변환 + if ($attrName === 'src' && ! str_starts_with($value, 'http')) { + $value = url($value); + } + + $attrStr .= " {$attrName}=\"".e($value).'"'; + } + + return $attrStr; + } + /** * format 타입: 포맷 문자열의 {key} 플레이스홀더를 props로 치환 * @@ -492,7 +939,14 @@ class ComponentHtmlMapper } /** - * raw 타입: 원본 HTML/텍스트를 그대로 출력 + * raw 타입: 콘텐츠를 `isHtml` 판정에 따라 평문 또는 정화된 HTML 로 출력 + * + * 일반 화면의 `HtmlContent` 컴포지트와 동일한 규칙입니다. + * - `isHtml=false` → 평문 (전체 이스케이프) + * - `isHtml=true`(기본) → 정화 후 HTML 로 출력 + * + * 정화·이스케이프를 생략하면 봇 화면에서만 사용자 작성 `'); + + $response->assertStatus(200); + } + + /** + * 연산자만 입력하면 오류 대신 빈 결과가 나오는지 확인 + * + * 정제 후 남는 토큰이 없으면 "찾을 것이 없다" 이지 "오류" 가 아니다. + * + * @effects operator_only_keyword_returns_empty + */ + #[Test] + public function operator_only_keyword_returns_empty_result(): void + { + $response = $this->searchPosts('+++'); + + $response->assertStatus(200); + $this->assertSame([], $response->json('data.data') ?? [], '연산자만 입력하면 매칭이 없어야 한다'); + } + + /** + * 정제가 정상 검색어의 토큰을 보존하는지 확인 + * + * FULLTEXT 인덱스는 커밋된 행만 색인하므로, 트랜잭션 안에서 만든 게시글은 MATCH 로 + * 찾을 수 없다. 그래서 "정제가 정상 검색어를 뭉개지 않는다" 는 명제는 정제기 자체의 + * 출력으로 확인하고, 실제 매칭은 아래 LIKE 경로 테스트가 담당한다. + * + * @effects normal_keyword_still_matches + */ + #[Test] + public function sanitizer_preserves_normal_keyword_tokens(): void + { + $this->assertSame( + '"정상"', + DatabaseFulltextEngine::sanitizeBooleanModeKeyword('정상'), + '정제가 정상 검색어까지 걸러 내면 안 된다' + ); + + $this->assertSame( + '"정상" "게시글"', + DatabaseFulltextEngine::sanitizeBooleanModeKeyword('정상 게시글'), + '여러 토큰은 각각 보존되어야 한다' + ); + + $this->assertSame( + '"검색" "제외"', + DatabaseFulltextEngine::sanitizeBooleanModeKeyword('+검색 -제외'), + '연산자만 제거하고 토큰은 남긴다' + ); + } + + /** + * FULLTEXT 를 타지 않는 검색 경로(작성자 LIKE)가 정상 동작하는지 확인 + * + * 정제 변경이 목록 검색 전체를 망가뜨리지 않았음을 실제 매칭으로 확인한다. + * + * @effects normal_keyword_still_matches + */ + #[Test] + public function like_path_search_still_matches(): void + { + $this->createTestPost([ + 'title' => '작성자 검색 대상', + 'content' => '본문', + 'author_name' => '홍길동', + 'status' => 'published', + ]); + + $this->resetPermissionMiddlewareCache(); + + $url = "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts?" + .http_build_query(['search' => '홍길동', 'search_field' => 'author']); + + $response = $this->getJson($url); + $response->assertStatus(200); + + $titles = collect($response->json('data.data') ?? [])->pluck('title')->all(); + + $this->assertContains('작성자 검색 대상', $titles, 'LIKE 경로 검색이 매칭되어야 한다'); + } + + /** + * 게시판 목록에 검색어를 얹어 조회합니다. + * + * @param string $keyword 검색어 + * @return TestResponse 응답 + */ + private function searchPosts(string $keyword) + { + $this->resetPermissionMiddlewareCache(); + + $url = "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts?" + .http_build_query(['search' => $keyword, 'search_field' => 'title_content']); + + return $this->getJson($url); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/SearchCursorPaginationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/SearchCursorPaginationTest.php new file mode 100644 index 00000000..5336ebdc --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/SearchCursorPaginationTest.php @@ -0,0 +1,205 @@ +postService = app(PostService::class); + } + + /** + * 검색 대상 게시글을 만듭니다. + * + * @param int $count 생성할 수 + */ + private function seedPosts(int $count): void + { + for ($i = 0; $i < $count; $i++) { + Post::create([ + 'board_id' => $this->board->id, + 'author_name' => '작성자', + 'ip_address' => '127.0.0.1', + 'title' => '커서키워드 게시글 '.$i, + 'content' => '커서키워드 본문 '.$i, + 'status' => 'published', + 'is_secret' => false, + ]); + } + } + + /** + * 실제 컬럼 기준 정렬은 커서 경로를 타는지 확인 + * + * @effects search_latest_sort_uses_cursor + */ + public function test_real_column_sorts_use_cursor(): void + { + // Given: 검색 대상 게시글 + $this->seedPosts(3); + + // When/Then: 선언된 정렬 전부가 커서 페이지네이터를 돌려준다 + foreach (array_keys(PostService::SEARCH_SORT_MAP) as $sort) { + $page = $this->postService->searchAcrossBoardsByCursor( + [$this->board->id], + '커서키워드', + $sort, + 4, + 'encoded-cursor' + ); + + $this->assertNotNull($page, $sort.' 정렬은 실제 컬럼이므로 커서를 써야 한다'); + } + } + + /** + * 관련도순은 커서를 쓰지 않고 offset 을 유지하는지 확인 + * + * FULLTEXT 점수는 계산값이라 WHERE 절 경계로 쓸 수 없다. + * + * @effects search_relevance_sort_stays_on_offset + */ + public function test_relevance_sort_stays_on_offset(): void + { + // Given: 검색 대상 게시글 + $this->seedPosts(3); + + // When: 관련도순으로 커서를 요청 + $page = $this->postService->searchAcrossBoardsByCursor( + [$this->board->id], + '커서키워드', + 'relevance', + 4, + 'encoded-cursor' + ); + + // Then: 커서를 쓰지 않는다 (호출자는 offset 경로로 떨어진다) + $this->assertNull($page); + } + + /** + * 커서가 없어도 첫 페이지면 커서 경로로 시작하는지 확인 + * + * 첫 페이지에 커서가 없는 것은 정상이다. 이를 "커서 없음" 으로 읽어 offset 으로 돌리면 + * 다음 커서가 발급될 자리가 사라져 화면이 영원히 offset 에 머문다. + * + * @effects search_latest_sort_uses_cursor + */ + public function test_first_page_without_cursor_starts_cursor_path(): void + { + // Given: 검색 대상 게시글 + $this->seedPosts(3); + + // When: 커서 없이 최신순 첫 페이지 요청 + $page = $this->postService->searchAcrossBoardsByCursor( + [$this->board->id], + '커서키워드', + 'latest', + 4, + null + ); + + // Then: 커서 경로로 처리한다 + $this->assertNotNull($page); + } + + /** + * 커서 없이 깊은 페이지를 직접 지목한 요청은 offset 을 유지하는지 확인 + * + * 주소로 특정 페이지를 열어 둔 링크는 그 페이지를 그대로 보여줘야 한다. + * + * @effects search_relevance_sort_stays_on_offset + */ + public function test_deep_page_without_cursor_stays_on_offset(): void + { + // Given: 검색 대상 게시글 + $this->seedPosts(3); + + // When: 커서 없이 2 페이지를 직접 지목 + $page = $this->postService->searchAcrossBoardsByCursor( + [$this->board->id], + '커서키워드', + 'latest', + 4, + null, + page: 2 + ); + + // Then: offset 으로 처리한다 + $this->assertNull($page); + } + + /** + * 커서·offset·건수전용 세 응답의 키 집합이 같은지 확인 + * + * 화면이 세 형태를 분기 없이 그릴 수 있어야 한다. 응답 조립을 도메인마다 손으로 하면 + * 이 형태가 조용히 갈라진다. + * + * @effects search_cursor_payload_keeps_offset_key_set + */ + public function test_cursor_and_offset_payloads_share_key_set(): void + { + // Given: 검색 대상 게시글과 세 형태의 페이지 결과 + $this->seedPosts(3); + $boardIds = [$this->board->id]; + + $offsetPage = $this->postService->searchAcrossBoards($boardIds, '커서키워드', 'latest', 4, 1); + $cursorPage = $this->postService->searchAcrossBoardsByCursor($boardIds, '커서키워드', 'latest', 4, 'first'); + $count = new BoundedCount(3, TotalRelation::Exact, 10000); + + $this->assertNotNull($cursorPage); + + // When: 세 형태의 페이로드를 만든다 + $payloads = [ + 'offset' => SearchCategoryPayload::fromBounded($offsetPage, []), + 'cursor' => SearchCategoryPayload::fromCursor($cursorPage, $count, []), + 'count_only' => SearchCategoryPayload::fromCountOnly($count), + ]; + + // Then: 키 집합이 완전히 같다 + $reference = array_keys($payloads['offset']); + sort($reference); + + foreach ($payloads as $label => $payload) { + $keys = array_keys($payload); + sort($keys); + $this->assertSame($reference, $keys, $label.' 응답의 키 집합이 offset 과 달라졌다'); + } + + // 커서 응답은 마지막 페이지를 계산하지 않는다 (그 값만 계산 불가하다) + $this->assertNull($payloads['cursor']['last_page']); + + // offset 응답에도 커서 키가 존재한다 — 값만 null 이라 화면이 분기하지 않는다 + $this->assertNull($payloads['offset']['next_cursor']); + $this->assertNull($payloads['offset']['prev_cursor']); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php new file mode 100644 index 00000000..588eb528 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php @@ -0,0 +1,144 @@ +app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class)); + $this->app->instance(SeoCacheRegenerator::class, Mockery::spy(SeoCacheRegenerator::class)); + Bus::fake([GenerateSitemapJob::class]); + + $this->listener = new SeoBoardCacheListener; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * 게시(Published)·비밀글 아님 게시글 생성 시 색인(append)된다. + */ + public function test_published_public_post_is_indexed(): void + { + $post = (object) ['id' => 100, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()]; + + $this->listener->onPostCreate($post, 'notice'); + + $row = SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '100')->first(); + $this->assertNotNull($row, '공개 게시글은 색인돼야 합니다.'); + $this->assertStringEndsWith('/board/notice/100', $row->loc); + Bus::assertDispatched(GenerateSitemapJob::class); + } + + /** + * 비밀글은 색인되지 않는다(전시 상태여도). + */ + public function test_secret_post_is_not_indexed(): void + { + $post = (object) ['id' => 101, 'status' => PostStatus::Published, 'is_secret' => true, 'updated_at' => now()]; + + $this->listener->onPostCreate($post, 'notice'); + + $this->assertSame(0, SitemapUrl::where('resource_id', '101')->count(), '비밀글은 색인되면 안 됩니다.'); + } + + /** + * 블라인드 처리(수정)되면 색인이 제거된다. + */ + public function test_blinded_post_update_removes_index(): void + { + $this->listener->onPostCreate( + (object) ['id' => 102, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '102')->count()); + + $this->listener->onPostUpdate( + (object) ['id' => 102, 'status' => PostStatus::Blinded, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + + $this->assertSame(0, SitemapUrl::where('resource_id', '102')->count(), '블라인드 게시글은 색인에서 제거돼야 합니다.'); + } + + /** + * 게시글 삭제 시 색인이 제거된다. + */ + public function test_post_delete_removes_index(): void + { + $this->listener->onPostCreate( + (object) ['id' => 103, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '103')->count()); + + $this->listener->onPostDelete((object) ['id' => 103], 'notice'); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '103')->count()); + } + + /** + * 활성/비활성 게시판 수정이 색인을 토글한다. + */ + public function test_board_visibility_toggles_index(): void + { + $this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => true, 'updated_at' => now()]); + $row = SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->first(); + $this->assertNotNull($row); + $this->assertStringEndsWith('/board/free', $row->loc); + + $this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => false, 'updated_at' => now()]); + $this->assertSame(0, SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->count()); + } + + /** + * 'SEO 제공 페이지(게시글 상세)' 토글이 OFF 면 공개 게시글이어도 색인이 제거된다. + * + * 리스너의 공개상태 판정은 게시 상태·비밀글 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치). + */ + public function test_post_detail_toggle_off_removes_index_even_when_public(): void + { + // 토글 ON 상태로 먼저 색인 + $this->listener->onPostCreate( + (object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '110')->count()); + + // 토글 OFF → 공개 게시글이어도 색인 제거 + Config::set('g7_settings.modules.sirsoft-board.seo.seo_post_detail', false); + $this->listener->onPostUpdate( + (object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + + $this->assertSame(0, SitemapUrl::where('resource_id', '110')->count(), 'seo_post_detail OFF 면 공개 게시글도 색인에서 제거돼야 합니다.'); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php index f2412274..5ae3138b 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php @@ -29,17 +29,27 @@ class PostSeoOgImageRenderingTest extends ModuleTestCase // 의존성 모듈 (sirsoft-ecommerce / sirsoft-page) 을 active 상태로 등록 — 템플릿 의존 만족용 // (전체 install 은 비용 크므로 Module DB 행만 직접 셋업) + // 버전은 번들 매니페스트 실버전으로 등록한다. sirsoft-basic 템플릿은 의존성 제약 + // (예: sirsoft-ecommerce >=1.0.2) 을 두므로, 하드코딩 버전은 확장 버전 bump 시 stale 되어 + // installTemplate() 이 "버전이 맞지 않습니다" 로 실패 → 활성 user 템플릿 부재 → + // SEO 라우트 미해석 → og:image 미출력으로 이어진다. updateOrCreate 로 기존 행의 + // stale 버전까지 매니페스트 값으로 교정한다(트랜잭션 롤백으로 격리). foreach ([ ['sirsoft-ecommerce', 'sirsoft', '이커머스', 'Ecommerce'], ['sirsoft-page', 'sirsoft', '페이지', 'Page'], ] as [$id, $vendor, $ko, $en]) { - \App\Models\Module::firstOrCreate( + $manifestVersion = json_decode( + (string) @file_get_contents(base_path("modules/_bundled/{$id}/module.json")), + true + )['version'] ?? '1.0.0'; + + \App\Models\Module::updateOrCreate( ['identifier' => $id], [ 'vendor' => $vendor, 'name' => ['ko' => $ko, 'en' => $en], 'status' => \App\Enums\ExtensionStatus::Active->value, - 'version' => '1.0.0', + 'version' => $manifestVersion, 'config' => [], ] ); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/UninstallDynamicRoleCleanupTest.php b/modules/_bundled/sirsoft-board/tests/Feature/UninstallDynamicRoleCleanupTest.php new file mode 100644 index 00000000..a0f8aa5b --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/UninstallDynamicRoleCleanupTest.php @@ -0,0 +1,195 @@ +each(callback)` 은 Eloquent 의 OFFSET 기반 + * chunk 순회(기본 1000건)다. 콜백이 순회 대상 행을 삭제하므로 두 번째 페이지의 + * OFFSET 1000 이 이미 줄어든 결과 집합을 지나쳐 남은 행을 건너뛴다. + * + * 따라서 동적 역할이 1000건을 넘는 사이트에서 모듈을 제거하면 초과분이 그대로 + * 남는다. `chunkById()`(키셋 순회) 로 바꾸면 삭제와 무관하게 전건이 처리된다. + * + * 시드 건수는 Eloquent `each()` 의 기본 청크 크기(1000)보다 커야 결함이 드러나므로 + * 1100건을 사용한다. + */ +class UninstallDynamicRoleCleanupTest extends ModuleTestCase +{ + /** + * 시드 건수 (Eloquent each() 기본 청크 1000 초과 필수) + */ + private const SEED_COUNT = 1100; + + /** + * 권한/사용자 피벗을 연결할 역할 수 (뒤쪽 = OFFSET skip 구간) + */ + private const PIVOT_TAIL_COUNT = 50; + + /** + * 시드 역할 식별자 프리픽스 (정리 대상 식별용) + */ + private const IDENTIFIER_PREFIX = 'chunkskip'; + + private Module $module; + + protected function setUp(): void + { + parent::setUp(); + + $this->module = new Module; + $this->purgeSeededRoles(); + } + + /** + * ModuleTestCase 는 프로세스당 1회 `migrate:fresh`(DDL) 를 실행하므로 그 케이스의 + * 트랜잭션이 암묵적으로 커밋된다 — 시드 행이 롤백되지 않고 다음 케이스로 새어 + * identifier unique 충돌을 일으킨다. 케이스 전후로 명시 정리한다. + */ + protected function tearDown(): void + { + $this->purgeSeededRoles(); + + parent::tearDown(); + } + + /** + * 이 테스트가 시드한 역할과 그 피벗을 제거합니다. + * + * @return void + */ + private function purgeSeededRoles(): void + { + $roleIds = DB::table('roles') + ->where('identifier', 'LIKE', '%.'.self::IDENTIFIER_PREFIX.'%') + ->pluck('id') + ->all(); + + if ($roleIds === []) { + return; + } + + DB::table('role_permissions')->whereIn('role_id', $roleIds)->delete(); + DB::table('user_roles')->whereIn('role_id', $roleIds)->delete(); + DB::table('roles')->whereIn('id', $roleIds)->delete(); + } + + /** + * 게시판별 동적 역할을 시드합니다. + * + * @return void + */ + private function seedDynamicRoles(): void + { + $rows = []; + + for ($i = 0; $i < self::SEED_COUNT; $i++) { + $rows[] = [ + 'identifier' => 'sirsoft-board.chunkskip'.$i.'.manager', + 'name' => json_encode(['ko' => '게시판 관리자 '.$i, 'en' => 'Board Manager '.$i]), + 'extension_type' => 'module', + 'extension_identifier' => 'sirsoft-board', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + foreach (array_chunk($rows, 200) as $batch) { + DB::table('roles')->insert($batch); + } + } + + /** + * 동적 역할 잔여 건수를 반환합니다. + * + * @return int 잔여 건수 + */ + private function remainingDynamicRoleCount(): int + { + return Role::where('extension_type', 'module') + ->where('extension_identifier', 'sirsoft-board') + ->count(); + } + + public function test_uninstall_removes_every_dynamic_role_beyond_the_first_chunk(): void + { + $this->seedDynamicRoles(); + + $this->assertSame( + self::SEED_COUNT, + $this->remainingDynamicRoleCount(), + '시드 직후에는 동적 역할이 시드 건수만큼 존재해야 합니다.' + ); + + $this->assertTrue($this->module->uninstall()); + + $this->assertSame( + 0, + $this->remainingDynamicRoleCount(), + 'OFFSET 순회로 인해 삭제되지 않고 남은 동적 역할이 있습니다 (each → chunkById 필요).' + ); + } + + public function test_uninstall_detaches_permissions_of_roles_in_the_skipped_range(): void + { + $this->seedDynamicRoles(); + + $permission = Permission::firstOrCreate( + ['identifier' => 'sirsoft-board.chunkskip.test'], + ['name' => ['ko' => '테스트 권한', 'en' => 'Test Permission'], 'type' => 'admin'] + ); + + $tailRoleIds = Role::where('extension_identifier', 'sirsoft-board') + ->orderByDesc('id') + ->limit(self::PIVOT_TAIL_COUNT) + ->pluck('id') + ->all(); + + foreach ($tailRoleIds as $roleId) { + DB::table('role_permissions')->insert([ + 'role_id' => $roleId, + 'permission_id' => $permission->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $this->module->uninstall(); + + $this->assertSame( + 0, + DB::table('role_permissions')->whereIn('role_id', $tailRoleIds)->count(), + '삭제된 역할의 권한 피벗이 남아 있습니다.' + ); + } + + public function test_uninstall_keeps_roles_owned_by_other_extensions(): void + { + $this->seedDynamicRoles(); + + DB::table('roles')->insert([ + 'identifier' => 'sirsoft-other.chunkskip.manager', + 'name' => json_encode(['ko' => '타 확장 역할', 'en' => 'Other Extension Role']), + 'extension_type' => 'module', + 'extension_identifier' => 'sirsoft-other', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->module->uninstall(); + + $this->assertTrue( + Role::where('extension_identifier', 'sirsoft-other')->exists(), + '다른 확장이 소유한 역할은 삭제되지 않아야 합니다.' + ); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php new file mode 100644 index 00000000..aeb0286d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php @@ -0,0 +1,280 @@ + $slug, + 'name' => ['ko' => '댓글 계층 정리 게시판', 'en' => 'Comment Hierarchy Cleanup Board'], + 'is_active' => true, + 'use_comment' => true, + 'max_comment_depth' => 10, + ]; + } + + /** + * 교차 게시글 부모 댓글이 최상위로 승격되고 본문은 보존된다 + * + * @scenario entity=board_comment, parent_target=foreign + * + * @effects existing_cross_post_parent_detached_content_preserved + */ + public function test_cross_post_parent_is_detached_and_content_preserved(): void + { + $postA = $this->createTestPost(['title' => '게시글 A']); + $postB = $this->createTestPost(['title' => '게시글 B']); + + $parentInA = $this->createTestComment($postA, ['depth' => 0]); + $orphan = $this->createTestComment($postB, [ + 'parent_id' => $parentInA, + 'depth' => 1, + 'content' => '보존되어야 하는 본문', + ]); + + $this->runCleanup(); + + $row = DB::table('board_comments')->where('id', $orphan)->first(); + + $this->assertNull($row->parent_id, '교차 게시글 부모는 해제되어야 합니다.'); + $this->assertSame(0, (int) $row->depth, '승격된 댓글의 depth 는 0 이어야 합니다.'); + $this->assertSame('보존되어야 하는 본문', $row->content, '본문은 보존되어야 합니다.'); + $this->assertSame($postB, (int) $row->post_id, 'post_id 는 바뀌지 않아야 합니다.'); + } + + /** + * 클램프로 뭉개진 depth 가 부모 depth + 1 로 재계산된다 + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects existing_clamped_depth_recalculated + */ + public function test_clamped_depth_is_recalculated_from_root(): void + { + $postId = $this->createTestPost(); + + // 8단계 체인을 만들되, 6단계 이후 depth 를 5 로 뭉개 둔다 (구 클램프 재현) + $parentId = null; + $ids = []; + for ($i = 0; $i < 8; $i++) { + $storedDepth = min($i, 5); + $parentId = $this->createTestComment($postId, [ + 'parent_id' => $parentId, + 'depth' => $storedDepth, + ]); + $ids[$i] = $parentId; + } + + $this->runCleanup(); + + foreach ($ids as $expectedDepth => $id) { + $this->assertSame( + $expectedDepth, + (int) DB::table('board_comments')->where('id', $id)->value('depth'), + "{$expectedDepth} 단계 댓글의 depth 가 재계산되어야 합니다." + ); + } + } + + /** + * 재실행해도 결과가 동일하다 (멱등) + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects cleanup_is_idempotent + */ + public function test_cleanup_is_idempotent(): void + { + $postA = $this->createTestPost(['title' => '게시글 A']); + $postB = $this->createTestPost(['title' => '게시글 B']); + + $parentInA = $this->createTestComment($postA, ['depth' => 0]); + $this->createTestComment($postB, ['parent_id' => $parentInA, 'depth' => 1]); + + $chainRoot = $this->createTestComment($postA, ['parent_id' => $parentInA, 'depth' => 5]); + $this->createTestComment($postA, ['parent_id' => $chainRoot, 'depth' => 5]); + + $this->runCleanup(); + $first = $this->snapshot(); + + $this->runCleanup(); + $second = $this->snapshot(); + + $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.'); + } + + /** + * 부모 행이 삭제된 고아 댓글은 값이 보존되고 경고 로그가 남는다 + * + * 정리 1단계는 부모와 INNER JOIN 하므로 부모 행 자체가 없는 댓글은 매칭되지 않고, + * 2단계 BFS 는 `parent_id IS NULL` 루트에서 출발하므로 이 댓글에 도달하지 못한다. + * 값을 임의로 고치거나 지우면 데이터 손실이므로 보존하되, 정정되지 않았다는 사실은 + * 경고로 남겨야 한다. + * + * @scenario entity=board_comment, parent_target=missing + * + * @effects dangling_parent_skipped_with_warning + */ + public function test_dangling_parent_is_skipped_with_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $parent = $this->createTestComment($post, ['depth' => 0]); + $dangling = $this->createTestComment($post, ['parent_id' => $parent, 'depth' => 3]); + + // 부모 행만 물리 삭제하여 dangling parent_id 상태를 만든다. + DB::table('board_comments')->where('id', $parent)->delete(); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + $this->assertDatabaseHas('board_comments', [ + 'id' => $dangling, + 'parent_id' => $parent, + 'depth' => 3, + ]); + + $logger->shouldHaveReceived('warning') + ->withArgs(fn (string $message) => str_contains($message, '부모 행이 없는 댓글') + && str_contains($message, (string) $dangling)) + ->once(); + } + + /** + * 루트에서 도달할 수 없는 순환 참조 댓글은 값이 보존되고 경고 로그가 남는다 + * + * 부모 행이 실재하므로 고아 판정(`부모 행이 없는 댓글`)에 걸리지 않고, + * BFS 는 루트에서만 출발하므로 순환 컴포넌트에 진입조차 못 해 세대 상한에도 + * 걸리지 않는다. 두 그물을 모두 빠져나가므로 전용 경고가 없으면 + * "정정되지 않았다" 는 사실이 조용히 묻힌다. + * + * @scenario entity=board_comment, parent_target=cycle + * + * @effects unreachable_cycle_skipped_with_warning + */ + public function test_unreachable_cycle_is_skipped_with_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $first = $this->createTestComment($post, ['depth' => 0]); + $second = $this->createTestComment($post, ['parent_id' => $first, 'depth' => 1]); + + // 첫 댓글의 부모를 두 번째로 돌려 루트 없는 순환(A→B→A)을 만든다. + DB::table('board_comments')->where('id', $first)->update([ + 'parent_id' => $second, + 'depth' => 4, + ]); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + // 값 보존 — 순환의 어느 지점을 끊을지는 운영자 판단이므로 건드리지 않는다. + $this->assertDatabaseHas('board_comments', [ + 'id' => $first, + 'parent_id' => $second, + 'depth' => 4, + ]); + $this->assertDatabaseHas('board_comments', [ + 'id' => $second, + 'parent_id' => $first, + 'depth' => 1, + ]); + + $logger->shouldHaveReceived('warning') + ->withArgs(fn (string $message) => str_contains($message, '루트에서 도달하지 못한 댓글') + && str_contains($message, (string) $first) + && str_contains($message, (string) $second)) + ->once(); + } + + /** + * 정합한 계층만 있으면 순환 경고가 발생하지 않는다 (과잉 경고 방지) + * + * @scenario entity=board_comment, parent_target=self + * + * @effects healthy_hierarchy_emits_no_cycle_warning + */ + public function test_healthy_hierarchy_emits_no_cycle_warning(): void + { + $post = $this->createTestPost(['title' => '게시글 A']); + + $root = $this->createTestComment($post, ['depth' => 0]); + $this->createTestComment($post, ['parent_id' => $root, 'depth' => 1]); + + $logger = \Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel') + ->with('extension-upgrade') + ->andReturn($logger); + + $this->runCleanup(); + + $logger->shouldNotHaveReceived('warning', [ + \Mockery::on(fn (string $message) => str_contains($message, '루트에서 도달하지 못한 댓글')), + ]); + } + + /** + * 1.0.3 정리 마이그레이션을 실행합니다. + */ + private function runCleanup(): void + { + $context = new UpgradeContext('1.0.2', '1.0.3', '1.0.3', 'extension-upgrade'); + + $step = new Upgrade_1_0_3; + $step->run($context); + } + + /** + * 이 게시판 댓글의 (id, parent_id, depth) 스냅샷을 반환합니다. + * + * @return array 스냅샷 배열 + */ + private function snapshot(): array + { + return DB::table('board_comments') + ->where('board_id', $this->board->id) + ->orderBy('id') + ->get(['id', 'parent_id', 'depth']) + ->map(fn ($row) => [ + 'id' => (int) $row->id, + 'parent_id' => $row->parent_id === null ? null : (int) $row->parent_id, + 'depth' => (int) $row->depth, + ]) + ->all(); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/PostListViewCountIndexUpgradeTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/PostListViewCountIndexUpgradeTest.php new file mode 100644 index 00000000..3874dd2f --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/PostListViewCountIndexUpgradeTest.php @@ -0,0 +1,151 @@ + $slug, + 'name' => ['ko' => '조회수 색인 게시판', 'en' => 'View Count Index Board'], + 'is_active' => true, + ]; + } + + /** + * 색인이 없는 기설치본에서 업그레이드 스텝이 색인을 만든다 + * + * @scenario case=post_list_view_count_index_upgrade + * + * @effects view_count_list_index_created_on_existing_site + */ + public function test_색인이_없으면_업그레이드_스텝이_생성한다(): void + { + $this->dropIndexIfExists(); + $this->assertFalse($this->hasIndex(self::INDEX_NAME), '사전 조건: 색인이 없어야 한다'); + + $this->runUpgrade(); + + $this->assertTrue( + $this->hasIndex(self::INDEX_NAME), + '업그레이드 스텝이 조회수 정렬 색인을 만들어야 한다' + ); + } + + /** + * 재실행해도 예외 없이 결과가 같다 (멱등) + * + * @scenario case=post_list_view_count_index_upgrade + * + * @effects view_count_list_index_upgrade_is_idempotent + */ + public function test_이미_있으면_재실행해도_결과가_같다(): void + { + $this->dropIndexIfExists(); + $this->runUpgrade(); + $this->assertTrue($this->hasIndex(self::INDEX_NAME)); + + // 색인이 이미 있는 상태에서 한 번 더 — 중복 생성 예외가 나면 실패한다. + $this->runUpgrade(); + + $this->assertTrue( + $this->hasIndex(self::INDEX_NAME), + '재실행 후에도 색인이 그대로 있어야 한다' + ); + } + + /** + * 인기글 조회가 쓰는 기존 색인을 건드리지 않는다 + * + * @scenario case=post_list_view_count_index_upgrade + * + * @effects legacy_popular_posts_index_preserved + */ + public function test_기존_인기글_색인을_보존한다(): void + { + $legacyBefore = $this->hasIndex(self::LEGACY_INDEX_NAME); + + $this->dropIndexIfExists(); + $this->runUpgrade(); + + $this->assertSame( + $legacyBefore, + $this->hasIndex(self::LEGACY_INDEX_NAME), + '조회수 목록 색인 추가가 인기글 색인의 존재 여부를 바꾸면 안 된다' + ); + } + + /** + * 1.0.3 업그레이드 스텝을 실행합니다. + */ + private function runUpgrade(): void + { + $context = new UpgradeContext('1.0.2', '1.0.3', '1.0.3', 'extension-upgrade'); + + (new Upgrade_1_0_3)->run($context); + } + + /** + * 대상 색인이 존재하면 제거합니다 (기설치본 모사). + */ + private function dropIndexIfExists(): void + { + if (! $this->hasIndex(self::INDEX_NAME)) { + return; + } + + Schema::table(self::TABLE, function ($table) { + $table->dropIndex(self::INDEX_NAME); + }); + } + + /** + * 색인 존재 여부를 조회합니다. + * + * @param string $name 색인 이름 + * @return bool 존재 여부 + */ + private function hasIndex(string $name): bool + { + if (! Schema::hasTable(self::TABLE)) { + return false; + } + + return in_array($name, array_column(Schema::getIndexes(self::TABLE), 'name'), true); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/AttachmentAllowedExtensionsFallbackTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/AttachmentAllowedExtensionsFallbackTest.php new file mode 100644 index 00000000..44b23555 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/AttachmentAllowedExtensionsFallbackTest.php @@ -0,0 +1,189 @@ + 게시판 속성 + */ + protected function getDefaultBoardAttributes(string $slug): array + { + return [ + 'slug' => $slug, + 'name' => ['ko' => '확장자 폴백 테스트 게시판', 'en' => 'Extension Fallback Test Board'], + 'is_active' => true, + 'use_file_upload' => true, + 'allowed_extensions' => [], + ]; + } + + /** + * 테스트 환경을 준비합니다. + */ + protected function setUp(): void + { + parent::setUp(); + + Storage::fake('public'); + + $this->grantUserRolePermissions(['posts.read', 'posts.write', 'attachments.upload']); + + $this->memberUser = User::factory()->create(['email' => 'ext-fallback-member@test.com']); + + $userRole = Role::where('identifier', 'user')->first(); + if ($userRole) { + $this->memberUser->roles()->attach($userRole->id); + } + } + + /** + * UploadAttachmentRequest 를 대상 게시판 슬러그로 생성합니다. + * + * @param string $slug 게시판 슬러그 + * @return UploadAttachmentRequest 라우트 슬러그가 주입된 요청 객체 + */ + private function makeUploadRequest(string $slug): UploadAttachmentRequest + { + $request = new UploadAttachmentRequest; + + $request->setRouteResolver(function () use ($slug) { + $route = \Mockery::mock(Route::class); + $route->shouldReceive('parameter')->with('slug', null)->andReturn($slug); + + return $route; + }); + + return $request; + } + + /** + * file 규칙에서 mimes 파라미터를 추출합니다. + * + * @param array $rules 검증 규칙 + * @return string|null mimes 확장자 목록 (규칙 부재 시 null) + */ + private function extractMimes(array $rules): ?string + { + foreach ($rules['file'] ?? [] as $rule) { + if (is_string($rule) && str_starts_with($rule, 'mimes:')) { + return substr($rule, strlen('mimes:')); + } + } + + return null; + } + + /** + * allowed_extensions 가 빈 배열이면 mimes 규칙이 비어있지 않아야 한다 (회귀) + */ + public function test_empty_allowed_extensions_does_not_produce_empty_mimes_rule(): void + { + $rules = $this->makeUploadRequest($this->board->slug)->rules(); + + $mimes = $this->extractMimes($rules); + + $this->assertNotSame('', $mimes, 'allowed_extensions=[] 에서 mimes: 빈 규칙이 생성되면 전 파일이 거부된다'); + $this->assertNotEmpty($mimes, 'mimes 규칙은 기본 확장자 목록으로 폴백되어야 함'); + } + + /** + * allowed_extensions 가 NULL 이어도 mimes 규칙이 비어있지 않아야 한다 + */ + public function test_null_allowed_extensions_does_not_produce_empty_mimes_rule(): void + { + $this->updateBoardSettings(['allowed_extensions' => null]); + + $rules = $this->makeUploadRequest($this->board->slug)->rules(); + + $this->assertNotEmpty($this->extractMimes($rules), 'allowed_extensions=null 도 기본 확장자로 폴백되어야 함'); + } + + /** + * allowed_extensions 가 빈 배열인 게시판에서도 실제 업로드가 성공해야 한다 (회귀) + * + * @scenario case=upload_enabled_extensions_empty + * + * @effects empty_extensions_upload_falls_back_to_defaults + */ + public function test_upload_succeeds_when_allowed_extensions_is_empty(): void + { + $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + + $response = $this->actingAs($this->memberUser) + ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/attachments", [ + 'file' => $file, + ]); + + $response->assertStatus(201)->assertJsonPath('success', true); + } + + /** + * 허용 확장자가 지정된 게시판은 목록 밖 확장자를 계속 거부해야 한다 (회귀 방지) + * + * 폴백이 검증 자체를 무력화하지 않는지 확인한다. + * + * @scenario case=upload_enabled_extensions_nonempty + * + * @effects specified_extensions_still_reject_other_types + */ + public function test_specified_allowed_extensions_still_reject_other_types(): void + { + $this->updateBoardSettings(['allowed_extensions' => ['jpg', 'png']]); + + $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + + $response = $this->actingAs($this->memberUser) + ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/attachments", [ + 'file' => $file, + ]); + + $response->assertStatus(422); + } + + /** + * 테스트 정리 + */ + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php index 9df1c4ce..8a86a861 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php @@ -231,6 +231,36 @@ class BoardPopularApiTest extends ModuleTestCase $this->assertNotContains('Over One Year', $titles); } + /** + * 미지원 period 가 거부되지 않고 year 로 해석되며, 캐시 키도 정규화되는지 테스트 + * + * 북마크 가능한 공개 URL 이라 미지원 값에 422 를 돌려주면 기존 링크가 깨진다. + * 동시에 period 는 캐시 키에 들어가므로, 원문을 그대로 쓰면 임의 문자열마다 + * 캐시 엔트리가 생겨 키 공간이 무한히 늘어난다 (요청 값이 아니라 해석된 값으로 키를 만든다). + */ + public function test_popular_unsupported_period_falls_back_to_year_and_normalizes_cache_key(): void + { + // Given: 1년 이내/이전 게시글 생성 + $board = Board::factory()->create(['is_active' => true]); + DB::table('board_posts')->insert([ + ['board_id' => $board->id, 'title' => 'Recent', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()], + ['board_id' => $board->id, 'title' => 'Over One Year', 'content' => 'Content', 'view_count' => 300, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subMonths(13), 'updated_at' => now()], + ]); + + // When: 지원하지 않는 period 로 호출 + $response = $this->getJson('/api/modules/sirsoft-board/boards/popular?period=nonsense&limit=5'); + + // Then: 422 가 아니라 200 이고, year 와 동일한 범위(1년 이내)로 해석된다 + $response->assertStatus(200); + $titles = array_column($response->json('data'), 'title'); + $this->assertContains('Recent', $titles); + $this->assertNotContains('Over One Year', $titles); + + // Then: 캐시 키에는 요청 원문이 아니라 해석된 값이 들어간다 + $this->assertTrue(Cache::has('g7:module.sirsoft-board:popular_posts_year_5')); + $this->assertFalse(Cache::has('g7:module.sirsoft-board:popular_posts_nonsense_5')); + } + /** * 게시글이 없을 때 빈 배열을 반환하는지 테스트 */ @@ -352,7 +382,7 @@ class BoardPopularApiTest extends ModuleTestCase $response1->assertStatus(200); // 캐시 키 확인 (형식: g7:module.sirsoft-board:popular_posts_{period}_{limit}) - $this->assertTrue(Cache::has("g7:module.sirsoft-board:popular_posts_week_5")); + $this->assertTrue(Cache::has('g7:module.sirsoft-board:popular_posts_week_5')); // When: 두 번째 API 호출 $response2 = $this->getJson('/api/modules/sirsoft-board/boards/popular?limit=5&period=week'); @@ -413,8 +443,8 @@ class BoardPopularApiTest extends ModuleTestCase /** * 게시판을 생성하고 게시글을 삽입하는 헬퍼 * - * @param int $postCount 생성할 게시글 수 - * @param Board|null $board 기존 게시판 (null이면 새로 생성) + * @param int $postCount 생성할 게시글 수 + * @param Board|null $board 기존 게시판 (null이면 새로 생성) * @return Board 생성된 게시판 */ private function createBoardWithPosts(int $postCount, ?Board $board = null): Board diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/CommentMassAssignmentTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentMassAssignmentTest.php new file mode 100644 index 00000000..e35ac78d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentMassAssignmentTest.php @@ -0,0 +1,131 @@ + $slug, + 'name' => ['ko' => '댓글 대량할당 게시판', 'en' => 'Comment Mass Assignment Board'], + 'is_active' => true, + 'use_comment' => true, + 'max_comment_depth' => 10, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + $this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + + $this->memberUser = User::factory()->create(['email' => 'mass-assign-owner@test.com']); + $this->otherUser = User::factory()->create(['email' => 'mass-assign-other@test.com']); + + $userRole = Role::where('identifier', 'user')->first(); + if ($userRole) { + $this->memberUser->roles()->attach($userRole->id); + $this->otherUser->roles()->attach($userRole->id); + } + } + + /** + * 검증되지 않은 필드는 저장되지 않는다 + * + * @scenario resource=board_comment_user, parent_scope=matching, actor=member + * + * @effects unvalidated_fields_not_persisted, sibling_resource_state_unchanged + */ + public function test_update_ignores_fields_outside_form_request(): void + { + $postA = $this->createTestPost(['title' => '게시글 A']); + $postB = $this->createTestPost(['title' => '게시글 B']); + $siblingComment = $this->createTestComment($postA, ['depth' => 0]); + + $commentId = $this->createTestComment($postA, [ + 'user_id' => $this->memberUser->id, + 'author_name' => '원래 작성자', + 'content' => '원래 내용', + 'depth' => 0, + 'ip_address' => '127.0.0.1', + ]); + + $before = DB::table('board_comments')->where('id', $commentId)->first(); + + $this->actingAs($this->memberUser) + ->putJson($this->url($postA, $commentId), [ + 'content' => '수정된 내용', + // 아래는 UpdateCommentRequest 에 정의되지 않은 필드 — 전부 무시되어야 함 + 'post_id' => $postB, + 'board_id' => $this->board->id + 999, + 'user_id' => $this->otherUser->id, + 'parent_id' => $siblingComment, + 'author_name' => '탈취된 작성자', + 'trigger_type' => 'admin', + 'action_logs' => [['action' => 'forged']], + 'depth' => 9, + 'ip_address' => '10.0.0.1', + 'replies_count' => 100, + ]) + ->assertStatus(200); + + $after = DB::table('board_comments')->where('id', $commentId)->first(); + + $this->assertSame('수정된 내용', $after->content, '검증된 필드는 반영되어야 합니다.'); + + $this->assertEquals($before->post_id, $after->post_id, 'post_id 가 변경되면 안 됩니다.'); + $this->assertEquals($before->board_id, $after->board_id, 'board_id 가 변경되면 안 됩니다.'); + $this->assertEquals($before->user_id, $after->user_id, 'user_id 가 변경되면 안 됩니다.'); + $this->assertEquals($before->parent_id, $after->parent_id, 'parent_id 가 변경되면 안 됩니다.'); + $this->assertEquals($before->author_name, $after->author_name, 'author_name 이 변경되면 안 됩니다.'); + $this->assertEquals($before->trigger_type, $after->trigger_type, 'trigger_type 이 변경되면 안 됩니다.'); + $this->assertEquals($before->action_logs, $after->action_logs, 'action_logs 가 변경되면 안 됩니다.'); + $this->assertEquals($before->depth, $after->depth, 'depth 가 변경되면 안 됩니다.'); + $this->assertEquals($before->ip_address, $after->ip_address, 'ip_address 가 변경되면 안 됩니다.'); + $this->assertEquals($before->replies_count, $after->replies_count, 'replies_count 가 변경되면 안 됩니다.'); + } + + /** + * 사용자 댓글 API URL 을 생성합니다. + * + * @param int $postId 게시글 ID + * @param int $commentId 댓글 ID + * @return string 댓글 수정 URL + */ + private function url(int $postId, int $commentId): string + { + return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/CommentParentIntegrityTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentParentIntegrityTest.php new file mode 100644 index 00000000..4fd2fd24 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentParentIntegrityTest.php @@ -0,0 +1,157 @@ + $slug, + 'name' => ['ko' => '댓글 부모 무결성 게시판', 'en' => 'Comment Parent Integrity Board'], + 'is_active' => true, + 'use_comment' => true, + 'max_comment_depth' => 10, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + $this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + + $this->memberUser = User::factory()->create(['email' => 'parent-integrity@test.com']); + + $userRole = Role::where('identifier', 'user')->first(); + if ($userRole) { + $this->memberUser->roles()->attach($userRole->id); + } + } + + /** + * 다른 게시글의 댓글을 parent_id 로 지정하면 422 + * + * @scenario entity=board_comment, parent_target=foreign + * + * @effects foreign_parent_rejected_422, parent_unchanged_on_rejection + */ + public function test_cannot_reply_to_comment_of_other_post(): void + { + $postA = $this->createTestPost(['title' => '게시글 A']); + $postB = $this->createTestPost(['title' => '게시글 B']); + + $parentInPostA = $this->createTestComment($postA, ['depth' => 0]); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postB), [ + 'content' => '교차 부모 답글 시도', + 'parent_id' => $parentInPostA, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['parent_id']); + + $this->assertDatabaseMissing('board_comments', [ + 'post_id' => $postB, + 'parent_id' => $parentInPostA, + ]); + } + + /** + * 같은 게시글의 댓글을 parent_id 로 지정하면 201 (회귀 방지) + * + * @scenario entity=board_comment, parent_target=unrelated + * + * @effects unrelated_parent_move_succeeds, depth_equals_parent_depth_plus_one + */ + public function test_can_reply_to_comment_of_same_post(): void + { + $postId = $this->createTestPost(); + $parentId = $this->createTestComment($postId, ['depth' => 0]); + + $this->actingAs($this->memberUser) + ->postJson($this->storeUrl($postId), [ + 'content' => '정상 답글', + 'parent_id' => $parentId, + ]) + ->assertStatus(201); + + $this->assertDatabaseHas('board_comments', [ + 'post_id' => $postId, + 'parent_id' => $parentId, + 'depth' => 1, + ]); + } + + /** + * Service 직접 호출 시에도 타 게시글 부모로 depth 가 계산되지 않는다 + * + * FormRequest 를 우회하는 훅/내부 호출 경로에 대한 2차 방어를 검증합니다. + * + * @scenario entity=board_comment, parent_target=foreign + * + * @effects foreign_parent_rejected_422 + */ + public function test_service_does_not_inherit_depth_from_other_post_parent(): void + { + $postA = $this->createTestPost(['title' => '게시글 A']); + $postB = $this->createTestPost(['title' => '게시글 B']); + + // 게시글 A 에 depth 3 인 부모 댓글 + $parentInPostA = $this->createTestComment($postA, ['depth' => 3]); + + /** @var CommentService $service */ + $service = app(CommentService::class); + + $comment = $service->createComment($this->board->slug, [ + 'post_id' => $postB, + 'parent_id' => $parentInPostA, + 'content' => 'Service 직접 호출 교차 부모', + 'author_name' => '테스트', + 'ip_address' => '127.0.0.1', + ]); + + $this->assertSame( + 0, + (int) $comment->depth, + '타 게시글 부모의 depth(3)를 상속해서는 안 됩니다.' + ); + } + + /** + * 댓글 생성 엔드포인트 URL 을 생성합니다. + * + * @param int $postId 게시글 ID + * @return string 댓글 생성 URL + */ + private function storeUrl(int $postId): string + { + return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/CommentScopeTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentScopeTest.php new file mode 100644 index 00000000..8738cb20 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/CommentScopeTest.php @@ -0,0 +1,168 @@ + $slug, + 'name' => ['ko' => '댓글 스코프 테스트 게시판', 'en' => 'Comment Scope Test Board'], + 'is_active' => true, + 'use_comment' => true, + 'max_comment_depth' => 10, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->setGuestPermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + $this->grantUserRolePermissions(['posts.read', 'posts.write', 'comments.read', 'comments.write']); + + $this->memberUser = User::factory()->create(['email' => 'comment-scope-member@test.com']); + + $userRole = Role::where('identifier', 'user')->first(); + if ($userRole) { + $this->memberUser->roles()->attach($userRole->id); + } + } + + /** + * 게시글 B 의 URL 로 게시글 A 의 댓글을 수정할 수 없다 → 404 + * + * @scenario resource=board_comment_user, parent_scope=mismatched, actor=member + * + * @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection + */ + public function test_cannot_update_comment_through_other_post_url(): void + { + $postA = $this->createTestPost(['title' => '게시글 A']); + $postB = $this->createTestPost(['title' => '게시글 B']); + + $commentId = $this->createTestComment($postA, [ + 'user_id' => $this->memberUser->id, + 'content' => '원래 내용', + ]); + + $this->actingAs($this->memberUser) + ->putJson($this->url($postB, $commentId), ['content' => '교차 경로 수정 시도']) + ->assertStatus(404); + + $this->assertDatabaseHas('board_comments', [ + 'id' => $commentId, + 'content' => '원래 내용', + ]); + } + + /** + * 게시글 B 의 URL 로 게시글 A 의 댓글을 삭제할 수 없다 → 404 + * + * @scenario resource=board_comment_user, parent_scope=mismatched, actor=member + * + * @effects cross_scope_write_rejected, target_resource_unchanged_on_rejection + */ + public function test_cannot_delete_comment_through_other_post_url(): void + { + $postA = $this->createTestPost(['title' => '게시글 A']); + $postB = $this->createTestPost(['title' => '게시글 B']); + + $commentId = $this->createTestComment($postA, [ + 'user_id' => $this->memberUser->id, + ]); + + $this->actingAs($this->memberUser) + ->deleteJson($this->url($postB, $commentId)) + ->assertStatus(404); + + $this->assertDatabaseHas('board_comments', [ + 'id' => $commentId, + 'deleted_at' => null, + ]); + } + + /** + * 정상 경로(자기 게시글 URL)로는 댓글이 수정된다 → 200 + * + * @scenario resource=board_comment_user, parent_scope=matching, actor=member + * + * @effects matching_scope_still_succeeds + */ + public function test_can_update_comment_through_own_post_url(): void + { + $postId = $this->createTestPost(); + $commentId = $this->createTestComment($postId, [ + 'user_id' => $this->memberUser->id, + 'content' => '원래 내용', + ]); + + $this->actingAs($this->memberUser) + ->putJson($this->url($postId, $commentId), ['content' => '정상 수정']) + ->assertStatus(200); + + $this->assertDatabaseHas('board_comments', [ + 'id' => $commentId, + 'content' => '정상 수정', + ]); + } + + /** + * 정상 경로(자기 게시글 URL)로는 댓글이 삭제된다 → 200 + * + * @scenario resource=board_comment_user, parent_scope=matching, actor=member + * + * @effects matching_scope_still_succeeds + */ + public function test_can_delete_comment_through_own_post_url(): void + { + $postId = $this->createTestPost(); + $commentId = $this->createTestComment($postId, [ + 'user_id' => $this->memberUser->id, + ]); + + $this->actingAs($this->memberUser) + ->deleteJson($this->url($postId, $commentId)) + ->assertStatus(200); + + $this->assertSoftDeleted('board_comments', ['id' => $commentId]); + } + + /** + * 사용자 댓글 API URL 을 생성합니다. + * + * @param int $postId 게시글 ID + * @param int $commentId 댓글 ID + * @return string 댓글 수정/삭제 엔드포인트 URL + */ + private function url(int $postId, int $commentId): string + { + return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/comments/{$commentId}"; + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivityApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivityApiTest.php index e66435ca..c30c8cea 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivityApiTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/UserActivityApiTest.php @@ -237,6 +237,40 @@ class UserActivityApiTest extends BoardTestCase /** * 댓글을 단 게시글이 활동에 포함된다. */ + /** + * per_page 경계값 회귀 가드 (#492 브라우저 실측). + * + * 같은 컨트롤러의 my-comments 는 min(max(per_page,1),100) 로 잘라내는데 + * board-activities 만 원시 입력을 그대로 페이지네이터에 넘겨, + * per_page=0 / 음수에서 500 이 나고 큰 값은 전량 조회가 됐다. + */ + public function test_board_activities_per_page_는_상한_100_으로_잘린다(): void + { + $response = $this->actingAs($this->user) + ->getJson('/api/modules/sirsoft-board/me/board-activities?per_page=100000'); + + $response->assertOk(); + $this->assertSame(100, $response->json('data.per_page')); + } + + public function test_board_activities_per_page_0_은_500_이_아니라_최소값으로_처리된다(): void + { + $response = $this->actingAs($this->user) + ->getJson('/api/modules/sirsoft-board/me/board-activities?per_page=0'); + + $response->assertOk(); + $this->assertSame(1, $response->json('data.per_page')); + } + + public function test_board_activities_per_page_음수는_500_이_아니라_최소값으로_처리된다(): void + { + $response = $this->actingAs($this->user) + ->getJson('/api/modules/sirsoft-board/me/board-activities?per_page=-5'); + + $response->assertOk(); + $this->assertSame(1, $response->json('data.per_page')); + } + public function test_activity_includes_commented_posts(): void { // Given: 다른 사용자의 게시글에 댓글 작성 @@ -766,10 +800,10 @@ class UserActivityApiTest extends BoardTestCase // 비즈니스 로직: 통계 쿼리는 board_posts.comments_count 컬럼 (SUM) 기반이므로 // CommentService 를 거치지 않는 직접 Comment::create 시에도 카운터 수동 동기화 필요. // (Listener 는 Service 레벨 훅에만 반응하므로 테스트에서 Model::create 직접 사용 시 미반영) - \Illuminate\Support\Facades\DB::table('board_posts') + DB::table('board_posts') ->where('id', $postId) ->update([ - 'comments_count' => \Illuminate\Support\Facades\DB::table('board_comments') + 'comments_count' => DB::table('board_comments') ->where('post_id', $postId) ->whereNull('deleted_at') ->count(), @@ -777,4 +811,4 @@ class UserActivityApiTest extends BoardTestCase return $comment; } -} \ No newline at end of file +} diff --git a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php index ab52c8dd..6c6890a0 100644 --- a/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php +++ b/modules/_bundled/sirsoft-board/tests/ModuleTestCase.php @@ -2,9 +2,21 @@ namespace Modules\Sirsoft\Board\Tests; +use App\Contracts\Extension\HookListenerInterface; +use App\Enums\ExtensionStatus; +use App\Extension\HookListenerRegistrar; +use App\Extension\HookManager; +use App\Extension\ModuleManager; +use App\Http\Middleware\PermissionMiddleware; +use App\Models\Module; +use App\Models\Permission; +use App\Models\Role; +use App\Models\User; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Schema; use Modules\Sirsoft\Board\Models\Board; +use Modules\Sirsoft\Board\Providers\BoardServiceProvider; use Tests\TestCase; /** @@ -62,7 +74,7 @@ abstract class ModuleTestCase extends TestCase $this->registerModuleAutoload(); // 모듈 ServiceProvider 등록 (Repository 바인딩) - $this->app->register(\Modules\Sirsoft\Board\Providers\BoardServiceProvider::class); + $this->app->register(BoardServiceProvider::class); // 모듈 마이그레이션 실행 (boards 테이블 등) $this->runModuleMigrationIfNeeded(); @@ -73,7 +85,7 @@ abstract class ModuleTestCase extends TestCase // ModuleManager 메모리 맵에 sirsoft-board 로드 (테스트 환경) // CoreServiceProvider::boot()에서 loadModules()가 호출되지만, // 테스트 컨테이너에서 ModuleManager 싱글톤이 비어있는 경우를 대비해 명시적으로 재로드 - $this->app->make(\App\Extension\ModuleManager::class)->loadModules(); + $this->app->make(ModuleManager::class)->loadModules(); // _bundled 디렉토리 모듈은 loadModules() 가 스캔하지 않아 // 모듈 인스턴스 등록 + 훅 리스너 자동 등록이 누락된다. @@ -91,11 +103,7 @@ abstract class ModuleTestCase extends TestCase // PermissionMiddleware::$guestRoleCache 초기화 — 이전 테스트에서 로드된 guest role/permissions 캐시가 // DatabaseTransactions 롤백 후에도 남아있어 다음 테스트의 새 permission 설정이 반영되지 않는 문제 회피. - $middlewareRef = new \ReflectionClass(\App\Http\Middleware\PermissionMiddleware::class); - if ($middlewareRef->hasProperty('guestRoleCache')) { - $prop = $middlewareRef->getProperty('guestRoleCache'); - $prop->setValue(null, null); - } + PermissionMiddleware::clearGuestRoleCache(); } /** @@ -113,7 +121,7 @@ abstract class ModuleTestCase extends TestCase */ private function snapshotHookManager(): void { - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $this->hookSnapshot = [ 'hooks' => $ref->getProperty('hooks')->getValue(), 'filters' => $ref->getProperty('filters')->getValue(), @@ -130,7 +138,7 @@ abstract class ModuleTestCase extends TestCase return; } - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); @@ -159,7 +167,7 @@ abstract class ModuleTestCase extends TestCase // 모듈 마이그레이션 실행 (코어 테이블 생성 후) $this->artisan('migrate', [ - '--path' => $this->getModuleBasePath() . '/database/migrations', + '--path' => $this->getModuleBasePath().'/database/migrations', '--realpath' => true, ]); @@ -179,13 +187,13 @@ abstract class ModuleTestCase extends TestCase $moduleClass = \Modules\Sirsoft\Board\Module::class; if (! class_exists($moduleClass)) { - require_once $this->getModuleBasePath() . '/module.php'; + require_once $this->getModuleBasePath().'/module.php'; } - $module = new $moduleClass(); + $module = new $moduleClass; - /** @var \App\Extension\ModuleManager $manager */ - $manager = $this->app->make(\App\Extension\ModuleManager::class); + /** @var ModuleManager $manager */ + $manager = $this->app->make(ModuleManager::class); // ModuleManager.modules 에 인스턴스 주입 $reflection = new \ReflectionClass($manager); @@ -203,11 +211,11 @@ abstract class ModuleTestCase extends TestCase if (! class_exists($listenerClass)) { continue; } - if (! in_array(\App\Contracts\Extension\HookListenerInterface::class, class_implements($listenerClass), true)) { + if (! in_array(HookListenerInterface::class, class_implements($listenerClass), true)) { continue; } try { - \App\Extension\HookListenerRegistrar::register($listenerClass, 'sirsoft-board'); + HookListenerRegistrar::register($listenerClass, 'sirsoft-board'); } catch (\Throwable $e) { // 중복 등록 등 무해한 예외는 무시 (snapshot/restore 패턴이 정리) } @@ -221,15 +229,15 @@ abstract class ModuleTestCase extends TestCase protected function registerModuleAsActive(): void { // 이미 등록되어 있으면 스킵 - if (\App\Models\Module::where('identifier', 'sirsoft-board')->exists()) { + if (Module::where('identifier', 'sirsoft-board')->exists()) { return; } - \App\Models\Module::create([ + Module::create([ 'identifier' => 'sirsoft-board', 'vendor' => 'sirsoft', 'name' => ['ko' => '게시판', 'en' => 'Board'], - 'status' => \App\Enums\ExtensionStatus::Active->value, + 'status' => ExtensionStatus::Active->value, 'version' => '1.0.0', 'config' => [], ]); @@ -240,7 +248,7 @@ abstract class ModuleTestCase extends TestCase */ protected function registerModuleAutoload(): void { - $moduleBasePath = $this->getModuleBasePath() . '/src/'; + $moduleBasePath = $this->getModuleBasePath().'/src/'; spl_autoload_register(function ($class) use ($moduleBasePath) { $prefix = 'Modules\\Sirsoft\\Board\\'; @@ -264,10 +272,10 @@ abstract class ModuleTestCase extends TestCase */ protected function registerModuleRoutes(): void { - $apiRoutesFile = $this->getModuleBasePath() . '/src/routes/api.php'; + $apiRoutesFile = $this->getModuleBasePath().'/src/routes/api.php'; if (file_exists($apiRoutesFile)) { - \Illuminate\Support\Facades\Route::prefix('api/modules/sirsoft-board') + Route::prefix('api/modules/sirsoft-board') ->name('api.modules.sirsoft-board.') ->middleware('api') ->group($apiRoutesFile); @@ -279,17 +287,17 @@ abstract class ModuleTestCase extends TestCase */ protected function createDefaultRoles(): void { - \App\Models\Role::firstOrCreate( + Role::firstOrCreate( ['identifier' => 'admin'], ['name' => ['ko' => '관리자', 'en' => 'Administrator']] ); - \App\Models\Role::firstOrCreate( + Role::firstOrCreate( ['identifier' => 'user'], ['name' => ['ko' => '일반 사용자', 'en' => 'User']] ); - \App\Models\Role::firstOrCreate( + Role::firstOrCreate( ['identifier' => 'guest'], ['name' => ['ko' => '비회원', 'en' => 'Guest']] ); @@ -300,18 +308,18 @@ abstract class ModuleTestCase extends TestCase * 관리자 역할을 가진 사용자를 생성합니다. * * @param array $permissions 추가 권한 목록 - * @return \App\Models\User + * @return User */ - protected function createAdminUser(array $permissions = []): \App\Models\User + protected function createAdminUser(array $permissions = []): User { - $adminRole = \App\Models\Role::where('identifier', 'admin')->first(); - $user = \App\Models\User::factory()->create(); + $adminRole = Role::where('identifier', 'admin')->first(); + $user = User::factory()->create(); $user->roles()->attach($adminRole->id); // 추가 권한이 있으면 생성 및 할당 if (! empty($permissions)) { foreach ($permissions as $permissionIdentifier) { - $permission = \App\Models\Permission::firstOrCreate( + $permission = Permission::firstOrCreate( ['identifier' => $permissionIdentifier], [ 'name' => ['ko' => $permissionIdentifier, 'en' => $permissionIdentifier], @@ -328,14 +336,14 @@ abstract class ModuleTestCase extends TestCase /** * 일반 사용자를 생성합니다. * - * @return \App\Models\User + * @return User */ - protected function createUser(): \App\Models\User + protected function createUser(): User { - $userRole = \App\Models\Role::where('identifier', 'user')->first(); - $user = \App\Models\User::factory()->create(); + $userRole = Role::where('identifier', 'user')->first(); + $user = User::factory()->create(); $user->roles()->attach($userRole->id); return $user; } -} \ No newline at end of file +} diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/playwright.config.ts b/modules/_bundled/sirsoft-board/tests/Playwright/playwright.config.ts index 6d809fb5..6de7748f 100644 --- a/modules/_bundled/sirsoft-board/tests/Playwright/playwright.config.ts +++ b/modules/_bundled/sirsoft-board/tests/Playwright/playwright.config.ts @@ -65,6 +65,12 @@ export default defineConfig({ ], use: { baseURL: resolveBaseUrl(), + // spec 이 한국어 화면 문구를 단언하므로 로케일을 고정한다. + // 로케일 우선순위는 localStorage g7_locale → 서버 응답값 → 'ko' 이고, 서버값은 미인증 + // 요청에서 Accept-Language 로 결정된다(SetLocale 미들웨어). Playwright 의 locale 옵션이 + // 그 헤더를 만들므로, 지정하지 않으면 첫 페이지 로드가 en-US 로 나가 화면이 영어로 렌더되고 + // 엔진이 그 값을 localStorage 에 저장해 이후 인증해도 세션 전체가 영어로 고정된다. + locale: 'ko-KR', trace: 'retain-on-failure', screenshot: 'only-on-failure', ignoreHTTPSErrors: true, diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-context-round-trip.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-context-round-trip.spec.ts new file mode 100644 index 00000000..1755f644 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-context-round-trip.spec.ts @@ -0,0 +1,79 @@ +/** + * 관리자 게시글 목록 컨텍스트 왕복 — 목록 상태(page/검색)가 상세·폼을 거쳐도 살아남는지 (공개 이슈 #75). + * + * 결함: 목록 클러스터(목록 ↔ 상세 ↔ 작성/수정 폼) 안의 이동에 `mergeQuery` 가 빠져 있어, + * 2페이지에서 글을 열었다가 목록으로 돌아오면 1페이지로 되돌아갔다. 특히 폼 '취소' 버튼은 + * 목적지가 삼항 표현식이라 정적 검사에서도 오래 빠져 있었다 — 저장 leg 만 고쳐지고 취소 leg 는 + * 남아 "저장하면 2페이지 유지 / 취소하면 1페이지 리셋" 비대칭이 생겼다. + * + * 단위/정적: + * - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts + * 가 실 JSON 의 액션 params 를 전수 고정 + * - audit 룰 layout-list-context-navigate-merge-query 가 표현식 경로까지 판정해 재발 차단 + * 이 spec 은 브라우저 수준(실제 주소창 URL)을 담당한다. + * + * 시드 의존: 샘플 시드의 자유게시판(`free`, 30건) — 관리자 목록 15건/페이지이므로 2페이지가 존재한다. + * + * @scenario board-list-context-round-trip + * @axes leg=enter leg=back + * @effects list_return_preserves_query + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const SLUG = 'free'; +const LIST_URL = `/admin/board/${SLUG}`; + +/** + * 현재 URL 의 쿼리 파라미터를 돌려줍니다. + * + * @param url 페이지 URL + * @returns 쿼리 파라미터 + */ +function queryOf(url: string): URLSearchParams { + return new URL(url).searchParams; +} + +test.describe('관리자 게시글 목록 컨텍스트 왕복 (#75)', () => { + // @scenario leg=enter + // @effects list_return_preserves_query + test('2페이지에서 글 제목을 누르면 상세 URL 에 page 가 실린다', async ({ page, boardManageToken }) => { + await authenticatePage(page, boardManageToken); + await page.goto(`${LIST_URL}?page=2`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + // 제목 Span 이 이동 액션을 갖는다 (행 전체가 아니라 제목만 클릭 대상). + await page.locator('tbody tr span.cursor-pointer').first().click(); + await page.waitForURL(new RegExp(`/admin/board/${SLUG}/post/\\d+`), { timeout: 30_000 }); + + expect(queryOf(page.url()).get('page')).toBe('2'); + }); + + // @scenario leg=back + // @effects list_return_preserves_query + test('상세에서 목록으로 돌아오면 2페이지가 복원된다', async ({ page, boardManageToken }) => { + await authenticatePage(page, boardManageToken); + await page.goto(`${LIST_URL}?page=2`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + await page.locator('tbody tr span.cursor-pointer').first().click(); + await page.waitForURL(new RegExp(`/admin/board/${SLUG}/post/\\d+`), { timeout: 30_000 }); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + await page.locator('#back_button').click(); + await page.waitForURL(new RegExp(`/admin/board/${SLUG}\\?`), { timeout: 30_000 }); + + expect(queryOf(page.url()).get('page')).toBe('2'); + }); + + // @scenario leg=back + // @effects list_return_preserves_query + test('작성 폼에서 취소하면 보고 있던 페이지로 돌아온다', async ({ page, boardManageToken }) => { + await authenticatePage(page, boardManageToken); + await page.goto(`${LIST_URL}/create?page=2`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + await page.locator('#header_cancel_button').click(); + await page.waitForURL(new RegExp(`/admin/board/${SLUG}\\?`), { timeout: 30_000 }); + + expect(queryOf(page.url()).get('page')).toBe('2'); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-role-payload.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-role-payload.spec.ts new file mode 100644 index 00000000..4df28350 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-role-payload.spec.ts @@ -0,0 +1,148 @@ +/** + * 관리자 게시판 목록 역할 페이로드 프루닝 — 목록이 역할·소속 회원 명단을 싣지 않는지 (#518 / 공개 #76). + * + * 결함: 목록 Collection 이 행마다 `Role::whereIn()->with('users')` 를 돌려 N+1 을 만들고, + * 매니저·스텝 역할에 소속된 **모든 사용자의 uuid·이름·이메일**을 응답에 실었다. 화면은 + * 그 값을 그리지 않는다 — 게시판 이름/식별자/유형/활성여부/분류만 그린다. + * + * 이 spec 이 브라우저 수준을 담당하는 이유: 서버 단언(BoardManagementTest)은 응답 형태만 본다. + * 목록 표현을 경량 경로로 갈아끼우면서 화면이 쓰던 필드를 함께 떨구면 열이 조용히 비는데, + * 그건 응답 단언으로는 드러나지 않는다. 여기서는 실제 렌더된 행을 확인한다. + * + * 시드 의존: 샘플 시드의 게시판 목록(최소 1건). + * + * @scenario board-admin-list-role-payload-pruning + * @axes endpoint=admin_list endpoint=detail + * @effects admin_list_omits_role_member_payload admin_list_never_exposes_member_email admin_list_keeps_display_fields detail_still_provides_role_assignments + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const LIST_URL = '/admin/boards'; +const LIST_API = /\/api\/modules\/sirsoft-board\/admin\/boards(\?|$)/; + +/** 목록에서 빠져야 하는 역할 페이로드 키 */ +const ROLE_KEYS = [ + 'board_managers', + 'board_steps', + 'board_manager_ids', + 'board_step_ids', +]; + +test.describe('관리자 게시판 목록 역할 페이로드 (#518)', () => { + // @scenario endpoint=admin_list + // @effects admin_list_omits_role_member_payload + test('목록 응답에 역할 배열과 역할 ID 목록이 없다', async ({ page, boardManageToken }) => { + await authenticatePage(page, boardManageToken); + + const listResponse = page.waitForResponse( + (res) => LIST_API.test(res.url()) && res.request().method() === 'GET', + { timeout: 30_000 }, + ); + + await page.goto(LIST_URL); + const payload = await (await listResponse).json(); + + const rows = payload?.data?.data ?? []; + expect(rows.length, '게시판이 최소 1건은 있어야 판정할 수 있다').toBeGreaterThan(0); + + for (const row of rows) { + for (const key of ROLE_KEYS) { + expect(row, `목록 행에 ${key} 가 실리면 안 된다`).not.toHaveProperty(key); + } + } + }); + + // @scenario endpoint=admin_list + // @effects admin_list_never_exposes_member_email + test('목록 응답 본문에 회원 이메일이 등장하지 않는다', async ({ page, boardManageToken }) => { + await authenticatePage(page, boardManageToken); + + const listResponse = page.waitForResponse( + (res) => LIST_API.test(res.url()) && res.request().method() === 'GET', + { timeout: 30_000 }, + ); + + await page.goto(LIST_URL); + const body = await (await listResponse).text(); + + // 키 이름이 아니라 본문 전체를 본다 — 역할 페이로드가 다른 키 이름으로 되살아나도 잡힌다. + expect(body, '게시판 목록에 회원 이메일이 실리면 안 된다').not.toMatch( + /"email"\s*:\s*"[^"]+@/, + ); + expect(body, '게시판 목록에 회원 uuid 가 실리면 안 된다').not.toMatch(/"uuid"\s*:/); + }); + + // @scenario endpoint=admin_list + // @effects admin_list_keeps_display_fields + test('목록이 그리는 열(이름·식별자·유형·활성여부)이 그대로 렌더된다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + + const listResponse = page.waitForResponse( + (res) => LIST_API.test(res.url()) && res.request().method() === 'GET', + { timeout: 30_000 }, + ); + + await page.goto(LIST_URL); + const payload = await (await listResponse).json(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const first = payload?.data?.data?.[0]; + expect(first, '첫 행이 있어야 한다').toBeTruthy(); + + // 응답 계약 — 화면이 순회하는 6필드 + for (const key of ['name', 'slug', 'type', 'is_active', 'categories', 'abilities']) { + expect(first, `목록이 그리는 필드 ${key} 가 빠졌다`).toHaveProperty(key); + } + + // 렌더 결과 — 존재를 먼저 확정한 뒤 값을 본다 + const rows = page.locator('tbody tr'); + await expect(rows.first()).toBeVisible({ timeout: 30_000 }); + + const slug = String(first.slug); + await expect( + page.locator('tbody').getByText(slug, { exact: false }).first(), + '식별자 열이 비면 경량 전환이 화면을 깎은 것이다', + ).toBeVisible(); + }); + + // @scenario endpoint=detail + // @effects detail_still_provides_role_assignments + test('단건 조회는 역할 정보를 그대로 제공한다 (대체 경로 보존)', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + + const listResponse = page.waitForResponse( + (res) => LIST_API.test(res.url()) && res.request().method() === 'GET', + { timeout: 30_000 }, + ); + await page.goto(LIST_URL); + const payload = await (await listResponse).json(); + const slug = payload?.data?.data?.[0]?.slug; + expect(slug, '단건 조회 대상 slug 가 필요하다').toBeTruthy(); + + // 인증은 Bearer 토큰이다 — 쿠키 세션이 아니므로 헤더를 명시해야 401 이 나지 않는다. + const detail = await page.evaluate( + async ({ boardSlug, token }) => { + const res = await fetch(`/api/modules/sirsoft-board/admin/boards/${boardSlug}`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + + return { status: res.status, body: await res.json() }; + }, + { boardSlug: slug, token: boardManageToken }, + ); + + expect(detail.status).toBe(200); + // 목록에서 뺀 값은 단건이 공급한다 — 없애는 것이 아니라 옮기는 것이다. + expect(detail.body?.data).toHaveProperty('board_manager_ids'); + expect(detail.body?.data).toHaveProperty('board_step_ids'); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-allowed-extensions-required.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-allowed-extensions-required.spec.ts index 1da47da6..addba1f9 100644 --- a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-allowed-extensions-required.spec.ts +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-allowed-extensions-required.spec.ts @@ -6,9 +6,14 @@ * 브라우저 수준(빈 채 저장 시 오류 노출 / 첨부 미사용 게시판은 빈 값 허용 / 안내 문구 갱신 노출)은 * 이 spec 이 담당한다. * + * 정책 정정 (#78): "빈 값 금지" 는 첨부를 **사용하는** 게시판/기본값에만 적용된다. + * 첨부 미사용이면 확장자는 검증 자체에서 배제된다 — 그러지 않으면 첨부를 쓰지 않는 + * 게시판을 무변경 저장하는 것조차 422 로 막힌다. + * * @scenario board-allowed-extensions-required * @axes surface=settings_attachment surface=board_form use_file_upload=true use_file_upload=false value=empty value=nonempty - * @effects empty_extensions_blocked_on_settings_save, + * @effects empty_extensions_blocked_on_settings_save_when_upload_enabled, + * empty_extensions_allowed_on_settings_save_when_upload_disabled, * empty_extensions_blocked_on_board_form_when_upload_enabled, * empty_extensions_allowed_on_board_form_when_upload_disabled, * allowed_extensions_guide_text_updated_to_min_one @@ -20,10 +25,25 @@ import { test, expect, authenticatePage } from '../../fixtures/board-auth'; const SETTINGS_URL = '/admin/boards/settings'; const CREATE_URL = '/admin/boards/create'; -test.describe.skip('게시판 허용 확장자 빈 값 저장 차단 (#413)', () => { - // @scenario surface=settings_attachment, value=empty - // @effects empty_extensions_blocked_on_settings_save - test('환경설정 첨부 탭에서 허용 확장자를 비우고 저장하면 오류가 노출된다', async ({ +const EXTENSION_REQUIRED_TEXT = /허용 파일 확장자를 최소 1개|At least one allowed file extension/; + +/** + * 첨부 탭의 허용 확장자 칩을 모두 제거합니다. + */ +async function clearAllowedExtensionChips(page: import('@playwright/test').Page): Promise { + const removeChipButtons = page.locator( + '[name="basic_defaults.allowed_extensions"] [data-chip-remove], [data-field="allowed_extensions"] button[aria-label*="제거"], [data-field="allowed_extensions"] button[aria-label*="remove"]' + ); + const count = await removeChipButtons.count(); + for (let i = 0; i < count; i++) { + await removeChipButtons.first().click(); + } +} + +test.describe.skip('게시판 허용 확장자 빈 값 저장 차단 (#413, #78)', () => { + // @scenario surface=settings_attachment, use_file_upload=true, value=empty + // @effects empty_extensions_blocked_on_settings_save_when_upload_enabled + test('환경설정에서 첨부 사용 ON + 허용 확장자를 비우고 저장하면 오류가 노출된다', async ({ page, boardManageToken, }) => { @@ -34,18 +54,41 @@ test.describe.skip('게시판 허용 확장자 빈 값 저장 차단 (#413)', () // 기본 설정 → 첨부파일 하위 탭으로 이동 await page.getByRole('tab', { name: /첨부|Attachment/ }).click(); - // 허용 확장자 칩을 모두 비운다 (TagInput 의 기존 칩 제거 버튼 반복 클릭) - const removeChipButtons = page.locator('[name="basic_defaults.allowed_extensions"] [data-chip-remove], [data-field="allowed_extensions"] button[aria-label*="제거"], [data-field="allowed_extensions"] button[aria-label*="remove"]'); - const count = await removeChipButtons.count(); - for (let i = 0; i < count; i++) { - await removeChipButtons.first().click(); + // 첨부 사용을 켜 둔 상태에서 확장자를 모두 비운다 + const uploadToggle = page.locator('[name="basic_defaults.use_file_upload"]'); + if (!(await uploadToggle.isChecked())) { + await uploadToggle.click(); } + await clearAllowedExtensionChips(page); // 저장 → min:1 검증 실패 안내 노출 await page.locator('button[type="submit"]').first().click(); - await expect( - page.getByText(/허용 파일 확장자를 최소 1개|At least one allowed file extension/) - ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(EXTENSION_REQUIRED_TEXT)).toBeVisible({ timeout: 10_000 }); + }); + + // @scenario surface=settings_attachment, use_file_upload=false, value=empty + // @effects empty_extensions_allowed_on_settings_save_when_upload_disabled + test('환경설정에서 첨부 미사용이면 허용 확장자를 비워도 저장된다 (#78)', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await page.goto(SETTINGS_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + await page.getByRole('tab', { name: /첨부|Attachment/ }).click(); + + // 첨부 사용을 끈 뒤 확장자를 모두 비운다 + const uploadToggle = page.locator('[name="basic_defaults.use_file_upload"]'); + if (await uploadToggle.isChecked()) { + await uploadToggle.click(); + } + await clearAllowedExtensionChips(page); + + await page.locator('button[type="submit"]').first().click(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + await expect(page.getByText(EXTENSION_REQUIRED_TEXT)).toHaveCount(0, { timeout: 10_000 }); }); // @scenario surface=board_form, use_file_upload=true, value=empty @@ -66,9 +109,7 @@ test.describe.skip('게시판 허용 확장자 빈 값 저장 차단 (#413)', () await page.locator('[name="use_file_upload"]').click(); await page.locator('button[type="submit"]').first().click(); - await expect( - page.getByText(/허용 파일 확장자를 최소 1개|At least one allowed file extension/) - ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(EXTENSION_REQUIRED_TEXT)).toBeVisible({ timeout: 10_000 }); }); // @scenario surface=board_form, use_file_upload=false, value=empty @@ -89,9 +130,7 @@ test.describe.skip('게시판 허용 확장자 빈 값 저장 차단 (#413)', () await page.waitForLoadState('networkidle', { timeout: 30_000 }); // 허용 확장자 검증 오류가 노출되지 않아야 한다 - await expect( - page.getByText(/허용 파일 확장자를 최소 1개|At least one allowed file extension/) - ).toHaveCount(0, { timeout: 10_000 }); + await expect(page.getByText(EXTENSION_REQUIRED_TEXT)).toHaveCount(0, { timeout: 10_000 }); }); // @scenario surface=board_form, value=nonempty diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts new file mode 100644 index 00000000..3e7b01b9 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-form-range-parity.spec.ts @@ -0,0 +1,78 @@ +/** + * 개별 게시판 수정 화면의 숫자 입력 경계값이 서버 한계값과 일치하는지 검증 (공개 이슈 #81). + * + * 결함: 같은 설정 항목이 두 화면에 있는데 경계 안내가 한쪽에만 있었다. + * - 게시판 환경설정(모듈 기본값): 숫자 입력에 허용 범위가 붙어 있음 + * - 개별 게시판 수정: min/max 가 아예 없어 범위를 알 수 없음 + * 저장은 서버가 동일하게 막으므로 잘못된 값이 들어가지는 않았지만, 사용자는 저장을 + * 눌러 422 를 받아야만 범위를 알 수 있었다. + * + * 단위/정적: + * - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-form-range-contract.test.ts + * 가 레이아웃 JSON 의 표현식(_local.form._meta.limits.*)과 폴백 값을 전수 고정 + * 이 spec 은 브라우저 수준 — 서버가 실제로 내려준 한계값이 DOM 속성으로 해석되는지를 담당한다. + * (표현식이 잘못된 경로를 가리켜도 폴백 값이 서버 기본치와 같으면 화면만 봐서는 구분되지 + * 않으므로, 응답의 `_meta.limits` 를 읽어 그 값과 대조한다.) + * + * 시드 의존: 샘플 시드의 자유게시판(`free`). + * + * @scenario board-form-range-parity + * @axes screen=per_board screen=module_defaults + * @effects form_boundary_matches_server_limits + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const SLUG = 'free'; + +/** 개별 게시판 화면의 입력 name → 서버 limits 키 prefix (per_page_mobile 은 per_page 범위 공유) */ +const FIELD_TO_LIMIT: Record = { + per_page: 'per_page', + per_page_mobile: 'per_page', + min_title_length: 'min_title_length', + max_title_length: 'max_title_length', + min_content_length: 'min_content_length', + max_content_length: 'max_content_length', + new_display_hours: 'new_display_hours', + min_comment_length: 'min_comment_length', + max_comment_length: 'max_comment_length', + max_comment_depth: 'max_comment_depth', + max_file_size: 'max_file_size', + max_file_count: 'max_file_count', +}; + +test('#81 - 개별 게시판 수정 화면의 숫자 경계값이 서버 한계값과 일치한다', async ({ page, boardManageToken }) => { + await authenticatePage(page, boardManageToken); + + await page.goto(`/admin/boards/${SLUG}/edit`); + await expect(page.locator('input[name="per_page"]')).toBeAttached({ timeout: 20_000 }); + + // 서버가 실제로 내려주는 한계값을 그대로 읽는다 (화면 폴백과 구분하기 위함) + const limits = await page.evaluate(async ({ slug, bearer }) => { + const response = await fetch( + `/api/modules/sirsoft-board/admin/boards/form-data?board_slug=${slug}`, + { headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' } } + ); + const body = await response.json(); + + return (body?.data?._meta?.limits ?? {}) as Record; + }, { slug: SLUG, bearer: boardManageToken }); + + expect( + Object.keys(limits).length, + '게시판 한계값이 폼 데이터 응답에 실려 있지 않습니다 — 화면 바인딩이 영구히 폴백으로 떨어집니다.' + ).toBeGreaterThan(0); + + for (const [field, limitKey] of Object.entries(FIELD_TO_LIMIT)) { + const input = page.locator(`input[name="${field}"]`).first(); + + // 토글 뒤에 숨은 입력(예: 답글 사용)은 이 화면 기본 상태에서 렌더되지 않을 수 있다 + if ((await input.count()) === 0) { + continue; + } + + await expect(input, `${field}: min 이 서버 한계값과 다릅니다`) + .toHaveAttribute('min', String(limits[`${limitKey}_min`])); + await expect(input, `${field}: max 가 서버 한계값과 다릅니다`) + .toHaveAttribute('max', String(limits[`${limitKey}_max`])); + } +}); diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-notification-definition-channel.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-notification-definition-channel.spec.ts new file mode 100644 index 00000000..b3da6e0b --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-notification-definition-channel.spec.ts @@ -0,0 +1,188 @@ +/** + * 게시판 알림 정의 탭 채널 계약 — 목록 요청이 화면 채널을 나르는지 (#518 / 공개 #76). + * + * 결함(2026-08-05 브라우저 실측): 목록 프루닝으로 서버가 `template_channel` 을 받은 요청에만 + * `templates` 를 싣게 됐는데, 코어 설정 화면만 그 파라미터를 보내도록 고쳐지고 게시판 탭 + * 데이터소스는 빠졌다. 7건 전 행이 「이 채널에 대한 템플릿이 없습니다」로 표시되고 + * 제목·수신자·활성 토글·편집 모달 초기값이 통째로 비었다. + * + * 이 spec 이 브라우저 수준을 담당하는 이유: 구조 테스트(Vitest)는 레이아웃 JSON 에 + * `template_channel` 키가 있는지만 본다. 그 키가 **실제로 요청에 실려 나가는지**와 + * 그 응답으로 행이 채워지는지는 렌더까지 가야 드러난다 — 결함 당시 응답은 200 이었고 + * 행 수도 정상이었다. + * + * 시드 의존: 게시판 모듈 알림 정의(최소 1건). 커스터마이즈 축은 화면에서 만들고 되돌린다. + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; +import type { Page } from '@playwright/test'; + +const SETTINGS_URL = '/admin/boards/settings?tab=notification_definitions'; +const DEFINITIONS_API = /\/api\/admin\/notification-definitions(\?|$)/; + +/** 템플릿이 비었을 때 행에 표시되는 문구 */ +const EMPTY_TEMPLATE_TEXT = '이 채널에 대한 템플릿이 없습니다'; + +/** + * 알림 정의 목록 응답을 기다리며 해당 채널의 설정 탭으로 이동합니다. + * + * @param page Playwright 페이지 + * @param channel 조회할 알림 채널 + * @returns 요청 URL 과 응답 본문 + */ +async function loadChannel(page: Page, channel: string) { + const pending = page.waitForResponse( + (res) => DEFINITIONS_API.test(res.url()) && res.request().method() === 'GET', + { timeout: 30_000 }, + ); + + await page.goto(`${SETTINGS_URL}&channel=${channel}`); + + const response = await pending; + + return { requestUrl: response.url(), payload: await response.json() }; +} + +/** + * 기본 상태(커스터마이즈 없음) 축의 공통 단언을 수행합니다. + * + * @param page Playwright 페이지 + * @param channel 검증 대상 채널 + */ +async function assertChannelIsCarried(page: Page, channel: string) { + const { requestUrl, payload } = await loadChannel(page, channel); + + expect( + requestUrl, + '화면이 보는 채널을 서버에 넘기지 않으면 응답에 templates 키 자체가 없다', + ).toContain(`template_channel=${channel}`); + + const rows = payload?.data?.data ?? []; + + test.skip(rows.length === 0, '게시판 알림 정의 시드가 없어 판정할 수 없다'); + + // 서버가 요청한 채널만 실었는지 — 다른 채널이 섞이면 프루닝이 무의미하다. + for (const row of rows) { + expect(row, 'templates 키가 응답에 있어야 한다').toHaveProperty('templates'); + + for (const template of row.templates ?? []) { + expect(template.channel, '요청하지 않은 채널이 실렸다').toBe(channel); + } + } + + // 렌더까지 확인 — 응답에 실렸어도 화면이 다른 채널을 찾으면 빈 문구가 남는다. + await expect( + page.getByText(EMPTY_TEMPLATE_TEXT).first(), + '템플릿이 실렸는데도 빈 문구가 보이면 화면과 요청의 채널이 어긋난 것이다', + ).toBeHidden(); +} + +/** + * 지정 채널의 첫 정의를 편집해 커스터마이즈 상태로 만든 뒤, + * 「기본값 복원」이 채널을 가리지 않고 노출되는지 확인하고 원상 복구합니다. + * + * @param page Playwright 페이지 + * @param channel 커스터마이즈를 수행할 채널 + */ +async function assertResetButtonSpansChannels(page: Page, channel: string) { + // 편집→저장→재조회→복원까지 도는 다단계 플로우라 기본 30초를 넘는다. + test.setTimeout(120_000); + + const { payload } = await loadChannel(page, channel); + + test.skip((payload?.data?.data ?? []).length === 0, '게시판 알림 정의 시드가 없어 판정할 수 없다'); + + const resetButton = page.getByRole('button', { name: '기본값 복원' }); + const initialCount = await resetButton.count(); + + await page.getByRole('button', { name: '편집' }).first().click(); + + const dialog = page.getByRole('dialog'); + const subject = dialog.getByRole('textbox').first(); + + await subject.fill(`${await subject.inputValue()} [E2E]`); + await dialog.getByRole('button', { name: '저장' }).click(); + await expect(dialog).toBeHidden({ timeout: 15_000 }); + + // 반대 채널로 옮겨도 복원 버튼이 보여야 한다. 화면이 templates 배열을 순회해 판정하면 + // 반대 채널 templates 에는 커스터마이즈가 없어 버튼이 사라진다. + const otherChannel = channel === 'mail' ? 'database' : 'mail'; + + try { + await loadChannel(page, otherChannel); + + await expect( + resetButton.first(), + '채널을 가리지 않는 판정은 서버 집계(has_customized_templates)가 유일한 출처다', + ).toBeVisible({ timeout: 15_000 }); + + expect( + await resetButton.count(), + '커스터마이즈한 정의에만 복원 버튼이 붙어야 한다', + ).toBeGreaterThan(initialCount); + } finally { + // 시드 상태로 되돌린다 — 남겨두면 다음 실행의 기준선이 달라진다. + await loadChannel(page, otherChannel); + + if ((await resetButton.count()) > 0) { + await resetButton.first().click(); + await page.getByRole('button', { name: '복원', exact: true }).click(); + await expect(resetButton).toHaveCount(initialCount, { timeout: 15_000 }); + } + } +} + +test.describe('게시판 알림 정의 탭 — 채널 계약 (#518)', () => { + /** + * @scenario channel=mail,template_state=default + * @effects board_notification_list_requests_template_channel, board_notification_channel_reads_single_source + */ + test('mail 채널: 목록 요청이 그 채널을 나르고 행에 템플릿이 채워진다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await assertChannelIsCarried(page, 'mail'); + }); + + /** + * @scenario channel=database,template_state=default + * @effects board_notification_list_requests_template_channel, board_notification_channel_reads_single_source + */ + test('database 채널: 목록 요청이 그 채널을 나르고 행에 템플릿이 채워진다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await assertChannelIsCarried(page, 'database'); + }); + + test.describe('커스터마이즈 상태', () => { + // 두 축 모두 같은 시드 정의를 편집·복원하므로 병렬로 돌면 서로의 기준선을 무너뜨린다. + // 이 블록 안에서만 직렬 — 부모 describe 에 걸면 기본 상태 2건까지 한 건 실패로 중단된다. + test.describe.configure({ mode: 'serial' }); + + /** + * @scenario channel=mail,template_state=customized + * @effects board_notification_reset_button_uses_server_aggregate + */ + test('mail 채널에서 커스터마이즈하면 database 화면에서도 기본값 복원이 보인다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await assertResetButtonSpansChannels(page, 'mail'); + }); + + /** + * @scenario channel=database,template_state=customized + * @effects board_notification_reset_button_uses_server_aggregate + */ + test('database 채널에서 커스터마이즈하면 mail 화면에서도 기본값 복원이 보인다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await assertResetButtonSpansChannels(page, 'database'); + }); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-post-form-attachments-desc.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-post-form-attachments-desc.spec.ts new file mode 100644 index 00000000..b3698310 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-post-form-attachments-desc.spec.ts @@ -0,0 +1,61 @@ +/** + * 관리자 게시글 폼 — 첨부 안내가 게시판 설정값으로 렌더 (공개 #81 L-01). + * + * 관리자 게시글 폼의 첨부 영역에는 안내 문구가 없었고, 대신 사장된 lang 키 + * ("최대 5개 파일, 각 10MB 이하")만 남아 있었다. 그 문구는 어디에도 렌더되지 않는데 + * 설령 렌더되었어도 게시판마다 다른 실제 제한과 무관한 고정 숫자였다. + * + * 안내는 `form_meta.data.board.max_file_count/max_file_size` 를 파라미터로 받아야 한다. + * 단위(Vitest admin-board-post-form-attachments-desc) 는 안내 노드만 떼어 렌더하므로, + * 실제 폼에서 form_meta 데이터소스가 도착한 뒤의 치환 결과는 브라우저로만 확인 가능하다 + * (위지윅 발행 회귀 #238 교훈). + * + * @scenario board-post-form-attachments-desc + * @effects admin_attachment_hint_renders_board_limits, + * admin_attachment_hint_placeholders_not_raw + * + * 활성화 절차: PlaywrightIssueToken 발급이 가능한 환경에서 test.describe.skip → test.describe. + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +/** 첨부 기능이 켜진 게시판의 관리자 글쓰기 화면 */ +const WRITE_URL = '/admin/board/free/write'; + +test.describe.skip('관리자 게시글 폼 — 첨부 안내 (#81 L-01)', () => { + test('첨부 안내가 게시판 설정의 개수·용량으로 렌더된다', async ({ page, settingsToken }) => { + await authenticatePage(page, settingsToken); + await page.goto(WRITE_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + // form_meta 도착 후 안내가 채워질 때까지 대기 — 숫자는 게시판 설정에서 온다 + const hint = page.getByText(/^최대 \d+개, 각 \d+MB 이하$/); + await expect(hint).toBeVisible({ timeout: 10_000 }); + }); + + test('raw 플레이스홀더({{maxFiles}}/{{maxSize}})가 노출되지 않는다', async ({ + page, + settingsToken, + }) => { + await authenticatePage(page, settingsToken); + await page.goto(WRITE_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + await expect(page.getByText(/^최대 \d+개, 각 \d+MB 이하$/)).toBeVisible({ timeout: 10_000 }); + + await expect(page.getByText('{{maxFiles}}', { exact: false })).toHaveCount(0); + await expect(page.getByText('{{maxSize}}', { exact: false })).toHaveCount(0); + }); + + test('사장된 고정 숫자 안내가 다시 나타나지 않는다', async ({ page, settingsToken }) => { + await authenticatePage(page, settingsToken); + await page.goto(WRITE_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + await expect(page.getByText(/^최대 \d+개, 각 \d+MB 이하$/)).toBeVisible({ timeout: 10_000 }); + + // 종전 사장 키의 고정 문구 (jpg, jpeg, ... 확장자 나열 포함) 는 제거되었다 + await expect( + page.getByText('최대 5개 파일, 각 10MB 이하', { exact: false }), + ).toHaveCount(0); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-unchanged-save-and-error-visibility.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-unchanged-save-and-error-visibility.spec.ts new file mode 100644 index 00000000..dc1cf2f8 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-unchanged-save-and-error-visibility.spec.ts @@ -0,0 +1,120 @@ +/** + * 게시판 무변경 저장 + 저장 실패 노출 (#78). + * + * 관리자 폼은 조회 응답 전체를 _local.form 에 통째로 담아 그대로 PUT 한다. 따라서 요청에는 + * 항상 모든 키가 존재하고, 토글이 꺼진 상태의 종속 필드까지 검증을 타면 아무것도 바꾸지 않은 + * 저장조차 422 로 거부된다. 첨부를 쓰지 않는 게시판(notice/archive)이 여기에 해당했다. + * + * 백엔드 계약은 PHPUnit(BoardRequestTest 매트릭스, BoardManagementTest 왕복 저장)이 고정하고, + * 브라우저 수준의 "무변경 저장 성공" 과 "실패 시 실제로 화면에 보이는가" 를 이 spec 이 담당한다. + * 후자는 오류 처리가 붙어 있어도 바인딩 키가 틀리면 빈 안내가 뜨므로 별도 축이 필요하다. + * + * @scenario board-unchanged-save-and-error-visibility + * @axes use_file_upload=false use_file_upload=true allowed_extensions=empty allowed_extensions=nonempty new_display_hours=0 outcome=success outcome=failure + * @effects unchanged_save_succeeds_for_upload_disabled_board, + * unchanged_save_succeeds_for_zero_new_display_hours, + * unchanged_save_succeeds_for_upload_enabled_board, + * save_failure_surfaces_toast_with_server_message + * + * 활성화 절차: PlaywrightIssueToken 발급이 가능한 환경에서 test.describe.skip → test.describe. + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const EDIT_URL = (slug: string) => `/admin/boards/${slug}/edit`; + +const EXTENSION_REQUIRED_TEXT = /허용 파일 확장자를 최소 1개|At least one allowed file extension/; + +/** + * 게시판 수정 화면을 열고 폼 로딩을 기다립니다. + */ +async function openBoardEdit(page: import('@playwright/test').Page, slug: string): Promise { + await page.goto(EDIT_URL(slug)); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator('button[type="submit"]').first()).toBeVisible({ timeout: 30_000 }); +} + +/** + * 아무것도 바꾸지 않고 저장하고, 저장 요청의 응답 상태를 반환합니다. + */ +async function saveUnchanged(page: import('@playwright/test').Page): Promise { + const [response] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === 'PUT' && r.url().includes('/admin/boards/'), + { timeout: 30_000 } + ), + page.locator('button[type="submit"]').first().click(), + ]); + + return response.status(); +} + +test.describe.skip('게시판 무변경 저장 및 저장 실패 노출 (#78)', () => { + // @scenario use_file_upload=false, allowed_extensions=empty, outcome=success + // @effects unchanged_save_succeeds_for_upload_disabled_board + test('첨부 미사용 게시판(notice)을 아무것도 바꾸지 않고 저장하면 성공한다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await openBoardEdit(page, 'notice'); + + expect(await saveUnchanged(page)).toBe(200); + await expect(page.getByText(EXTENSION_REQUIRED_TEXT)).toHaveCount(0); + }); + + // @scenario use_file_upload=false, new_display_hours=0, outcome=success + // @effects unchanged_save_succeeds_for_zero_new_display_hours + test('NEW 배지 표시 기간이 0인 게시판(archive)을 무변경 저장하면 성공한다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await openBoardEdit(page, 'archive'); + + expect(await saveUnchanged(page)).toBe(200); + }); + + // @scenario use_file_upload=true, allowed_extensions=nonempty, outcome=success + // @effects unchanged_save_succeeds_for_upload_enabled_board + test('첨부 사용 게시판(gallery)의 무변경 저장은 회귀 없이 계속 성공한다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await openBoardEdit(page, 'gallery'); + + expect(await saveUnchanged(page)).toBe(200); + }); + + // @scenario use_file_upload=true, allowed_extensions=empty, outcome=failure + // @effects save_failure_surfaces_toast_with_server_message + test('저장이 422로 거부되면 서버 메시지가 담긴 안내가 실제로 화면에 노출된다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await openBoardEdit(page, 'gallery'); + + // 첨부 사용은 켠 채로 확장자를 모두 비워 의도적으로 422 를 유발한다 + await page.getByRole('tab', { name: /게시글 설정|Post Settings/ }).click(); + const removeChipButtons = page.locator( + '[data-field="allowed_extensions"] button[aria-label*="제거"], [data-field="allowed_extensions"] button[aria-label*="remove"]' + ); + const count = await removeChipButtons.count(); + for (let i = 0; i < count; i++) { + await removeChipButtons.first().click(); + } + + const [response] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === 'PUT' && r.url().includes('/admin/boards/'), + { timeout: 30_000 } + ), + page.locator('button[type="submit"]').first().click(), + ]); + expect(response.status()).toBe(422); + + // 안내가 비어 있지 않아야 한다 — 오류 처리가 붙어 있어도 바인딩 키가 틀리면 빈 값이 노출된다 + await expect(page.getByText(EXTENSION_REQUIRED_TEXT).first()).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-empty-extensions-attachment.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-empty-extensions-attachment.spec.ts new file mode 100644 index 00000000..4743fb7c --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-empty-extensions-attachment.spec.ts @@ -0,0 +1,95 @@ +/** + * 허용 확장자 미지정 게시판의 첨부 선택 (#78 R3). + * + * 백엔드는 Phase 2 #7 로 이미 고쳐졌지만(빈 확장자 → 기본 확장자 폴백), 프런트에 동일한 + * `[]`-truthiness 쌍둥이가 남아 있어 사용자는 **파일 선택 단계에서** 막혔다. 요청 자체가 + * 발생하지 않으므로 서버측 회귀 테스트로는 잡히지 않는다. + * + * 연쇄: DB `NULL` → API 가 `allowed_extensions: []` 로 직렬화(BoardResource 계약) → + * 레이아웃 `ext ? '.' + ext.join(',.') : fallback` 에서 `[]` 가 truthy → `accept="."` → + * `useFileUploader` 확장자 게이트가 전 파일 거부 + 안내문이 `(.)` 로 깨짐. + * + * 브라우저 실측 대조군(2026-07-22): `free`(ext=NULL) accept=`"."` → 첨부 차단 / + * `gallery`(ext 5개) accept=`".jpg,..."` → 정상. 서버측 curl 은 `free`+`.txt` 201 로 정상이었다. + * + * @scenario board-empty-extensions-attachment + * @axes allowed_extensions=empty allowed_extensions=specified surface=user_write surface=admin_write + * @effects empty_extensions_accept_falls_back_to_defaults, + * empty_extensions_file_selection_succeeds, + * specified_extensions_accept_reflects_list + * + * 활성화 절차: PlaywrightIssueToken 발급이 가능한 환경에서 test.describe.skip → test.describe. + */ +import { test, expect, authenticatePage } from '../../fixtures/board-auth'; + +const DEFAULT_ACCEPT = '.jpg,.jpeg,.png,.gif,.pdf,.zip'; + +/** + * 글쓰기 화면의 파일 입력에서 accept 속성을 읽습니다. + */ +async function readAcceptAttribute(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const input = document.querySelector('input[type="file"]'); + + return input ? input.getAttribute('accept') : null; + }); +} + +test.describe.skip('허용 확장자 미지정 게시판의 첨부 (#78 R3)', () => { + // @scenario allowed_extensions=empty, surface=user_write + // @effects empty_extensions_accept_falls_back_to_defaults + test('확장자 미지정 게시판의 사용자 글쓰기 accept 는 기본 확장자로 폴백된다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await page.goto('/board/free/write'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const accept = await readAcceptAttribute(page); + + expect(accept).not.toBe('.'); + expect(accept).toBe(DEFAULT_ACCEPT); + }); + + // @scenario allowed_extensions=empty, surface=user_write + // @effects empty_extensions_file_selection_succeeds + test('확장자 미지정 게시판에서 파일을 선택하면 거부되지 않는다', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await page.goto('/board/free/write'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + await page.setInputFiles('input[type="file"]', { + name: 'probe.png', + mimeType: 'image/png', + buffer: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ), + }); + + // 확장자 게이트에 걸리면 "허용되지 않는 파일 형식입니다. (허용: .)" 가 노출된다 + await expect(page.getByText(/허용되지 않는 파일 형식/)).toHaveCount(0, { timeout: 10_000 }); + await expect(page.getByText(/1\s*\/\s*\d+개 첨부됨|첨부됨/)).toBeVisible({ timeout: 10_000 }); + }); + + // @scenario allowed_extensions=specified, surface=user_write + // @effects specified_extensions_accept_reflects_list + test('확장자를 지정한 게시판은 accept 에 그 목록이 반영된다 (회귀 방지)', async ({ + page, + boardManageToken, + }) => { + await authenticatePage(page, boardManageToken); + await page.goto('/board/gallery/write'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + + const accept = await readAcceptAttribute(page); + + expect(accept).toContain('.jpg'); + expect(accept).not.toBe('.'); + expect(accept).not.toBe(DEFAULT_ACCEPT); + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-list-context-round-trip.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-list-context-round-trip.spec.ts new file mode 100644 index 00000000..e22a6a67 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-list-context-round-trip.spec.ts @@ -0,0 +1,156 @@ +/** + * 목록 컨텍스트 왕복 — 이전글/다음글 leg 의 URL 목록 상태 보존 (공개 이슈 #75). + * + * 결함: 목록에서 상세로 들어갈 때는 `mergeQuery: true` 로 page/search/category 가 상세 URL 에 + * 실렸지만, 상세의 이전글/다음글 버튼은 규약에서 누락돼 `del` 하나만 남기고 나머지를 전부 + * 떨궜다. 그 뒤 '목록' 버튼은 복원할 상태가 없어 1페이지로 되돌아갔다. + * + * 이 spec 은 시드 의존을 최소화한다 — 샘플 시드의 자유게시판(`free`, 28건/3페이지)만 있으면 + * 돌아간다. 별도 전용 게시판(navtest) 시드를 요구하는 board-post-navigation.spec.ts 와 달리 + * skip 없이 실행된다. + * + * 단위/렌더링: + * - templates/_bundled/sirsoft-basic/src/__tests__/layouts/board-post-navigation-list-return.test.tsx + * 가 실 `_navigation.json` 의 액션 params 로 mergeQuery 왕복을 고정 + * - 저장소 전역 정적 검사가 목록 클러스터 내 navigate 의 mergeQuery 누락을 재발 차단 + * 이 spec 은 브라우저 수준(실제 URL + 목록 API 요청 파라미터)을 담당한다. + * + * @scenario board-list-context-round-trip + * @axes leg=enter leg=back leg=prev leg=next + * @effects navigation_preserves_list_query, + * list_return_preserves_query + */ +import { test, expect } from '@playwright/test'; + +const BOARD_SLUG = 'free'; +const LIST_URL = `/board/${BOARD_SLUG}`; + +/** + * 현재 URL 의 쿼리 파라미터를 돌려줍니다. + * + * @param url 페이지 URL + * @returns 쿼리 파라미터 + */ +function queryOf(url: string): URLSearchParams { + return new URL(url).searchParams; +} + +/** + * 목록의 게시글 행 로케이터. + * + * basic 형 목록의 행은 `` 가 아니라 navigate 액션이 달린 Button 이며, 접근성 이름이 + * "번호 제목 … 작성일 조회수" 형태다. 페이지네이션 버튼("페이지 1")과 구분하기 위해 + * 번호로 시작하는 이름만 고른다. + * + * @param page Playwright 페이지 + * @returns 행 로케이터 + */ +function postRows(page: import('@playwright/test').Page) { + return page.getByRole('button', { name: /^\d+\s+\S/ }); +} + +/** + * 목록 2페이지로 진입해 첫 글 상세까지 들어갑니다. + * + * @param page Playwright 페이지 + * @returns 진입한 상세 URL + */ +async function enterDetailFromPage2(page: import('@playwright/test').Page): Promise { + await page.goto(`${LIST_URL}?page=2`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + await postRows(page).first().click(); + await page.waitForURL(new RegExp(`/board/${BOARD_SLUG}/\\d+`), { timeout: 30_000 }); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + return page.url(); +} + +test.describe('목록 컨텍스트 왕복 — 이전글/다음글 (#75)', () => { + // @scenario leg=enter + // @effects list_return_preserves_query + test('목록 2페이지에서 상세로 들어가면 page 가 상세 URL 에 실린다', async ({ page }) => { + const detailUrl = await enterDetailFromPage2(page); + + expect(detailUrl).toContain(`/board/${BOARD_SLUG}/`); + expect(queryOf(detailUrl).get('page')).toBe('2'); + }); + + // @scenario leg=prev + // @effects navigation_preserves_list_query + test('이전글로 이동해도 page 가 유지된다', async ({ page }) => { + await enterDetailFromPage2(page); + + const prev = page.getByRole('button', { name: /(이전글|Previous)(?!\s*page)/i }).first(); + await prev.click(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + expect(queryOf(page.url()).get('page')).toBe('2'); + }); + + // @scenario leg=next + // @effects navigation_preserves_list_query + test('다음글로 이동해도 page 가 유지된다', async ({ page }) => { + await enterDetailFromPage2(page); + + const next = page.getByRole('button', { name: /(다음글|Next)(?!\s*page)/i }).first(); + await next.click(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + expect(queryOf(page.url()).get('page')).toBe('2'); + }); + + // @scenario leg=prev, leg=back + // @effects navigation_preserves_list_query, list_return_preserves_query + test('이전글 3회 연속 후 목록으로 돌아가도 2페이지가 복원된다', async ({ page }) => { + await enterDetailFromPage2(page); + + for (let i = 0; i < 3; i++) { + const prev = page.getByRole('button', { name: /(이전글|Previous)(?!\s*page)/i }).first(); + if (!(await prev.isVisible().catch(() => false))) break; + await prev.click(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + expect(queryOf(page.url()).get('page'), `${i + 1}회차 이전글 이동에서 page 소실`).toBe('2'); + } + + // 목록 복귀 — 목록 API 가 실제로 2페이지를 요청해야 한다. + // 직전 이동 직후에는 상세가 다시 렌더되며 버튼이 교체되므로, 버튼이 안정된 뒤 클릭한다. + const listButton = page.getByRole('button', { name: /(목록|Back to List)$/i }).first(); + await expect(listButton).toBeVisible({ timeout: 30_000 }); + + const [listRequest] = await Promise.all([ + page.waitForRequest( + (req) => req.url().includes('/posts') && req.url().includes('page=2'), + { timeout: 30_000 } + ), + listButton.click(), + ]); + + expect(listRequest.url()).toContain('page=2'); + await page.waitForURL(new RegExp(`/board/${BOARD_SLUG}\\?`), { timeout: 30_000 }); + expect(queryOf(page.url()).get('page')).toBe('2'); + }); + + // @scenario leg=prev + // @effects navigation_preserves_list_query + test('검색어가 걸린 상태에서도 이전글 이동이 검색어를 유지한다', async ({ page }) => { + await page.goto(`${LIST_URL}?page=1&search=맛집`); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + + const row = postRows(page).first(); + if (!(await row.isVisible().catch(() => false))) { + test.skip(true, '검색 결과가 없어 이 축을 실행할 수 없습니다.'); + return; + } + await row.click(); + await page.waitForURL(new RegExp(`/board/${BOARD_SLUG}/\\d+`), { timeout: 30_000 }); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + expect(queryOf(page.url()).get('search')).toBe('맛집'); + + const prev = page.getByRole('button', { name: /(이전글|Previous)(?!\s*page)/i }).first(); + if (await prev.isVisible().catch(() => false)) { + await prev.click(); + await page.waitForLoadState('networkidle', { timeout: 30_000 }); + expect(queryOf(page.url()).get('search')).toBe('맛집'); + } + }); +}); diff --git a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentRepositoryTest.php b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentRepositoryTest.php index e6ce5b96..617df0a6 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentRepositoryTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentRepositoryTest.php @@ -6,6 +6,7 @@ namespace Modules\Sirsoft\Board\Tests\Unit; require_once __DIR__.'/../ModuleTestCase.php'; use App\Models\User; +use Illuminate\Database\Eloquent\Collection; use Modules\Sirsoft\Board\Models\Attachment; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Repositories\AttachmentRepository; @@ -222,7 +223,7 @@ class AttachmentRepositoryTest extends ModuleTestCase public function test_get_by_temp_key_returns_temp_attachments(): void { // Given: 고유 temp_key로 임시 첨부파일 2개 생성 (테스트 격리) - $tempKey = 'get-temp-test-' . uniqid(); + $tempKey = 'get-temp-test-'.uniqid(); $this->createTempAttachment([ 'temp_key' => $tempKey, 'original_filename' => 'temp1.pdf', @@ -273,4 +274,21 @@ class AttachmentRepositoryTest extends ModuleTestCase // Then: 0 반환 $this->assertEquals(0, $maxOrder); } + + /** + * 빈 입력 분기도 선언된 Eloquent 컬렉션이어야 한다. + * + * `findByIds()` 는 진짜 모델 컬렉션을 반환하므로 Eloquent 선언이 정당한데, 빈 입력 + * 분기만 `collect()`(Support)를 돌려주면 분기별 타입이 갈려 호출 즉시 TypeError 다. + */ + #[Test] + public function test_find_by_ids_returns_eloquent_collection_for_empty_input(): void + { + // When: 빈 ID 배열로 조회 + $result = $this->repository->findByIds('attach-repo-test', []); + + // Then: 선언과 동일한 Eloquent 컬렉션 + $this->assertInstanceOf(Collection::class, $result); + $this->assertTrue($result->isEmpty()); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php index dedbf652..89d4fa12 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentServiceTest.php @@ -8,10 +8,12 @@ require_once __DIR__.'/../ModuleTestCase.php'; use App\Contracts\Extension\StorageInterface; use App\Extension\HookManager; use App\Models\User; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Auth; use Mockery; +use Mockery\MockInterface; use Modules\Sirsoft\Board\Models\Attachment; use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Repositories\Contracts\AttachmentRepositoryInterface; @@ -19,6 +21,7 @@ use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; use Modules\Sirsoft\Board\Services\AttachmentService; use Modules\Sirsoft\Board\Tests\ModuleTestCase; use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * AttachmentService 단위 테스트 @@ -27,16 +30,15 @@ use PHPUnit\Framework\Attributes\Test; */ class AttachmentServiceTest extends ModuleTestCase { - private AttachmentService $service; - /** @var \Mockery\MockInterface&AttachmentRepositoryInterface */ + /** @var MockInterface&AttachmentRepositoryInterface */ private $repository; - /** @var \Mockery\MockInterface&BoardRepositoryInterface */ + /** @var MockInterface&BoardRepositoryInterface */ private $boardRepository; - /** @var \Mockery\MockInterface&StorageInterface */ + /** @var MockInterface&StorageInterface */ private $storage; private User $user; @@ -522,13 +524,24 @@ class AttachmentServiceTest extends ModuleTestCase $tempKey = 'temp-uuid-123'; $postId = 5; - // Service 는 훅 발화를 위해 getByTempKey → linkTempAttachments → getById 순으로 호출 + // Service 는 훅 발화를 위해 getByTempKey → linkTempAttachments → findById 순으로 호출한다. // Repository 시그니처: Eloquent\Collection 반환 필수 (Support\Collection 불일치) + // + // 후보 컬렉션이 비어 있으면 재조회 루프가 0회 실행되어 재조회 메서드 이름이 틀려도 + // 통과한다(실제로 없는 `getById()` 를 호출하고 있었는데 그 상태로 green 이었다). + // 따라서 첨부 1건이 든 컬렉션으로 루프를 반드시 태운다. + $tempAttachment = new Attachment(['temp_key' => $tempKey]); + $tempAttachment->id = 11; + + $linkedAttachment = new Attachment; + $linkedAttachment->id = 11; + $linkedAttachment->post_id = $postId; + $this->repository ->shouldReceive('getByTempKey') ->once() ->with($slug, $tempKey) - ->andReturn(new \Illuminate\Database\Eloquent\Collection()); + ->andReturn(new Collection([$tempAttachment])); $this->repository ->shouldReceive('linkTempAttachments') @@ -536,6 +549,12 @@ class AttachmentServiceTest extends ModuleTestCase ->with($slug, $tempKey, $postId) ->andReturn(3); + $this->repository + ->shouldReceive('findById') + ->once() + ->with($slug, 11) + ->andReturn($linkedAttachment); + // Act $result = $this->service->linkTempAttachments($slug, $tempKey, $postId); @@ -613,7 +632,7 @@ class AttachmentServiceTest extends ModuleTestCase ->with('notice', 1) ->andReturn($attachment); - $expectedResponse = new \Symfony\Component\HttpFoundation\StreamedResponse; + $expectedResponse = new StreamedResponse; $this->storage->shouldReceive('response') ->once() @@ -632,7 +651,7 @@ class AttachmentServiceTest extends ModuleTestCase $result = $this->service->download('notice', 1); // Assert - $this->assertInstanceOf(\Symfony\Component\HttpFoundation\StreamedResponse::class, $result); + $this->assertInstanceOf(StreamedResponse::class, $result); } #[Test] @@ -667,7 +686,7 @@ class AttachmentServiceTest extends ModuleTestCase ->with('notice', 1) ->andReturn($attachment); - $expectedResponse = new \Symfony\Component\HttpFoundation\StreamedResponse; + $expectedResponse = new StreamedResponse; $this->storage->shouldReceive('response') ->once() @@ -688,7 +707,7 @@ class AttachmentServiceTest extends ModuleTestCase $result = $this->service->download('notice', 1); // Assert - $this->assertInstanceOf(\Symfony\Component\HttpFoundation\StreamedResponse::class, $result); + $this->assertInstanceOf(StreamedResponse::class, $result); } #[Test] @@ -722,7 +741,7 @@ class AttachmentServiceTest extends ModuleTestCase $this->storage->shouldReceive('response') ->once() - ->andReturn(new \Symfony\Component\HttpFoundation\StreamedResponse); + ->andReturn(new StreamedResponse); // Act (user 컨텍스트) $this->service->download('notice', 1, context: 'user'); @@ -763,7 +782,7 @@ class AttachmentServiceTest extends ModuleTestCase $this->storage->shouldReceive('response') ->once() - ->andReturn(new \Symfony\Component\HttpFoundation\StreamedResponse); + ->andReturn(new StreamedResponse); // Act (admin 컨텍스트 기본값) $this->service->download('notice', 1); diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Benchmark/ListIndexCoverageTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Benchmark/ListIndexCoverageTest.php new file mode 100644 index 00000000..8f25ce8f --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Benchmark/ListIndexCoverageTest.php @@ -0,0 +1,30 @@ +assertListIndexCoverageForExtension(new Module); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/Sample/NotificationLogSeederTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/Sample/NotificationLogSeederTest.php index 3a809dea..60265396 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/Sample/NotificationLogSeederTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/Sample/NotificationLogSeederTest.php @@ -20,6 +20,13 @@ use Modules\Sirsoft\Board\Tests\ModuleTestCase; */ class NotificationLogSeederTest extends ModuleTestCase { + /** + * 이 테스트가 커밋한 사용자 ID (tearDown 에서 정리) + * + * @var array + */ + private array $seededUserIds = []; + private function bootstrap(): void { // 테스트 격리: 다른 테스트 클래스가 트랜잭션 외부에서 남긴 잔여 데이터 정리. @@ -31,10 +38,19 @@ class NotificationLogSeederTest extends ModuleTestCase $this->syncBoardNotificationDefinitions(); $userRole = Role::query()->where('identifier', 'user')->firstOrFail(); - User::factory() + + // 이메일은 팩토리 기본값(fake()->unique()->safeEmail()) 대신 결정적 고유값을 쓴다. + // faker 의 unique() 는 생성기 수명 안에서만 유일하고 safeEmail() 의 도메인 풀이 좁아, + // 트랜잭션 없이 커밋된 사용자가 누적되면 g7_users.email 유니크 충돌이 난다. + $suffix = uniqid('', true); + $users = User::factory() ->count(15) - ->create() - ->each(fn (User $u) => $u->roles()->attach($userRole->id, ['assigned_at' => now()])); + ->sequence(fn ($sequence) => ['email' => "board-notilog-{$sequence->index}-{$suffix}@example.test"]) + ->create(); + + $this->seededUserIds = array_merge($this->seededUserIds, $users->pluck('id')->all()); + + $users->each(fn (User $u) => $u->roles()->attach($userRole->id, ['assigned_at' => now()])); } public function test_seeder_creates_default_count(): void @@ -95,4 +111,30 @@ class NotificationLogSeederTest extends ModuleTestCase } } } + + /** + * 테스트 정리 + * + * 이 클래스는 트랜잭션 롤백 없이(ModuleTestCase 기본) 시더로 알림 로그를 커밋한다. + * 정리하지 않으면 이후 실행되는 다른 테스트 클래스가 이 잔여 행을 함께 세어 + * 건수 단언이 깨진다 (예: NotificationLogServiceTest 의 채널 필터 건수). + * + * 알림 정의(NotificationDefinition)는 지우지 않는다 — 트랜잭션을 쓰지 않는 다른 테스트 + * 클래스들이 시드된 정의를 공유 전제로 삼고 있어, 함께 지우면 그쪽이 깨진다. + * + * bootstrap() 이 만든 사용자도 같은 이유로 커밋되어 남으므로 함께 정리한다. 정리하지 + * 않으면 스위트를 돌릴수록 g7_users 가 누적되어 다른 테스트의 사용자 건수 단언과 + * 이메일 유니크 제약을 함께 무너뜨린다. + */ + protected function tearDown(): void + { + NotificationLog::query()->delete(); + + if ($this->seededUserIds !== []) { + User::query()->whereIn('id', $this->seededUserIds)->delete(); + $this->seededUserIds = []; + } + + parent::tearDown(); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php index a4d2f2e7..35e3724c 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php @@ -2,7 +2,9 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Listeners; +use App\Enums\TotalRelation; use App\Models\User; +use App\Support\Query\BoundedPage; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Gate; use Modules\Sirsoft\Board\Listeners\SearchPostsListener; @@ -15,6 +17,28 @@ use Tests\TestCase; */ class SearchPostsListenerTest extends TestCase { + /** + * 검색 결과 페이지(BoundedPage)를 만듭니다. + * + * 저장소가 반환하는 계약과 같은 형태를 테스트에서도 그대로 씁니다. + * + * @param Collection $items 페이지 항목 + * @param int $total 총 건수 + * @return BoundedPage 페이지 결과 + */ + private function boundedPage(Collection $items, int $total): BoundedPage + { + return new BoundedPage( + items: $items, + total: $total, + perPage: 5, + currentPage: 1, + totalRelation: TotalRelation::Exact, + resultCap: 10000, + hasMorePages: false, + ); + } + private SearchPostsListener $listener; private PostService $postService; @@ -32,13 +56,13 @@ class SearchPostsListenerTest extends TestCase /** * getSubscribedHooks()가 올바른 훅 목록을 반환하는지 확인 */ - public function test_getSubscribedHooks_returns_correct_hooks(): void + public function test_get_subscribed_hooks_returns_correct_hooks(): void { $hooks = SearchPostsListener::getSubscribedHooks(); $this->assertArrayHasKey('core.search.results', $hooks); $this->assertArrayHasKey('core.search.build_response', $hooks); - $this->assertArrayHasKey('core.search.validation_rules', $hooks); + $this->assertArrayHasKey('core.search.index_validation_rules', $hooks); foreach ($hooks as $hook) { $this->assertEquals('filter', $hook['type']); @@ -48,7 +72,7 @@ class SearchPostsListenerTest extends TestCase /** * 권한 있는 게시판만 검색 결과에 포함되는지 확인 */ - public function test_searchPosts_filters_boards_by_permission(): void + public function test_search_posts_filters_boards_by_permission(): void { $user = User::factory()->make(['id' => 1001]); @@ -78,25 +102,22 @@ class SearchPostsListenerTest extends TestCase $this->postService ->method('searchAcrossBoards') ->with([1], '테스트', $this->anything(), $this->anything(), $this->anything()) - ->willReturn([ - 'total' => 1, - 'items' => new Collection([ - $this->createPostStub(1, 'notice', '공지사항'), - ]), - ]); + ->willReturn($this->boundedPage(new Collection([ + $this->createPostStub(1, 'notice', '공지사항'), + ]), 1)); $this->boardService ->method('getActiveBoardsListForFilter') ->willReturn([]); $context = [ - 'type' => 'all', - 'q' => '테스트', - 'sort' => 'relevance', - 'page' => 1, + 'type' => 'all', + 'q' => '테스트', + 'sort' => 'relevance', + 'page' => 1, 'per_page' => 10, - 'user' => $user, - 'request' => null, + 'user' => $user, + 'request' => null, ]; $result = $this->listener->searchPosts([], $context); @@ -108,7 +129,7 @@ class SearchPostsListenerTest extends TestCase /** * 모든 게시판 권한이 없을 때 빈 결과를 반환하는지 확인 */ - public function test_searchPosts_returns_empty_when_all_boards_denied(): void + public function test_search_posts_returns_empty_when_all_boards_denied(): void { $user = User::factory()->make(); @@ -123,9 +144,9 @@ class SearchPostsListenerTest extends TestCase $results = []; $context = [ - 'type' => 'all', - 'q' => '테스트', - 'user' => $user, + 'type' => 'all', + 'q' => '테스트', + 'user' => $user, 'request' => null, ]; @@ -141,7 +162,7 @@ class SearchPostsListenerTest extends TestCase /** * 빈 검색어일 때 스킵하는지 확인 */ - public function test_searchPosts_skips_when_keyword_is_empty(): void + public function test_search_posts_skips_when_keyword_is_empty(): void { $results = []; $context = ['type' => 'all', 'q' => '']; @@ -154,7 +175,7 @@ class SearchPostsListenerTest extends TestCase /** * formatPostResult()가 created_at(Y-m-d H:i:s 포맷)과 created_at_formatted(표시용) 필드를 반환하는지 확인 */ - public function test_formatPostResult_includes_created_at_and_created_at_formatted(): void + public function test_format_post_result_includes_created_at_and_created_at_formatted(): void { $user = User::factory()->make(['id' => 9999]); @@ -170,23 +191,20 @@ class SearchPostsListenerTest extends TestCase $this->postService ->method('searchAcrossBoards') - ->willReturn([ - 'total' => 1, - 'items' => new Collection([ - $this->createPostStub(1, 'notice', '공지사항'), - ]), - ]); + ->willReturn($this->boundedPage(new Collection([ + $this->createPostStub(1, 'notice', '공지사항'), + ]), 1)); Gate::before(fn ($u) => $u->id === 9999 ? true : null); $context = [ - 'type' => 'all', - 'q' => '테스트', - 'sort' => 'relevance', - 'page' => 1, + 'type' => 'all', + 'q' => '테스트', + 'sort' => 'relevance', + 'page' => 1, 'per_page' => 10, - 'user' => $user, - 'request' => null, + 'user' => $user, + 'request' => null, ]; $result = $this->listener->searchPosts([], $context); @@ -209,9 +227,9 @@ class SearchPostsListenerTest extends TestCase /** * id를 포함하는 Board 스텁 생성 * - * @param int $id 게시판 ID - * @param string $slug 게시판 슬러그 - * @param string $name 게시판 이름 + * @param int $id 게시판 ID + * @param string $slug 게시판 슬러그 + * @param string $name 게시판 이름 * @return object */ private function createBoardStub(int $id, string $slug, string $name): object @@ -241,9 +259,9 @@ class SearchPostsListenerTest extends TestCase /** * board relation이 포함된 Post 스텁 생성 * - * @param int $id 게시글 ID - * @param string $boardSlug 게시판 슬러그 - * @param string $boardName 게시판 이름 + * @param int $id 게시글 ID + * @param string $boardSlug 게시판 슬러그 + * @param string $boardName 게시판 이름 * @return object */ private function createPostStub(int $id, string $boardSlug, string $boardName): object @@ -267,16 +285,16 @@ class SearchPostsListenerTest extends TestCase }; return (object) [ - 'id' => $id, - 'title' => '테스트 게시글', - 'content' => '테스트 내용', - 'content_mode' => 'text', - 'author_name' => '작성자', - 'created_at' => now(), - 'view_count' => 5, + 'id' => $id, + 'title' => '테스트 게시글', + 'content' => '테스트 내용', + 'content_mode' => 'text', + 'author_name' => '작성자', + 'created_at' => now(), + 'view_count' => 5, 'comments_count' => 2, - 'user' => null, - 'board' => $boardStub, + 'user' => null, + 'board' => $boardStub, ]; } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php index 5f3cd456..1da4b733 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php @@ -2,8 +2,12 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Listeners; +use App\Contracts\Extension\CacheInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; use Mockery; use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener; @@ -38,6 +42,10 @@ class SeoBoardCacheListenerTest extends ModuleTestCase $this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class); $this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock); + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoBoardCacheListener; } @@ -77,6 +85,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase * 이 훅 미구독이 "게시판 정보 수정 후 SEO 메타 캐시 미무효화" 버그의 원인이었습니다. * * @scenario name_changed=no, description_changed=no + * * @effects board_after_update_hook_subscribed */ public function test_subscribes_board_after_update_hook(): void @@ -98,6 +107,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase * sitemap 캐시를 무효화해야 합니다. (게시글 상세는 board name 미노출이라 제외) * * @scenario name_changed=yes, description_changed=no + * * @effects board_update_invalidates_board_list_url, board_update_invalidates_boards_index_url, board_update_invalidates_home_and_search_layouts, board_update_invalidates_sitemap_cache */ public function test_on_board_update_invalidates_board_seo_caches(): void @@ -390,9 +400,9 @@ class SeoBoardCacheListenerTest extends ModuleTestCase }); // sitemap 캐시(seo.sitemap) forget 검증 — CacheInterface - $cacheInterfaceMock = Mockery::mock(\App\Contracts\Extension\CacheInterface::class); + $cacheInterfaceMock = Mockery::mock(CacheInterface::class); $cacheInterfaceMock->shouldReceive('forget')->once()->with('seo.sitemap'); - $this->app->instance(\App\Contracts\Extension\CacheInterface::class, $cacheInterfaceMock); + $this->app->instance(CacheInterface::class, $cacheInterfaceMock); Log::shouldReceive('debug')->atLeast()->once(); diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Models/NewDisplayHoursCastTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Models/NewDisplayHoursCastTest.php new file mode 100644 index 00000000..79896ffc --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Models/NewDisplayHoursCastTest.php @@ -0,0 +1,82 @@ + '48']); + + $this->assertSame(48, $board->new_display_hours); + } + + #[Test] + public function new_display_hours_is_integer_after_create_without_refresh(): void + { + $board = Board::create([ + 'slug' => 'cast-new-display-hours', + 'name' => ['ko' => '캐스트 테스트', 'en' => 'Cast Test'], + 'is_active' => true, + 'new_display_hours' => '48', + ]); + + $this->assertSame(48, $board->new_display_hours); + } + + #[Test] + public function new_display_hours_is_cast_to_integer_when_loaded_from_db(): void + { + DB::table('boards')->where('id', $this->board->id)->update(['new_display_hours' => 12]); + + $board = Board::findOrFail($this->board->id); + + $this->assertSame(12, $board->new_display_hours); + } + + #[Test] + public function post_is_new_does_not_crash_when_board_attribute_is_string(): void + { + $postId = $this->createTestPost(); + $post = Post::with('board')->findOrFail($postId); + + // 문자열 유입 재현 (캐스트 도입 후 정수로 해석되어야 함 — 비회귀 가드) + $post->board->new_display_hours = '24'; + + $this->assertTrue($post->isNew()); + } + + #[Test] + public function post_is_new_returns_false_for_old_post(): void + { + $postId = $this->createTestPost(); + DB::table('board_posts')->where('id', $postId)->update(['created_at' => now()->subHours(5)]); + + $post = Post::with('board')->findOrFail($postId); + $post->board->new_display_hours = '1'; + + $this->assertFalse($post->isNew()); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php new file mode 100644 index 00000000..609ea855 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php @@ -0,0 +1,140 @@ + $slug, + 'name' => ['ko' => '레포지토리 사이트맵 테스트', 'en' => 'Repository Sitemap Test'], + 'is_active' => true, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + $this->repository = $this->app->make(BoardRepositoryInterface::class); + } + + /** + * 테스트 정리 - 이 클래스가 생성한 게시판을 제거합니다. + * + * BoardTestCase 는 트랜잭션 롤백 대신 수동 삭제 전략을 쓰며 게시판 자체는 + * 지우지 않는다. 추가 게시판을 만든 쪽이 정리하지 않으면 활성 게시판이 + * 다른 테스트 클래스로 누출되어 게시판 목록 단언을 깨뜨린다. + */ + protected function tearDown(): void + { + Board::where('slug', 'like', 'repo-sitemap-%')->forceDelete(); + + parent::tearDown(); + } + + /** + * streamActiveForSitemap: 전체 적재가 아닌 순회자를 반환한다 + */ + public function test_stream_active_for_sitemap_returns_lazy_collection(): void + { + $this->assertInstanceOf( + LazyCollection::class, + $this->repository->streamActiveForSitemap(), + 'lazyById 순회자여야 전체 적재를 피할 수 있습니다.' + ); + } + + /** + * streamActiveForSitemap: sitemap 에 필요한 컬럼만 적재한다 (회귀) + * + * 범용 getActiveBoards() 는 전컬럼을 적재하므로 sitemap 경로가 그것을 재사용하면 + * 메모리가 후퇴한다. select 가 유지되는지 속성 키로 검증한다. + */ + public function test_stream_active_for_sitemap_selects_only_sitemap_columns(): void + { + $board = $this->repository->streamActiveForSitemap()->first(); + + $this->assertNotNull($board); + $this->assertSame( + ['id', 'slug', 'updated_at'], + array_keys($board->getAttributes()), + 'sitemap 조회는 id/slug/updated_at 만 적재해야 합니다.' + ); + } + + /** + * streamActiveForSitemap: 비활성 게시판은 반환하지 않는다 + */ + public function test_stream_active_for_sitemap_excludes_inactive_boards(): void + { + $this->updateBoardSettings(['is_active' => false]); + + $slugs = collect($this->repository->streamActiveForSitemap()) + ->pluck('slug') + ->all(); + + $this->assertNotContains($this->board->slug, $slugs); + } + + /** + * streamActiveForSitemap: 활성 게시판이 포함된다 + */ + public function test_stream_active_for_sitemap_includes_active_boards(): void + { + $slugs = collect($this->repository->streamActiveForSitemap()) + ->pluck('slug') + ->all(); + + $this->assertContains($this->board->slug, $slugs); + } + + /** + * streamActiveForSitemap: 청크 크기보다 많은 게시판도 전부 순회한다 + */ + public function test_stream_active_for_sitemap_traverses_beyond_chunk_size(): void + { + foreach (range(1, 3) as $i) { + Board::create([ + 'slug' => "repo-sitemap-extra-{$i}", + 'name' => ['ko' => "추가 게시판 {$i}", 'en' => "Extra Board {$i}"], + 'is_active' => true, + ]); + } + + // 청크 크기 1 → 여러 번 쿼리해도 누락 없이 전부 순회해야 한다 + $slugs = collect($this->repository->streamActiveForSitemap(1)) + ->pluck('slug') + ->all(); + + foreach (range(1, 3) as $i) { + $this->assertContains("repo-sitemap-extra-{$i}", $slugs); + } + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/CommentListDeferredJoinTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/CommentListDeferredJoinTest.php new file mode 100644 index 00000000..1bbcc800 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/CommentListDeferredJoinTest.php @@ -0,0 +1,196 @@ +repository = app(CommentRepository::class); + } + + /** 쿼리 로그 수집을 시작합니다. */ + private function startCapture(): void + { + $this->queries = []; + + DB::listen(function ($query) { + $this->queries[] = $query->sql; + }); + } + + /** + * 수집된 쿼리 중 조건에 맞는 것만 돌려줍니다. + * + * @param string $needle 포함 문자열 + * @return array 일치 쿼리 목록 + */ + private function queriesContaining(string $needle): array + { + return array_values(array_filter($this->queries, fn (string $sql) => str_contains($sql, $needle))); + } + + /** + * 이 사용자의 댓글을 생성합니다. + * + * @param int $postId 대상 게시글 ID + * @param array $attributes 추가 속성 + * @return int 생성된 댓글 ID + */ + private function createUserComment(int $postId, array $attributes = []): int + { + return $this->createTestComment($postId, array_merge(['user_id' => $this->userId], $attributes)); + } + + public function test_soft_deleted_comments_are_excluded(): void + { + $postId = $this->createTestPost(); + $this->createUserComment($postId, ['content' => '살아있는 댓글']); + $this->createUserComment($postId, ['content' => '삭제된 댓글', 'deleted_at' => now()]); + + $result = $this->repository->getUserComments($this->userId, [], 10); + $contents = collect($result->items())->pluck('content')->all(); + + $this->assertContains('살아있는 댓글', $contents); + $this->assertNotContains('삭제된 댓글', $contents); + $this->assertSame(1, $result->total()); + } + + public function test_comments_of_soft_deleted_posts_are_excluded(): void + { + $livePostId = $this->createTestPost(['title' => '살아있는 글']); + $deletedPostId = $this->createTestPost(['title' => '삭제된 글', 'deleted_at' => now()]); + + $this->createUserComment($livePostId, ['content' => '보여야 하는 댓글']); + $this->createUserComment($deletedPostId, ['content' => '숨겨져야 하는 댓글']); + + $result = $this->repository->getUserComments($this->userId, [], 10); + $contents = collect($result->items())->pluck('content')->all(); + + $this->assertSame(['보여야 하는 댓글'], $contents); + $this->assertSame(1, $result->total(), '삭제된 글의 댓글은 총 건수에서도 빠져야 한다'); + } + + public function test_other_users_comments_are_not_included(): void + { + $postId = $this->createTestPost(); + $this->createUserComment($postId, ['content' => '내 댓글']); + $this->createTestComment($postId, ['user_id' => $this->userId + 1, 'content' => '남의 댓글']); + + $result = $this->repository->getUserComments($this->userId, [], 10); + + $this->assertSame(['내 댓글'], collect($result->items())->pluck('content')->all()); + } + + public function test_pages_do_not_overlap_and_last_page_is_reported(): void + { + $postId = $this->createTestPost(); + + for ($i = 1; $i <= 15; $i++) { + $this->createUserComment($postId, [ + 'content' => "댓글 {$i}", + 'created_at' => now()->subMinutes(20 - $i), + ]); + } + + // 저장소는 페이지 번호를 인자로 받지 않고 Paginator 가 요청에서 해석한다. + // 단위 테스트에는 요청 컨텍스트가 없으므로 리졸버로 현재 페이지를 지정한다. + Paginator::currentPageResolver(fn () => 1); + $page1 = $this->repository->getUserComments($this->userId, [], 10); + + Paginator::currentPageResolver(fn () => 2); + $page2 = $this->repository->getUserComments($this->userId, [], 10); + + Paginator::currentPageResolver(fn () => 1); + + $this->assertSame(15, $page1->total()); + $this->assertSame(2, $page1->lastPage()); + $this->assertCount(10, $page1->items()); + $this->assertCount(5, $page2->items(), '마지막 페이지는 남은 5건만 있어야 한다'); + + $firstIds = collect($page1->items())->pluck('id')->all(); + $secondIds = collect($page2->items())->pluck('id')->all(); + + $this->assertEmpty(array_intersect($firstIds, $secondIds), '페이지 간 중복 행이 없어야 한다'); + $this->assertCount(15, array_unique(array_merge($firstIds, $secondIds)), '누락된 행이 없어야 한다'); + } + + public function test_search_filter_counts_only_matching_rows(): void + { + $postId = $this->createTestPost(); + $this->createUserComment($postId, ['content' => '검색어 포함 댓글 A']); + $this->createUserComment($postId, ['content' => '검색어 포함 댓글 B']); + $this->createUserComment($postId, ['content' => '무관한 댓글']); + + $result = $this->repository->getUserComments($this->userId, ['search' => '검색어'], 10); + + $this->assertSame(2, $result->total(), '검색 조건이 총 건수에 반영되어야 한다'); + $this->assertCount(2, $result->items()); + } + + public function test_cached_total_skips_the_count_query(): void + { + $postId = $this->createTestPost(); + $this->createUserComment($postId, ['content' => '댓글']); + + $this->startCapture(); + $result = $this->repository->getUserComments($this->userId, ['cached_total' => 999], 10); + + $this->assertEmpty($this->queriesContaining('count(*)'), '캐시된 total 이 있으면 COUNT 를 실행하지 않아야 한다'); + $this->assertSame(999, $result->total()); + } + + public function test_offset_scan_does_not_read_comment_content(): void + { + $postId = $this->createTestPost(); + + for ($i = 1; $i <= 25; $i++) { + $this->createUserComment($postId, ['content' => "댓글 본문 {$i}"]); + } + + $this->startCapture(); + $this->repository->getUserComments($this->userId, [], 10); + + $offsetQueries = $this->queriesContaining('offset'); + + $this->assertNotEmpty($offsetQueries, 'inner 는 OFFSET 으로 ID 집합을 구해야 한다'); + + foreach ($offsetQueries as $sql) { + $this->assertStringNotContainsString( + 'content`', + $sql, + 'OFFSET 스캔 구간에서 댓글 본문을 읽으면 안 된다' + ); + } + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostListDeferredJoinTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostListDeferredJoinTest.php new file mode 100644 index 00000000..346c0ef7 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/PostListDeferredJoinTest.php @@ -0,0 +1,292 @@ +repository = app(PostRepository::class); + } + + /** 쿼리 로그 수집을 시작합니다. */ + private function startCapture(): void + { + $this->queries = []; + + DB::listen(function ($query) { + $this->queries[] = $query->sql; + }); + } + + /** + * 수집된 쿼리 중 조건에 맞는 것만 돌려줍니다. + * + * @param string $needle 포함 문자열 + * @return array 일치 쿼리 목록 + */ + private function queriesContaining(string $needle): array + { + return array_values(array_filter($this->queries, fn (string $sql) => str_contains($sql, $needle))); + } + + public function test_offset_scan_does_not_read_content_preview(): void + { + for ($i = 1; $i <= 25; $i++) { + $this->createTestPost(['title' => "게시글 {$i}"]); + } + + $this->startCapture(); + $this->repository->paginate($this->board->slug, ['page' => 2], 10); + + $offsetQueries = $this->queriesContaining('offset'); + + $this->assertNotEmpty($offsetQueries, '페이지 조회에는 OFFSET 쿼리가 있어야 한다'); + + foreach ($offsetQueries as $sql) { + // 본문 앞부분을 잘라 읽는 것도 오버플로 페이지를 그대로 읽으므로 프루닝이 아니다. + // 잘라 읽기는 이번 페이지 분량(outer)에서만 일어나야 한다. + $this->assertStringNotContainsString('SUBSTRING', $sql); + $this->assertStringNotContainsString('content', $sql); + } + } + + public function test_page_one_shows_notices_and_page_two_does_not(): void + { + $this->createTestPost(['title' => '공지사항', 'is_notice' => true]); + + for ($i = 1; $i <= 25; $i++) { + $this->createTestPost(['title' => "일반글 {$i}"]); + } + + $page1 = $this->repository->paginate($this->board->slug, ['page' => 1], 10); + $page2 = $this->repository->paginate($this->board->slug, ['page' => 2], 10); + + $titles1 = collect($page1->items())->pluck('title')->all(); + $titles2 = collect($page2->items())->pluck('title')->all(); + + $this->assertSame('공지사항', $titles1[0], '공지는 1페이지 선두에 있어야 한다'); + $this->assertNotContains('공지사항', $titles2, '공지는 2페이지에 반복되지 않아야 한다'); + } + + public function test_replies_follow_their_parent_in_the_same_page(): void + { + $parentId = $this->createTestPost(['title' => '원글']); + $this->createTestPost(['title' => '답글 1', 'parent_id' => $parentId, 'depth' => 1]); + $this->createTestPost(['title' => '답글 2', 'parent_id' => $parentId, 'depth' => 1]); + + $result = $this->repository->paginate($this->board->slug, [], 10); + $titles = collect($result->items())->pluck('title')->all(); + + $this->assertSame(['원글', '답글 1', '답글 2'], $titles); + } + + public function test_reply_content_preview_is_available_like_parent(): void + { + $parentId = $this->createTestPost(['title' => '원글', 'content' => '원글 본문입니다.']); + $this->createTestPost([ + 'title' => '답글', + 'content' => '답글 본문입니다.', + 'parent_id' => $parentId, + 'depth' => 1, + ]); + + $result = $this->repository->paginate($this->board->slug, [], 10); + $items = collect($result->items()); + + // 원글과 답글은 같은 컬럼 목록으로 조회되므로 스키마가 어긋나면 안 된다 + // (한쪽만 바뀌면 화면에서 조용히 빈 값이 된다) + foreach ($items as $item) { + $this->assertNotNull($item->content_preview_raw ?? null); + } + } + + public function test_pages_do_not_overlap(): void + { + for ($i = 1; $i <= 25; $i++) { + $this->createTestPost(['title' => "게시글 {$i}"]); + } + + $page1 = $this->repository->paginate($this->board->slug, ['page' => 1], 10); + $page2 = $this->repository->paginate($this->board->slug, ['page' => 2], 10); + + $ids1 = collect($page1->items())->pluck('id')->all(); + $ids2 = collect($page2->items())->pluck('id')->all(); + + $this->assertCount(10, $ids1); + $this->assertEmpty(array_intersect($ids1, $ids2)); + } + + public function test_last_page_reports_no_more_pages(): void + { + for ($i = 1; $i <= 15; $i++) { + $this->createTestPost(['title' => "게시글 {$i}"]); + } + + $page1 = $this->repository->paginate($this->board->slug, ['page' => 1], 10); + $page2 = $this->repository->paginate($this->board->slug, ['page' => 2], 10); + + $this->assertTrue($page1->hasMorePages(), '1페이지에서는 다음 페이지가 있어야 한다'); + $this->assertFalse($page2->hasMorePages(), '마지막 페이지에서 다음 버튼이 남으면 안 된다'); + } + + public function test_soft_deleted_posts_are_excluded_by_default(): void + { + $this->createTestPost(['title' => '살아있는 글']); + $this->createTestPost(['title' => '삭제된 글', 'deleted_at' => now()]); + + $result = $this->repository->paginate($this->board->slug, [], 10); + $titles = collect($result->items())->pluck('title')->all(); + + $this->assertContains('살아있는 글', $titles); + $this->assertNotContains('삭제된 글', $titles); + } + + public function test_with_trashed_includes_deleted_posts(): void + { + $this->createTestPost(['title' => '살아있는 글']); + $this->createTestPost(['title' => '삭제된 글', 'deleted_at' => now()]); + + $result = $this->repository->paginate($this->board->slug, [], 10, true); + $titles = collect($result->items())->pluck('title')->all(); + + $this->assertContains('삭제된 글', $titles); + } + + public function test_sort_order_is_applied_and_deterministic(): void + { + // 조회수가 모두 같은 상태 — 2차 정렬(id)이 없으면 페이지 경계가 흔들린다 + for ($i = 1; $i <= 12; $i++) { + $this->createTestPost(['title' => "게시글 {$i}", 'view_count' => 5]); + } + + $result = $this->repository->paginate( + $this->board->slug, + ['order_by' => 'view_count', 'order_direction' => 'desc'], + 5 + ); + + $ids = collect($result->items())->pluck('id')->all(); + $sorted = $ids; + rsort($sorted); + + $this->assertSame($sorted, $ids, '동률 정렬에서도 id 내림차순이 유지되어야 한다'); + } + + /** + * self 스코프 사용자의 목록에 타인 글이 섞이지 않는지 검증합니다. + * + * 권한 스코프는 `PermissionHelper::applyPermissionScope()` 가 쿼리에 붙이는 where 다. + * 지연 조인은 이 쿼리를 clone 해 inner/outer 두 번 실행하므로, 스코프가 한쪽에만 + * 걸리면 목록에 타인 글이 노출되거나(권한 누출) 페이지가 비게 된다. 이 실패는 + * 예외 없이 조용히 일어나므로 결과 집합으로 직접 단언한다. + */ + public function test_self_scoped_list_returns_only_own_rows(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + + for ($i = 1; $i <= 6; $i++) { + $this->createTestPost(['title' => "내 글 {$i}", 'user_id' => $owner->id]); + $this->createTestPost(['title' => "남의 글 {$i}", 'user_id' => $other->id]); + } + + $permission = "sirsoft-board.{$this->board->slug}.admin.posts.read"; + + // owner_key 는 config/board.php 의 권한 정의가 SSoT 다. 여기에 값을 적어 두면 + // 정의가 바뀌어 스코프가 꺼져도 테스트는 계속 통과한다. + $scopeMeta = config('sirsoft-board.board_permission_definitions')['admin.posts.read']['scope'] ?? null; + + $this->assertNotNull($scopeMeta, 'admin.posts.read 에 scope 정의가 없다 — 스코프 필터가 적용되지 않는다'); + + $this->grantScopedPermission($owner, $permission, 'self', $scopeMeta); + + // 권한 메타가 실제로 스코프를 켜는지 확인 — 이게 null 이면 아래 단언이 무의미해진다 + $this->assertSame( + $scopeMeta['owner_key'], + PermissionHelper::getOwnerKey($permission), + "권한 {$permission} 의 owner_key 가 config 정의와 다르다" + ); + $this->actingAs($owner); + + $result = $this->repository->paginate( + $this->board->slug, + ['scope_permission' => $permission], + 5 + ); + + $titles = collect($result->items())->pluck('title')->all(); + $userIds = collect($result->items())->pluck('user_id')->unique()->all(); + + $this->assertNotEmpty($titles, 'self 스코프에서도 본인 글은 조회되어야 한다'); + $this->assertSame([$owner->id], $userIds, 'self 스코프 목록에 타인 글이 섞이면 안 된다'); + + foreach ($titles as $title) { + $this->assertStringNotContainsString('남의 글', $title); + } + } + + /** + * 사용자에게 지정한 스코프로 권한을 부여합니다. + * + * @param User $user 대상 사용자 + * @param string $identifier 권한 식별자 + * @param string $scopeType 스코프 유형 (self/role) + * @param array $scopeMeta config 의 scope 정의 (resource_route_key / owner_key) + */ + private function grantScopedPermission(User $user, string $identifier, string $scopeType, array $scopeMeta): void + { + $permission = Permission::updateOrCreate( + ['identifier' => $identifier], + [ + 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'description' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'extension_type' => 'module', + 'extension_identifier' => 'sirsoft-board', + 'type' => 'admin', + 'resource_route_key' => $scopeMeta['resource_route_key'], + 'owner_key' => $scopeMeta['owner_key'], + ] + ); + + $role = Role::create([ + 'identifier' => 'scoped_test_'.uniqid(), + 'name' => json_encode(['ko' => '스코프 테스트', 'en' => 'Scoped Test']), + 'is_active' => true, + ]); + + $role->permissions()->attach($permission->id, ['scope_type' => $scopeType]); + $user->roles()->attach($role->id, ['assigned_at' => now()]); + + $user->refresh(); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/UserCommentedActivityDeferredJoinTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/UserCommentedActivityDeferredJoinTest.php new file mode 100644 index 00000000..336838ae --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/UserCommentedActivityDeferredJoinTest.php @@ -0,0 +1,167 @@ +app->make(PostRepositoryInterface::class); + } + + /** 쿼리 로그 수집을 시작합니다. */ + private function startCapture(): void + { + $this->queries = []; + + DB::listen(function ($query) { + $this->queries[] = $query->sql; + }); + } + + /** + * 수집된 쿼리 중 조건에 맞는 것만 돌려줍니다. + * + * @param string $needle 포함 문자열 + * @return array 일치 쿼리 목록 + */ + private function queriesContaining(string $needle): array + { + return array_values(array_filter($this->queries, fn (string $sql) => str_contains($sql, $needle))); + } + + public function test_offset_scan_excludes_latest_comment_subquery(): void + { + $user = $this->createUser(); + $other = $this->createUser(); + + for ($i = 1; $i <= 25; $i++) { + $postId = $this->createTestPost(['user_id' => $other->id, 'title' => "글 {$i}"]); + $this->createTestComment($postId, ['user_id' => $user->id, 'content' => "댓글 {$i}"]); + } + + $this->startCapture(); + $this->repository()->getUserActivities($user->id, ['activity_type' => 'commented'], 10); + + $offsetQueries = $this->queriesContaining('offset'); + + $this->assertNotEmpty($offsetQueries, 'inner 는 OFFSET 으로 ID 집합을 구해야 한다'); + + foreach ($offsetQueries as $sql) { + $this->assertStringNotContainsString( + 'as `lc`', + $sql, + 'OFFSET 스캔 구간에 최근 댓글 서브쿼리가 남으면 건너뛸 행마다 상관 서브쿼리가 실행된다' + ); + $this->assertStringNotContainsString( + 'substring', + strtolower($sql), + 'OFFSET 스캔 구간에서 본문을 읽으면 안 된다' + ); + } + } + + public function test_latest_comment_columns_are_available_in_result(): void + { + $user = $this->createUser(); + $other = $this->createUser(); + + $postId = $this->createTestPost(['user_id' => $other->id, 'title' => '대상 글']); + $this->createTestComment($postId, [ + 'user_id' => $user->id, + 'content' => '예전 댓글', + 'created_at' => now()->subDay(), + ]); + $this->createTestComment($postId, [ + 'user_id' => $user->id, + 'content' => '가장 최근 댓글', + 'created_at' => now(), + ]); + + $items = $this->repository() + ->getUserActivities($user->id, ['activity_type' => 'commented'], 10) + ->items(); + + $this->assertCount(1, $items, '같은 글에 댓글을 여러 번 달아도 목록에는 글 1건으로 접혀야 한다'); + $this->assertSame(2, $items[0]['activity_count'], '해당 글에 단 댓글 수가 집계되어야 한다'); + } + + public function test_total_counts_posts_not_comments_when_filter_bypasses_cache(): void + { + $user = $this->createUser(); + $other = $this->createUser(); + + // 첫 글에만 댓글 3건 — 그룹 수(3)와 첫 그룹의 행 수(3)가 우연히 같아지지 않도록 + // 나머지 글은 댓글 1건씩 둔다. + $firstPost = $this->createTestPost(['user_id' => $other->id, 'title' => '검색어 글 A']); + for ($i = 0; $i < 3; $i++) { + $this->createTestComment($firstPost, ['user_id' => $user->id, 'content' => '검색어 댓글']); + } + + foreach (['검색어 글 B', '검색어 글 C', '검색어 글 D', '검색어 글 E'] as $title) { + $postId = $this->createTestPost(['user_id' => $other->id, 'title' => $title]); + $this->createTestComment($postId, ['user_id' => $user->id, 'content' => '검색어 댓글']); + } + + // 검색 필터가 걸리면 서비스가 캐시 total 을 담지 않으므로 trait 이 직접 COUNT 한다. + $paginator = $this->repository()->getUserActivities( + $user->id, + ['activity_type' => 'commented', 'search' => '검색어'], + 10 + ); + + $this->assertSame(5, $paginator->total(), '총 건수는 댓글 수가 아니라 글(그룹) 수여야 한다'); + $this->assertCount(5, $paginator->items()); + } + + public function test_pages_do_not_overlap(): void + { + $user = $this->createUser(); + $other = $this->createUser(); + + for ($i = 1; $i <= 15; $i++) { + $postId = $this->createTestPost([ + 'user_id' => $other->id, + 'title' => "글 {$i}", + 'created_at' => now()->subMinutes(20 - $i), + ]); + $this->createTestComment($postId, ['user_id' => $user->id, 'content' => "댓글 {$i}"]); + } + + $paginator = $this->repository()->getUserActivities($user->id, ['activity_type' => 'commented'], 10); + + $this->assertSame(15, $paginator->total()); + $this->assertSame(2, $paginator->lastPage()); + $this->assertCount(10, $paginator->items()); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php index c974b66f..07bcb451 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php @@ -4,8 +4,11 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Seo; require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Seo\AbstractSitemapContributor; +use App\Seo\Contracts\SitemapContributorInterface; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; +use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Seo\BoardSitemapContributor; use Modules\Sirsoft\Board\Tests\BoardTestCase; @@ -47,7 +50,16 @@ class BoardSitemapContributorTest extends BoardTestCase protected function setUp(): void { parent::setUp(); - $this->contributor = new BoardSitemapContributor; + // Repository 주입이 필요하므로 컨테이너로 해석합니다. + $this->contributor = $this->app->make(BoardSitemapContributor::class); + } + + /** + * SitemapContributorInterface 를 구현한다 + */ + public function test_implements_sitemap_contributor_interface(): void + { + $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); } /** @@ -58,6 +70,55 @@ class BoardSitemapContributorTest extends BoardTestCase $this->assertSame('sirsoft-board', $this->contributor->getIdentifier()); } + /** + * getUrls: 활성 게시판이 없으면 정적 목록 URL만 반환한다 + */ + public function test_get_urls_returns_only_static_url_when_no_active_boards(): void + { + // "활성 게시판이 0개" 전제를 이 테스트가 직접 만든다. + // 자기 게시판만 비활성화하면, 트랜잭션을 쓰지 않는 다른 테스트 클래스가 남긴 + // 게시판이 활성 상태로 남아 있어 전제가 성립하지 않는다. + Board::query()->update(['is_active' => false]); + $this->board->refresh(); + + $urls = $this->contributor->getUrls(); + + $this->assertSame(['/boards'], array_column($urls, 'url')); + } + + /** + * getUrls: 기여자당 URL 안전 상한을 초과하면 게시글 URL이 잘린다 + */ + public function test_get_urls_truncates_at_max_urls_per_contributor(): void + { + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + + // /boards + /board/{slug} 로 이미 2건이므로 게시글은 1건까지만 담긴다. + // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 3); + + $this->assertCount(3, $this->contributor->getUrls()); + } + + /** + * getUrls: 상한이 게시판 루프에도 적용된다 (회귀) + * + * 상한 검사가 게시글 루프에만 있으면, 게시판 URL 만으로 상한을 초과해도 잘리지 않는다. + */ + public function test_get_urls_truncates_within_board_loop(): void + { + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + + // /boards 1건만 담기면 상한 도달 → 게시판/게시글 URL 은 하나도 담기지 않아야 한다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 1); + + $urls = $this->contributor->getUrls(); + + $this->assertCount(1, $urls, '게시판 루프에서도 상한이 지켜져야 합니다.'); + $this->assertSame('/boards', $urls[0]['url']); + } + /** * getUrls: /boards 항목이 반드시 포함된다 */ @@ -231,4 +292,23 @@ class BoardSitemapContributorTest extends BoardTestCase $this->assertContains("/board/{$this->board->slug}", $urlPaths); $this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths); } + + /** + * getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍) + * + * 소비 경로는 getUrls() 가 아니라 getUrlsLazy() 이므로, 이것이 Traversable 로 + * 한 건씩 yield 되어야 대용량 게시글에서 메모리가 유계로 유지된다. + */ + public function test_get_urls_lazy_streams_entries(): void + { + $postId = $this->createTestPost(['status' => 'published', 'is_secret' => false]); + + $this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor); + + $lazy = $this->contributor->getUrlsLazy(); + $this->assertInstanceOf(\Traversable::class, $lazy); + + $urlPaths = array_column(iterator_to_array($lazy, false), 'url'); + $this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths); + } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Traits/ChecksBoardPermissionTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Traits/ChecksBoardPermissionTest.php index 2f34b9b3..bd599147 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Traits/ChecksBoardPermissionTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Traits/ChecksBoardPermissionTest.php @@ -24,7 +24,6 @@ use PHPUnit\Framework\Attributes\Test; */ class ChecksBoardPermissionTest extends ModuleTestCase { - /** * Trait을 사용하는 테스트용 클래스 */ @@ -48,12 +47,12 @@ class ChecksBoardPermissionTest extends ModuleTestCase { use ChecksBoardPermission; - public function testCheckPermissionByIdentifier(string $identifier): bool + public function invokeCheckPermissionByIdentifier(string $identifier): bool { return $this->checkPermissionByIdentifier($identifier); } - public function testCheckBoardPermission(string $slug, string $action, PermissionType $type = PermissionType::User): bool + public function invokeCheckBoardPermission(string $slug, string $action, PermissionType $type = PermissionType::User): bool { return $this->checkBoardPermission($slug, $action, $type); } @@ -65,16 +64,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase */ private function resetPermissionMiddlewareCache(): void { - try { - $reflection = new \ReflectionClass(PermissionMiddleware::class); - if ($reflection->hasProperty('guestRoleCache')) { - $prop = $reflection->getProperty('guestRoleCache'); - $prop->setAccessible(true); - $prop->setValue(null, null); - } - } catch (\ReflectionException $e) { - // 무시 - } + PermissionMiddleware::clearGuestRoleCache(); } /** @@ -111,7 +101,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase request()->setUserResolver(fn () => $user); // When: 권한 체크 - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); // Then: true 반환 $this->assertTrue($result); @@ -140,7 +130,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase Auth::login($user); // When: 권한 체크 - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.write'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.write'); // Then: false 반환 $this->assertFalse($result); @@ -168,7 +158,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase Auth::logout(); // When: 권한 체크 - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); // Then: true 반환 $this->assertTrue($result); @@ -195,7 +185,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase Auth::logout(); // When: 다른 권한 체크 (guest role에 없는 권한) - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.delete'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.delete'); // Then: false 반환 $this->assertFalse($result); @@ -211,7 +201,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase Auth::logout(); // When: 권한 체크 - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); // Then: false 반환 (전체 허용도 없음) $this->assertFalse($result); @@ -233,7 +223,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase Auth::logout(); // When: 권한 체크 - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); // Then: false 반환 (역할 할당 필수) $this->assertFalse($result); @@ -243,7 +233,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase * checkBoardPermission 메서드가 올바른 권한 식별자를 생성하는지 테스트 */ #[Test] - public function checkBoardPermission_메서드_올바른_식별자_생성(): void + public function check_board_permission_메서드_올바른_식별자_생성(): void { // Given: 권한 생성 + guest role에 할당 $permission = Permission::factory()->create([ @@ -259,7 +249,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase Auth::logout(); // When: checkBoardPermission 호출 - $result = $this->traitObject->testCheckBoardPermission('notice', 'posts.read'); + $result = $this->traitObject->invokeCheckBoardPermission('notice', 'posts.read'); // Then: 올바른 식별자로 권한 체크됨 (guest role 권한 보유) $this->assertTrue($result); @@ -275,7 +265,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase Auth::logout(); // When: 존재하지 않는 권한 체크 - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.nonexistent.permission'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.nonexistent.permission'); // Then: false 반환 $this->assertFalse($result); @@ -285,7 +275,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase * 회원 권한 체크 우선순위 테스트 (회원이 로그인하면 Gate 먼저 체크) */ #[Test] - public function 회원_로그인_시_Gate_우선_체크(): void + public function 회원_로그인_시_gate_우선_체크(): void { // Given: guest role에도 권한 부여, 회원에게도 권한 부여 $guestRole = Role::firstOrCreate( @@ -312,7 +302,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase request()->setUserResolver(fn () => $user); // When: 권한 체크 - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); // Then: true 반환 (회원 권한으로 체크됨) $this->assertTrue($result); @@ -339,7 +329,7 @@ class ChecksBoardPermissionTest extends ModuleTestCase Auth::logout(); // When: 권한 체크 - $result = $this->traitObject->testCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); + $result = $this->traitObject->invokeCheckPermissionByIdentifier('sirsoft-board.notice.posts.read'); // Then: false 반환 (guest role에 권한 미할당) $this->assertFalse($result); diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml new file mode 100644 index 00000000..9613dfd7 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/attachment-count-limit.yaml @@ -0,0 +1,49 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(첨부 개수 불변조건) +# 이며 PHPUnit Feature 테스트가 경로·경계 조합을 커버하고 green 이다. + +feature: 게시판 첨부파일 개수 상한 서버측 강제 (B3) + +description: | + 게시판 설정 `max_file_count` 를 모든 첨부 연결 경로에서 강제한다. + + 배경: + - `files[]` 직접 업로드만 FormRequest 가 막고 `attachment_ids` · `temp_key` · + 단일 업로드 반복 경로에는 개수 검사가 없었다. 같은 8개 파일을 `temp_key` 로 보내면 + 201 로 전부 연결되어 사용자 페이지에 그대로 노출됐다. + + 계층 결정: + - 총합은 직접 파일 + `attachment_ids` + `temp_key` 임시첨부 + 기존 첨부 4소스이고 + temp_key 쪽은 DB 조회가 필요하므로 FormRequest 만으로 판정 불가 + - `AttachmentService::assertAttachmentCountWithin()` 이 불변조건의 SSoT + - FormRequest 선차단은 "글 다 쓰고 저장 실패" 를 막는 UX 역할 (중복이지만 역할이 다름) + +axes: + link_path: [direct_files, attachment_ids, temp_key, single_upload_repeat] + existing_state: [none, below_limit, at_limit] + request_count: [under, exact, over] + board_limit: [configured, unlimited] + +exclusions: + - { board_limit: unlimited, request_count: over, reason: "상한 미설정 게시판은 제한하지 않음 (기존 동작)" } + +effects: + - temp_key_link_over_limit_blocked + - temp_key_link_at_limit_passes + - existing_plus_new_over_limit_blocked + - existing_plus_new_at_limit_passes + - attachment_ids_path_counted_in_total + - single_upload_repeat_blocked_at_limit + - unlimited_board_not_restricted + - limit_violation_returns_422_not_500 + - existing_over_limit_attachments_are_not_deleted + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php + +rules_layer_coverage: + - rule: i18n-throw-hardcoded-korean + coverage: AttachmentLimitExceededException 메시지는 sirsoft-board::messages.errors.attachment_limit_exceeded + - rule: service-direct-data-access + coverage: AttachmentService 는 AttachmentRepositoryInterface / BoardRepositoryInterface 만 사용 diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-admin-list-role-payload-pruning.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-admin-list-role-payload-pruning.yaml new file mode 100644 index 00000000..db331344 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-admin-list-role-payload-pruning.yaml @@ -0,0 +1,50 @@ +feature: 관리자 게시판 목록의 역할 소속 사용자 프루닝 (#518 / 공개 #76) + +description: | + 관리자 게시판 목록이 행마다 매니저/스텝 역할을 조회해 **그 역할에 속한 사용자 전원의 + uuid·이름·이메일**을 응답에 실었다. 목록 화면이 그리는 행 필드는 이름·슬러그·유형·활성 + 여부·분류·권한뿐이라 그 값은 쓰이지 않았고, 게시판 수만큼 추가 쿼리가 나가면서 개인정보인 + 이메일이 함께 노출됐다. + + 목록은 화면이 실제로 그리는 것만 싣고, 역할 편집에 필요한 값은 게시판 상세/설정 화면이 + 공급한다. 목록에서 뺀 값이 상세에 남아 있는지를 함께 고정한다 — 남아 있지 않다면 그것은 + 성능 개선이 아니라 기능 삭제다. + + 게시판 복사(`copyBoard`)는 Resource 를 거치지 않고 역할 조회 헬퍼를 직접 부른다. 목록에서 + 호출이 사라졌다고 그 헬퍼를 정리하면 복사가 깨지므로 회귀로 함께 고정한다. + +# axis 의미: +# 무엇이 실리는지를 가르는 축은 (응답 경로) × (역할 배정 상태) 다. +# 역할이 배정되지 않은 게시판은 "빈 배열" 과 "필드 자체 부재" 가 갈리는 지점이라 분리한다. +axes: + endpoint: [admin_list, detail] + role_assignment: [assigned, unassigned] + +effects: + - admin_list_omits_role_member_payload + - admin_list_never_exposes_member_email + - admin_list_keeps_display_fields + - admin_list_row_carries_created_at + - detail_still_provides_role_assignments + - role_query_count_does_not_scale_with_board_count + - board_copy_still_resolves_roles + +sub_flows: + - id: copy_board_regression + description: 목록이 역할 헬퍼를 더 이상 부르지 않아도 게시판 복사는 그대로 동작한다 + effects: + - board_copy_still_resolves_roles + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/Admin/AdminBoardListPayloadTest.php + - modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php + - modules/_bundled/sirsoft-board/tests/Feature/BoardCloneTest.php + - modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-role-payload.spec.ts + +notes: | + E2E 를 두는 이유: 변경 자체는 백엔드(Resource/Collection)지만, 목록 표현을 경량 경로로 + 갈아끼우면서 화면이 쓰던 필드를 함께 떨구면 열이 조용히 빈다. 응답 형태만 보는 서버 단언은 + 그것을 잡지 못한다 — 실제 렌더된 행에서 식별자 열이 살아 있는지까지 확인한다. + + 이메일 부재는 키 이름이 아니라 **응답 본문 전체**를 정규식으로 본다. 역할 페이로드가 다른 + 키 이름으로 되살아나도 걸리게 하기 위해서다. diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-conditional-validation.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-conditional-validation.yaml new file mode 100644 index 00000000..88bf58cf --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-conditional-validation.yaml @@ -0,0 +1,68 @@ +feature: 게시판 조건부 검증 — 토글 OFF 상태의 종속 필드 배제 + +description: | + 이슈 #78 — 첨부를 사용하지 않는 게시판(notice/archive)을 아무것도 바꾸지 않고 저장하면 + 422 로 거부되던 문제를 해소한다. + + 근본 원인: 관리자 폼이 조회 응답 전체를 _local.form 에 통째로 담아 그대로 PUT 한다 + (admin_board_form.json: initLocal="form" → body="{{_local.form}}"). 요청에는 항상 모든 키가 + 존재하므로 `sometimes` 는 아무 보호도 하지 못하고, 첨부 미사용 게시판의 빈 allowed_extensions + 까지 min:1 검증을 타서 저장이 막혔다. 작성자들이 `sometimes` 를 "부분 수정 지원"으로 오해했고 + 그 오해가 담긴 주석까지 파일 간에 복제되어 있었다. + + 조치: + - allowed_extensions 를 exclude_if 로 배제 (Update / Settings 양쪽) + · Settings 는 use_file_upload=null 조건도 필요 — prepareForValidation 의 boolean 캐스팅이 + isset() 가드를 써서 null 이 false 로 캐스팅되지 않고 통과한다 + - new_display_hours 하한을 config 선언(0 = NEW 배지 끄기)에 맞춰 완화 + · Post::isNew() 는 created_at > now()->subHours(0) → 항상 false 로 이미 0 을 정상 처리 + - min_title_length / min_comment_length 하한을 config 선언(0)에 맞춰 정정 + - 허용 확장자 소비 지점(첨부 업로드 / 게시글 작성)에서 빈 배열도 기본값으로 폴백 + · `?? 기본값` 은 null 만 잡고 [] 를 통과시켜 implode(',', []) → 'mimes:' 빈 규칙 → + 해당 게시판 전 파일 거부. 레거시 데이터가 있으므로 소비 시점 방어가 필수 + - 저장 시점에 빈 배열을 NULL 로 정규화해 "미지정" 센티널을 하나로 붕괴 (AsAllowedExtensions) + + 재발 방지: audit 룰 formrequest-store-update-conditional-symmetry(error) / + formrequest-sometimes-array-min-inert(warning) 가 같은 실수를 자동 차단한다. + +# axis 의미: +# case 는 (첨부 사용 여부 + 확장자 값 + 저장 결과) 의 의미 있는 조합을 한 축으로 열거한다. +# 전체 객체 PUT 계약이 전제이므로 "키 부재" 도 별도 케이스로 둔다. +axes: + case: [upload_disabled_extensions_empty, upload_disabled_extensions_null, upload_enabled_extensions_empty, upload_enabled_extensions_nonempty, extensions_key_absent, new_display_hours_zero, zero_lower_bound_length_fields] + +effects: + - upload_disabled_empty_extensions_passes + - upload_disabled_extensions_excluded_from_validated + - upload_enabled_empty_extensions_fails + - upload_enabled_nonempty_extensions_passes + - extensions_key_absent_passes + - settings_upload_null_empty_extensions_passes + - new_display_hours_zero_passes + - new_display_hours_out_of_range_fails + - zero_lower_bound_length_fields_pass + - unchanged_form_data_round_trip_saves + - empty_extensions_upload_falls_back_to_defaults + - specified_extensions_still_reject_other_types + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/BoardRequestTest.php + - modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php + - modules/_bundled/sirsoft-board/tests/Feature/User/AttachmentAllowedExtensionsFallbackTest.php + +validation: + note: | + 입력 조합 전수는 BoardRequestTest 의 조건부 검증 매트릭스가 커버한다 (Update 7 케이스 + + Settings 5 케이스 + new_display_hours 경계 5값). + + "전체 객체 PUT" 이라는 프런트 계약 자체는 BoardManagementTest 의 왕복 저장 테스트가 + 백엔드에 고정한다 — 폼 데이터를 GET 해서 받은 객체를 그대로 PUT 해 200 을 단언하므로 + 미래의 동종 결함(다른 토글-종속 필드 쌍)까지 방어된다. + + 레거시 데이터(allowed_extensions 가 [] 또는 NULL 인 게시판)의 실제 업로드 성공과, + 확장자를 지정한 게시판이 목록 밖 확장자를 계속 거부하는지는 + AttachmentAllowedExtensionsFallbackTest 가 커버한다. + + 브라우저 수준의 무변경 저장 성공 / 422 시 안내 실제 노출은 + tests/Playwright/specs/admin/board-unchanged-save-and-error-visibility.spec.ts 가 담당한다 + (PlaywrightIssueToken 발급 가능 환경에서 describe.skip 해제). diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-list-context-round-trip.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-list-context-round-trip.yaml new file mode 100644 index 00000000..d94010a3 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-list-context-round-trip.yaml @@ -0,0 +1,45 @@ +feature: 목록 컨텍스트 왕복 시 URL 목록 상태 보존 + +description: | + 페이지네이션 목록 화면과 그에 딸린 상세·형제 상세(이전글/다음글)·작성/수정 폼·확인 모달은 + 하나의 "목록 클러스터" 다. 이 클러스터 안에서의 모든 이동은 URL 에 실린 목록 상태 + (page / search / category / filters[*] / 정렬 / per_page)를 손실 없이 이어받아야 한다. + + 공개 이슈 #75 (@jiwonpapa) 는 이 규약의 **부분 적용**이 원인이었다. + - 45-1 에서 목록 진입(enter)·목록 복귀(back) leg 에는 mergeQuery:true 가 적용됐다. + - 그러나 같은 화면의 이전글/다음글(prev/next) leg 은 누락돼 `del` 하나만 남기고 + page/search/category 를 전부 떨궜다. + - 그 뒤 '목록' 버튼은 복원할 상태가 없어 1페이지로 되돌아갔다. + + 따라서 변별 축은 글 유형이 아니라 **이동 leg** 이다. post_kind 와는 직교하지 않는다 — + 답글·공지 상세에는 prev/next leg 자체가 존재하지 않으며, 그 부재는 + board-post-navigation.yaml 의 navigation_null_for_reply / navigation_null_for_notice 가 담당한다. + + 수정 범위는 게시판에 한정되지 않는다. 동일 결함이 관리자 게시판·이커머스·페이지·회원· + 마이페이지 등 9개 확장에 걸쳐 있었고, 전 저장소를 한 번에 교정했다. 재발은 audit 룰 + `layout-list-context-navigate-merge-query` (error) 가 차단한다. + +axes: + # 목록 클러스터 안에서 발생하는 이동 leg. 하나라도 빠지면 그 지점에서 상태가 끊긴다. + leg: [enter, back, prev, next] # 목록→상세 / 상세→목록 / 이전글 / 다음글 + +effects: + - navigation_preserves_list_query # 형제 상세(prev/next) 이동이 목록 상태를 승계 + - list_return_preserves_query # 목록 복귀가 상세 URL 의 목록 상태를 그대로 복원 + +test_files: + - modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-list-context-round-trip.spec.ts + - modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-context-round-trip.spec.ts + - templates/_bundled/sirsoft-basic/src/__tests__/layouts/board-post-navigation-list-return.test.tsx + - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts + - modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts + - templates/_bundled/sirsoft-admin_basic/src/__tests__/layouts/admin-list-context-round-trip.test.ts + +validation: + note: | + leg 4종은 E2E(board-list-context-round-trip.spec.ts)가 실제 브라우저 URL 과 목록 API 요청 + 파라미터로 실측한다. 확장별 레이아웃 테스트는 실 JSON 의 액션 params 를 읽어 클러스터 내 + navigate 가 전부 mergeQuery:true 인지 전수 고정하고, audit 룰 테스트가 저장소 전체 잔여 + 위반 0건을 회귀로 못박는다. 엔진 게이트(mergeQuery:true + query 생략) 는 + ActionDispatcher.test.ts 가 navigate/replaceUrl 양쪽에서 검증한다. diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-notification-definition-channel-contract.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-notification-definition-channel-contract.yaml new file mode 100644 index 00000000..0e4ab19c --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-notification-definition-channel-contract.yaml @@ -0,0 +1,45 @@ +feature: 게시판 알림 정의 탭의 채널 계약 (#518 / 공개 #76) + +description: | + 알림 정의 목록은 정의 하나당 (채널 × 로케일) 템플릿 본문을 전부 실었다. 화면은 한 번에 한 + 채널만 그리므로, 서버는 `template_channel` 로 지정된 채널의 템플릿만 싣도록 좁혔다. + + 그 좁힘은 **요청과 화면이 같은 채널을 볼 때만** 성립한다. 이 매니페스트가 고정하는 것은 + 두 종류의 어긋남이다. + + 1. 화면이 채널을 서버에 넘기지 않는 경우 — 응답에 `templates` 키 자체가 없어 전 행이 + 「이 채널에 대한 템플릿이 없습니다」가 된다. 제목·수신자·활성 토글·편집 모달 초기값이 + 통째로 빈다. `(def.templates ?? [])` 가드 뒤라 예외도 콘솔 경고도 없고 응답은 200, + 행 수도 정상이라 "템플릿을 아직 안 만들었나 보다" 로 읽힌다. + 2. 넘기는 채널과 화면이 찾는 채널이 다른 경우 — 서버는 A 채널을 실어 보내고 화면은 B 채널을 + 찾아 같은 증상이 난다. 채널 출처가 URL 하나여야 하는 이유다. + + 「기본값 복원」 노출은 채널을 가리지 않는 판정이다. templates 가 한 채널로 좁혀 실리므로 + 배열 순회로는 다른 채널의 커스터마이즈를 알 수 없다 — 서버 집계가 유일한 출처다. + +# axis 의미: +# 갈리는 입력은 (화면이 보는 채널) × (그 채널의 템플릿 커스터마이즈 상태) 다. +# 채널 전환 경로는 URL 만 바꾸므로 초기 진입과 같은 규칙이고, effects 로 검증한다. +axes: + channel: [mail, database] + template_state: [default, customized] + +effects: + - board_notification_channel_reads_single_source + - board_notification_list_requests_template_channel + - board_notification_reset_button_uses_server_aggregate + +test_files: + - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-single-source.test.ts + - modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-notification-definition-channel.spec.ts + +notes: | + 회귀 이력: 목록 프루닝 도입 시 코어 설정 화면만 `template_channel` 을 보내도록 고쳐지고 + 게시판 탭 데이터소스가 빠져, 7건 전 행이 빈 상태로 표시됐다 (2026-08-05 브라우저 실측). + + 그래서 단언은 소비와 전송을 **함께** 묶는다 — templates 를 읽는 표현식이 있는데 데이터소스가 + 채널을 넘기지 않으면 실패한다. 한쪽만 보면 "화면은 templates 를 안 쓴다" 는 잘못된 전제로 + 통과해 버린다. + + 활성 토글은 URL 을 `templates[...].id` 로 조립한다. 템플릿이 없으면 그 값이 `undefined` 가 + 되어 존재하지 않는 경로로 요청이 나가므로, 실제 템플릿 ID 로 나가는지까지 축에 포함한다. diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-post-navigation.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-post-navigation.yaml index 8b5823c0..f1879fb9 100644 --- a/modules/_bundled/sirsoft-board/tests/scenarios/board-post-navigation.yaml +++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-post-navigation.yaml @@ -28,6 +28,9 @@ axes: # navigation 결과를 가르는 핵심 변별 축은 글 유형(post_kind). # 카테고리 유무·목록 위치·비밀/블라인드 변종은 effects 와 개별 테스트 메서드로 커버한다. post_kind: [original, reply, notice] # 원글(정상 순회) / 답글(미노출) / 공지(미노출) + # 목록 상태 보존(#75)의 변별 축은 "어느 leg 으로 이동했는가" 이며 post_kind 와 직교하지 않는다 + # (답글·공지에는 이전/다음 leg 자체가 없다). 별도 기능으로 분리한다 → + # tests/scenarios/board-list-context-round-trip.yaml effects: - navigation_limited_to_same_category # 47-1: 동일 카테고리 글만 prev/next @@ -38,12 +41,14 @@ effects: - navigation_blinded_excluded # 47-2: 블라인드 제외 (기존 유지) - navigation_boundary_null # 47-3: 첫/마지막 글 prev/next null - list_return_preserves_query # 45-1: 행 클릭→상세→목록 복귀 시 query 보존 + - navigation_preserves_list_query # 75: 이전글/다음글 leg 도 목록 상태(page/search/category)를 승계 test_files: - modules/_bundled/sirsoft-board/tests/Feature/User/PostNavigationTest.php - modules/_bundled/sirsoft-board/tests/Feature/User/PostNavigationApiTest.php - templates/_bundled/sirsoft-basic/src/__tests__/layouts/board-post-navigation-list-return.test.tsx - modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-post-navigation.spec.ts + - modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-list-context-round-trip.spec.ts validation: note: | diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml new file mode 100644 index 00000000..1fa816b8 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/scenarios/comment-depth-limit.yaml @@ -0,0 +1,63 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해 +# 검출되지 않는다(다른 번들 시나리오와 동일 한계). 본 변경은 백엔드 전용(댓글 깊이 불변조건 + +# 업그레이드 데이터 정정)이며 PHPUnit Feature 테스트가 경로·경계 조합을 커버하고 green 이다. + +feature: 대댓글 깊이 상한 강제 + 포화 depth 데이터 정정 (B1) + +description: | + 게시판 설정 `max_comment_depth` 를 저장 시점에도 강제하고, 과거에 포화 저장된 depth 를 + 업그레이드에서 재계산한다. + + 배경: + - 검증(`CommentValidationRule`)은 부모의 *저장된* depth 로 판정하는데 저장 시 + `min(parent.depth + 1, 5)` 로 포화시켰다. 부모 depth 가 5 를 넘지 못하니 + `max_comment_depth=10` 게시판에서 판정식이 영원히 참이 되지 않아 **무한 중첩**이 가능했다. + - 실측: 20단 중첩이 전부 201 로 저장되고 depth 가 `0,1,2,3,4,5,5,5,…` 로 5 에서 포화. + + 계층 결정: + - 저장부의 리터럴 클램프를 제거하고 `부모 depth + 1` 을 그대로 쓴다 + (검증을 통과한 값이므로 클램프가 불필요). + - 방어적 상한은 두되 **초과 시 클램프가 아니라 예외** — 클램프가 이 버그의 원인이었다. + - 코드 수정만으로는 결함이 닫히지 않는다. 기존 스레드의 부모 depth 가 5 로 고정되어 있어 + 그 스레드에서는 계속 우회가 가능하므로 업그레이드 데이터 정정이 함께 필요하다. + + 데이터 정정 규율: + - 루트(`parent_id IS NULL`)부터 **레벨 단위 청크 UPDATE**(재귀 금지), 방문 집합으로 유한 종료 + - 재계산 결과가 상한을 넘어도 **값만 정정, 삭제 금지** + - 루트에서 도달하지 못한 행(고아 부모 / 순환 컴포넌트)은 값 보존 + 경고 로그 + +axes: + entity: [board_comment] + parent_target: [unrelated, foreign, missing, cycle, self] + board_max_depth: [zero, lowered, default] + request_depth: [under, exact, over] + entry_path: [http_user, http_admin, service_direct] + +exclusions: + - { board_max_depth: zero, request_depth: under, reason: "상한 0 은 대댓글 자체가 불가 — 하위 조합 없음" } + - { parent_target: missing, entry_path: http_user, reason: "고아 부모는 업그레이드 정정 축이며 등록 경로 조합이 아님" } + - { parent_target: cycle, entry_path: http_user, reason: "순환은 앱 경로로 생성 불가 — 오염 데이터 정정 축" } + +effects: + - depth_equals_parent_depth_plus_one + - depth_follows_board_setting_not_literal + - depth_beyond_board_max_rejected_422 + - existing_clamped_depth_recalculated + - existing_cross_post_parent_detached_content_preserved + - dangling_parent_skipped_with_warning + - unreachable_cycle_skipped_with_warning + - healthy_hierarchy_emits_no_cycle_warning + - cleanup_is_idempotent + +test_files: + - modules/_bundled/sirsoft-board/tests/Feature/CommentDepthLimitTest.php + - modules/_bundled/sirsoft-board/tests/Feature/Upgrade/CommentHierarchyCleanupTest.php + +rules_layer_coverage: + - rule: i18n-throw-hardcoded-korean + coverage: CommentDepthExceededException 메시지는 sirsoft-board::validation.comment.depth.exceeded + - rule: service-config-value-clamp + coverage: CommentService 의 리터럴 클램프 제거 — 이 룰이 재유입을 검출한다 + - rule: upgrade-step-path-a + coverage: 정정 스텝은 Facades\DB/Schema 만 사용하고 로직을 private 헬퍼로 로컬 완결 diff --git a/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_0_3.php b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_0_3.php new file mode 100644 index 00000000..0fe5e304 --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_0_3.php @@ -0,0 +1,20 @@ +logger->info('[board:1.0.3] 댓글 부모 정리 — 대상 스키마 부재로 스킵'); + + return; + } + + $orphans = DB::table(self::TABLE.' as c') + ->join(self::TABLE.' as p', 'c.parent_id', '=', 'p.id') + ->whereColumn('c.post_id', '<>', 'p.post_id') + ->select('c.id', 'c.post_id', 'c.parent_id', 'c.depth', 'p.post_id as parent_post_id') + ->orderBy('c.id') + ->get(); + + if ($orphans->isEmpty()) { + $context->logger->info('[board:1.0.3] 교차 게시글 부모 댓글 없음 — 정리 스킵'); + + return; + } + + // 변경 전 스냅샷 기록 (복구 근거) + foreach ($orphans as $row) { + $context->logger->info(sprintf( + '[board:1.0.3] 스냅샷 comment_id=%d post_id=%d parent_id=%d parent_post_id=%d depth=%d', + $row->id, + $row->post_id, + $row->parent_id, + $row->parent_post_id, + (int) $row->depth + )); + } + + $ids = $orphans->pluck('id')->all(); + + foreach (array_chunk($ids, 500) as $chunk) { + DB::table(self::TABLE) + ->whereIn('id', $chunk) + ->update(['parent_id' => null, 'depth' => 0]); + } + + $context->logger->info(sprintf( + '[board:1.0.3] 교차 게시글 부모 댓글 %d 건을 최상위 댓글로 승격 완료', + count($ids) + )); + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php new file mode 100644 index 00000000..6885ccdb --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/02_RecalculateCommentDepth.php @@ -0,0 +1,297 @@ +logger->info('[board:1.0.3] depth 재계산 — 대상 스키마 부재로 스킵'); + + return; + } + + $updated = $this->assignRootDepth($context); + + $visited = []; + $parentIds = DB::table(self::TABLE)->whereNull('parent_id')->pluck('id')->all(); + foreach ($parentIds as $id) { + $visited[(int) $id] = true; + } + + $level = 0; + + while ($parentIds !== [] && $level < self::MAX_LEVELS) { + $level++; + $childIds = $this->childIdsOf($parentIds, $visited); + + if ($childIds === []) { + break; + } + + $updated += $this->assignDepth($childIds, $level, $context); + $parentIds = $childIds; + } + + if ($level >= self::MAX_LEVELS) { + $context->logger->warning(sprintf( + '[board:1.0.3] depth 재계산이 세대 상한(%d)에 도달 — 순환 참조 잔존 가능. 수동 확인 필요', + self::MAX_LEVELS + )); + } + + $this->logOrphanParents($context); + $this->logUnreachableComments($context, $visited); + + $context->logger->info(sprintf( + '[board:1.0.3] depth 재계산 완료 — %d 건 갱신 (최대 세대 %d)', + $updated, + $level + )); + } + + /** + * 최상위 댓글의 depth 를 0 으로 맞춥니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @return int 갱신된 행 수 + */ + private function assignRootDepth(UpgradeContext $context): int + { + $stale = DB::table(self::TABLE) + ->whereNull('parent_id') + ->where('depth', '<>', 0) + ->pluck('id') + ->all(); + + if ($stale === []) { + return 0; + } + + $this->logSnapshot($context, 0, $stale); + + return DB::table(self::TABLE) + ->whereNull('parent_id') + ->where('depth', '<>', 0) + ->update(['depth' => 0]); + } + + /** + * 주어진 부모 ID 목록의 자식 ID 를 조회합니다 (이미 방문한 ID 는 제외). + * + * @param array $parentIds 부모 댓글 ID 목록 + * @param array $visited 방문 ID 집합 (참조 전달, 갱신됨) + * @return array 자식 댓글 ID 목록 + */ + private function childIdsOf(array $parentIds, array &$visited): array + { + $children = []; + + foreach (array_chunk($parentIds, self::CHUNK) as $chunk) { + $ids = DB::table(self::TABLE) + ->whereIn('parent_id', $chunk) + ->pluck('id') + ->all(); + + foreach ($ids as $id) { + $id = (int) $id; + if (isset($visited[$id])) { + continue; + } + $visited[$id] = true; + $children[] = $id; + } + } + + return $children; + } + + /** + * 주어진 댓글들의 depth 를 지정 값으로 갱신합니다. + * + * @param array $ids 댓글 ID 목록 + * @param int $depth 설정할 depth + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @return int 갱신된 행 수 + */ + private function assignDepth(array $ids, int $depth, UpgradeContext $context): int + { + $updated = 0; + + foreach (array_chunk($ids, self::CHUNK) as $chunk) { + $stale = DB::table(self::TABLE) + ->whereIn('id', $chunk) + ->where('depth', '<>', $depth) + ->pluck('id') + ->all(); + + if ($stale === []) { + continue; + } + + $this->logSnapshot($context, $depth, $stale); + + $updated += DB::table(self::TABLE) + ->whereIn('id', $stale) + ->update(['depth' => $depth]); + } + + return $updated; + } + + /** + * 부모 행이 존재하지 않는 고아 댓글을 경고 로그로 남깁니다. + * + * BFS 는 `parent_id IS NULL` 인 루트에서만 출발하므로, 부모가 이미 삭제된 댓글은 + * 어느 세대에도 도달하지 못해 depth 가 옛 값 그대로 남는다. 값을 임의로 고치거나 + * 행을 지우면 데이터 손실이므로 건드리지 않되, 정정되지 않았다는 사실은 남긴다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + private function logOrphanParents(UpgradeContext $context): void + { + $orphanIds = DB::table(self::TABLE.' as c') + ->whereNotNull('c.parent_id') + ->whereNotExists(function ($query) { + $query->select('p.id') + ->from(self::TABLE.' as p') + ->whereColumn('p.id', 'c.parent_id'); + }) + ->pluck('c.id') + ->all(); + + if ($orphanIds === []) { + return; + } + + $context->logger->warning(sprintf( + '[board:1.0.3] 부모 행이 없는 댓글 %d 건 — depth 재계산에서 스킵(값 보존). 대상 id: %s', + count($orphanIds), + json_encode(array_map('intval', $orphanIds), JSON_UNESCAPED_UNICODE) + )); + } + + /** + * 루트에서 도달하지 못한 댓글(순환 참조 컴포넌트)을 경고 로그로 남깁니다. + * + * 부모 행이 존재하는데도 BFS 가 방문하지 못했다면 그 부모 역시 도달 불가라는 뜻이고, + * 거슬러 올라가면 루트가 없는 순환(A→B→A)에 닿는다. 이런 행은 세대 상한에 걸리지도 + * (BFS 가 진입 자체를 못 함) 고아 판정에 걸리지도(부모 행은 실재함) 않아, + * 경고가 없으면 정정되지 않은 사실이 조용히 묻힌다. + * + * 값은 건드리지 않는다 — 순환의 어느 지점을 끊을지는 운영자 판단 영역이다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @param array $visited BFS 가 방문한 댓글 ID 집합 + */ + private function logUnreachableComments(UpgradeContext $context, array $visited): void + { + $cycleIds = []; + $lastId = 0; + + while (true) { + $ids = DB::table(self::TABLE.' as c') + ->whereNotNull('c.parent_id') + ->where('c.id', '>', $lastId) + ->whereExists(function ($query) { + $query->select('p.id') + ->from(self::TABLE.' as p') + ->whereColumn('p.id', 'c.parent_id'); + }) + ->orderBy('c.id') + ->limit(self::CHUNK) + ->pluck('c.id') + ->all(); + + if ($ids === []) { + break; + } + + foreach ($ids as $id) { + $lastId = (int) $id; + + if (! isset($visited[$lastId])) { + $cycleIds[] = $lastId; + } + } + } + + if ($cycleIds === []) { + return; + } + + $context->logger->warning(sprintf( + '[board:1.0.3] 루트에서 도달하지 못한 댓글 %d 건 — 순환 참조로 보이며 depth 재계산에서 스킵(값 보존). 대상 id: %s', + count($cycleIds), + json_encode($cycleIds, JSON_UNESCAPED_UNICODE) + )); + } + + /** + * 갱신 대상의 변경 전 상태를 로그로 남깁니다 (복구 근거). + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @param int $newDepth 새로 부여할 depth + * @param array $ids 갱신 대상 댓글 ID 목록 + */ + private function logSnapshot(UpgradeContext $context, int $newDepth, array $ids): void + { + $before = DB::table(self::TABLE) + ->whereIn('id', $ids) + ->pluck('depth', 'id') + ->map(fn ($depth) => (int) $depth) + ->all(); + + $context->logger->info(sprintf( + '[board:1.0.3] 스냅샷 depth→%d %d 건 — 변경 전 %s', + $newDepth, + count($ids), + json_encode($before, JSON_UNESCAPED_UNICODE) + )); + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/03_ReportCommentsExceedingMaxDepth.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/03_ReportCommentsExceedingMaxDepth.php new file mode 100644 index 00000000..eb9ee411 --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/03_ReportCommentsExceedingMaxDepth.php @@ -0,0 +1,88 @@ +logger->info('[board:1.0.3] 설정 초과 depth 리포트 — 대상 스키마 부재로 스킵'); + + return; + } + + // 별칭 조인 대신 게시판 단위 순회 — DB 프리픽스가 별칭에도 적용되어 + // raw 표현식의 별칭 참조가 깨지는 것을 회피한다 (게시판 수는 소규모). + $boards = DB::table(self::BOARDS_TABLE) + ->select('id', 'slug', 'max_comment_depth') + ->orderBy('id') + ->get(); + + $reported = 0; + + foreach ($boards as $board) { + $max = (int) $board->max_comment_depth; + + $query = DB::table(self::COMMENTS_TABLE) + ->where('board_id', $board->id) + ->where('depth', '>', $max); + + $overCount = (clone $query)->count(); + + if ($overCount === 0) { + continue; + } + + $reported++; + + $context->logger->warning(sprintf( + '[board:1.0.3] 게시판 %s(id=%d) 설정 max_comment_depth=%d 초과 댓글 %d 건 (최대 depth=%d) — 자동 이동하지 않음, 운영자 확인 필요', + $board->slug, + $board->id, + $max, + $overCount, + (int) (clone $query)->max('depth') + )); + } + + if ($reported === 0) { + $context->logger->info('[board:1.0.3] 게시판 설정을 초과한 depth 댓글 없음'); + } + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_AddTiebreakToPostListIndex.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_AddTiebreakToPostListIndex.php new file mode 100644 index 00000000..f688efce --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_AddTiebreakToPostListIndex.php @@ -0,0 +1,90 @@ + */ + private const NEW_COLUMNS = ['board_id', 'is_notice', 'parent_id', 'deleted_at', 'created_at', 'id']; + + /** + * 마이그레이션 식별자를 반환합니다. + * + * @return string 마이그레이션 이름 + */ + public function name(): string + { + return 'AddTiebreakToPostListIndex'; + } + + /** + * 목록 인덱스를 기본키 포함 형태로 교체합니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! Schema::hasTable(self::TABLE)) { + $context->logger->info('[board:1.0.3] 게시글 테이블 부재 — 목록 인덱스 교체 스킵'); + + return; + } + + $existing = array_column(Schema::getIndexes(self::TABLE), 'name'); + + if (! in_array(self::NEW_INDEX, $existing, true)) { + $context->logger->info('[board:1.0.3] 목록 인덱스 교체 시작 (게시글이 많으면 수 분 이상 걸릴 수 있고 그동안 게시글 쓰기가 대기합니다)'); + + Schema::table(self::TABLE, function ($table) { + $table->index(self::NEW_COLUMNS, self::NEW_INDEX); + }); + + $context->logger->info('[board:1.0.3] 새 목록 인덱스 생성 완료: '.self::NEW_INDEX); + } else { + $context->logger->info('[board:1.0.3] 새 목록 인덱스가 이미 존재 — 생성 스킵'); + } + + // 새 인덱스가 기존 인덱스의 상위집합이므로 남겨 두면 쓰기 비용만 늘어난다. + // 반드시 새 인덱스 생성 이후에 지운다 — 순서를 바꾸면 중간 중단 시 목록 조회가 + // 인덱스 없이 남는 구간이 생긴다. + if (in_array(self::OLD_INDEX, $existing, true)) { + Schema::table(self::TABLE, function ($table) { + $table->dropIndex(self::OLD_INDEX); + }); + + $context->logger->info('[board:1.0.3] 상위집합에 포함된 기존 인덱스 제거: '.self::OLD_INDEX); + } + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_BackfillForeignKeyColumnComments.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_BackfillForeignKeyColumnComments.php new file mode 100644 index 00000000..f8970b4b --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/04_BackfillForeignKeyColumnComments.php @@ -0,0 +1,175 @@ +foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가 + * 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가 + * 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친 + * 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다. + * + * 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다. + * + * 안전: + * - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다. + * - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는 + * 아무 것도 바뀌지 않습니다. + * - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다). + * + * 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다. + * + * V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을 + * 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조). + */ +class BackfillForeignKeyColumnComments implements DataMigration +{ + /** + * 보정 대상 (1.0.3 시점 동결) — 테이블 => [컬럼 => 설명]. + */ + private const TARGETS = [ + 'boards_report_logs' => [ + 'report_id' => '케이스 ID (boards_reports.id)', + ], + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'BackfillForeignKeyColumnComments'; + } + + /** + * 비어 있는 외래키 컬럼 설명을 채웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) { + $context->logger->info('[1.0.3] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다'); + + return; + } + + $filled = 0; + $skipped = 0; + $failed = 0; + + foreach (self::TARGETS as $table => $columns) { + $prefixed = $context->table($table); + + foreach ($columns as $column => $comment) { + try { + $meta = $this->columnMeta($prefixed, $column); + + if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') { + $skipped++; + + continue; + } + + DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment)); + $filled++; + } catch (\Throwable $e) { + $failed++; + $context->logger->warning(sprintf( + '[1.0.3] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s', + $prefixed, + $column, + $e->getMessage(), + )); + } + } + } + + $context->logger->info(sprintf( + '[1.0.3] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건', + $filled, + $skipped, + $failed, + )); + } + + /** + * 컬럼의 현재 스키마 메타데이터를 조회합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null) + */ + private function columnMeta(string $table, string $column): ?object + { + $rows = DB::select( + 'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', + [$table, $column], + ); + + return $rows[0] ?? null; + } + + /** + * 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다. + * + * @param string $table 프리픽스가 적용된 테이블명 + * @param string $column 컬럼명 + * @param object $meta columnMeta() 결과 + * @param string $comment 넣을 설명 + * @return string 실행할 SQL + */ + private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string + { + $sql = sprintf( + 'ALTER TABLE %s MODIFY COLUMN %s %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($column), + $meta->COLUMN_TYPE, + ); + + $sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL'; + + if ($meta->COLUMN_DEFAULT !== null) { + $sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT); + } + + if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) { + $sql .= ' AUTO_INCREMENT'; + } + + return $sql.' COMMENT '.$this->quoteValue($comment); + } + + /** + * 식별자를 백틱으로 감쌉니다. + * + * @param string $identifier 테이블/컬럼명 + * @return string 이스케이프된 식별자 + */ + private function quoteIdentifier(string $identifier): string + { + return '`'.str_replace('`', '``', $identifier).'`'; + } + + /** + * 문자열 값을 SQL 리터럴로 인용합니다. + * + * @param string $value 값 + * @return string 인용된 리터럴 + */ + private function quoteValue(string $value): string + { + return DB::connection()->getPdo()->quote($value); + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/05_AddReportListSortIndex.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/05_AddReportListSortIndex.php new file mode 100644 index 00000000..37e98f5d --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/05_AddReportListSortIndex.php @@ -0,0 +1,70 @@ + */ + private const INDEX_COLUMNS = ['deleted_at', 'created_at', 'id']; + + /** + * 마이그레이션 식별자를 반환합니다. + * + * @return string 마이그레이션 이름 + */ + public function name(): string + { + return 'AddReportListSortIndex'; + } + + /** + * 신고 목록 정렬 색인을 추가합니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! Schema::hasTable(self::TABLE)) { + $context->logger->info('[board:1.0.3] 신고 테이블 부재 — 정렬 색인 추가 스킵'); + + return; + } + + $existing = array_column(Schema::getIndexes(self::TABLE), 'name'); + + if (in_array(self::INDEX_NAME, $existing, true)) { + $context->logger->info('[board:1.0.3] 이미 존재하는 색인 — 스킵: '.self::INDEX_NAME); + + return; + } + + $context->logger->info('[board:1.0.3] 신고 목록 정렬 색인 추가 시작 (신고가 많으면 수 분 걸릴 수 있고 그동안 신고 접수가 대기합니다)'); + + Schema::table(self::TABLE, function ($table) { + $table->index(self::INDEX_COLUMNS, self::INDEX_NAME); + }); + + $context->logger->info('[board:1.0.3] 신고 목록 정렬 색인 추가 완료: '.self::INDEX_NAME); + } +} diff --git a/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/06_AddPostListViewCountIndex.php b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/06_AddPostListViewCountIndex.php new file mode 100644 index 00000000..170930fa --- /dev/null +++ b/modules/_bundled/sirsoft-board/upgrades/data/1.0.3/migrations/06_AddPostListViewCountIndex.php @@ -0,0 +1,74 @@ + */ + private const INDEX_COLUMNS = ['board_id', 'is_notice', 'parent_id', 'deleted_at', 'view_count', 'id']; + + /** + * 마이그레이션 식별자를 반환합니다. + * + * @return string 마이그레이션 이름 + */ + public function name(): string + { + return 'AddPostListViewCountIndex'; + } + + /** + * 게시글 목록 조회수 정렬 색인을 추가합니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + if (! Schema::hasTable(self::TABLE)) { + $context->logger->info('[board:1.0.3] 게시글 테이블 부재 — 조회수 정렬 색인 추가 스킵'); + + return; + } + + $existing = array_column(Schema::getIndexes(self::TABLE), 'name'); + + if (in_array(self::INDEX_NAME, $existing, true)) { + $context->logger->info('[board:1.0.3] 이미 존재하는 색인 — 스킵: '.self::INDEX_NAME); + + return; + } + + $context->logger->info('[board:1.0.3] 게시글 목록 조회수 정렬 색인 추가 시작 (게시글이 많으면 수 분 걸릴 수 있고 그동안 글쓰기가 대기합니다)'); + + Schema::table(self::TABLE, function ($table) { + $table->index(self::INDEX_COLUMNS, self::INDEX_NAME); + }); + + $context->logger->info('[board:1.0.3] 게시글 목록 조회수 정렬 색인 추가 완료: '.self::INDEX_NAME); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 6becea4f..fc3a8816 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,196 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.1.0] - 2026-08-10 + +### Security + +- 관리자가 설정한 마일리지 사용 정책(최소 사용액·사용 단위·최대 사용 한도)이 주문서 작성과 주문 확정 양쪽에서 실제로 적용됩니다. 이전에는 보유 잔액만 확인해, 사용 한도를 30%로 제한해 두어도 결제금액 전액을 마일리지로 결제할 수 있었습니다. 한도를 넘으면 사용 가능한 최대 금액을 함께 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문에는 주문 시점의 마일리지 사용 정책이 함께 보관됩니다. 이후 관리자가 한도를 바꿔도 과거 주문이 어떤 기준으로 성립했는지 확인할 수 있습니다. (기존 주문은 당시 설정을 알 수 없어 비어 있습니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 무통장입금 기한이 주문 요청에 실린 값으로 정해지던 문제를 수정했습니다. 이제 가상계좌와 동일하게 관리자 환경설정의 미입금 자동취소 기한만 사용합니다. 이전에는 안내된 입금기한과 자동취소 시점이 어긋날 수 있었습니다. 이미 생성된 주문의 기한은 그대로 유지됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 장바구니 담기 수량 제한이 실제로 적용되도록 수정했습니다. 설정값(상품당 99개)이 화면·저장 어디에도 반영되지 않아 한 번에 9,999개까지 담을 수 있었고, 이미 담긴 수량과 합산되는 경우에는 제한 자체가 없었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 장바구니 수량 제한 안내 문구가 실제 설정값을 알려 주도록 수정했습니다. 이전에는 설정과 무관하게 "최대 9,999개까지 가능"으로 고정 표시돼, 안내받은 수량보다 적은 수량에서 저장이 막혔습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품의 최대 구매수량 제한이 장바구니 담기와 옵션 변경에서도 적용되도록 수정했습니다. 이전에는 수량 변경 화면에서만 검사해, 담기나 옵션 변경으로는 한도를 넘길 수 있었습니다. 비회원 장바구니가 로그인 시 합쳐질 때는 로그인이 실패하지 않도록 한도까지 자동 조정하고 조정 내역을 함께 알려 줍니다. 이때 같은 상품을 옵션별로 나눠 담아 한도를 넘기는 것도 함께 막습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 재주문으로 장바구니에 담을 때도 상품의 최대 구매수량 제한이 적용됩니다. 한도를 넘는 항목은 품절 상품과 같이 사유와 함께 제외되고, 나머지 항목은 정상적으로 담깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) + +### Added + +#### 현금영수증 + +- 현금영수증 발급 업체를 결제대행사와 따로 고를 수 있습니다 — KG이니시스로 결제받으면서 현금영수증만 토스페이먼츠로 발급하는 구성이 가능합니다. 발급 업체는 이커머스 환경설정에서 선택합니다. +- 무통장입금 주문의 입금이 확인되면 현금영수증이 자동으로 발급됩니다. 관리자가 "결제완료로 변경"한 경우와 "입금만 기록"한 경우 모두 발급되며, 마일리지로 전액 결제되어 실제 입금액이 없는 주문에는 발급하지 않습니다. 발급은 백그라운드에서 처리되므로 발급 업체 응답이 늦어도 입금 확인 자체는 지연되지 않습니다. +- 주문할 때 현금영수증을 신청하지 않았어도 나중에 발급할 수 있습니다 — 관리자 주문 상세와 구매자 주문 상세 양쪽에서 발급할 수 있고, 비회원도 주문 조회 후 발급할 수 있습니다. 발급 취소는 관리자만 할 수 있습니다. +- 발급 용도(소득공제/지출증빙)와 발급 수단(휴대폰번호·현금영수증카드번호·사업자등록번호)을 따로 고를 수 있습니다. 지출증빙용도 휴대폰번호로 발급할 수 있으며, 사업자등록번호는 입력 시 유효성을 검사합니다. +- 현금영수증을 신청하지 않은 무통장입금 건을 사업자가 자진발급하는 설정을 추가했습니다 (기본 사용 안 함). 현금영수증 의무발행 업종이라면 켜 두면 됩니다. +- 현금영수증 발급·취소 이력이 남습니다. 부분 환불 등으로 결제금액이 바뀌면 기존 영수증을 취소하고 바뀐 금액으로 다시 발급하며, 그 과정이 모두 기록되어 국세청 신고 근거로 쓸 수 있습니다. 재발급은 환불 처리가 끝난 뒤 이뤄지므로, 재발급이 실패하더라도 환불 자체는 그대로 완료되고 관리자가 주문 상세에서 다시 발급할 수 있습니다. +- 현금영수증 식별번호(휴대폰번호·사업자등록번호 등)는 재발급에 쓸 수 있도록 암호화해 보관하고 구매확정 시점에 폐기합니다. 이력에는 뒤 4자리를 제외한 마스킹 값만 남으며, 발급 업체가 보내온 응답에 섞여 오는 개인정보도 가려서 저장합니다. 주민등록번호는 수집하지 않습니다. +- 결제 영수증(카드 매출전표 등)과 현금영수증을 각각 따로 조회할 수 있습니다. 두 영수증은 서로 다른 값으로 보관되어 한쪽이 다른 쪽을 덮어쓰지 않습니다. + +#### 신청·발급 화면 + +- 주문서의 무통장입금 결제 영역에서 현금영수증을 신청할 수 있습니다. 용도(소득공제/지출증빙)에 따라 고를 수 있는 발급 수단이 바뀌며, 그 용도에 쓸 수 없는 수단이 선택되어 있으면 자동으로 되돌립니다. 발급 업체를 설정하지 않은 상점에서는 신청란이 나타나지 않습니다. 결제수단을 다른 것으로 바꾸면 입력했던 신청 내용은 지워집니다. +- 구매자 주문 상세에 현금영수증 카드가 표시됩니다. 입금 전에는 안내만, 입금 후 미발급이면 발급 버튼, 발급 후에는 발급일시·용도·식별번호와 영수증 보기 링크가 나타납니다. 환불에 따른 재발급이 실패한 경우에는 안내 문구가 표시됩니다. +- 관리자 주문 상세에 현금영수증 카드가 표시됩니다. 발급·발급취소·수동 재발급을 할 수 있고 발급·취소 이력을 함께 볼 수 있습니다. 재발급이 실패한 주문에는 경고와 함께 다시 발급하는 버튼이 나타납니다. +- 이커머스 환경설정 주문설정 탭에서 현금영수증 발급 업체, 배송비 과세 방식, 자진발급 사용 여부를 설정할 수 있습니다. + +#### 환불 계좌 + +- 주문서의 무통장입금·가상계좌 결제 영역에서 환불받을 계좌(은행·계좌번호·예금주)를 미리 입력할 수 있습니다. 입력하지 않아도 주문할 수 있지만, 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다. 무통장입금·가상계좌가 아닌 결제수단으로 주문하면 환불 계좌는 저장되지 않습니다 — 계좌를 입력한 뒤 카드 등으로 결제수단을 바꿔도 필요 없는 계좌정보가 주문에 남지 않습니다. +- 관리자 주문 취소 화면에서 환불 계좌를 입력하거나 수정할 수 있습니다. 주문할 때 입력한 계좌가 있으면 미리 채워집니다. 입금이 완료된 가상계좌 주문은 환불에 계좌가 반드시 필요하므로 입력하지 않으면 취소할 수 없습니다. 무통장입금은 관리자가 직접 이체할 때 참고하도록 선택 입력이며, 카드·간편결제는 원래 결제수단으로 환불되므로 계좌란이 나타나지 않습니다. + +#### 금액·과세 + +- 주문에 현금성 금액이 기록됩니다. 무통장입금 주문에서 마일리지를 사용한 경우, 실제로 계좌에 입금된 금액만 현금영수증 발급 대상이 됩니다. 주문을 일부 취소하면 남은 실입금액 기준으로 다시 계산됩니다. +- 배송비 과세 방식을 이커머스 환경설정에서 고를 수 있습니다 — 안분(기본, 과세상품 비율만큼 배송비를 과세로 계산) / 전액 과세 / 주된 재화를 따름 중 상점의 세무 판단에 맞춰 선택합니다. + +#### 결제수단 + +- 결제 플러그인이 등록한 결제수단(예: "가상계좌 (토스페이먼츠)")을 주문서에서 선택해 주문할 수 있습니다. 이런 결제수단은 화면에서는 독립된 항목으로 보이지만 주문에는 대응하는 기본 결제수단(가상계좌·계좌이체·휴대폰·카드)으로 기록되어, 입금 확인·취소·환불 등 이후 처리가 정상적으로 이어집니다. + +#### 사이트맵·알림·마일리지 + +- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. +- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다. +- 마일리지 설정의 통화별 규칙에 적립 절사 기준(1점 / 10점 / 100점 단위 × 버림 / 반올림 / 올림)을 추가했습니다. 적립 포인트를 계산할 때와 부분취소로 주문이 나뉠 때 모두 이 기준이 적용됩니다. 기존 쇼핑몰은 지금까지와 같은 "1점 단위 버림"으로 시작하므로 적립액이 달라지지 않습니다. 이 기준은 주문 시점의 값이 함께 보관되어, 이후 기준을 바꿔도 과거 주문의 적립액이 소급해서 바뀌지 않습니다. 언어/통화 설정의 환율 절사와는 별개 항목입니다(그쪽은 외화 표시 환산에만 적용됩니다). + +### Changed + +- 과세·면세 금액 합계에 배송비가 포함됩니다. 그동안 배송비는 과세/면세 어느 쪽에도 집계되지 않아 상품 금액만 합산되었습니다. +- 현금영수증 발급 용도 값을 소득공제(income) / 지출증빙(expense)으로 통일했습니다. 기존에 다른 값으로 저장된 주문은 모듈 업데이트 시 자동으로 정리됩니다. +- 주문 상세 응답에 현금영수증 정보와 결제 영수증 링크가 포함됩니다. +- 결제대행사 환불 연동에 환불 건 정보를 함께 전달합니다. 결제 플러그인이 이 값으로 중복 취소를 방지할 수 있습니다 — 네트워크 재시도로 같은 취소 요청이 두 번 전달되어도 이중 환불되지 않습니다. +- 관리자 상품 목록이 상품마다 옵션 전체를 함께 내려주지 않습니다. 목록에는 옵션 개수와 재고 합계만 실리고, 옵션 상세는 행을 펼칠 때 필요한 상품의 것만 한 번에 가져옵니다. 옵션을 많이 쓰는 쇼핑몰일수록 목록을 여는 속도와 전송량이 크게 개선됩니다(상품 100건에 상품당 옵션 20건 기준, 목록 응답 크기 약 94% 감소). 화면에서 보이는 내용과 일괄 변경 적용 범위는 이전과 동일하며, 옵션을 목록 응답에 함께 받으려면 주소 뒤에 `?with_options=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 관리자 주문 목록이 주문마다 주문상품 전체를 함께 내려주지 않습니다. 목록은 대표 상품 1건과 "외 N건" 표기만 사용하므로, 이제 그 1건과 개수만 읽습니다. 한 주문에 상품을 많이 담는 쇼핑몰일수록 목록을 여는 속도와 전송량이 개선됩니다. 화면에 보이는 내용(대표 상품·추가옵션 요약·"외 N건"·부분취소 표시)은 이전과 동일하며, 마이페이지 주문내역은 종전대로 주문상품 전체를 보여줍니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 상품 옵션의 표시 순서를 상품 등록/수정 화면과 동일하게 정렬 순서 기준으로 통일했습니다. 이전에는 관리자 상품 목록의 펼친 옵션만 정해진 순서 없이 표시돼 화면마다 순서가 달라 보일 수 있었습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 상품 목록에서 대표 이미지를 고를 때 이미 읽어 둔 이미지 정보를 다시 조회하지 않도록 바꿨습니다. 관리자 상품 목록·쇼핑몰 상품 목록·위시리스트·관리자 리뷰 목록이 함께 빨라집니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 관리자 주문 목록이 주문마다 배송 정보 전체를 함께 내려주지 않습니다. 목록은 대표 배송 1건(배송유형·배송방법·택배사·송장번호)만 표시하므로 그 1건만 읽으며, 택배사 이름도 함께 읽어 행마다 다시 조회하지 않습니다. 분할 배송이 많은 쇼핑몰일수록 목록이 가벼워집니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 배송정책 목록이 정책마다 국가별 배송 설정을 통째로 내려주지 않습니다. 목록에는 화면이 실제로 표시하는 값(국가·배송방법·부과정책·배송비·무료배송 기준액·도서산간 사용 여부·사용 여부)만 실리고, 구간별 배송비 표·도서산간 지역 설정·배송비 계산 API 설정은 빠집니다. 정책 하나에 국가 설정이 수백 건까지 달릴 수 있어 목록을 여는 것만으로 그만큼의 상세 설정이 전송됐습니다. 배송정책 관리 목록과 상품 등록/수정 화면의 표시 내용은 종전과 동일하며, 전체 설정이 필요한 외부 연동은 주소 뒤에 `?with_country_settings=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 마이페이지 주문내역 조회가 기본으로 주문마다 주문상품 전체를 내려주지 않습니다. 기본 응답에는 대표 상품 1건과 전체 개수만 실리며, 주문상품을 전부 나열하는 마이페이지 화면은 전량을 함께 요청하므로 보이는 내용은 종전과 동일합니다. 주문내역 조회를 직접 사용하는 외부 연동에서 상품 전체가 필요하면 주소 뒤에 `?with_items=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 통합 검색의 상품 결과를 최신순·가격순으로 볼 때 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 결과가 많아도 페이지를 넘길수록 느려지지 않습니다. +- 분류 단건 조회가 화면에서 쓰지 않는 상품 수·하위 분류 수 집계를 기본으로 계산하지 않도록 바꿨습니다. 분류 순서 변경처럼 그 값을 읽지 않는 작업이 빨라집니다. +- 상품 목록의 판매량 정렬과 인기 상품 조회 방식을 개선했습니다. 예전에는 순서를 정하기 위해 전체 상품의 판매량을 매번 계산했지만, 이제 미리 묶어 둔 집계를 사용합니다. 상품이 많을수록 차이가 큽니다. +- 상품 목록의 리뷰 개수·평점도 같은 방식으로 한 번에 집계합니다. 예전에는 상품 한 건마다 리뷰 표를 두 번씩 훑었습니다. +- 주문·마일리지·쿠폰 발급내역·리뷰·1:1 문의 목록도 총 건수가 아주 많으면 "N건 이상" 으로 표시합니다. 페이지 이동은 끝까지 열려 있습니다. +- 상품·쿠폰 검색이 조건에 맞는 항목 전체를 먼저 메모리에 올린 뒤 다시 조회하던 방식을 없앴습니다. 검색 결과가 많아도 메모리 사용량이 늘지 않습니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.) +- 플러그인으로 다른 검색엔진을 연결하면 상품·쿠폰·브랜드 검색에도 그 엔진이 적용됩니다. 이전에는 연결한 엔진과 무관하게 기본 방식으로만 검색됐습니다. +- 상품 검색 결과의 총 건수가 아주 많으면 "N건 이상" 으로 표시하고, 다음 페이지 이동은 끝까지 열어 둡니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다. +- 기간으로 걸러 보는 목록(주문·쿠폰)의 날짜 조건을 색인을 활용하는 방식으로 바꿨습니다. +- 주문 상세에서 여러 옵션의 상태를 한꺼번에 바꿀 때, 대상 옵션을 한 번에 조회하도록 바꿨습니다. 예전에는 항목마다 같은 옵션을 다시 읽었고, 결제완료로 바꿀 때는 주문과 결제수단까지 주문 수만큼 되풀이해 읽었습니다. 한 번에 처리하는 항목이 많을수록 차이가 큽니다. +- 주문 관리 화면 상단의 통계를 짧게 저장해 두고 재사용합니다. 주문이 바뀌면 즉시 새로 계산됩니다. +- 상품 대량 수정(가격·재고)이 상품 수만큼 저장을 반복하던 것을 한 번에 처리하도록 바꿨습니다. +- 결제 진행과 비회원 장바구니 병합에서 항목마다 반복하던 조회를 한 번에 처리하도록 바꿨습니다. 장바구니에 담긴 상품이 많을수록 차이가 큽니다. +- 카테고리 트리를 만들 때 하위 분류마다 조회를 반복하던 것을 한 번에 읽어 조립하도록 바꿨습니다. 거의 모든 쇼핑 화면이 읽는 값이라 결과를 짧게 저장해 두고 재사용하며, 분류나 상품이 바뀌면 즉시 새로 계산됩니다. +- 상품 관리 화면 상단의 통계를 짧게 저장해 두고 재사용합니다. 상품·재고가 바뀌면 즉시 새로 계산됩니다. +- 상품 검색 탭의 배지 숫자도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. +- 상품 목록의 분류 경로 표시가 같은 조상 분류를 반복 조회하던 것을 한 번으로 줄였습니다. +- 상품 목록·주문 옵션 집계에 필요한 색인을 추가했습니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 데이터가 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 해당 데이터의 저장이 잠시 대기할 수 있습니다. +- 실제로 사용되지 않던 설정 항목(상품·주문 목록의 페이지당 개수, 결제 자동취소 기한 중복 항목)을 정리했습니다. 미입금 자동취소 기한은 관리자 환경설정의 값 하나만 사용합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 상품 리뷰·마일리지 내역·쿠폰 발급 내역 목록도 같은 방식으로 뒤쪽 페이지 조회를 개선했습니다. 상품 리뷰는 작성일 정렬용 인덱스를 추가하고 기간 검색 조건을 인덱스가 활용되는 형태로 바꿔, 리뷰가 많은 쇼핑몰에서 목록과 기간 검색이 함께 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문·상품·상품문의·상품후기·쿠폰 발급이력 목록의 정렬 색인을 정비했습니다. 정렬 순서를 색인이 그대로 담고 있지 않아 목록을 열 때마다 따로 줄을 세우던 작업이 사라져, 데이터가 많은 쇼핑몰일수록 목록이 빨라집니다. 기존 쇼핑몰도 업데이트 시 자동으로 반영되며, 데이터가 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 해당 데이터의 저장이 잠시 대기할 수 있습니다. +- 상품 목록(관리자·쇼핑몰 화면)·상품 문의 관리 목록·추가배송비 목록도 같은 방식으로 개선했습니다. 상품 문의는 기간 검색 조건도 인덱스가 활용되는 형태로 바꿔 기간을 좁혀 찾을 때 더 빨라집니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 주문의 스냅샷·관리자 메모 같은 큰 항목까지 함께 읽어 느려졌지만, 이제 현재 페이지의 주문만 상세 정보를 읽습니다. 주문 10만 건 기준 5,000번째 페이지에서 조회 시간이 약 6분의 1로 줄어듭니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.) +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. + +### Fixed + +- 기본 통화를 바꾼 뒤 통화 설정을 저장한 상점에서, 결제창에 청구되는 금액이 주문서에 표시된 금액과 다르게 넘어가던 문제를 수정했습니다. 이전 기본 통화가 기본 통화 표시를 계속 유지해 상점에 기본 통화가 둘로 인식됐고, 그 통화의 환율이 1로 간주되어 환산 없이 청구됐습니다. 예를 들어 기본 통화를 엔화로 바꾼 상점에서 23,000엔 주문이 결제창에는 230원으로 넘어갔습니다. 이제 환율을 입력하지 않은 통화로는 결제가 진행되지 않고 미지원 통화로 안내됩니다. +- 같은 화면에서 상품별 금액과 총액의 다른 통화 표기가 서로 다른 기준으로 환산되던 문제를 함께 수정했습니다. 위 문제와 원인이 같으며, 주문서의 상품 금액과 총 결제금액의 외화 표기가 어긋나 보였습니다. +- 결제대행사 플러그인이 더 이상 제공하지 않는 결제수단이 주문서에 계속 노출되던 문제를 수정했습니다. 예를 들어 토스페이먼츠의 주문서형 결제를 끄면 관리자 환경설정에서는 해당 결제수단이 즉시 사용 불가로 바뀌는데, 주문서에서는 그대로 선택할 수 있었습니다. 플러그인을 삭제하거나 비활성화한 경우에도 동일했습니다. 이제 관리자 화면과 주문서가 같은 기준으로 판단합니다. +- 이커머스 환경설정 주문설정의 결제수단 목록에서, 결제대행사를 아직 고르지 않은 줄의 선택 상자가 지나치게 좁게 표시되던 문제를 수정했습니다. 선택 상자 폭이 선택된 값의 글자 수를 따라가 줄마다 항목 위치도 어긋나 있었습니다. 이제 결제대행사·재고 차감 시점·마일리지 차감 시점이 모든 줄에서 같은 폭으로 정렬됩니다. +- 같은 목록에서 더 이상 사용할 수 없는 결제수단(공급 플러그인을 지웠거나 끈 경우) 줄에 "마일리지 차감 시점" 항목만 남아 표시되던 문제를 수정했습니다. 다른 설정 항목은 모두 감춰지는데 이 항목만 남아 엉뚱한 위치에 보였고, 마일리지 기능을 사용하는 상점에서는 이미 쓸 수 없는 결제수단의 값을 바꿔 저장할 수도 있었습니다. 화면을 좁게 볼 때는 정상적으로 감춰지고 있어 넓은 화면에서만 나타났습니다. +- 기본 통화가 원화가 아닌 상점에서 금액에 원화 단위가 붙던 문제를 수정했습니다. 마일리지 적립·사용·복원·소멸 내역, 결제금액 불일치 안내, 상품 일괄수정 요약, 할인코드 안내가 이제 상점에 설정된 통화의 단위로 표시됩니다. +- 통합 검색 결과의 상품 가격이 상점 기본 통화가 아닌 원화 단위로 표기되던 문제를 수정했습니다. +- 관리자에게 발송되는 신규 주문 알림의 금액이 구매자 알림과 달리 원화로 고정되던 문제를 수정했습니다. 이제 두 알림 모두 결제 통화로 표시됩니다. +- 관리자 주문 상세와 주문 취소 화면의 상품 단가·소계·쿠폰 할인액·추가옵션 금액이 원화 단위로 고정 표시되던 문제를 수정했습니다. +- 상품의 기본 통화와 다른 통화로 결제한 주문에서 관리자가 입금확인을 저장할 수 없던 문제를 수정했습니다. 입금액이 기본 통화 기준 금액으로 미리 채워져 실제 청구된 결제 통화 금액과 어긋난 탓에 저장할 때마다 금액이 맞지 않는다며 거절됐습니다. 이제 실제 청구된 결제 통화 금액이 채워집니다. +- 관리자 마일리지 수동 지급·차감 화면의 통화가 원화로 고정돼, 기본 통화가 다른 상점에서 실제로 쓰지 않는 통화로 지급될 수 있던 문제를 수정했습니다. 이제 상점 기본 통화가 기본 선택됩니다. +- 환경설정 통화 관리의 절사 단위 선택지가 원화·엔화에서만 정수 단위로 나오던 문제를 수정했습니다. 이제 소수점을 쓰지 않는 통화라면 관리자가 추가한 통화에서도 정수 단위가 제공됩니다. +- 배송비 계산 API 테스트 결과의 추출 금액에 원화 단위가 붙던 문제를 수정했습니다. +- 기본 통화가 원(KRW)이 아닌 쇼핑몰에서, 다른 통화로 볼 때 추가옵션 추가금만 기준 통화 금액으로 표시되고 그 금액이 그대로 합계에 더해지던 문제를 수정했습니다. 예를 들어 기본 통화가 엔(JPY)인 상점을 원화로 보면 상품가는 환산되는데 추가금은 `+¥5,000` 그대로 남아, 총 금액이 두 통화가 섞인 값으로 표시됐습니다. 이제 상품 상세·장바구니·주문서의 추가옵션 금액이 보고 있는 통화로 환산되어 표시되고 합계에도 같은 통화로 더해집니다. 기본 통화가 원인 상점은 이전과 동일합니다. +- 장바구니 담기와 장바구니 옵션 변경의 응답에 선택한 추가옵션이 빠져 있던 문제를 수정했습니다. 선택 자체는 정상 저장되어 장바구니 화면에는 제대로 표시됐지만, 담기·옵션 변경 응답만 읽으면 추가옵션이 없는 것으로 보이고 소계도 추가금이 빠진 금액으로 나왔습니다. 이제 두 응답 모두 장바구니 조회와 동일한 값을 돌려줍니다. +- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 서버가 만들어 보내는 상점 주소가 기본 주소로 잘못 만들어지던 문제를 수정했습니다. 주문 완료 후 이동, 비회원 주문 조회 안내, 알림 메일의 주문 조회 주소, 통합검색·상품문의 목록의 상품 링크, 검색엔진용 주소(사이트맵·봇 화면 캐시)가 모두 해당됐습니다. 링크가 만들어지기는 해서 오류로 드러나지 않고 없는 페이지로만 이어졌습니다. 기본 주소를 쓰는 상점은 이전과 동일합니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.) +- 쇼핑몰 주소를 바꾸거나 주소 없이 운영하는 상점에서 화면 편집기의 상품·장바구니·주문서 등 상점 화면을 편집할 때 상태 미리보기 전환(품절/재고 있음 등)이 표시되지 않던 문제를 수정했습니다. 편집기가 기본 주소인 상점만 알아보던 탓이며, 이제 어떤 주소로 바꾸어도 — 주소 없이 운영하는 상점까지 — 동일하게 표시됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.) +- 상품 상세 화면에서만 상태 미리보기 전환(재고 있음/품절/판매중지/판매예정)이 어떤 쇼핑몰 주소에서도 표시되지 않던 문제를 수정했습니다. 편집기가 찾는 화면 주소와 실제 상품 상세 주소의 표기가 서로 달라 끝내 연결되지 않았습니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.) +- "최근 본 상품" 과 찜 목록의 상품 카드가 리뷰가 달린 상품도 별점 0 개 · 리뷰 0건으로 표시하던 문제를 수정했습니다. 두 조회만 리뷰를 세지 않아 값이 비어 있었고, 빈 값이 0 으로 표기되어 실제 평점과 다르게 보였습니다. 이제 다른 상품 목록과 동일하게 실제 평점·리뷰 수가 표시됩니다. +- 리뷰를 세지 않는 조회 경로(관리자 상품 목록 등)에서는 평점·리뷰 수 항목을 아예 싣지 않도록 바꿨습니다. 종전에는 세지 않았을 뿐인데 0 이 실려 "리뷰 없음" 과 구분되지 않았습니다. 리뷰가 실제로 0건이면 종전대로 0 으로 표시됩니다. +- 모바일·태블릿 화면에서 쿠폰 목록의 카드 보기가 내용 없이 나오던 문제를 수정했습니다. 목록의 각 쿠폰 정보가 카드에 전달되지 않아, 쿠폰이 있어도 빈 카드만 표시됐습니다. +- 주문 검색 프리셋 관리 창에서 "프리셋 추가" 버튼을 눌러도 저장 창이 열리지 않던 문제를 수정했습니다. +- 상품 리뷰 목록의 작성자 이름과 쿠폰 목록의 쿠폰 이름을 키보드로 선택해 이동할 수 있게 했습니다. 이전에는 마우스 클릭으로만 이동할 수 있었습니다. +- 주문 검색 프리셋 관리 창의 수정·삭제 아이콘이 의도한 크기보다 작게 보이던 문제를 수정했습니다. +- 할인코드 목록에서 선택 개수·전체 건수·페이지당 표시 개수 문구에 숫자가 채워지지 않고 자리표시자가 그대로 보이던 문제를 수정했습니다. +- 마이페이지와 비회원 주문 조회의 주문 상세에서 상품별 배송정책명과 개별 배송비가 아무 안내 없이 표시되지 않던 문제를 수정했습니다. 주문에 보관된 배송정책 기록의 저장 형태가 화면이 기대하는 형태와 달라 해당 줄이 통째로 빠져 있었습니다. 이미 저장된 주문도 업그레이드 시 함께 정리됩니다. +- 비회원 주문 조회의 주문 상세에서 상품별 배송정책명과 개별 배송비가 표시되지 않던 문제를 추가로 수정했습니다. 회원 주문 상세와 같은 화면 구성을 쓰는데 비회원 응답에만 배송정책 정보가 빠져 있어, 오류 없이 그 줄만 비어 있었습니다. 표시에 필요한 정책명과 배송비만 내려주며 내부 계산 정보는 종전대로 제공하지 않습니다. +- 쇼핑몰 알림 설정 화면에서 모든 알림의 제목·수신자·활성 여부가 비어 보이고 「이 채널에 대한 템플릿이 없습니다」로만 표시되던 문제를 수정했습니다. 편집 창을 열어도 내용이 채워지지 않았습니다. 채널을 전환하거나 새로고침해도 마찬가지였습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.) +- 관리자 상품 목록의 새로고침·상품등록·엑셀 내려받기 아이콘이 의도한 크기보다 작게 표시되던 문제와, 일괄적용 버튼이 다크 모드에서 밝은 배경 그대로 보이던 문제를 수정했습니다. +- 미입금 자동취소 기한을 비운 채 저장하면 "저장되었습니다"라고 안내되면서도 값은 사라지던 문제를 수정했습니다. 숫자 항목에 숫자가 아닌 값을 입력하면 입력칸이 비워지는데, 그대로 저장하면 설정이 없는 상태로 남아 화면에 보이는 값과 실제 동작이 어긋났습니다. 이제 비워 둔 채로는 저장되지 않고 값을 입력하라고 안내합니다. +- 미입금 자동취소 기한 입력 안내가 실제 입력 하한과 다른 "0일 이상"으로 표시되던 문구를 "1일 이상"으로 바로잡았습니다. +- 쿠폰 수정 화면에서 유효기간을 "발급일로부터"로 두고 일수를 비운 채 저장할 수 있던 문제를 수정했습니다. 그렇게 저장된 쿠폰은 발급되는 즉시 만료 상태가 되어, 받은 회원이 사용할 수 없으면서도 아무 오류가 나지 않았습니다. 이제 저장 단계에서 일수를 입력하도록 안내하고, 이미 그렇게 저장돼 있던 쿠폰은 발급 시 유효기간이 설정되지 않았다고 알려 줍니다. +- 리뷰 작성 기한 판정이 화면 표시와 실제 저장 가능 여부에서 서로 다른 기준을 쓸 수 있던 문제를 수정했습니다. 이제 두 곳이 같은 설정값을 사용하므로 "작성 가능"으로 보이는데 저장이 거부되는 일이 없습니다. +- 관리자 환경설정에서 미입금 자동취소 기한을 한 번이라도 저장하면, 이후 모든 무통장입금·가상계좌 주문이 결제 처리 오류로 실패하던 문제를 수정했습니다. 저장된 기한 값의 형태 때문에 입금기한을 계산하지 못한 것으로, 이제 저장 형태와 무관하게 주문이 정상 생성됩니다. 기한이 비어 있거나 허용 범위(1~30일)를 벗어난 값이면 기본값 3일로 처리합니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.) +- 설정 화면에서 숫자 항목(기한·수량·금액 등)을 저장할 때 값이 숫자가 아닌 형태로 보관되던 문제를 수정했습니다. 화면 표시는 같았지만 저장된 형태가 달라, 그 값을 사용하는 기능에서 오류가 날 수 있었습니다. +- 관리자 상품 목록의 일괄 가격 변경(정액·정률 증액/감액)과 일괄 재고 증감이 실행되지 않던 문제를 수정했습니다. 지정한 금액이나 수량이 반영되지 않고 오류가 발생했습니다. 값을 그대로 지정하는 방식(가격/재고를 특정 값으로 설정)은 영향이 없었습니다. +- 상품 리뷰·상품 문의 목록에서 총 건수를 끝까지 세지 못했을 때 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. +- 마이페이지의 주문 내역·마일리지 내역·위시리스트에서 총 건수를 끝까지 세지 못했을 때 페이지 이동 막대가 통째로 사라지던 문제를 수정했습니다. +- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 로그인하지 않은 상태로 장바구니에 담아 둔 상품이 로그인 후 사라지던 문제를 수정했습니다. 로그인 시 장바구니를 회원 계정으로 옮기는 처리가 실행되지 않아, 담아 둔 상품이 아무 안내 없이 없어졌습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 설정 화면이 허용하는 값과 저장 규칙이 서로 달라 저장이 거부되거나 그 반대가 되던 항목들을 맞췄습니다. 리뷰 이미지 최대 개수(최대 20개), 리뷰 이미지 최대 크기(정수 MB, 최대 50MB), 마일리지 기본 적립률(최대 100%), 미입금 자동취소 기한(최소 1일 — 0일은 주문 즉시 만료라 사용할 수 없는 값)이 대상입니다. 정수만 받는 항목은 입력칸의 증감 단위도 1로 맞춰, 화살표로 값을 올리다 소수가 되어 저장이 거부되는 일이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품 고시정보 템플릿 목록에서 한 번에 가져올 개수에 상한(100개)을 두었습니다. 이전에는 임의의 큰 값을 넘겨 한 요청으로 전체 데이터를 가져갈 수 있었습니다. '전체' 조회는 종전대로 동작합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품 옵션의 정률 적립률을 100%를 넘겨 저장할 수 있던 문제를 수정했습니다. 전체 기본 적립률은 100%까지만 허용되는데 옵션별 적립률에는 상한이 없어, 결제금액보다 많은 마일리지가 적립되도록 설정할 수 있었습니다. 정액(원 단위) 적립은 종전처럼 상한이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품·옵션 일괄 수정에서 가격 검증이 등록/수정 화면보다 느슨하던 문제를 수정했습니다. 원화처럼 소수점을 쓰지 않는 통화에서 소수 가격이 저장되거나, 판매가가 정가보다 크게 저장되거나, 0원으로 저장되던 경우가 막힙니다. 정가·판매가 중 한쪽만 수정하는 경우도 저장된 나머지 값과 함께 판정하므로, 판매가만 정가 위로 올려 저장할 수 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 부분취소 후에도 주문의 부가세가 취소 전 금액으로 남아 과세표준과 어긋나던 문제를 수정했습니다. 취소로 과세표준이 줄면 부가세도 함께 다시 계산되며, 계산에는 주문 당시의 세율이 사용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문의 부가세가 항상 0으로 표시되던 문제를 수정했습니다. 부가세는 부분취소를 거친 주문에서만 채워져 같은 목록에서도 주문마다 세금 표기가 달랐습니다. 이제 주문을 만들 때 계산해 기록하며, 상품별 세율이 다르면 상품별로 계산해 합산합니다. 기존 주문의 부가세는 업데이트 시 자동으로 채워지며, 채운 주문 건수가 업그레이드 로그에 남습니다(이미 값이 있는 주문은 건드리지 않습니다). (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 주문서의 마일리지 '사용 가능 최대' 안내가 실제 결제 기준 금액과 최소 사용액을 반영하도록 수정했습니다. 이전에는 할인·배송비 반영 전 상품합계를 기준으로 계산해 실제로 쓸 수 있는 금액보다 크게 안내되었고, 최소 사용액에 못 미치는 경우에도 사용 가능한 것처럼 보였습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.) +- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다). +- 휴대폰으로 관리자 주문 목록을 볼 때 왼쪽 위 '뒤로' 버튼을 누르면 이전 화면이 아니라 빈 페이지로 넘어가던 문제를 수정했습니다. +- 관리자 주문·상품·쿠폰·배송정책·리뷰·마일리지 목록에서 상세나 등록/수정 화면에 다녀오면 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. 필터를 여러 개 건 상태에서 주문 하나를 확인하고 돌아와도 조건이 그대로 유지됩니다. 정렬·표시 개수를 바꾸거나 새로고침해도 걸어 둔 조건은 그대로입니다. 반대로 초기화 버튼과 프리셋 적용은 종전처럼 조건을 새로 세팅합니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) +- 관리자 브랜드·상품 공통정보·상품 고시정보 목록에서 검색어(브랜드는 정렬 포함)를 걸어 둔 채 새로고침 버튼을 누르면 걸어 둔 검색이 풀려 전체 목록으로 돌아가던 문제를 수정했습니다. 이제 새로고침은 보던 조건 그대로 다시 불러옵니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) +- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. 비회원 주문자명으로 검색할 때 회원 지정 조건이 함께 걸려 결과가 비어 보이던 문제도 함께 해소되며, 주문 상세의 주문자 카드에서 같은 검색을 실행할 때도 동일하게 적용됩니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) +- 관리자 브랜드 관리에서 브랜드명으로 검색하면 결과가 항상 비어 있던 문제를 수정했습니다. 이제 브랜드명·slug·웹사이트 주소 중 하나만 일치해도 검색됩니다. +- 관리자 상품 등록/수정 화면의 배송 정보에서 '배송정책 등록' 버튼을 눌러도 아무 반응이 없던 문제를 수정했습니다. 이제 배송정책 등록 화면으로 이동합니다. +- 관리자 브랜드·상품 카테고리·상품 공통정보·상품 고시정보 목록에서 왼쪽 목록의 항목을 고르면 걸어 둔 검색어(브랜드는 정렬 포함)가 사라지던 문제를 수정했습니다. 항목 선택은 선택 상태만 주소에 기록하고 목록 조건은 그대로 둡니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) +- 관리자 쿠폰 목록에서 '등록' 으로 들어가거나 상품을 새로 저장한 직후, 보고 있던 목록의 조건이 사라져 취소·복귀 시 1페이지로 돌아가던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) +- 상품 문의 등록이 실패했을 때 "문의 등록에 실패했습니다." 만 뜨고 이유를 알 수 없던 문제를 수정했습니다. 이제 "문의 게시판이 설정되지 않았습니다" 처럼 구체적인 사유가 표시되어, 문의 수정·답변 화면과 안내 수준이 같아집니다. +- 찜 목록에서 판매자가 삭제한 상품이 빈 칸으로 남아 "데이터를 표시할 수 없습니다" 로 보이던 문제를 수정했습니다. 삭제된 상품은 목록에서 빠집니다. +- 회원 마일리지 내역에서 사용·차감 내역만 「내용」 칸이 비어 있던 문제를 수정했습니다. 적립과 동일하게 사유가 표시됩니다. +- 관리자 마일리지 내역 목록의 NO 칸이 항상 비어 있던 문제를 수정했습니다. +- 내 쿠폰 목록에서 유효기간이 지난 쿠폰이 "사용가능" 으로 표시되던 문제를 수정했습니다. 만료 상태로 표시됩니다. +- 상품 문의 목록과 찜 목록에서 한 페이지에 보여줄 개수를 0·음수·과도하게 큰 값으로 요청하면 오류가 나거나 목록 전체가 한 번에 조회되던 문제를 수정했습니다. 1~100 범위를 벗어나면 요청이 거부됩니다. +- 상품 문의 목록·찜 목록·다운로드 가능 쿠폰 목록·통합검색 상품 목록에서 같은 시각에 등록된 항목이 여러 건일 때, 페이지를 넘기면 같은 항목이 두 번 보이고 다른 항목은 아예 보이지 않던 문제를 수정했습니다. 이제 페이지를 모두 넘겨도 목록에 있는 항목이 빠짐없이 한 번씩만 나옵니다. +- 주문 관리 목록에서 "최근 발송순" 또는 "오래된 발송순"을 고르면 목록이 갱신되지 않고 정렬 이름만 바뀌던 문제를 수정했습니다. 이제 주문별 발송일 기준으로 정렬되며, 발송 이력이 없는 주문도 목록에서 사라지지 않습니다. 주문이 많아도 빠르게 정렬되도록 색인을 함께 추가했습니다. +- 쿠폰 관리 목록에서 "유효기간 임박순"·"유효기간 여유순"을 고르면 목록이 그대로이던 문제를 수정했습니다. 화면이 제공하는 정렬인데 서버가 거부하고 있었으며, 이제 두 정렬 모두 정상 동작합니다. +- 상품을 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다. +- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 서버가 알려 준 실패 사유가 그대로 표시됩니다. +- 헤더에서 통화나 배송 국가를 바꿀 때 저장에 실패해도 아무 안내 없이 넘어가던 문제를 수정했습니다. 실패하면 그 이유가 화면에 표시됩니다. +- 일부 안내 문구가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다. 리뷰 작성·답글·일괄처리, 장바구니 선택 항목 처리, 주문서 쿠폰 적용, 클레임 사유 관리, 주문 목록의 배송비 검색 조건, 택배사·상품 공통정보 관리, 결제 대기 주문 취소가 해당됩니다. +- 관리자 주문 메모 안내가 실제 입력 한도(2,000자)와 다르게 "최대 1000자"로 표시되던 문제를 수정했습니다. +- 금액·가격 범위 검색에서 상한을 0으로 지정하면 조건이 무시되고 전체 목록이 나오던 문제를 수정했습니다. 주문 목록의 "배송비 0원(무료배송)만 보기", 결제금액 0원 조회, 상품 목록의 가격 범위, 쿠폰 목록의 혜택금액·최소주문금액 범위가 해당됩니다. 이제 0도 정상적인 경계값으로 처리됩니다. +- 쇼핑몰 환경설정 저장·조회, 카테고리 이미지 등록·삭제·순서 변경, 브랜드·상품 카테고리 전시 상태 변경, 검색 프리셋 조회, 결제 설정 조회 화면에서 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다. +- 화면과 문서에 "클래임"으로 잘못 적혀 있던 용어를 "클레임"으로 바로잡았습니다. +- 쿠폰 목록의 "발급수"가 실제 발급 건수보다 적게 표시되던 문제를 수정했습니다. 목록의 발급수와 발급 내역 창의 "총 N건"이 서로 달랐으며, 이제 두 값이 일치합니다. +- 활동 로그 내용에 실제 값 대신 `:option_id` 같은 표시가 그대로 나오던 문제를 수정했습니다. 주문 상세의 활동 로그 탭, 상품 수정 화면의 로그 탭, 관리자 활동 로그 목록이 해당됩니다. +- 클레임 사유 목록 조회 시 유형·귀책 구분·사용여부·검색어에 잘못된 값을 넣어도 그대로 조회되던 것을 검증하도록 바꿨습니다. 잘못된 값은 이제 안내와 함께 거절됩니다. +- 검색 프리셋의 대상 화면 이름(상품 목록·주문 목록·고객 목록)이 표시되지 않던 문제를 수정했습니다. +- 상품 문의 답변 상태와 배송정책이 없는 배송 그룹의 표시명이 비어 보이던 문제를 수정했습니다. +- 상품 옵션 설정, 주문·배송정책·쿠폰·브랜드·카테고리 관리 화면의 아이콘이 의도한 크기보다 크거나 작게 보이던 문제와, 다크 모드에서 일부 버튼의 색 대비가 낮던 문제를 수정했습니다. +- 주문 상세에서 상품 옵션 상태를 일괄 변경할 때 다른 주문의 옵션이 함께 변경될 수 있던 문제를 수정했습니다. 이제 해당 주문에 속하지 않은 옵션이 포함되면 변경이 거절됩니다. +- 상품의 대표 이미지를 지정할 때 다른 상품의 이미지가 대표로 바뀔 수 있던 문제를 수정했습니다. +- 카테고리를 자기 자신이나 자기 하위 카테고리 아래로 옮길 때 화면이 응답하지 않고 해당 카테고리가 목록에서 사라지던 문제를 수정했습니다. 카테고리 수정과 순서 변경 양쪽 모두에 적용됩니다. +- 카테고리를 최상위로 옮길 때 상위 연결은 풀렸지만 계층 정보(경로·단계)가 이전 값 그대로 남아, 하위 카테고리의 위치가 어긋나 보이던 문제를 수정했습니다. +- 기존에 잘못 연결된 카테고리가 있으면 업데이트 시 자동으로 정리하고 계층 정보를 다시 계산합니다. 카테고리는 삭제하지 않고 최상위로 옮깁니다. +- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다. +- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다. +- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다. +- 쿠폰을 수정할 때 유효기간을 비운 채로 저장할 수 있던 문제를 수정했습니다. 등록 화면과 동일하게, "기간 지정" 쿠폰은 시작일·종료일이, "발급일 기준" 쿠폰은 유효일수가 있어야 저장됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 쿠폰의 다른 항목만 고치는 경우에도 "1인당 사용 제한"을 함께 보내지 않으면 저장이 거절되던 문제를 수정했습니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 배송지를 수정할 때 주소를 통째로 비운 채 저장할 수 있던 문제를 수정했습니다. 국내 배송지는 우편번호와 주소가, 해외 배송지는 주소·도시·우편번호가 갖춰져야 저장됩니다. 기본 배송지 지정처럼 주소를 건드리지 않는 수정은 이전과 동일하게 저장됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 해외 배송지를 수정하면 도시·주·우편번호가 안내 없이 비워지던 문제를 수정했습니다. 이번 수정에서 다시 입력하지 않은 항목은 저장된 값이 그대로 유지됩니다. (#78 @glitter-gim 님께서 제보해주셨습니다.) +- 관리자 목록에서 기록이 아주 많아 총 건수를 끝까지 세지 못하면 "1페이지뿐" 으로 표시되어 뒤쪽 페이지를 볼 수 없던 문제를 수정했습니다. 주문·상품·마일리지·쿠폰·쿠폰 발급 이력·상품 리뷰·배송 정책·활동 이력 목록이 해당하며, 마지막 페이지로 바로 뛰는 버튼만 감춰지고 이전·다음 이동은 그대로 동작합니다. +- 쿠폰 사용조건의 상품 검색에서 결과가 많을 때 목록 끝까지 스크롤해도 다음 묶음이 더 불러와지지 않던 문제를 수정했습니다. +- 관리자가 주문상품을 일괄 구매확정할 때와 구매자가 직접 구매확정할 때, 구매확정 시점이 최초 확정 시각으로 동일하게 보존됩니다 — 이미 확정된 주문을 다시 확정 처리해도 시점이 덮어써지지 않습니다. +- 구매자 주문 상세의 결제 정보 영역에 결제 플러그인이 제공하는 공급가액·부가세·영수증 링크·에스크로 구매확정 버튼이 표시되지 않던 문제를 수정했습니다. +- 관리자가 카테고리 이미지를 등록·삭제하거나 순서를 바꾼 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 사용 중인 언어에 맞는 안내 문구가 표시됩니다. + ## [1.0.4] - 2026-07-16 ### Fixed @@ -685,12 +875,12 @@ ### Added -- 클래임 사유 관리 시스템 — DB 기반 환불 사유 관리 (ClaimReason 모델, Repository, Service, Controller, FormRequest, Resource) +- 클레임 사유 관리 시스템 — DB 기반 환불 사유 관리 (ClaimReason 모델, Repository, Service, Controller, FormRequest, Resource) - `ecommerce_claim_reasons` 테이블: type, code, name(다국어), fault_type, is_user_selectable, is_active, sort_order - Admin API 7개: 목록/생성/상세/수정/삭제/상태토글/활성목록 (`/admin/claim-reasons`) - User API 1개: 사용자 선택 가능 사유 목록 (`/user/claim-reasons`) - ClaimReasonFaultTypeEnum (customer/seller/carrier), ClaimReasonTypeEnum (refund) -- 쇼핑몰 환경설정 > 클래임 탭 — 환불 사유 인라인 편집 UI (배송사 관리 패턴) +- 쇼핑몰 환경설정 > 클레임 탭 — 환불 사유 인라인 편집 UI (배송사 관리 패턴) - 테이블 뷰(PC) + 카드 뷰(모바일) 반응형 레이아웃 - EcommerceSettingsController에 syncReasons() 연동 - 업그레이드 스크립트 (Upgrade_0_14_0) — 기존 CancelReasonTypeEnum 7개 값을 DB 시드 데이터로 마이그레이션 diff --git a/modules/_bundled/sirsoft-ecommerce/components.json b/modules/_bundled/sirsoft-ecommerce/components.json index 40b4cba8..d0e9b462 100644 --- a/modules/_bundled/sirsoft-ecommerce/components.json +++ b/modules/_bundled/sirsoft-ecommerce/components.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "identifier": "sirsoft-ecommerce", - "version": "1.0.3", + "version": "1.1.0", "components": { "basic": [], "composite": [], diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index 3eda744e..4ce24561 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.0.4", + "version": "1.1.0", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php index 7cd8e56b..4aef2a12 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php +++ b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php @@ -17,7 +17,6 @@ return [ // 상품 설정 'product' => [ - 'per_page' => 12, 'max_images' => 10, 'max_image_size' => 5 * 1024 * 1024, // 5MB 'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'], @@ -25,14 +24,13 @@ return [ // 주문 설정 'order' => [ - 'per_page' => 20, 'order_number_prefix' => 'ORD', ], // 결제 설정 'payment' => [ - // 미입금 주문 자동취소 기한 (일) — 결제수단 무관 단일 SSoT - 'auto_cancel_days' => 3, + // 미입금 주문 자동취소 기한은 관리자 환경설정 order_settings.auto_cancel_days 가 SSoT 다. + // 여기에 같은 이름의 값을 두면 두 번째 SSoT 가 되어 어느 쪽이 적용되는지 알 수 없어진다. // 입금 기한 만료 시 자동 취소 여부 'auto_cancel_expired' => true, @@ -85,6 +83,53 @@ return [ 'max_image_size_mb' => 10, // 리뷰 이미지 최대 크기 (MB) ], + /* + |-------------------------------------------------------------------------- + | 환경설정 입력 한계값 + |-------------------------------------------------------------------------- + | 관리자 환경설정 화면의 숫자 입력 경계값(min/max)과 저장 검증 규칙이 공유하는 단일 출처. + | 규칙(StoreEcommerceSettingsRequest)이 이 값을 읽어 규칙 문자열을 만들고, + | 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다. + */ + 'limits' => [ + // 주문 + 'auto_cancel_days_min' => 1, + 'auto_cancel_days_max' => 30, + 'cart_expiry_days_min' => 1, + 'cart_expiry_days_max' => 365, + + // 마일리지 (전역) + 'default_earn_rate_min' => 0, + 'default_earn_rate_max' => 100, + 'earn_delay_days_min' => 0, + 'earn_delay_days_max' => 365, + 'expiry_days_min' => 1, + 'expiry_days_max' => 3650, + 'expiry_notification_days_before_min' => 1, + 'expiry_notification_days_before_max' => 365, + + // 마일리지 (통화별 규칙 행) + 'point_value_min' => 0.001, + 'min_use_amount_min' => 0, + 'use_unit_min' => 1, + 'max_use_percent_min' => 0, + 'max_use_percent_max' => 100, + 'max_use_value_min' => 0, + 'max_use_value_max' => 1000000000, + + // 통화 행 + 'decimal_places_min' => 0, + 'decimal_places_max' => 8, + + // 리뷰 + 'write_deadline_days_min' => 1, + 'write_deadline_days_max' => 365, + 'max_images_min' => 0, + 'max_images_max' => 20, + 'max_image_size_mb_min' => 1, + 'max_image_size_mb_max' => 50, + ], + // 관리자 대시보드 이커머스 영역 설정 'dashboard' => [ 'scheduler_enabled' => true, // 판매 현황 집계 스케줄러(hourly) 활성화 여부 diff --git a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json index 824d8773..f2b209eb 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json @@ -107,6 +107,9 @@ }, "order_settings": { "default_pg_provider": null, + "cash_receipt_provider": "", + "cash_receipt_self_issue": false, + "shipping_fee_tax_policy": "proportional", "payment_methods": [ { "id": "card", @@ -303,7 +306,9 @@ "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, diff --git a/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php b/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php index d718ba80..87782a82 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php +++ b/modules/_bundled/sirsoft-ecommerce/database/factories/OrderOptionFactory.php @@ -35,7 +35,11 @@ class OrderOptionFactory extends Factory 'order_id' => Order::factory(), 'parent_option_id' => null, 'product_id' => Product::factory(), - 'product_option_id' => ProductOption::factory(), + // 연결 옵션의 재고는 아래 option_snapshot 이 적는 값(100)과 맞춘다. + // ProductOption 기본 정의는 재고를 0~100 난수로 뽑는데, 주문 수량(1~5)보다 작게 + // 뽑히면 결제 완료 시 재고 차감이 실패해 주문 상태를 다루는 테스트가 재고 부족으로 + // 무작위 실패한다. 재고 부족 시나리오는 그 상태를 명시하는 테스트가 따로 지정한다. + 'product_option_id' => ProductOption::factory()->state(['stock_quantity' => 100]), 'option_status' => OrderStatusEnum::PENDING_ORDER, 'source_type' => 'order', 'source_option_id' => null, diff --git a/modules/_bundled/sirsoft-ecommerce/database/factories/ProductFactory.php b/modules/_bundled/sirsoft-ecommerce/database/factories/ProductFactory.php index 157565fc..f55e810c 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/factories/ProductFactory.php +++ b/modules/_bundled/sirsoft-ecommerce/database/factories/ProductFactory.php @@ -17,8 +17,6 @@ class ProductFactory extends Factory /** * 기본 정의 - * - * @return array */ public function definition(): array { @@ -30,7 +28,10 @@ class ProductFactory extends Factory 'ko' => $this->faker->words(3, true), 'en' => $this->faker->words(3, true), ], - 'product_code' => strtoupper($this->faker->bothify('PROD-????-####')), + // 운영 product_code 는 SequenceService(NANOID, 16자, 0-9A-Z) 산출물이라 하이픈이 없다. + // 하이픈이 섞이면 공개 상세 라우트의 `->where('product', '[0-9A-Za-z]+')` 제약에 + // 걸려 404 가 되므로, 팩토리도 동일한 문자 집합·길이를 따른다. + 'product_code' => strtoupper($this->faker->regexify('[0-9A-Z]{16}')), 'sku' => strtoupper($this->faker->bothify('SKU-????-####')), 'brand_id' => null, 'list_price' => $listPrice, @@ -58,8 +59,6 @@ class ProductFactory extends Factory /** * 옵션 있는 상품 (다국어 지원) - * - * @return static */ public function withOptions(): static { @@ -88,8 +87,6 @@ class ProductFactory extends Factory /** * 판매중 상태 - * - * @return static */ public function onSale(): static { @@ -101,8 +98,6 @@ class ProductFactory extends Factory /** * 품절 상태 - * - * @return static */ public function soldOut(): static { @@ -114,8 +109,6 @@ class ProductFactory extends Factory /** * 판매 중지 상태 - * - * @return static */ public function suspended(): static { @@ -126,8 +119,6 @@ class ProductFactory extends Factory /** * 출시예정 상태 - * - * @return static */ public function comingSoon(): static { @@ -138,8 +129,6 @@ class ProductFactory extends Factory /** * 숨김 상태 - * - * @return static */ public function hidden(): static { @@ -150,8 +139,6 @@ class ProductFactory extends Factory /** * 면세 상품 - * - * @return static */ public function taxFree(): static { diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000039_create_ecommerce_product_reviews_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000039_create_ecommerce_product_reviews_table.php index 83e9da0b..a935b05d 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000039_create_ecommerce_product_reviews_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000039_create_ecommerce_product_reviews_table.php @@ -33,8 +33,8 @@ return new class extends Migration $table->text('reply_content')->nullable()->comment('판매자 답변 내용'); $table->string('reply_content_mode', 10)->default('text')->comment('답변 콘텐츠 모드: text / html'); $table->foreignId('reply_admin_id')->nullable() - ->constrained('users')->nullOnDelete() - ->comment('답변 등록 관리자 ID'); + ->comment('답변 등록 관리자 ID') + ->constrained('users')->nullOnDelete(); $table->timestamp('replied_at')->nullable()->comment('답변 등록일시'); $table->timestamp('reply_updated_at')->nullable()->comment('답변 수정일시'); diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000040_create_ecommerce_product_review_images_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000040_create_ecommerce_product_review_images_table.php index 80e4ef88..9f761ea0 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000040_create_ecommerce_product_review_images_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000040_create_ecommerce_product_review_images_table.php @@ -43,8 +43,8 @@ return new class extends Migration // 감사 필드 $table->foreignId('created_by')->nullable() - ->constrained('users')->nullOnDelete() - ->comment('업로더 ID'); + ->comment('업로더 ID') + ->constrained('users')->nullOnDelete(); $table->timestamps(); $table->softDeletes()->comment('소프트 삭제 일시'); diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000047_modify_i18n_columns_in_ecommerce_order_options_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000047_modify_i18n_columns_in_ecommerce_order_options_table.php index bc6961de..362eb03b 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000047_modify_i18n_columns_in_ecommerce_order_options_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000047_modify_i18n_columns_in_ecommerce_order_options_table.php @@ -20,6 +20,9 @@ return new class extends Migration ->whereNotNull($column) ->where($column, '!=', '') ->orderBy('id') + // audit:allow offset-chunk-mutates-filter 갱신값 json_encode(['ko'=>…]) 은 항상 + // `{"ko":…}` 라 non-null·non-empty — 갱신 후에도 whereNotNull + != '' 소속이 + // 유지되어 결과 집합 크기가 변하지 않는다(OFFSET 밀림 없음). ->chunk(500, function ($rows) use ($column) { foreach ($rows as $row) { $value = $row->{$column}; @@ -60,6 +63,9 @@ return new class extends Migration DB::table('ecommerce_order_options') ->whereNotNull($column) ->orderBy('id') + // audit:allow offset-chunk-mutates-filter 갱신값 $koValue 는 최악의 경우 빈 + // 문자열이며 NULL 이 아니다 — 갱신 후에도 whereNotNull 소속이 유지되어 + // 결과 집합 크기가 변하지 않는다(OFFSET 밀림 없음). ->chunk(500, function ($rows) use ($column) { foreach ($rows as $row) { $value = $row->{$column}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000048_create_ecommerce_claim_reasons_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000048_create_ecommerce_claim_reasons_table.php index 7d075ad8..13b25e8e 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000048_create_ecommerce_claim_reasons_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_01_000048_create_ecommerce_claim_reasons_table.php @@ -13,7 +13,7 @@ return new class extends Migration public function up(): void { Schema::create('ecommerce_claim_reasons', function (Blueprint $table) { - $table->id()->comment('클래임 사유 ID'); + $table->id()->comment('클레임 사유 ID'); $table->string('type', 20)->comment('사유 유형 (refund, exchange, return 등)'); $table->string('code', 50)->comment('고유 코드 (order_mistake 등)'); $table->text('name')->comment('다국어 사유명 {"ko":"주문 실수","en":"Order Mistake"}'); @@ -32,7 +32,7 @@ return new class extends Migration }); if (DB::getDriverName() === 'mysql') { - DB::statement("ALTER TABLE `".DB::getTablePrefix()."ecommerce_claim_reasons` COMMENT '클래임 사유 템플릿'"); + DB::statement('ALTER TABLE `'.DB::getTablePrefix()."ecommerce_claim_reasons` COMMENT '클레임 사유 템플릿'"); } } diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_13_000001_drop_ecommerce_mail_templates_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_13_000001_drop_ecommerce_mail_templates_table.php index a76b0baf..21c4d8fd 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_13_000001_drop_ecommerce_mail_templates_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_04_13_000001_drop_ecommerce_mail_templates_table.php @@ -21,7 +21,9 @@ return new class extends Migration } if (! Schema::hasTable('notification_templates')) { - throw new \RuntimeException( + // audit:allow i18n-throw-hardcoded-korean reason: 마이그레이션 선행조건 가드 — + // 콘솔에서 개발자·운영자가 보는 진단 메시지이며 사용자 화면에 닿지 않는다. + throw new RuntimeException( 'ecommerce_mail_templates 제거 전 notification_templates 가 존재해야 합니다. ' .'코어 Upgrade_7_0_0_beta_2 + 이커머스 Upgrade_1_0_0_beta_2 업그레이드 스텝을 먼저 실행하세요.' ); @@ -52,7 +54,7 @@ return new class extends Migration $table->boolean('is_active')->default(true)->comment('활성 여부'); $table->boolean('is_default')->default(true)->comment('시더 생성 여부'); $table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록'); - $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자'); + $table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete(); $table->timestamps(); }); } diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000001_create_ecommerce_mileage_transactions_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000001_create_ecommerce_mileage_transactions_table.php index 7e2df623..302af31e 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000001_create_ecommerce_mileage_transactions_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000001_create_ecommerce_mileage_transactions_table.php @@ -13,13 +13,13 @@ return new class extends Migration { Schema::create('ecommerce_mileage_transactions', function (Blueprint $table) { $table->id()->comment('거래 ID'); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('회원 ID'); + $table->foreignId('user_id')->comment('회원 ID')->constrained('users')->cascadeOnDelete(); $table->string('currency', 10)->default('KRW')->comment('통화 코드 (주문 기준통화 스냅샷)'); $table->string('type', 30)->comment('거래 유형 (MileageTransactionTypeEnum)'); $table->decimal('amount', 12, 2)->comment('거래 금액 (양수=적립, 음수=차감)'); $table->decimal('remaining_amount', 12, 2)->default(0)->comment('잔여 금액 (적립건만 양수, FIFO 차감용)'); $table->decimal('balance_after', 12, 2)->comment('거래 후 잔액 (감사 스냅샷, 베스트에포트)'); - $table->foreignId('order_id')->nullable()->constrained('ecommerce_orders')->nullOnDelete()->comment('관련 주문 ID'); + $table->foreignId('order_id')->nullable()->comment('관련 주문 ID')->constrained('ecommerce_orders')->nullOnDelete(); // 옵션 병합 시 하드 삭제(SoftDeletes 미사용)되므로 FK 미설정 — unsignedBigInteger + 인덱스만 유지 $table->unsignedBigInteger('order_option_id')->nullable()->comment('관련 주문옵션 ID (병합 하드삭제 대응 — FK 미설정)'); $table->unsignedBigInteger('order_cancel_id')->nullable()->comment('관련 주문취소 ID (복원 멱등 기준)'); diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000004_create_ecommerce_mileage_balances_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000004_create_ecommerce_mileage_balances_table.php index 3488fa82..2cb07fbf 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000004_create_ecommerce_mileage_balances_table.php +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_06_11_000004_create_ecommerce_mileage_balances_table.php @@ -15,7 +15,7 @@ return new class extends Migration // 진실의 원천(SSoT)은 ecommerce_mileage_transactions(원장)이며, 본 테이블은 표시 전용 캐시. Schema::create('ecommerce_mileage_balances', function (Blueprint $table) { $table->id()->comment('잔액 캐시 ID'); - $table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('회원 ID'); + $table->foreignId('user_id')->comment('회원 ID')->constrained('users')->cascadeOnDelete(); $table->string('currency', 10)->default('KRW')->comment('통화 코드 (통화별 행)'); $table->decimal('available', 12, 2)->default(0)->comment('사용 가능 잔액 (활성 lot SUM 스냅샷)'); $table->decimal('pending', 12, 2)->default(0)->comment('적립 예정 (미취소·earn ledger 부재 옵션 적립액 합)'); diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php new file mode 100644 index 00000000..c7befc31 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000001_create_ecommerce_order_cash_receipts_table.php @@ -0,0 +1,62 @@ +id()->comment('현금영수증 이력 ID'); + $table->foreignId('order_id') + ->comment('주문 ID') + ->constrained('ecommerce_orders') + ->onDelete('cascade'); + $table->unsignedBigInteger('order_payment_id')->nullable()->comment('결제 ID'); + $table->string('provider', 50)->comment('발급 프로바이더 (tosspayments, kginicis 등)'); + $table->string('receipt_key', 200)->nullable()->comment('프로바이더 영수증 키'); + $table->string('transaction_type', 20) + ->comment('거래 유형 (issue: 발급, cancel: 취소)'); + $table->string('receipt_type', 20) + ->comment('영수증 유형 (income: 소득공제, expense: 지출증빙)'); + $table->decimal('amount', 12, 2)->comment('발급/취소 금액'); + $table->decimal('tax_free_amount', 12, 2)->default(0)->comment('면세 금액'); + $table->string('identifier_masked', 50)->nullable()->comment('식별번호 (뒤 4자리 외 마스킹)'); + $table->text('receipt_url')->nullable()->comment('영수증 조회 URL'); + $table->string('issue_number', 20)->nullable()->comment('발급번호'); + $table->string('issue_status', 20) + ->comment('발급 상태 (IN_PROGRESS: 처리중, COMPLETED: 완료, FAILED: 실패)'); + $table->string('error_code', 100)->nullable()->comment('실패 시 프로바이더 에러코드'); + $table->text('error_message')->nullable()->comment('실패 시 에러 메시지'); + $table->timestamp('issued_at')->nullable()->comment('발급일시'); + $table->json('raw_response')->nullable()->comment('프로바이더 원응답 (민감키 마스킹 후 저장)'); + $table->timestamps(); + + $table->index(['order_id', 'transaction_type'], 'ecommerce_order_cash_receipts_order_type_index'); + $table->index('receipt_key', 'ecommerce_order_cash_receipts_receipt_key_index'); + $table->index('order_payment_id', 'ecommerce_order_cash_receipts_payment_id_index'); + }); + } + + /** + * 마이그레이션 롤백 + */ + public function down(): void + { + Schema::dropIfExists('ecommerce_order_cash_receipts'); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php new file mode 100644 index 00000000..bdff75a4 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000002_add_cash_equivalent_amount_to_ecommerce_orders_table.php @@ -0,0 +1,69 @@ +decimal('total_cash_equivalent_amount', 12, 2) + ->default(0) + ->after('total_due_amount') + ->comment('주문 전체 현금성 결제 금액 (현금영수증 발급 대상)'); + } + + if (! Schema::hasColumn('ecommerce_orders', 'mc_total_cash_equivalent_amount')) { + $table->text('mc_total_cash_equivalent_amount') + ->nullable() + ->comment('현금성 결제 금액 다중 통화'); + } + }); + + Schema::table('ecommerce_order_options', function (Blueprint $table) { + if (! Schema::hasColumn('ecommerce_order_options', 'subtotal_cash_equivalent_amount')) { + $table->decimal('subtotal_cash_equivalent_amount', 12, 2) + ->default(0) + ->comment('옵션별 현금성 금액 안분액'); + } + + if (! Schema::hasColumn('ecommerce_order_options', 'mc_subtotal_cash_equivalent_amount')) { + $table->text('mc_subtotal_cash_equivalent_amount') + ->nullable() + ->comment('옵션별 현금성 금액 다중 통화'); + } + }); + } + + /** + * 마이그레이션 롤백 + */ + public function down(): void + { + if (Schema::hasTable('ecommerce_orders')) { + Schema::table('ecommerce_orders', function (Blueprint $table) { + foreach (['total_cash_equivalent_amount', 'mc_total_cash_equivalent_amount'] as $column) { + if (Schema::hasColumn('ecommerce_orders', $column)) { + $table->dropColumn($column); + } + } + }); + } + + if (Schema::hasTable('ecommerce_order_options')) { + Schema::table('ecommerce_order_options', function (Blueprint $table) { + foreach (['subtotal_cash_equivalent_amount', 'mc_subtotal_cash_equivalent_amount'] as $column) { + if (Schema::hasColumn('ecommerce_order_options', $column)) { + $table->dropColumn($column); + } + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php new file mode 100644 index 00000000..388a9a62 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000003_add_cash_receipt_identifier_encrypted_to_ecommerce_order_payments_table.php @@ -0,0 +1,43 @@ +text('cash_receipt_identifier_encrypted') + ->nullable() + ->after('cash_receipt_identifier') + ->comment('현금영수증 식별번호 암호문 (재발급용, 구매확정 시 폐기)'); + } + }); + } + + /** + * 마이그레이션 롤백 + */ + public function down(): void + { + if (! Schema::hasTable('ecommerce_order_payments')) { + return; + } + + Schema::table('ecommerce_order_payments', function (Blueprint $table) { + if (Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_encrypted')) { + $table->dropColumn('cash_receipt_identifier_encrypted'); + } + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php new file mode 100644 index 00000000..bacd1d9a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_09_000004_add_cash_receipt_identifier_type_to_ecommerce_order_payments_table.php @@ -0,0 +1,45 @@ +string('cash_receipt_identifier_type', 20) + ->nullable() + ->after('cash_receipt_type') + ->comment('현금영수증 식별번호 종류 (phone: 휴대폰번호, card: 현금영수증카드번호, business: 사업자등록번호)'); + } + }); + } + + /** + * 마이그레이션 롤백 + */ + public function down(): void + { + if (! Schema::hasTable('ecommerce_order_payments')) { + return; + } + + Schema::table('ecommerce_order_payments', function (Blueprint $table) { + if (Schema::hasColumn('ecommerce_order_payments', 'cash_receipt_identifier_type')) { + $table->dropColumn('cash_receipt_identifier_type'); + } + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php new file mode 100644 index 00000000..b41d28aa --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_27_000001_add_mileage_policy_snapshot_to_ecommerce_orders_table.php @@ -0,0 +1,53 @@ +mediumText('mileage_policy_snapshot') + ->nullable() + ->after('shipping_policy_applied_snapshot') + ->comment('주문 시점 마일리지 사용 정책 스냅샷 (최소 사용액/사용 단위/한도 재현용)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('ecommerce_orders')) { + return; + } + + if (! Schema::hasColumn('ecommerce_orders', 'mileage_policy_snapshot')) { + return; + } + + Schema::table('ecommerce_orders', function (Blueprint $table) { + $table->dropColumn('mileage_policy_snapshot'); + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php new file mode 100644 index 00000000..a460f62d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_30_000001_add_created_at_indexes_to_ecommerce_product_reviews_table.php @@ -0,0 +1,72 @@ +index( + ['product_id', 'status', 'deleted_at', 'created_at'], + 'idx_reviews_product_status_deleted_created' + ); + } + + if (! in_array('idx_reviews_deleted_created', $existingIndexes)) { + $table->index(['deleted_at', 'created_at'], 'idx_reviews_deleted_created'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('ecommerce_product_reviews')) { + return; + } + + $existingIndexes = array_column(Schema::getIndexes('ecommerce_product_reviews'), 'name'); + + Schema::table('ecommerce_product_reviews', function (Blueprint $table) use ($existingIndexes) { + $indexes = [ + 'idx_reviews_product_status_deleted_created', + 'idx_reviews_deleted_created', + ]; + + foreach ($indexes as $index) { + if (in_array($index, $existingIndexes)) { + $table->dropIndex($index); + } + } + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_31_000001_add_shipped_at_index_to_ecommerce_order_shippings_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_31_000001_add_shipped_at_index_to_ecommerce_order_shippings_table.php new file mode 100644 index 00000000..e341cd18 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_07_31_000001_add_shipped_at_index_to_ecommerce_order_shippings_table.php @@ -0,0 +1,54 @@ +index(['order_id', 'shipped_at'], 'ecommerce_order_shippings_order_id_shipped_at_index'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('ecommerce_order_shippings')) { + return; + } + + $existingIndexes = array_column(Schema::getIndexes('ecommerce_order_shippings'), 'name'); + + Schema::table('ecommerce_order_shippings', function (Blueprint $table) use ($existingIndexes) { + if (in_array('ecommerce_order_shippings_order_id_shipped_at_index', $existingIndexes)) { + $table->dropIndex('ecommerce_order_shippings_order_id_shipped_at_index'); + } + }); + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_01_000001_add_list_sort_indexes_to_ecommerce_tables.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_01_000001_add_list_sort_indexes_to_ecommerce_tables.php new file mode 100644 index 00000000..dbab1a7d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_01_000001_add_list_sort_indexes_to_ecommerce_tables.php @@ -0,0 +1,103 @@ + [신규 색인명, 컬럼, 교체 대상 기존 색인명(없으면 null), 되돌릴 컬럼]] + * + * @var array, 2: string|null, 3: array}> + */ + private const TARGETS = [ + 'ecommerce_orders' => [ + 'idx_orders_deleted_ordered_id', ['deleted_at', 'ordered_at', 'id'], null, [], + ], + 'ecommerce_products' => [ + 'idx_products_deleted_created_id', ['deleted_at', 'created_at', 'id'], null, [], + ], + 'ecommerce_product_inquiries' => [ + 'idx_inquiries_created_id', ['created_at', 'id'], null, [], + ], + 'ecommerce_product_reviews' => [ + 'idx_reviews_deleted_created_id', ['deleted_at', 'created_at', 'id'], + 'idx_reviews_deleted_created', ['deleted_at', 'created_at'], + ], + 'ecommerce_promotion_coupon_issues' => [ + 'idx_coupon_issues_issued_id', ['issued_at', 'id'], 'idx_issued_at', ['issued_at'], + ], + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + foreach (self::TARGETS as $table => [$newIndex, $columns, $oldIndex, $oldColumns]) { + if (! Schema::hasTable($table)) { + continue; + } + + $existing = array_column(Schema::getIndexes($table), 'name'); + + Schema::table($table, function (Blueprint $blueprint) use ($existing, $newIndex, $columns, $oldIndex) { + if (! in_array($newIndex, $existing, true)) { + $blueprint->index($columns, $newIndex); + } + + if ($oldIndex !== null && in_array($oldIndex, $existing, true)) { + $blueprint->dropIndex($oldIndex); + } + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (self::TARGETS as $table => [$newIndex, $columns, $oldIndex, $oldColumns]) { + if (! Schema::hasTable($table)) { + continue; + } + + $existing = array_column(Schema::getIndexes($table), 'name'); + + Schema::table($table, function (Blueprint $blueprint) use ($existing, $newIndex, $oldIndex, $oldColumns) { + if ($oldIndex !== null && $oldColumns !== [] && ! in_array($oldIndex, $existing, true)) { + $blueprint->index($oldColumns, $oldIndex); + } + + if (in_array($newIndex, $existing, true)) { + $blueprint->dropIndex($newIndex); + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php new file mode 100644 index 00000000..9a5a8267 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_02_000001_add_storefront_indexes_to_ecommerce_tables.php @@ -0,0 +1,80 @@ + [테이블, 컬럼]] + * + * @var array}> + */ + private const TARGETS = [ + 'idx_products_display_created_id' => ['ecommerce_products', ['display_status', 'created_at', 'id']], + 'idx_products_display_price_id' => ['ecommerce_products', ['display_status', 'selling_price', 'id']], + 'idx_order_options_product_created_qty' => ['ecommerce_order_options', ['product_id', 'created_at', 'quantity']], + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + foreach (self::TARGETS as $indexName => [$table, $columns]) { + if (! Schema::hasTable($table)) { + continue; + } + + $existing = array_column(Schema::getIndexes($table), 'name'); + + if (in_array($indexName, $existing, true)) { + continue; + } + + Schema::table($table, function (Blueprint $blueprint) use ($columns, $indexName) { + $blueprint->index($columns, $indexName); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + foreach (self::TARGETS as $indexName => [$table, $columns]) { + if (! Schema::hasTable($table)) { + continue; + } + + $existing = array_column(Schema::getIndexes($table), 'name'); + + if (! in_array($indexName, $existing, true)) { + continue; + } + + Schema::table($table, function (Blueprint $blueprint) use ($indexName) { + $blueprint->dropIndex($indexName); + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-ecommerce/database/seeders/ActivityLogSampleSeeder.php b/modules/_bundled/sirsoft-ecommerce/database/seeders/ActivityLogSampleSeeder.php index 0900c0f2..e16d827b 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/seeders/ActivityLogSampleSeeder.php +++ b/modules/_bundled/sirsoft-ecommerce/database/seeders/ActivityLogSampleSeeder.php @@ -204,17 +204,17 @@ class ActivityLogSampleSeeder extends Seeder $actions = [ ['action' => 'product_option.bulk_price_update', 'key' => 'product_option_bulk_price_update', 'loggable' => true, - 'params' => fn ($opt) => ['count' => rand(2, 20)], + 'params' => fn ($opt) => ['option_id' => $opt->id], 'changes' => fn ($opt) => [ ['field' => 'price_adjustment', 'label_key' => 'sirsoft-ecommerce::activity_log.fields.price_adjustment', 'old' => rand(0, 5000), 'new' => $opt->price_adjustment ?? 0, 'type' => 'currency'], ]], ['action' => 'product_option.bulk_stock_update', 'key' => 'product_option_bulk_stock_update', 'loggable' => true, - 'params' => fn ($opt) => ['count' => rand(2, 20)], + 'params' => fn ($opt) => ['option_id' => $opt->id], 'changes' => fn ($opt) => [ ['field' => 'stock_quantity', 'label_key' => 'sirsoft-ecommerce::activity_log.fields.stock_quantity', 'old' => rand(0, 30), 'new' => $opt->stock_quantity ?? rand(10, 100), 'type' => 'number'], ]], ['action' => 'product_option.bulk_update', 'key' => 'product_option_bulk_update', 'loggable' => true, - 'params' => fn ($opt) => ['count' => rand(2, 20)], + 'params' => fn ($opt) => ['option_id' => $opt->id], 'changes' => fn ($opt) => [ ['field' => 'sku', 'label_key' => 'sirsoft-ecommerce::activity_log.fields.sku', 'old' => 'OLD-SKU-'.rand(100, 999), 'new' => $opt->sku ?? 'SKU-'.rand(100, 999), 'type' => 'text'], ]], @@ -271,7 +271,7 @@ class ActivityLogSampleSeeder extends Seeder 'params' => fn ($o) => ['order_number' => $o->order_number, 'count' => 1], 'changes' => fn ($o) => [ ['field' => 'order_status', 'label_key' => 'sirsoft-ecommerce::activity_log.fields.order_status', 'old' => OrderStatusEnum::PREPARING->value, 'new' => OrderStatusEnum::SHIPPING->value, 'type' => 'enum', - 'old_label_key' => OrderStatusEnum::PREPARING->labelKey(), 'new_label_key' => OrderStatusEnum::SHIPPING->labelKey()], + 'old_label_key' => OrderStatusEnum::PREPARING->labelKey(), 'new_label_key' => OrderStatusEnum::SHIPPING->labelKey()], ]], ['action' => 'order.payment_complete', 'key' => 'order_payment_complete', 'loggable' => true, 'params' => fn ($o) => ['order_number' => $o->order_number]], @@ -310,11 +310,11 @@ class ActivityLogSampleSeeder extends Seeder ['action' => 'order_option.bulk_status_change', 'key' => 'order_option_bulk_status_change', 'loggable' => true, 'params' => fn ($opt) => ['count' => 1]], ['action' => 'order_option.confirm', 'key' => 'order_option_confirm', 'loggable' => true, - 'params' => fn ($opt) => ['order_number' => $opt->order?->order_number ?? 'N/A'], - 'properties' => fn ($opt) => ['order_id' => $opt->order_id]], + 'params' => fn ($opt) => ['option_id' => $opt->id], + 'properties' => fn ($opt) => ['order_id' => $opt->order_id, 'option_id' => $opt->id]], ['action' => 'order_option.partial_cancel', 'key' => 'order_option_partial_cancel', 'loggable' => true, - 'params' => fn ($opt) => ['order_number' => $opt->order?->order_number ?? 'N/A'], - 'properties' => fn ($opt) => ['order_id' => $opt->order_id, 'product_name' => $opt->product_name, 'quantity' => $opt->quantity]], + 'params' => fn ($opt) => ['option_id' => $opt->id], + 'properties' => fn ($opt) => ['order_id' => $opt->order_id, 'option_id' => $opt->id, 'product_name' => $opt->product_name, 'quantity' => $opt->quantity]], ]; $count += $this->generateResourceLogs($allOptions, $admins, ActivityLogType::Admin, (new OrderOption)->getMorphClass(), $optionActions); @@ -524,9 +524,9 @@ class ActivityLogSampleSeeder extends Seeder ['action' => 'product_review.bulk_delete', 'key' => 'review_bulk_delete', 'loggable' => true, 'params' => fn ($r) => ['review_id' => $r->id, 'count' => 1]], ['action' => 'product_review.create', 'key' => 'product_review_create', 'loggable' => true, - 'params' => fn ($r) => ['product_name' => $r->product ? $this->getLocalizedName($r->product->name) : '삭제된 상품']], + 'params' => fn ($r) => ['review_id' => $r->id]], ['action' => 'product_review.delete', 'key' => 'product_review_delete', 'loggable' => false, - 'params' => fn ($r) => ['product_name' => $r->product ? $this->getLocalizedName($r->product->name) : '삭제된 상품'], + 'params' => fn ($r) => ['review_id' => $r->id], 'properties' => fn ($r) => ['deleted_id' => $r->id]], ]; @@ -855,7 +855,8 @@ class ActivityLogSampleSeeder extends Seeder // 주문 레벨 $orderActions = [ ['action' => 'order.create', 'key' => 'user_order_create', 'loggable' => true, - 'params' => fn ($o) => ['order_number' => $o->order_number]], + 'params' => fn ($o) => ['order_id' => $o->id], + 'properties' => fn ($o) => ['order_id' => $o->id, 'order_number' => $o->order_number]], ]; $count = $this->generateResourceLogs($orders, $users, ActivityLogType::User, (new Order)->getMorphClass(), $orderActions); @@ -865,8 +866,8 @@ class ActivityLogSampleSeeder extends Seeder if ($allOptions->isNotEmpty()) { $optionActions = [ ['action' => 'order_option.confirm', 'key' => 'user_order_option_confirm', 'loggable' => true, - 'params' => fn ($opt) => ['order_number' => $opt->order?->order_number ?? 'N/A'], - 'properties' => fn ($opt) => ['order_id' => $opt->order_id]], + 'params' => fn ($opt) => ['option_id' => $opt->id], + 'properties' => fn ($opt) => ['order_id' => $opt->order_id, 'option_id' => $opt->id]], ]; $count += $this->generateResourceLogs($allOptions, $users, ActivityLogType::User, (new OrderOption)->getMorphClass(), $optionActions); diff --git a/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.php b/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.php index 8af77f43..ed40a31f 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.php +++ b/modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/OrderSeeder.php @@ -1553,6 +1553,7 @@ class OrderSeeder extends Seeder $now = now(); $replenished = 0; + $perCoupon = []; foreach ($unique as $record) { CouponIssue::create([ @@ -1567,9 +1568,16 @@ class OrderSeeder extends Seeder 'discount_amount' => null, ]); + $perCoupon[$record['coupon_id']] = ($perCoupon[$record['coupon_id']] ?? 0) + 1; $replenished++; } + // 발급수 카운터 동기화 — 보충 발급도 발급이므로 issued_count 에 반영한다. + // 누락하면 쿠폰 목록의 "발급수" 와 발급 이력의 "총 N건" 이 영구히 어긋난다. + foreach ($perCoupon as $couponId => $count) { + Coupon::where('id', $couponId)->increment('issued_count', $count); + } + $this->command->line(" - 주문서 테스트용 쿠폰 {$replenished}건 보충 발급 완료"); } } diff --git a/modules/_bundled/sirsoft-ecommerce/database/seeders/TestingSeeder.php b/modules/_bundled/sirsoft-ecommerce/database/seeders/TestingSeeder.php index 8f4e6b0b..b9ef4d69 100644 --- a/modules/_bundled/sirsoft-ecommerce/database/seeders/TestingSeeder.php +++ b/modules/_bundled/sirsoft-ecommerce/database/seeders/TestingSeeder.php @@ -8,10 +8,9 @@ use App\Models\Module; use App\Models\Permission; use App\Models\Role; use Illuminate\Database\Seeder; -use Modules\Sirsoft\Ecommerce\Enums\SequenceAlgorithm; use Modules\Sirsoft\Ecommerce\Enums\SequenceType; -use Modules\Sirsoft\Ecommerce\Models\Sequence; use Modules\Sirsoft\Ecommerce\Models\ClaimReason; +use Modules\Sirsoft\Ecommerce\Models\Sequence; use Modules\Sirsoft\Ecommerce\Models\ShippingCarrier; /** @@ -73,7 +72,7 @@ class TestingSeeder extends Seeder // 배송사 초기 데이터 생성 $this->createShippingCarriers(); - // 클래임 사유 초기 데이터 생성 + // 클레임 사유 초기 데이터 생성 $this->createClaimReasons(); } @@ -127,7 +126,7 @@ class TestingSeeder extends Seeder } /** - * 클래임 사유 초기 데이터를 생성합니다. + * 클레임 사유 초기 데이터를 생성합니다. */ private function createClaimReasons(): void { diff --git a/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js b/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js index 00640ee4..f1df4878 100644 --- a/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js +++ b/modules/_bundled/sirsoft-ecommerce/dist/js/module.iife.js @@ -1 +1,2 @@ -var SirsoftEcommerce=function(xe){"use strict";const gt={KRW:0,JPY:0},_t={KRW:"₩",USD:"$",JPY:"¥",CNY:"元",EUR:"€",GBP:"£"},wt={KRW:1e3,JPY:100};function yt(r){if(!r)return 1;const c=r.base_unit??wt[r.code]??1;return Math.max(1,c)}function ht(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function he(r,c){const e=typeof c=="string"?{code:c}:c,t=e.code,o=e.symbol&&e.symbol.length>0?e.symbol:_t[t]??"",a=e.decimal_places??gt[t]??2,n=r.toLocaleString(void 0,{minimumFractionDigits:a,maximumFractionDigits:a});return o?t==="KRW"||o==="₩"||o==="원"?n+"원":o+n:n+" "+t}function ae(r,c){const{basePrice:e,currencies:t}=r,o=parseFloat(String(e))||0;if(!t||!Array.isArray(t)||t.length===0)return{};const a=t.find(i=>i.is_default),n=yt(a),s={};for(const i of t){const l=i.code;if(i.is_default??!1)s[l]={price:o,formatted:he(o,i),is_default:!0,editable:!0};else if(i.exchange_rate&&i.exchange_rate>0){const d=o/n*i.exchange_rate,m=ht(d,i.rounding_unit||"0.01",i.rounding_method||"round");s[l]={price:m,formatted:he(m,i),is_default:!1,editable:!1,exchange_rate:i.exchange_rate}}}return s}const z=window.G7Core?.createLogger?.("Ecom:UpdateProduct")??{log:(...r)=>console.log("[Ecom:UpdateProduct]",...r),warn:(...r)=>console.warn("[Ecom:UpdateProduct]",...r),error:(...r)=>console.error("[Ecom:UpdateProduct]",...r)};function Ct(r,c){const e=r.params||{},{productId:t,field:o,dataSourceId:a="products"}=e;let{value:n}=e;if(n&&typeof n=="object"&&n.target!==void 0&&(n=n.target?.value,z.log("[updateProductField] Extracted value from event:",n)),!t||!o){z.warn("[updateProductField] Missing required params:",{productId:t,field:o});return}if(n===void 0){z.warn("[updateProductField] Value is undefined, skipping update");return}const s=window.G7Core;if(!s?.dataSource?.get||!s?.dataSource?.set){z.warn("[updateProductField] G7Core.dataSource API is not available");return}const i=s.dataSource.get(a);if(!i){console.warn(`[updateProductField] DataSource '${a}' not found`);return}const l=i.data?.data||[];if(!Array.isArray(l)||l.length===0){z.warn("[updateProductField] Products data is empty or invalid");return}const u=s.state.get()||{},d=u.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let g=[...u._local?.productFieldErrors||[]];const f=l.map(C=>{if(String(C.id)===String(t)){const A=C[o];if(typeof n=="object"&&n!==null?JSON.stringify(A)===JSON.stringify(n):String(A)===String(n))return z.log(`[updateProductField] No change for product ${t}.${o}, skipping`),C;let L=n;const R=parseFloat(n)||0;if(o==="selling_price"){const F=parseFloat(C.list_price)||0;F>0&&R>F?(g.some(oe=>oe.productId===String(t)&&oe.field===o)||g.push({productId:String(t),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),L=F):g=g.filter($=>!($.productId===String(t)&&$.field===o))}else g=g.filter(F=>!(F.productId===String(t)&&F.field===o));const x={...C,[o]:L,_modified:!0};return o==="selling_price"&&d&&Array.isArray(d)&&(x.multi_currency_selling_price=ae({basePrice:L,currencies:d}),z.log(`[updateProductField] Recalculated multi_currency_selling_price for product ${t}`)),x}return C});s.dataSource.set(a,{...i,data:{...i.data,data:f}});const w=s.state.getLocal()||{},y=new Set(w.modifiedProductIds||[]);y.add(t);const _={...w.modifiedProductFields||{}},p=String(t),S=new Set(_[p]||[]);S.add(o),_[p]=Array.from(S),s.state.setLocal({modifiedProductIds:Array.from(y),modifiedProductFields:_,productFieldErrors:g}),z.log(`[updateProductField] Updated product ${t}.${o} =`,n)}const K=window.G7Core?.createLogger?.("Ecom:UpdateOption")??{log:(...r)=>console.log("[Ecom:UpdateOption]",...r),warn:(...r)=>console.warn("[Ecom:UpdateOption]",...r),error:(...r)=>console.error("[Ecom:UpdateOption]",...r)};function bt(r,c){const e=r.params||{},{productId:t,optionId:o,field:a,value:n,dataSourceId:s="products"}=e;if(!t||!o||!a){K.warn("[updateOptionField] Missing required params:",{productId:t,optionId:o,field:a});return}const i=window.G7Core;if(!i?.dataSource?.get||!i?.dataSource?.set){K.warn("[updateOptionField] G7Core.dataSource API is not available");return}const l=i.dataSource.get(s);if(!l){console.warn(`[updateOptionField] DataSource '${s}' not found`);return}const u=l.data?.data||[];if(!Array.isArray(u)||u.length===0){K.warn("[updateOptionField] Products data is empty or invalid");return}const d=i.state.get()||{},m=d.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let f=[...d._local?.optionFieldErrors||[]],w=!1;const y=u.map(L=>{if(String(L.id)===String(t)){const R=L.options||[],x=R.findIndex(D=>String(D.id)===String(o));if(x===-1)return K.warn("[updateOptionField] Option not found:",o),L;const F=R[x],$=F[a];if(typeof n=="object"&&n!==null?JSON.stringify($)===JSON.stringify(n):String($)===String(n))return K.log(`[updateOptionField] No change for option ${o}.${a}, skipping`),L;let le=n;const we=parseFloat(n)||0;if(a==="selling_price"){const D=parseFloat(F.list_price)||0;D>0&&we>D?(f.some(ye=>ye.productId===String(t)&&ye.optionId===String(o)&&ye.field===a)||f.push({productId:String(t),optionId:String(o),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),le=D):f=f.filter(H=>!(H.productId===String(t)&&H.optionId===String(o)&&H.field===a))}else f=f.filter(D=>!(D.productId===String(t)&&D.optionId===String(o)&&D.field===a));const B=[...R],re={...B[x],[a]:le,_modified:!0};let ne=null;if(a==="selling_price"){const D=parseFloat(le)||0;if(F.is_default===!0)ne=D,re.price_adjustment=0;else{const H=parseFloat(L.selling_price)||0;re.price_adjustment=D-H}re.price_adjustment_formatted=(re.price_adjustment>=0?"+":"")+re.price_adjustment.toLocaleString()+"원"}if(a==="selling_price"&&m&&Array.isArray(m)&&(re.multi_currency_selling_price=ae({basePrice:le,currencies:m}),K.log(`[updateOptionField] Recalculated multi_currency_selling_price for option ${o}`)),B[x]=re,ne!==null)for(let D=0;DH.is_active===!1?D:D+(parseInt(H.stock_quantity,10)||0),0),He=qe,K.log(`[updateOptionField] Updated product stock to option sum: ${He}`));const Ue={...L,options:B,stock_quantity:He,option_stock_sum:qe,_modified:!0};return ne!==null&&(Ue.selling_price=ne,m&&Array.isArray(m)&&(Ue.multi_currency_selling_price=ae({basePrice:ne,currencies:m})),w=!0,K.log(`[updateOptionField] Reverse-synced product ${t} selling_price to ${ne} from default option`)),Ue}return L});i.dataSource.set(s,{...l,data:{...l.data,data:y}});const _=i.state.getLocal()||{},p=new Set(_.modifiedOptionIds||[]),S=`${t}-${o}`;p.add(S);const C={..._.modifiedOptionFields||{}},A=new Set(C[S]||[]);A.add(a),C[S]=Array.from(A);const b={modifiedOptionIds:Array.from(p),modifiedOptionFields:C,optionFieldErrors:f};if(w){const L=new Set(_.modifiedProductIds||[]);L.add(t);const R={..._.modifiedProductFields||{}},x=String(t),F=new Set(R[x]||[]);F.add("selling_price"),R[x]=Array.from(F),b.modifiedProductIds=Array.from(L),b.modifiedProductFields=R}i.state.setLocal(b),K.log(`[updateOptionField] Updated option ${t}/${o}.${a} =`,n)}const It="g7_preferred_currency",St="g7_locale",Ve=window.G7Core?.createLogger?.("Handler:InitPreferredCurrency")??{log:(...r)=>console.log("[Handler:InitPreferredCurrency]",...r),warn:(...r)=>console.warn("[Handler:InitPreferredCurrency]",...r),error:(...r)=>console.error("[Handler:InitPreferredCurrency]",...r)};function vt(r){const c=window.G7Core;c?.state?.set?c.state.set(r):Ve.warn("G7Core.state.set not available")}function Lt(){try{return localStorage.getItem(St)}catch{return null}}function xt(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function Pt(r){if(!r)return null;const c=r.toLowerCase().split("-")[0].split("_")[0];if(!c)return null;const t=xt().filter(o=>(Array.isArray(o?.locales)?o.locales:[]).some(n=>String(n).toLowerCase()===c));return t.length===1?t[0]?.code??null:t.length>1?t.find(a=>a?.is_default===!0)?.code??null:null}function At(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function kt(r,c){const e=r?.params?.defaultCurrency||"KRW",t=l=>typeof l=="string"&&/^[A-Z]{3}$/.test(l),o=At(),a=r?.params?.accountCurrency??o?.ecommerce_preferred_currency;let n=null;try{n=localStorage.getItem(It)}catch{}const s=a?null:Pt(Lt()),i=t(a)?a:t(n)?n:t(s)?s:t(e)?e:"KRW";vt({preferredCurrency:i}),Ve.log("Preferred currency initialized:",i)}const Gt="g7_preferred_shipping_country",je=window.G7Core?.createLogger?.("Handler:InitPreferredShippingCountry")??{log:(...r)=>console.log("[Handler:InitPreferredShippingCountry]",...r),warn:(...r)=>console.warn("[Handler:InitPreferredShippingCountry]",...r),error:(...r)=>console.error("[Handler:InitPreferredShippingCountry]",...r)};function Ot(r){const c=window.G7Core;c?.state?.set?c.state.set(r):je.warn("G7Core.state.set not available")}function Et(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Dt(r,c){const e=r?.params?.defaultCountry||"KR",t=i=>typeof i=="string"&&/^[A-Z]{2}$/.test(i),o=Et(),a=r?.params?.accountCountry??o?.ecommerce_preferred_shipping_country;let n=null;try{n=localStorage.getItem(Gt)}catch{}const s=t(a)?a:t(n)?n:t(e)?e:"KR";Ot({preferredShippingCountry:s}),je.log("Preferred shipping country initialized:",s)}function W(r){const c=r.getFullYear(),e=String(r.getMonth()+1).padStart(2,"0"),t=String(r.getDate()).padStart(2,"0");return`${c}-${e}-${t}`}function Ft(r){const c=new Date,e=W(c);let t;switch(r){case"today":t=e;break;case"3days":{const o=new Date(c);o.setDate(o.getDate()-2),t=W(o);break}case"week":{const o=new Date(c);o.setDate(o.getDate()-6),t=W(o);break}case"month":{const o=new Date(c);o.setMonth(o.getMonth()-1),t=W(o);break}case"3months":{const o=new Date(c);o.setMonth(o.getMonth()-3),t=W(o);break}case"6months":{const o=new Date(c);o.setMonth(o.getMonth()-6),t=W(o);break}case"1year":{const o=new Date(c);o.setFullYear(o.getFullYear()-1),t=W(o);break}case"all":default:return t="",{startDate:"",endDate:""}}return{startDate:t,endDate:e}}function $t(r){const{preset:c}=r.params||{preset:"today"},{startDate:e,endDate:t}=Ft(c);return{startDate:e,endDate:t,preset:c}}const se=window.G7Core?.createLogger?.("Ecom:DefaultOption")??{log:(...r)=>console.log("[Ecom:DefaultOption]",...r),warn:(...r)=>console.warn("[Ecom:DefaultOption]",...r),error:(...r)=>console.error("[Ecom:DefaultOption]",...r)};function Nt(r,c){const e=r.params||{},{productId:t,optionId:o,dataSourceId:a="products"}=e;if(!t||!o){se.warn("[setDefaultOption] Missing required params:",{productId:t,optionId:o});return}const n=window.G7Core;if(!n?.dataSource?.get||!n?.dataSource?.set){se.warn("[setDefaultOption] G7Core.dataSource API is not available");return}const s=n.dataSource.get(a);if(!s){console.warn(`[setDefaultOption] DataSource '${a}' not found`);return}const i=s.data?.data||[];if(!Array.isArray(i)||i.length===0){se.warn("[setDefaultOption] Products data is empty or invalid");return}const u=(n.state.get()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies,d=i.map(w=>{if(String(w.id)!==String(t))return w;const y=w.options||[];if(y.length===0)return w;const _=y.find(b=>String(b.id)===String(o));if(!_)return se.warn("[setDefaultOption] Option not found:",o),w;const p=y.map(b=>{const L=String(b.id)===String(o);return{...b,is_default:L,_modified:L?!0:b._modified}}),S=_.list_price,C=_.selling_price;let A=w.multi_currency_selling_price;return u&&Array.isArray(u)&&(A=ae({basePrice:C,currencies:u})),se.log(`[setDefaultOption] Set option ${o} as default for product ${t}. Syncing prices: list_price=${S}, selling_price=${C}`),{...w,options:p,list_price:S,selling_price:C,multi_currency_selling_price:A,_modified:!0}});n.dataSource.set(a,{...s,data:{...s.data,data:d}});const m=n.state.getLocal()||{},g=new Set(m.modifiedProductIds||[]);g.add(String(t));const f=new Set(m.modifiedOptionIds||[]);f.add(`${t}-${o}`),n.state.setLocal({modifiedProductIds:Array.from(g),modifiedOptionIds:Array.from(f)}),se.log(`[setDefaultOption] Option ${o} set as default for product ${t}`)}const Pe=window.G7Core?.createLogger?.("Ecom:OptionSelect")??{log:(...r)=>console.log("[Ecom:OptionSelect]",...r),warn:(...r)=>console.warn("[Ecom:OptionSelect]",...r),error:(...r)=>console.error("[Ecom:OptionSelect]",...r)},de=(r,c)=>`${r}-${c}`,Ae=r=>r.split("-")[0];function Tt(r,c){const e=r.params||{},{productId:t,optionId:o,checked:a}=e,n=window.G7Core;if(!n?.state){Pe.warn("[toggleOption] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=de(t,o);let d;a?i.includes(u)?d=i:d=[...i,u]:d=i.filter(p=>p!==u);const w=(n.dataSource?.get("products")?.data?.data||[]).find(p=>String(p.id)===String(t))?.options||[];let y=[...l];const _=Number(t);w.length>0&&(w.map(C=>de(t,C.id)).filter(C=>d.includes(C)).length===w.length?y.includes(_)||y.push(_):y=y.filter(C=>C!==_)),n.state.setLocal({selectedOptionIds:d,selectedItems:y})}function Be(r,c){const e=r.params||{},{productId:t,options:o,checked:a}=e,n=window.G7Core;if(!n?.state){Pe.warn("[toggleProductOptions] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=(o||[]).map(f=>de(t,f.id));let d,m=[...l];const g=Number(t);a?(d=[...i.filter(w=>Ae(w)!==String(t)),...u],m.includes(g)||m.push(g)):(d=i.filter(f=>Ae(f)!==String(t)),m=m.filter(f=>f!==g)),n.state.setLocal({selectedOptionIds:d,selectedItems:m})}function Rt(r,c){return Be(r)}function Mt(r,c){const e=r.params||{products:[],selectedOptionIds:[]},{products:t,selectedOptionIds:o}=e,a=o||[];if(!t||!Array.isArray(t))return{};const n={};for(const s of t){const i=String(s.id),l=s.options||[];if(l.length===0){n[i]="none";continue}const d=l.map(m=>de(i,m.id)).filter(m=>a.includes(m)).length;d===0?n[i]="none":d===l.length?n[i]="all":n[i]="some"}return n}function Ht(r,c){const e=r.params||{},{selectedIds:t}=e,o=window.G7Core;if(!o?.state){Pe.warn("[syncProductSelection] G7Core.state를 사용할 수 없습니다.");return}const a=o.state.getLocal(),n=a.selectedItems||[],s=a.selectedOptionIds||[],l=o.dataSource?.get("products")?.data?.data||[],u=t.filter(g=>!n.includes(g)),d=n.filter(g=>!t.includes(g));let m=[...s];for(const g of u){const f=l.find(w=>Number(w.id)===g);if(f?.options)for(const w of f.options){const y=de(g,w.id);m.includes(y)||m.push(y)}}for(const g of d)m=m.filter(f=>Ae(f)!==String(g));o.state.setLocal({selectedItems:t,selectedOptionIds:m})}const Ce=window.G7Core?.createLogger?.("Ecom:CopyCode")??{log:(...r)=>console.log("[Ecom:CopyCode]",...r),warn:(...r)=>console.warn("[Ecom:CopyCode]",...r),error:(...r)=>console.error("[Ecom:CopyCode]",...r)};async function qt(r,c){const e=window.G7Core;if(!e?.state||!e?.api){Ce.warn("[generateCopyProductCode] G7Core API is not available");return}try{const t=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/generate-code");if(!t?.data?.product_code)throw new Error("Invalid API response");const o=t.data.product_code;if((window.__g7LayoutContextStack||[]).length>0&&e.state.setParentLocal)e.state.setParentLocal({"ui.copyOptions.newProductCode":o}),Ce.log(`[generateCopyProductCode] Generated (parent): ${o}`);else{const s=e.state.getLocal()||{};e.state.setLocal({ui:{...s.ui,copyOptions:{...s.ui?.copyOptions||{},newProductCode:o}}}),Ce.log(`[generateCopyProductCode] Generated (local): ${o}`)}}catch(t){Ce.error("[generateCopyProductCode] Failed:",t),e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code.")}}const Ke=window.G7Core?.createLogger?.("Ecom:CopyProduct")??{log:(...r)=>console.log("[Ecom:CopyProduct]",...r),warn:(...r)=>console.warn("[Ecom:CopyProduct]",...r),error:(...r)=>console.error("[Ecom:CopyProduct]",...r)};async function Ut(r,c){const e=window.G7Core;if(!e?.state){Ke.warn("[copyProduct] G7Core API is not available");return}const t=e.state.getLocal()||{},a=(e.state.getParent?.()||{})._local||{},n=a.ui?.copyOptions||t.ui?.copyOptions||{},s=t.form?.product_code||a.form?.product_code||a.productList?.copyTargetProduct?.product_code||t.productList?.copyTargetProduct?.product_code;if(!s){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.copy_source_not_found")??"Could not find source product information.");return}e.modal?.close?.();const i={copy_id:s,copy_images:n.images??!0?"1":"0",copy_options:n.options??!0?"1":"0",copy_categories:n.categories??!0?"1":"0",copy_sales_info:n.sales_info??!0?"1":"0",copy_description:n.description??!0?"1":"0",copy_notice:n.notice??!0?"1":"0",copy_common_info:n.common_info??!0?"1":"0",copy_other_info:n.other_info??!0?"1":"0",copy_shipping:n.shipping??!0?"1":"0",copy_seo:n.seo??!1?"1":"0",copy_identification:n.identification??!0?"1":"0"},u=`/admin/ecommerce/products/create?${new URLSearchParams(i).toString()}`;Ke.log("[copyProduct] Navigating to:",u),e.navigate?e.navigate(u):c.navigate?c.navigate(u):window.location.href=u}const P=window.G7Core?.createLogger?.("Ecom:Category")??{log:(...r)=>console.log("[Ecom:Category]",...r),warn:(...r)=>console.warn("[Ecom:Category]",...r),error:(...r)=>console.error("[Ecom:Category]",...r)};function Qe(r,c,e,t=[]){for(const o of r){const a=o.name?.[e]||o.name?.ko||"";if(o.id===c)return[...t,a].join(" > ");if(o.children&&o.children.length>0){const n=Qe(o.children,c,e,[...t,a]);if(n)return n}}return null}function Vt(r,c,e){return r.map(t=>{if(t.breadcrumb)return{id:t.id,breadcrumb:t.breadcrumb};const o=Qe(c,t.id,e);if(o)return{id:t.id,breadcrumb:o};const a=t.name?.[e]||t.name?.ko||t.name_localized||"";return{id:t.id,breadcrumb:a}})}function jt(r,c){const e=r.params||{},t=e.depth,o=e.category;if(t===void 0||!o){P.warn("[selectCategory] Missing depth or category param");return}const a=c.isolatedContext;if(a){const u=a.state.selectedCategories||[null,null,null,null];if(u[t]?.id===o.id){P.log(`[selectCategory] Same category already selected at depth ${t}, skipping`);return}const d=u.map((m,g)=>gdp0?t+2:g;a.mergeState({selectedCategories:f,categoryStep:y}),P.log(`[selectCategoryMobile] Selected category at depth ${t} (isolated):`,o.id,"nextStep:",y);return}const n=window.G7Core;if(!n?.state){P.warn("[selectCategoryMobile] G7Core.state API is not available");return}const s=n.state.getLocal()||{},l=(s.ui?.selectedCategories||[null,null,null,null]).map((m,g)=>g0?t+2:s.ui?.categoryStep;n.state.setLocal({ui:{...s.ui,selectedCategories:l,categoryStep:d}}),P.log(`[selectCategoryMobile] Selected category at depth ${t}:`,o.id,"nextStep:",d)}function Kt(r,c){P.log("[addCategoryToSelection] START");const e=window.G7Core;if(!e?.state){P.warn("[addCategoryToSelection] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.locale?.current?.()||"ko",a=c.isolatedContext,n=a?a.state.selectedCategories||[]:t.ui?.selectedCategories||[];P.log("[addCategoryToSelection] Current state:",{selectedCats:n.map(p=>p?.id??null),currentCategoryIds:t.form?.category_ids,currentSelectedCategoryInfos:t.selectedCategoryInfos,usingIsolatedContext:!!a});const s=n.filter(Boolean).pop();if(!s){P.warn("[addCategoryToSelection] No category selected");return}P.log("[addCategoryToSelection] Last selected category:",s.id,s.name);const i=t.form?.category_ids||[];if(i.length>=5){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.category.messages.max_5")??"You can select up to 5 categories.");return}if(i.includes(s.id)){P.log("[addCategoryToSelection] Duplicate detected:",s.id,"in",i),e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.category.messages.already_selected")??"This category is already selected.");return}const l=n.filter(Boolean).map(p=>p.name?.[o]||p.name?.ko||"").join(" > "),u=[...i,s.id],d=t.form?.primary_category_id||s.id,g=(e.state.getDataSources?.()||{}).categories?.data?.data||[];let f=t.selectedCategoryInfos||[];const w=t.form?.categories||[];f.length===0&&w.length>0&&(P.log("[addCategoryToSelection] Initializing from form.categories:",w.length,"items"),f=Vt(w,g,o));const y=[...f,{id:s.id,breadcrumb:l}];P.log("[addCategoryToSelection] Before setLocal:",{newCategoryIds:u,primaryCategoryId:d,newInfos:y});try{e.state.setLocal({form:{...t.form,category_ids:u,primary_category_id:d},selectedCategoryInfos:y,hasChanges:!0}),P.log("[addCategoryToSelection] setLocal SUCCESS")}catch(p){P.error("[addCategoryToSelection] setLocal FAILED:",p)}const _=e.state.getLocal()||{};P.log("[addCategoryToSelection] After setLocal:",{category_ids:_.form?.category_ids,selectedCategoryInfos:_.selectedCategoryInfos}),P.log(`[addCategoryToSelection] Added category ${s.id} with breadcrumb: ${l}. Total: ${u.length}`)}function Qt(r,c){const t=(r.params||{}).categoryId;if(!t){P.warn("[removeCategoryFromSelection] Missing categoryId param");return}const o=window.G7Core;if(!o?.state){P.warn("[removeCategoryFromSelection] G7Core.state API is not available");return}const a=o.state.getLocal()||{},s=(a.form?.category_ids||[]).filter(d=>d!==t);let i=a.form?.primary_category_id;i===t&&(i=s[0]||null);const u=(a.selectedCategoryInfos||[]).filter(d=>d.id!==t);o.state.setLocal({form:{...a.form,category_ids:s,primary_category_id:i},selectedCategoryInfos:u,hasChanges:!0}),P.log(`[removeCategoryFromSelection] Removed category ${t}. Remaining: ${s.length}`)}function zt(r,c){const e=r.params||{},t=e.categoryId,o=e.categories;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=(l,u,d=[])=>{for(const m of l){const g=m.name?.[n]||m.name?.ko||"";if(m.id===u)return[...d,g];if(m.children&&m.children.length>0){const f=s(m.children,u,[...d,g]);if(f)return f}}return null},i=s(o,t);return i?i.join(" > "):""}function Wt(r,c){P.log("[initCategoryInfosFromProduct] START");const e=window.G7Core;if(!e?.state){P.warn("[initCategoryInfosFromProduct] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.locale?.current?.()||"ko",n=(e.state.getDataSources?.()||{}).categories?.data?.data||[],s=t.form?.categories||[],i=t.form?.category_ids||[];if(P.log("[initCategoryInfosFromProduct] Product categories:",s),P.log("[initCategoryInfosFromProduct] Category IDs:",i),P.log("[initCategoryInfosFromProduct] Categories tree available:",n.length>0),i.length===0){P.log("[initCategoryInfosFromProduct] No categories to initialize");return}const l=(d,m,g=[])=>{for(const f of d){const w=f.name?.[o]||f.name?.ko||"";if(f.id===m)return[...g,w];if(f.children&&f.children.length>0){const y=l(f.children,m,[...g,w]);if(y)return y}}return null},u=[];for(const d of i){const m=l(n,d);if(m)u.push({id:d,breadcrumb:m.join(" > ")});else{const g=s.find(f=>f.id===d);if(g){const f=g.name?.[o]||g.name?.ko||g.name_localized||"";u.push({id:d,breadcrumb:f})}}}P.log("[initCategoryInfosFromProduct] Generated selectedCategoryInfos:",u),e.state.setLocal({selectedCategoryInfos:u}),P.log("[initCategoryInfosFromProduct] DONE")}function Yt(r,c){const e={category1:"",category2:"",category3:"",category4:""},t=r.params||{},o=t.categoryId1?String(t.categoryId1):"",a=t.categoryId2?String(t.categoryId2):"",n=t.categoryId3?String(t.categoryId3):"",s=t.categoryId4?String(t.categoryId4):"",i=t.categories;if(!i||!Array.isArray(i)||i.length===0)return P.warn("[validateCategoryPath] No categories data provided"),e;if(o){const l=i.find(u=>String(u.id)===o);if(l&&l.depth===0&&(e.category1=o,a&&l.children)){const u=l.children.find(d=>String(d.id)===a);if(u&&u.depth===1&&(e.category2=a,n&&u.children)){const d=u.children.find(m=>String(m.id)===n);if(d&&d.depth===2&&(e.category3=n,s&&d.children)){const m=d.children.find(g=>String(g.id)===s);m&&m.depth===3&&(e.category4=s)}}}}return P.log("[validateCategoryPath] Result:",e),e}function Jt(r,c){const e=r.params||{},t=e.brandId,o=e.brands;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?s.name?.[n]??s.name?.ko??"":""}function Xt(r,c){const e=r.params||{},t=e.brandId,o=e.brands;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?.description?.[n]??s?.description?.ko??""}const Y=window.G7Core?.createLogger?.("Ecom:Price")??{log:(...r)=>console.log("[Ecom:Price]",...r),warn:(...r)=>console.warn("[Ecom:Price]",...r),error:(...r)=>console.error("[Ecom:Price]",...r)},Zt={KRW:1e3,JPY:100};function eo(r,c){const t=r.find(o=>o.code===c)?.base_unit??Zt[c]??1;return Math.max(1,t)}function to(r,c,e,t){const o=r/t*c;switch(e){case"ceil":return Math.ceil(o);case"floor":return Math.floor(o);case"round":default:return Math.round(o)}}function oo(r,c){const e=r.params||{},t=e.field,o=e.currency,a=e.value,n=e.autoFill;if(!t||!o||a===void 0){Y.warn("[updatePrice] Missing required params: field, currency, or value");return}const s=window.G7Core;if(!s?.state){Y.warn("[updatePrice] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=c.datasources?.currencies?.data||{},u=l?.list||[],d=l?.default_currency||"KRW",m=l?.rounding_method||"round",g={...i.form?.multi_currency_prices||{}};if(g[o]={...g[o]||{},[t]:a},n&&o===d){const y=eo(u,d);u.forEach(_=>{if(_.code!==d){const p=_.exchange_rate??1,S=to(a,p,m,y);g[_.code]={...g[_.code]||{},[t]:S}}})}const f={list:"list_price",selling:"selling_price"},w=o===d?a:i.form?.[f[t]];s.state.setLocal({form:{...i.form,multi_currency_prices:g,[f[t]]:w},hasChanges:!0}),Y.log(`[updatePrice] Updated ${t} price for ${o}: ${a}`)}function ro(r,c){const t=(r.params||{}).options;if(!t||!Array.isArray(t)||t.length===0)return 0;const o=t.reduce((a,n)=>n.is_active===!1?a:a+(n.stock_quantity||0),0);return Y.log(`[calculateTotalOptionStock] Total stock: ${o}`),o}function no(r,c){const e=window.G7Core;if(!e?.state){Y.warn("[validatePriceRelation] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=parseFloat(String(t.form?.list_price))||0,a=parseFloat(String(t.form?.selling_price))||0,n=a>o&&o>0,s=e.t?.("sirsoft-ecommerce.admin.product.validation.selling_price_exceeds_list_price")??"판매가는 정가보다 클 수 없습니다.",i={...t.errors||{}};n?i.selling_price=[s]:delete i.selling_price;const l=Object.keys(i).length>0;e.state.setLocal({validation:{...t.validation,price_error:n},errors:l?i:null}),n?(e.toast?.warning?.(s),Y.warn(`[validatePriceRelation] Selling price (${a}) exceeds list price (${o})`)):Y.log("[validatePriceRelation] Price validation passed")}const N=window.G7Core?.createLogger?.("Ecom:ProductOption")??{log:(...r)=>console.log("[Ecom:ProductOption]",...r),warn:(...r)=>console.warn("[Ecom:ProductOption]",...r),error:(...r)=>console.error("[Ecom:ProductOption]",...r)};function ao(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ze(){return ao().reduce((c,e)=>({...c,[e]:""}),{})}function be(r){return(r??[]).filter(c=>c.is_active!==!1).reduce((c,e)=>c+(parseInt(String(e.stock_quantity),10)||0),0)}const so={KRW:1e3,JPY:100};function ke(){const e=((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).find(o=>o.is_default);if(!e)return 1;const t=e.base_unit??so[e.code]??1;return Math.max(1,t)}function io(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function Ge(r,c,e){const t=c.exchange_rate||0;if(t<=0)return{price:0,formatted:he(0,c)};const o=e??ke(),a=r/o*t,n=io(a,c.rounding_unit||"0.01",c.rounding_method||"round"),s=c.decimal_places??2,i=parseFloat(n.toFixed(s));return{price:i,formatted:he(i,c)}}function Oe(r,c,e){return r.map(t=>{const o=parseFloat(String(t.price_adjustment))||0,a=c+o,n={...t,selling_price:a};if(e.length>0){const s=ke(),i={};e.forEach(l=>{i[l.code]=Ge(a,l,s)}),n.multi_currency_selling_price=i}return n})}function Ee(){return((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(c=>!c.is_default&&c.exchange_rate)}function co(r,c){const e=r.params||{},t=window.G7Core;if(!t?.state){N.warn("[setDefaultOption] G7Core.state API is not available");return}const o=t.state.getLocal()||{},a=[...o.form?.options??[]],n=e.index,s=n!=null?typeof n=="string"?parseInt(n,10):n:-1,i=e.optionCode??a[s]?.option_code;if(!i){N.warn("[setDefaultOption] Missing optionCode/index param",{index:n});return}let l=a.map(m=>({...m,is_default:m.option_code===i}));const u=l.find(m=>m.is_default===!0),d=u?parseFloat(String(u.selling_price))||parseFloat(String(o.form?.selling_price))||0:parseFloat(String(o.form?.selling_price))||0;if(u){u.price_adjustment=0;const m=Ee();l=Oe(l,d,m)}t.state.setLocal({form:{...o.form,options:l,selling_price:d},hasChanges:!0}),N.log(`[setDefaultOption] Set default option to ${i}, synced product selling_price to ${d}`)}function lo(r,c){const e=r.params||{},t=typeof e.index=="string"?parseInt(e.index,10):e.index,o=e.field,a=e.value;if(t==null||!o){N.warn("[updateFormOptionField] Missing required params:",{index:t,field:o});return}const n=window.G7Core;if(!n?.state){N.warn("[updateFormOptionField] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.options??[]];if(t<0||t>=i.length){N.warn("[updateFormOptionField] Index out of bounds:",{index:t,length:i.length});return}const l=["regular_price","sale_price","list_price","selling_price","stock_quantity","safe_stock_quantity","weight","volume","mileage_value"];let u=a;if(l.includes(o)&&(u=parseFloat(a)||0),i[t]={...i[t],[o]:u},o==="selling_price"){const d=parseFloat(String(u))||0,m=Ee();if(i[t].is_default===!0){i[t].price_adjustment=0;const f=Oe(i,d,m);n.state.setLocal({form:{...s.form,options:f,selling_price:d},hasChanges:!0}),N.log(`[updateFormOptionField] Reverse-synced product selling_price to ${d} from default option`);return}const g=parseFloat(String(s.form?.selling_price))||0;if(i[t].price_adjustment=d-g,m.length>0){const f=ke(),w={};m.forEach(y=>{w[y.code]=Ge(d,y,f)}),i[t].multi_currency_selling_price=w,N.log(`[updateFormOptionField] Recalculated multi_currency_selling_price for option at index ${t}:`,m.map(y=>y.code))}}if((o==="stock_quantity"||o==="is_active")&&i.length>0){const d=be(i);n.state.setLocal({form:{...s.form,options:i,stock_quantity:d},hasChanges:!0}),N.log(`[updateFormOptionField] Updated product stock_quantity to ${d} (sum of active options)`);return}n.state.setLocal({form:{...s.form,options:i},hasChanges:!0}),N.log(`[updateFormOptionField] Updated options[${t}].${o} =`,u)}function uo(r,c){const e=window.G7Core;if(!e?.state){N.warn("[addOptionRow] G7Core.state API is not available");return}const o=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,a=e.state.getLocal()||{},n=[...a.form?.options??[]],s=a.form?.option_groups??[],i=`OPT-${Date.now()}`,l=s.map(g=>({key:g.name,value:ze()})),u={id:null,option_code:i,option_name:ze(),option_values:l,is_default:n.length===0,regular_price:0,sale_price:0,list_price:0,selling_price:0,price_adjustment:0,multi_currency_selling_price:{},sku:"",stock_quantity:o,safe_stock_quantity:0,weight:0,volume:0,mileage_value:0,mileage_type:"percent",is_active:!0},d=[...n,u],m=be(d);e.state.setLocal({form:{...a.form,options:d,stock_quantity:m,has_options:d.length>0},hasChanges:!0}),N.log(`[addOptionRow] Added new option row: ${i}, product stock_quantity = ${m}`)}function mo(r,c){const e=window.G7Core;if(!e?.state){N.warn("[recalculateOptionPriceAdjustments] G7Core.state API is not available");return}const t=r.params||{},o=e.state.getLocal()||{},a=t.newSellingPrice!==void 0?parseFloat(String(t.newSellingPrice))||0:parseFloat(String(o.form?.selling_price))||0,n=[...o.form?.options??[]];if(n.length===0){N.log("[recalculateOptionPriceAdjustments] No options to recalculate");return}const s=Ee(),i=Oe(n,a,s);e.state.setLocal({form:{...o.form,options:i}}),N.log(`[recalculateOptionPriceAdjustments] Recalculated selling_price for ${n.length} options based on product selling_price: ${a}`)}const h=window.G7Core?.createLogger?.("Ecom:Option")??{log:(...r)=>console.log("[Ecom:Option]",...r),warn:(...r)=>console.warn("[Ecom:Option]",...r),error:(...r)=>console.error("[Ecom:Option]",...r)};function De(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ie(){return window.G7Core?.config?.("app.locale")??De()[0]??"ko"}function q(){return De().reduce((c,e)=>({...c,[e]:""}),{})}function We(r){return{id:null,name:q(),price_adjustment:0,is_default:r===0,is_active:!0,allow_custom_text:!1,sort_order:r}}function po(r,c){return typeof r=="string"?r:r[c]??r.ko??Object.values(r)[0]??""}function Ye(r){return r&&typeof r=="object"&&!Array.isArray(r)}function fo(r){return Array.isArray(r)?r:r&&typeof r=="object"?Object.values(r):[]}function go(r){return r.reduce((c,e)=>{const t=[];return c.forEach(o=>{e.forEach(a=>{t.push([...o,a])})}),t},[[]])}function _o(r){const c=De(),e={};for(const t of c){const o=r.map(a=>po(a,t));e[t]=o.join("/")}return e}function wo(r,c){return c.map((e,t)=>({key:typeof e.name=="string"?{...q(),[ie()]:e.name}:e.name,value:Ye(r[t])?r[t]:{...q(),[ie()]:r[t]}}))}function yo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addOptionInput] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.ui?.optionInputs??[];if(o.length>=3){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.input_max_3")??"You can add up to 3 option inputs.");return}e.state.setLocal({ui:{...t.ui,optionInputs:[...o,{name:q(),values:[]}]}}),h.log(`[addOptionInput] Added option input. Total: ${o.length+1}`)}function ho(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeOptionInput] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeOptionInput] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.ui?.optionInputs??[]];n.splice(t,1),o.state.setLocal({ui:{...a.ui,optionInputs:n}}),h.log(`[removeOptionInput] Removed option input at index ${t}`)}function Co(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.value;if(t===void 0||!o){h.warn("[updateOptionInput] Missing required params");return}const n=window.G7Core;if(!n?.state){h.warn("[updateOptionInput] G7Core.state API is not available");return}h.log(`[updateOptionInput] Called with index=${t}, field="${o}" (type: ${typeof o}), value=`,a);const s=n.state.getLocal()||{},i=[...s.ui?.optionInputs??[]];if(h.log(`[updateOptionInput] Current inputs[${t}]:`,i[t]),i[t]||(h.log(`[updateOptionInput] inputs[${t}] not found, initializing with empty structure`),i[t]={name:q(),values:[]}),o==="name"){const l=ie(),u=i[t].name;h.log(`[updateOptionInput] Processing 'name' field - defaultLocale: ${l}, existingName:`,u,`type: ${typeof u}`);let d;a&&typeof a=="object"&&!Array.isArray(a)?(d=a,h.log("[updateOptionInput] Using multilingual value directly:",d)):(d={...u&&typeof u=="object"&&!Array.isArray(u)?u:q(),[l]:a},h.log("[updateOptionInput] Setting single locale value:",d)),i[t]={...i[t],name:d}}else h.log(`[updateOptionInput] Processing non-name field '${o}'`),i[t]={...i[t],[o]:a};h.log(`[updateOptionInput] Final inputs[${t}]:`,i[t]),n.state.setLocal({ui:{...s.ui,optionInputs:i}})}function bo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[generateOptions] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.state.get()||{},a=t.ui?.optionInputs??[],n=ie(),s=p=>p.name?typeof p.name=="string"?p.name:p.name[n]??"":"",i=a.map(p=>({...p,values:fo(p.values)})),l=i.filter(p=>s(p)&&p.values&&p.values.length>0);if(h.log(`[generateOptions] Validating inputs - defaultLocale: ${n}, normalizedInputs:`,i.map(p=>({name:p.name,nameType:typeof p.name,extractedName:s(p),values:p.values,valuesIsArray:Array.isArray(p.values)}))),h.log(`[generateOptions] Valid inputs count: ${l.length}`),l.length===0){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.name_value_required")??"Please enter option name and values.");return}const u=t.form?.options??[],d=r.params?.skipConfirm===!0;if(u.length>0&&!d){e.modal?.open?.("modal_confirm_regenerate");return}const g=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,f=parseFloat(String(t.form?.list_price))||0,w=parseFloat(String(t.form?.selling_price))||0,y=t.form?.multi_currency_selling_price,_=(o.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(p=>!p.is_default&&p.exchange_rate);try{h.log("[generateOptions] Step 1: Creating optionGroups...");const p=l.map((x,F)=>(h.log(`[generateOptions] Processing input[${F}]:`,{name:x.name,values:x.values,valuesType:typeof x.values,valuesIsArray:Array.isArray(x.values)}),{name:typeof x.name=="string"?{...q(),[n]:x.name}:x.name,values:x.values.map($=>Ye($)?$:{...q(),[n]:$})})),C=go(l.map(x=>x.values)).map((x,F)=>{const $=wo(x,l),oe=_o(x),le=`OPT-${String(F+1).padStart(3,"0")}`;let we={};return y&&typeof y=="object"&&Object.keys(y).length>0?we=JSON.parse(JSON.stringify(y)):w>0&&_.length>0&&_.forEach(B=>{we[B.code]=Ge(w,B)}),{id:null,option_code:le,option_values:$,option_name:oe,list_price:f,selling_price:w,price_adjustment:0,stock_quantity:g,safe_stock_quantity:0,is_default:F===0,is_active:!0,sku:"",multi_currency_selling_price:we}}),A=t.form?.options??[],b=x=>x.option_name?typeof x.option_name=="string"?x.option_name:x.option_name[n]??x.option_name.ko??"":"",L=C.map(x=>{const F=b(x),$=A.find(oe=>b(oe)===F);return $?{...x,...$,id:$.id}:x}),R=be(L);e.state.setLocal({form:{...t.form,option_groups:p,options:L,has_options:L.length>0,stock_quantity:R},hasChanges:!0}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.generated",{count:L.length})??`${L.length} options have been generated.`),h.log(`[generateOptions] Generated ${L.length} options`)}catch(p){throw h.error("[generateOptions] Error occurred:",p),h.error("[generateOptions] Error message:",p.message),h.error("[generateOptions] Error stack:",p.stack),p}}function Io(r,c){const t=(r.params||{}).rowIndex;if(t===void 0){h.warn("[deleteOption] Missing rowIndex param");return}const o=window.G7Core;if(!o?.state){h.warn("[deleteOption] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.options??[]];n.splice(t,1);const s=be(n),i=n.length>0;o.state.setLocal({form:{...a.form,options:n,has_options:i,...i?{stock_quantity:s}:{}},hasChanges:!0}),h.log(`[deleteOption] Deleted option at index ${t}, product stock_quantity = ${s}`)}function So(r,c){const e=r.params||{},t=e.optionName,o=e.applyToAll,a=e.inheritPrice,n=window.G7Core;if(!n?.state){h.warn("[applyOptionAddTool] G7Core.state API is not available");return}if(!t?.trim()){n.toast?.warning?.(n.t?.("sirsoft-ecommerce.admin.product.options.messages.name_required")??"Please enter option name.");return}const s=n.state.getLocal()||{},l=[...s.form?.options??[]].map(u=>{const d={...u};return o&&(d.option_values={...u.option_values,[t]:""}),a&&(d.price_adjustment=u.price_adjustment??0),d});n.state.setLocal({form:{...s.form,options:l},ui:{...s.ui,optionAddToolName:"",optionAddToolApplyToAll:!1,optionAddToolInheritPrice:!1},hasChanges:!0}),n.toast?.success?.(n.t?.("sirsoft-ecommerce.admin.product.options.messages.add_tool_applied")??"Option add tool has been applied.")}function vo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addRequiredItem] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.required_items??[],a={id:null,sort_order:o.length,option_name:{ko:"",en:""},description:{ko:"",en:""}};e.state.setLocal({form:{...t.form,required_items:[...o,a]},hasChanges:!0}),h.log(`[addRequiredItem] Added required item. Total: ${o.length+1}`)}function Lo(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.locale,n=e.value;if(t===void 0||!o||!a){h.warn("[updateRequiredItem] Missing required params");return}const s=window.G7Core;if(!s?.state){h.warn("[updateRequiredItem] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=[...i.form?.required_items??[]];l[t]={...l[t],[o]:{...l[t][o],[a]:n}},s.state.setLocal({form:{...i.form,required_items:l},hasChanges:!0})}function xo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeRequiredItem] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeRequiredItem] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.required_items??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,required_items:n},hasChanges:!0}),h.log(`[removeRequiredItem] Removed required item at index ${t}`)}function Po(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){h.warn("[reorderRequiredItems] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){h.warn("[reorderRequiredItems] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.required_items??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,required_items:s},hasChanges:!0}),h.log(`[reorderRequiredItems] Moved item from ${t} to ${o}`)}function Ao(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addAdditionalOption] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.additional_options??[];if(o.length>=5){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_max_5")??"You can add up to 5 additional options.");return}const a=`temp_${Date.now()}_${Math.random().toString(36).substring(2,9)}`;e.state.setLocal({form:{...t.form,additional_options:[...o,{id:a,name:q(),is_required:!1,sort_order:o.length,values:[We(0)]}]},hasChanges:!0}),h.log(`[addAdditionalOption] Added additional option. Total: ${o.length+1}`)}function ko(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.value;if(t===void 0||!o){h.warn("[updateAdditionalOption] Missing required params");return}const n=window.G7Core;if(!n?.state){h.warn("[updateAdditionalOption] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.additional_options??[]];if(!i[t]){h.warn("[updateAdditionalOption] Option not found at index",t);return}if(o==="name")if(a&&typeof a=="object"&&!Array.isArray(a))i[t]={...i[t],name:a};else{const l=ie(),u=i[t].name,d=u&&typeof u=="object"&&!Array.isArray(u)?u:q();i[t]={...i[t],name:{...d,[l]:a}}}else i[t]={...i[t],[o]:a};n.state.setLocal({form:{...s.form,additional_options:i},hasChanges:!0}),h.log(`[updateAdditionalOption] Updated options[${t}].${o}`)}function Go(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeAdditionalOption] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeAdditionalOption] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.additional_options??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,additional_options:n},hasChanges:!0}),h.log(`[removeAdditionalOption] Removed additional option at index ${t}`)}function Oo(r,c){const e=r.params||{},t=parseInt(String(e.oldIndex),10),o=parseInt(String(e.newIndex),10);if(isNaN(t)||isNaN(o)||t===o)return;const a=window.G7Core;if(!a?.state){h.warn("[reorderAdditionalOptions] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.additional_options??[]];if(t<0||t>=s.length||o<0||o>=s.length)return;const[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,additional_options:s},ui:{...n.ui,dragIndex:null},hasChanges:!0}),h.log(`[reorderAdditionalOptions] Moved item from ${t} to ${o}`)}function Eo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[clearAdditionalOptions] G7Core.state API is not available");return}const t=e.state.getLocal()||{};e.state.setLocal({form:{...t.form,additional_options:[]},hasChanges:!0}),h.log("[clearAdditionalOptions] Cleared all additional options.")}function Fe(r){if(!r)return[];const c=r.values;return Array.isArray(c)?c:c&&typeof c=="object"?Object.values(c):[]}function Do(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex;if(t==null||isNaN(t)){h.warn("[addAdditionalOptionValue] Missing groupIndex param");return}const o=window.G7Core;if(!o?.state){h.warn("[addAdditionalOptionValue] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.additional_options??[]];if(!n[t]){h.warn("[addAdditionalOptionValue] Group not found at index",t);return}const s=[...Fe(n[t])];if(s.length>=20){o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_value_max_20")??"You can add up to 20 choices per group.");return}s.push(We(s.length)),n[t]={...n[t],values:s},o.state.setLocal({form:{...a.form,additional_options:n},hasChanges:!0}),h.log(`[addAdditionalOptionValue] Added value to group ${t}. Total: ${s.length}`)}function Fo(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex,o=typeof e.valueIndex=="string"?parseInt(e.valueIndex,10):e.valueIndex,a=e.field,n=e.value;if(t===void 0||o===void 0||!a){h.warn("[updateAdditionalOptionValue] Missing required params");return}const s=window.G7Core;if(!s?.state){h.warn("[updateAdditionalOptionValue] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=[...i.form?.additional_options??[]];if(!l[t]){h.warn("[updateAdditionalOptionValue] Group not found at index",t);return}const u=[...Fe(l[t])];if(!u[o]){h.warn("[updateAdditionalOptionValue] Value not found at index",o);return}if(a==="name")if(n&&typeof n=="object"&&!Array.isArray(n))u[o]={...u[o],name:n};else{const d=ie(),m=u[o].name,g=m&&typeof m=="object"&&!Array.isArray(m)?m:q();u[o]={...u[o],name:{...g,[d]:n}}}else if(a==="price_adjustment"){let d=parseFloat(String(n));(isNaN(d)||d<0)&&(d=0),u[o]={...u[o],price_adjustment:d}}else if(a==="is_default")n===!0||n==="true"?u.forEach((m,g)=>{m.is_default=g===o}):u[o].is_default=!1;else if(a==="is_active"||a==="allow_custom_text"){const d=n===!0||n==="true";u[o]={...u[o],[a]:d}}else u[o]={...u[o],[a]:n};l[t]={...l[t],values:u},s.state.setLocal({form:{...i.form,additional_options:l},hasChanges:!0}),h.log(`[updateAdditionalOptionValue] Updated group ${t} value ${o} field ${a}`)}function $o(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex,o=typeof e.valueIndex=="string"?parseInt(e.valueIndex,10):e.valueIndex;if(t===void 0||o===void 0){h.warn("[removeAdditionalOptionValue] Missing required params");return}const a=window.G7Core;if(!a?.state){h.warn("[removeAdditionalOptionValue] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.additional_options??[]];if(!s[t]){h.warn("[removeAdditionalOptionValue] Group not found at index",t);return}const i=[...Fe(s[t])];if(i.length<=1){a.toast?.warning?.(a.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_value_min_1")??"At least one choice is required per group.");return}i.splice(o,1),i.forEach((l,u)=>{l.sort_order=u}),i.some(l=>l.is_default)||(i[0].is_default=!0),s[t]={...s[t],values:i},a.state.setLocal({form:{...n.form,additional_options:s},hasChanges:!0}),h.log(`[removeAdditionalOptionValue] Removed value ${o} from group ${t}`)}const j=window.G7Core?.createLogger?.("Ecom:Image")??{log:(...r)=>console.log("[Ecom:Image]",...r),warn:(...r)=>console.warn("[Ecom:Image]",...r),error:(...r)=>console.error("[Ecom:Image]",...r)},No=["image/jpeg","image/png","image/gif","image/webp"],To=10*1024*1024,Ro=20;async function Mo(r,c){const t=(r.params||{}).files;if(!t||t.length===0){j.warn("[uploadImages] No files provided");return}const o=window.G7Core;if(!o?.state||!o?.api){j.warn("[uploadImages] G7Core.state or G7Core.api is not available");return}const n=(o.state.getLocal()||{}).form?.images??[],s=Ro-n.length;t.length>s&&o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.max_count",{max:s})??`You can add up to ${s} images.`);const i=Array.from(t).slice(0,s);for(const l of i){if(!No.includes(l.type)){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.unsupported_format",{name:l.name})??`${l.name}: Unsupported format.`);continue}if(l.size>To){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.size_exceeded",{name:l.name})??`${l.name}: Exceeds 10MB.`);continue}const u=o.state.getLocal()||{};o.state.setLocal({ui:{...u.ui,uploadProgress:{...u.ui?.uploadProgress||{},[l.name]:0}}});try{const d=new FormData;d.append("file",l),d.append("collection","products");const m=await o.api.upload("/api/admin/media/upload",d,{onProgress:S=>{const C=o.state.getLocal()||{};o.state.setLocal({ui:{...C.ui,uploadProgress:{...C.ui?.uploadProgress||{},[l.name]:S}}})}}),g=o.state.getLocal()||{},f=g.form?.images??[],w={...m.data,is_thumbnail:f.length===0,sort_order:f.length};o.state.setLocal({form:{...g.form,images:[...f,w]},hasChanges:!0});const y=o.state.getLocal()||{},{[l.name]:_,...p}=y.ui?.uploadProgress||{};o.state.setLocal({ui:{...y.ui,uploadProgress:p}}),j.log(`[uploadImages] Uploaded ${l.name}`)}catch(d){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.upload_failed",{name:l.name})??`${l.name}: Upload failed`);const m=o.state.getLocal()||{},{[l.name]:g,...f}=m.ui?.uploadProgress||{};o.state.setLocal({ui:{...m.ui,uploadProgress:f}}),j.error(`[uploadImages] Failed to upload ${l.name}:`,d)}}}function Ho(r,c){const t=(r.params||{}).index;if(t===void 0){j.warn("[setThumbnail] Missing index param");return}const o=window.G7Core;if(!o?.state){j.warn("[setThumbnail] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=(a.form?.images??[]).map((s,i)=>({...s,is_thumbnail:i===t}));o.state.setLocal({form:{...a.form,images:n},hasChanges:!0}),j.log(`[setThumbnail] Set thumbnail to image at index ${t}`)}function qo(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){j.warn("[reorderImages] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){j.warn("[reorderImages] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.images??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,images:s},hasChanges:!0}),j.log(`[reorderImages] Moved image from ${t} to ${o}`)}const Je=window.G7Core?.createLogger?.("Ecom:Description")??{log:(...r)=>console.log("[Ecom:Description]",...r),warn:(...r)=>console.warn("[Ecom:Description]",...r),error:(...r)=>console.error("[Ecom:Description]",...r)};function Uo(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){Je.warn("[updateDescription] Missing locale param");return}const a=window.G7Core;if(!a?.state){Je.warn("[updateDescription] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,description:{...n.form?.description,[t]:o}},hasChanges:!0})}const k=window.G7Core?.createLogger?.("Ecom:Notice")??{log:(...r)=>console.log("[Ecom:Notice]",...r),warn:(...r)=>console.warn("[Ecom:Notice]",...r),error:(...r)=>console.error("[Ecom:Notice]",...r)};function ue(r=""){const e=window.G7Core?.locale?.supported?.()??["ko","en"],t={};for(const o of e)t[o]=r;return t}function Vo(r,c){const t=(r.params||{}).templateId,o=window.G7Core;if(!o?.state){k.warn("[confirmSelectNoticeTemplate] G7Core.state API is not available");return}const n=(o.state.getLocal()||{}).form?.notice_items??[],s=n.length>0&&n.some(i=>Object.values(i.content||{}).some(u=>u&&u.trim()!==""));if(!t||!s){Xe(r);return}o.state.setGlobal({pendingNoticeTemplateId:t}),o.modal?.open?.("notice_template_confirm_modal"),k.log(`[confirmSelectNoticeTemplate] Showing confirm modal for template: ${t}`)}function Xe(r,c){const t=(r.params||{}).templateId,o=window.G7Core;if(!o?.state){k.warn("[selectNoticeTemplate] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=o.dataSource?.get?.("notice_templates"),s=n?.data?.data??n?.data??[];if(!t){o.state.setLocal({form:{...a.form,notice_items:[]},ui:{...a.ui,selectedNoticeTemplateId:null},hasChanges:!0});return}const i=s.find(d=>d.id===t);if(!i){k.warn(`[selectNoticeTemplate] Template not found: ${t}`);return}const l=a.form?.notice_items??[],u=(i.fields??[]).map((d,m)=>{const g=JSON.stringify(d.name),f=l.find(w=>JSON.stringify(w.name)===g);return{key:`field_${m}`,name:d.name,content:f?.content??d.content??ue(),sort_order:m,is_custom:!1}});o.state.setLocal({form:{...a.form,notice_items:u},ui:{...a.ui,selectedNoticeTemplateId:t},hasChanges:!0}),k.log(`[selectNoticeTemplate] Selected template: ${t}`)}function jo(r,c){const e=r.params||{},t=e.index,o=e.locale,a=e.value;if(t===void 0||!o){k.warn("[updateNoticeItem] Missing required params");return}const n=window.G7Core;if(!n?.state){k.warn("[updateNoticeItem] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.notice_items??[]];i[t]={...i[t],content:{...i[t].content,[o]:a}},n.state.setLocal({form:{...s.form,notice_items:i},hasChanges:!0})}function Bo(r,c){const t=(r.params||{}).index;if(t===void 0){k.warn("[removeNoticeItem] Missing index param");return}const o=window.G7Core;if(!o?.state){k.warn("[removeNoticeItem] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.notice_items??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,notice_items:n},hasChanges:!0}),k.log(`[removeNoticeItem] Removed notice item at index ${t}`)}function Ko(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){k.warn("[reorderNoticeItems] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){k.warn("[reorderNoticeItems] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.notice_items??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,notice_items:s},hasChanges:!0}),k.log(`[reorderNoticeItems] Moved item from ${t} to ${o}`)}function Qo(r,c){const t=(r.params||{}).value,o=window.G7Core;if(!o?.state){k.warn("[fillNoticeWithValue] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=a.ui?.noticeLocale??"ko",s=(a.form?.notice_items??[]).map(i=>({...i,content:{...i.content,[n]:i.content?.[n]||t}}));o.state.setLocal({form:{...a.form,notice_items:s},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.notice.messages.filled")??"Empty fields have been filled.")}function zo(r,c){const t=(r.params||{}).mode;if(!t){k.warn("[switchNoticeMode] Missing mode param");return}const o=window.G7Core;if(!o?.state){k.warn("[switchNoticeMode] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,noticeMode:t}}),k.log(`[switchNoticeMode] Switched to ${t} mode`)}function Wo(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){k.warn("[updateNewTemplateName] Missing locale param");return}const a=window.G7Core;if(!a?.state){k.warn("[updateNewTemplateName] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,new_template_name:{...n.form?.new_template_name,[t]:o}}})}function Yo(r,c){const e=window.G7Core;if(!e?.state){k.warn("[addNoticeItem] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.notice_items??[],a={key:`custom_${Date.now()}`,name:ue(),content:ue(),sort_order:o.length,is_custom:!0};e.state.setLocal({form:{...t.form,notice_items:[...o,a]},hasChanges:!0}),k.log(`[addNoticeItem] Added new notice item. Total: ${o.length+1}`)}function Jo(r,c){const e=r.params||{},t=e.index,o=e.locale,a=e.value;if(t===void 0||!o){k.warn("[updateNoticeItemName] Missing required params");return}const n=window.G7Core;if(!n?.state){k.warn("[updateNoticeItemName] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.notice_items??[]];i[t]={...i[t],name:{...i[t].name,[o]:a}},n.state.setLocal({form:{...s.form,notice_items:i},hasChanges:!0})}function Xo(r,c){const e=window.G7Core;if(!e?.state){k.warn("[saveAsNoticeTemplate] G7Core.state API is not available");return}if(((e.state.getLocal()||{}).form?.notice_items??[]).length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.no_items")??"No items to save.");return}e.state.set?.({"ui.saveTemplateData":{name:ue(),category_id:null,is_default:!1}}),e.dispatch?.({handler:"openModal",target:"modal_save_template"})}const Ie={ko:"상세설명참조",en:"See detail description"};function Zo(r,c){const e=window.G7Core;if(!e?.state){k.warn("[fillTemplateFieldsWithDetailReference] G7Core.state API is not available");return}const a=((r.params||{}).form||{}).fields??[];if(a.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product_notice_template.messages.no_fields_to_change")??"변경할 항목이 없습니다.");return}const n=e.locale?.supported?.()??["ko","en"],s={};for(const l of n)s[l]=Ie[l]??Ie.en??"See detail description";const i=a.map(l=>({...l,content:{...s}}));e.state.setLocal({"form.fields":i}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product_notice_template.messages.all_fields_changed_to_detail_ref")??"모든 항목이 '상세설명참조'로 변경되었습니다."),k.log("[fillTemplateFieldsWithDetailReference] All fields updated to detail reference")}function er(r,c){const e=window.G7Core;if(!e?.state){k.warn("[fillNoticeItemsWithDetailReference] G7Core.state API is not available");return}const a=((r.params||{}).form||{}).notice_items??[];if(a.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.no_items_to_change")??"변경할 항목이 없습니다.");return}const n=e.locale?.supported?.()??["ko","en"],s={};for(const l of n)s[l]=Ie[l]??Ie.en??"See detail description";const i=a.map(l=>({...l,content:{...s}}));e.state.setLocal({"form.notice_items":i}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.all_items_changed_to_detail_ref")??"모든 항목이 '상세설명참조'로 변경되었습니다."),k.log("[fillNoticeItemsWithDetailReference] All notice items updated to detail reference")}async function tr(r,c){const e=window.G7Core;if(!e?.state||!e?.api){k.warn("[confirmSaveNoticeTemplate] G7Core.state or G7Core.api is not available");return}const t=e.state.getLocal()||{},o=e.state.get?.()?.ui?.saveTemplateData??{},a=o.name;if(!a?.ko?.trim()){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_name_required")??"Please enter template name.");return}try{const n=t.form?.notice_items??[],s=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/notice-templates",{name:a,is_active:o.is_default??!1,fields:n.map(i=>({name:i.name,content:i.content}))});e.state.set?.({"ui.saveTemplateData":{name:ue(),category_id:null,is_default:!1}}),e.state.setLocal({ui:{...t.ui,noticeMode:"existing"},hasChanges:!0}),e.dispatch?.({handler:"closeModal"}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_saved")??"Template has been saved."),e.datasources?.refresh?.("notice_templates"),k.log(`[confirmSaveNoticeTemplate] Template saved: ${s.data.id}`)}catch(n){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_save_error")??"Failed to save template."),k.error("[confirmSaveNoticeTemplate] Failed to save template:",n)}}const v=window.G7Core?.createLogger?.("Ecom:Misc")??{log:(...r)=>console.log("[Ecom:Misc]",...r),warn:(...r)=>console.warn("[Ecom:Misc]",...r),error:(...r)=>console.error("[Ecom:Misc]",...r)};function or(r,c){const t=(r.params||{}).labelId;if(!t){v.warn("[toggleLabel] Missing labelId param");return}const o=window.G7Core;if(!o?.state){v.warn("[toggleLabel] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=a.form?.labels??[],s=n.includes(t)?n.filter(i=>i!==t):[...n,t];o.state.setLocal({form:{...a.form,labels:s},hasChanges:!0}),v.log(`[toggleLabel] Toggled label ${t}. Current labels: ${s.length}`)}async function rr(r,c){const e=window.G7Core;if(!e?.state||!e?.api){v.warn("[generateProductCode] G7Core.state or G7Core.api is not available");return}try{const t=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/generate-code"),o=e.state.getLocal()||{};e.state.setLocal({form:{...o.form,product_code:t.data.product_code}}),v.log(`[generateProductCode] Generated: ${t.data.product_code}`)}catch(t){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code."),v.error("[generateProductCode] Failed:",t)}}function nr(r,c){const e=r.params||{},t=e.policyId,o=e.field,a=e.policies;if(!t||!o||!a)return"-";const n=a.find(s=>s.id===t);return n?n[o]??"-":"-"}function ar(r,c){const e=r.params||{},t=e.templateId,o=e.templates;if(!t||!o)return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?.content?.[n]??s?.content?.ko??""}function sr(r,c){const e=r.params||{},t=e.platform,o=e.field,a=e.value;if(!t||!o){v.warn("[updateShoppingIntegration] Missing required params");return}const n=window.G7Core;if(!n?.state){v.warn("[updateShoppingIntegration] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=s.form?.shopping_integrations??{naver:{enabled:!1,sync_interval:"manual"},daum:{enabled:!1,sync_interval:"manual"},facebook:{enabled:!1,sync_interval:"manual"},google:{enabled:!1,sync_interval:"manual"}};n.state.setLocal({form:{...s.form,shopping_integrations:{...i,[t]:{...i[t],[o]:a}}},hasChanges:!0}),v.log(`[updateShoppingIntegration] Updated ${t}.${o} to ${a}`)}function ir(r,c){const t=(r.params||{}).type;if(!t){v.warn("[updateShippingType] Missing type param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateShippingType] G7Core.state API is not available");return}const a=o.state.getLocal()||{};let n=0,s=0;t==="fixed_paid"?n=3e3:t==="threshold_free"&&(s=3e4),o.state.setLocal({form:{...a.form,shipping_type:t,shipping_fee:t==="fixed_free"?0:a.form?.shipping_fee??n,free_shipping_threshold:t==="threshold_free"?a.form?.free_shipping_threshold??s:0},hasChanges:!0}),v.log(`[updateShippingType] Updated shipping type to ${t}`)}function cr(r,c){const e=r.params||{},t=e.field,o=e.value;if(!t){v.warn("[updateIdentificationCode] Missing field param");return}const a=window.G7Core;if(!a?.state){v.warn("[updateIdentificationCode] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,[t]:o},hasChanges:!0})}function lr(r,c){const e=r.params||{},t=e.labelId,o=e.labelName;if(!t){v.warn("[openLabelPeriodModal] Missing labelId param");return}const a=window.G7Core;if(!a?.state){v.warn("[openLabelPeriodModal] G7Core.state API is not available");return}const n=a.state.getLocal()||{},i=(n.form?.labels??[]).find(l=>l.id===t);a.state.setLocal({ui:{...n.ui,showLabelPeriodModal:!0,labelPeriodModalData:{labelId:t,labelName:o,startDate:i?.start_date??null,endDate:i?.end_date??null}}})}function dr(r,c){const e=r.params||{},t=e.labelId,o=e.startDate,a=e.endDate;if(!t){v.warn("[saveLabelPeriod] Missing labelId param");return}const n=window.G7Core;if(!n?.state){v.warn("[saveLabelPeriod] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.labels??[]],l=i.findIndex(u=>u.id===t);l>=0?i[l]={...i[l],start_date:o,end_date:a}:i.push({id:t,start_date:o,end_date:a}),n.state.setLocal({form:{...s.form,labels:i},ui:{...s.ui,showLabelPeriodModal:!1,labelPeriodModalData:null},hasChanges:!0}),n.toast?.success?.(n.t?.("sirsoft-ecommerce.admin.product.messages.label_period_saved")??"Label period has been saved."),v.log(`[saveLabelPeriod] Saved period for label ${t}`)}function ur(r,c){const t=(r.params||{}).labelId;if(!t){v.warn("[removeLabelPeriod] Missing labelId param");return}const o=window.G7Core;if(!o?.state){v.warn("[removeLabelPeriod] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.labels??[]],s=n.findIndex(i=>i.id===t);s>=0&&(n[s]={...n[s],start_date:null,end_date:null}),o.state.setLocal({form:{...a.form,labels:n},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.messages.label_period_removed")??"Label period has been removed."),v.log(`[removeLabelPeriod] Removed period for label ${t}`)}function mr(r,c){const t=(r.params||{}).sort;if(!t){v.warn("[updateActivityLogSort] Missing sort param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateActivityLogSort] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,activityLogSort:t}}),o.datasources?.refresh?.("activity_logs",{params:{sort:t==="latest"?"desc":"asc",per_page:a.ui?.activityLogPerPage??10}}),v.log(`[updateActivityLogSort] Updated sort to ${t}`)}function pr(r,c){const t=(r.params||{}).perPage;if(!t){v.warn("[updateActivityLogPerPage] Missing perPage param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateActivityLogPerPage] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,activityLogPerPage:t}}),o.datasources?.refresh?.("activity_logs",{params:{sort:a.ui?.activityLogSort==="oldest"?"asc":"desc",per_page:t}}),v.log(`[updateActivityLogPerPage] Updated perPage to ${t}`)}function fr(r,c){const e=window.G7Core;if(!e?.state){v.warn("[setDefaultShippingPolicy] G7Core.state API is not available");return}const t=e.state.getLocal()||{};if(t.form?.shipping_policy_id){v.log("[setDefaultShippingPolicy] Shipping policy already set, skipping");return}if(t.ui?.useDefaultShippingPolicy===!1){v.log("[setDefaultShippingPolicy] useDefaultShippingPolicy is OFF, skipping");return}const o=e.dataSource?.get?.("shipping_policies"),a=o?.data?.data??o?.data??[];if(a.length===0){v.log("[setDefaultShippingPolicy] No shipping policies found");return}const n=a.find(s=>s.is_default===!0);n?(e.state.setLocal({form:{...t.form,shipping_policy_id:n.id}}),v.log(`[setDefaultShippingPolicy] Set default shipping policy: ${n.id} (${n.name})`)):v.log("[setDefaultShippingPolicy] No default shipping policy found")}function gr(r,c){const e=window.G7Core;if(!e?.state){v.warn("[toggleDefaultShippingPolicy] G7Core.state API is not available");return}const t=r.params?.checked??!1,o=e.state.getLocal()||{},a={...o.ui,useDefaultShippingPolicy:t};if(t){const n=e.dataSource?.get?.("shipping_policies"),i=(n?.data?.data??n?.data??[]).find(l=>l.is_default===!0);e.state.setLocal({ui:a,form:{...o.form,shipping_policy_id:i?.id??null}}),i?v.log(`[toggleDefaultShippingPolicy] ON - Selected default policy: ${i.id}`):v.log("[toggleDefaultShippingPolicy] ON - No default policy found")}else e.state.setLocal({ui:a}),v.log("[toggleDefaultShippingPolicy] OFF - Keeping current shipping policy")}function _r(r,c){const e=window.G7Core;if(!e?.state){v.warn("[setLabelDatePreset] G7Core.state API is not available");return}const t=r.params?.preset;if(!t){v.warn("[setLabelDatePreset] Missing preset param");return}const o=new Date,a=i=>{const l=i.getFullYear(),u=String(i.getMonth()+1).padStart(2,"0"),d=String(i.getDate()).padStart(2,"0");return`${l}-${u}-${d}`},n=a(o);let s=null;switch(t){case"7d":{const i=new Date(o);i.setDate(i.getDate()+7),s=a(i);break}case"14d":{const i=new Date(o);i.setDate(i.getDate()+14),s=a(i);break}case"30d":{const i=new Date(o);i.setDate(i.getDate()+30),s=a(i);break}case"permanent":s=null;break;default:v.warn(`[setLabelDatePreset] Unknown preset: ${t}`);return}e.state.setGlobal({"labelFormData.start_date":n,"labelFormData.end_date":s}),v.log(`[setLabelDatePreset] Applied preset '${t}': ${n} ~ ${s??"permanent"}`)}const wr=(r,c)=>{const e=window.G7Core,o=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],a=Number(r.params?.labelId);if(!a)return;const n=[...o];if(n.find(l=>l.label_id===a)){e?.state?.setLocal?.({"ui.lastClickedLabelId":a});return}const i=[...n,{label_id:a,started_at:null,ended_at:null}];e?.state?.setLocal?.({"form.label_assignments":i,"ui.lastClickedLabelId":a,hasChanges:!0})},yr=(r,c)=>{const e=window.G7Core,o=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],a=Number(r.params?.labelId),n=r.params?.field,s=r.params?.value;if(!a||!n)return;const i=[...o],l=i.findIndex(u=>u.label_id===a);l<0||(i[l]={...i[l],[n]:s||null},e?.state?.setLocal?.({"form.label_assignments":i,hasChanges:!0}))},hr=(r,c)=>{const e=window.G7Core,t=e?.state?.getLocal?.()||{},o=t.form?.label_assignments??[],a=t.ui?.lastClickedLabelId,n=r.params?.preset;if(!a||!n)return;const s=[...o],i=s.findIndex(g=>g.label_id===a);if(i<0)return;const l=new Date,u=g=>g.toISOString().split("T")[0],d=u(l);let m=null;switch(n){case"7d":m=u(new Date(l.getTime()+7*24*60*60*1e3));break;case"14d":m=u(new Date(l.getTime()+14*24*60*60*1e3));break;case"30d":m=u(new Date(l.getTime()+30*24*60*60*1e3));break;case"permanent":m=null;break}s[i]={...s[i],started_at:d,ended_at:m},e?.state?.setLocal?.({"form.label_assignments":s,hasChanges:!0})},Cr=(r,c)=>{const e=window.G7Core,o=(e?.state?.get()||{}).labelToUncheckId;if(!o)return;const i=[...(e?.state?.getParent?.()?._local||{}).form?.label_assignments??[]].filter(l=>l.label_id!==o);e?.state?.setParentLocal?.({"form.label_assignments":i,hasChanges:!0}),e?.state?.setGlobal?.({labelToUncheckId:null}),e?.modal?.close?.()},br=async(r,c)=>{const e=window.G7Core,t=e?.state?.get()||{},a=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],n=t.labelToDeleteId;if(n){e?.state?.setGlobal?.({isDeletingLabel:!0});try{const s=await e.api.delete(`/api/modules/sirsoft-ecommerce/admin/product-labels/${n}`);if(!s?.success)throw new Error(s?.message||"Failed to delete label");const l=[...a].filter(u=>u.label_id!==n);e?.state?.setLocal?.({"form.label_assignments":l,"ui.lastClickedLabelId":null}),await e?.dataSource?.refetch?.("product_labels"),e?.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.labels.delete_success")??"Label deleted."),e?.modal?.close?.()}catch(s){e?.toast?.error?.(s?.response?.data?.message||s?.message||e.t?.("sirsoft-ecommerce.admin.product.labels.delete_error")||"Failed to delete label.")}finally{e?.state?.setGlobal?.({isDeletingLabel:!1,labelToDeleteId:null})}}},Ir=async(r,c)=>{const e=window.G7Core,t=e?.state?.get()||{},a=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],n=t.editingLabelId,s=t.labelFormData;if(s?.name?.ko){e?.state?.setGlobal?.({isSavingLabel:!0});try{if(n){const i=await e.api.put(`/api/modules/sirsoft-ecommerce/admin/product-labels/${n}`,{name:s.name,color:s.color});if(!i?.success)throw new Error(i?.message||"Failed to update label");const u=[...a].map(d=>d.label_id===n?{...d,started_at:s.started_at||null,ended_at:s.ended_at||null}:d);e?.state?.setLocal?.({"form.label_assignments":u,hasChanges:!0})}else{const i=await e.api.post("/api/modules/sirsoft-ecommerce/admin/product-labels",{name:s.name,color:s.color||"#6B7280"});if(!i?.success)throw new Error(i?.message||"Failed to create label");const l=Number(i?.data?.id);if(l){const d=[...[...a],{label_id:l,started_at:null,ended_at:null}];e?.state?.setLocal?.({"form.label_assignments":d,"ui.lastClickedLabelId":l,hasChanges:!0})}}await e?.dataSource?.refetch?.("product_labels"),e?.toast?.success?.(n?e.t?.("sirsoft-ecommerce.admin.product.labels.save_success")??"Label saved.":e.t?.("sirsoft-ecommerce.admin.product.labels.create_success")??"Label created."),e?.modal?.close?.()}catch(i){e?.toast?.error?.(i?.response?.data?.message||i?.message||e.t?.("sirsoft-ecommerce.admin.product.labels.save_error")||"Failed to save label.")}finally{e?.state?.setGlobal?.({isSavingLabel:!1})}}},J=window.G7Core?.createLogger?.("Ecom:DescriptionLocale")??{log:(...r)=>console.log("[Ecom:DescriptionLocale]",...r),warn:(...r)=>console.warn("[Ecom:DescriptionLocale]",...r),error:(...r)=>console.error("[Ecom:DescriptionLocale]",...r)};function Ze(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function Sr(r,c){const t=(r.params||{}).locale;if(!t){J.warn("[removeDescriptionLocale] Missing locale param");return}const o=window.G7Core;if(!o?.state){J.warn("[removeDescriptionLocale] G7Core.state API is not available");return}const a=o?.state?.get?.("_global.settings.general.language")??o?.config?.("app.fallback_locale")??"ko";if(t===a){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.default_locale_cannot_delete")??"Default language cannot be deleted.");return}const n=o.state.getLocal()||{},s=Ze(),i={...n.form?.description};delete i[t];const l={...n.form?.description_mobile};delete l[t];const u=(n.ui?.descriptionActiveLocales??s).filter(d=>d!==t);o.state.setLocal({form:{...n.form,description:i,description_mobile:l},ui:{...n.ui,descriptionActiveLocales:u,descriptionLocale:u[0]??"ko"},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_removed",{locale:t.toUpperCase()})??`${t.toUpperCase()} language has been removed.`),J.log(`[removeDescriptionLocale] Removed locale: ${t}`)}function vr(r,c){const e=window.G7Core;if(!e?.state){J.warn("[showAddLocaleModal] G7Core.state API is not available");return}const t=e.state.getLocal()||{};e.state.setLocal({ui:{...t.ui,showAddLocaleModal:!0}})}function Lr(r,c){const t=(r.params||{}).locale;if(!t){J.warn("[addDescriptionLocale] Missing locale param");return}const o=window.G7Core;if(!o?.state){J.warn("[addDescriptionLocale] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=Ze(),s=a.ui?.descriptionActiveLocales??["ko"];if(s.includes(t)){o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_already_added")??"This language is already added.");return}if(!n.includes(t)){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_unsupported")??"Unsupported language.");return}o.state.setLocal({form:{...a.form,description:{...a.form?.description,[t]:""},description_mobile:{...a.form?.description_mobile,[t]:""}},ui:{...a.ui,descriptionActiveLocales:[...s,t],descriptionLocale:t,showAddLocaleModal:!1},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_added",{locale:t.toUpperCase()})??`${t.toUpperCase()} language has been added.`),J.log(`[addDescriptionLocale] Added locale: ${t}`)}const X=window.G7Core?.createLogger?.("Ecom:BulkUpdate")??{log:(...r)=>console.log("[Ecom:BulkUpdate]",...r),warn:(...r)=>console.warn("[Ecom:BulkUpdate]",...r),error:(...r)=>console.error("[Ecom:BulkUpdate]",...r)};function et(r,c,e){const t=c?.response?.data??{},o=t?.message||c?.message||"",a=t?.errors,n=a&&typeof a=="object"?Object.values(a).map(i=>Array.isArray(i)?i[0]:String(i)):[],s=o||r.t(e);r.toast.error(s),r.state.set({bulkUpdateErrors:n.length>0?n:s?[s]:[]})}function Z(r){return typeof r=="string"?r:r&&typeof r=="object"?r.ko||r.en||JSON.stringify(r):String(r??"")}function Se(r,c,e){return e?r.t(`sirsoft-ecommerce.enums.${c}.${e}`)||e:String(e??"")}function tt(){const r=window.G7Core;return r||(X.error("[bulkUpdate] G7Core is not available"),null)}function xr(r,c){const e=tt();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},a=o.bulkSelectedItems||t.selectedItems||[],n=o.bulkSelectedOptionIds||t.selectedOptionIds||[];X.log("[bulkUpdate] globalState.bulkSelectedItems:",o.bulkSelectedItems),X.log("[bulkUpdate] localState.selectedItems:",t.selectedItems),X.log("[bulkUpdate] Final selectedItems:",a),a.length>0?Pr(e,o,t,a):n.length>0?Ar(e,o,t,n):e.toast.warning(e.t("sirsoft-ecommerce.admin.product.bulk.no_selection"))}function Pr(r,c,e,t){const a=r.dataSource.get("products")?.data?.data||[],n={};c.bulkSalesStatus&&(n.sales_status=c.bulkSalesStatus),c.bulkDisplayStatus&&(n.display_status=c.bulkDisplayStatus);const s={};if(c.bulkPriceMethod&&c.bulkPriceValue!==void 0&&c.bulkPriceValue!==null&&c.bulkPriceValue!==""){const p={fixed:"set",increase:"add",decrease:"subtract"};c.bulkPriceUnit==="percent"?s.price_adjustment={method:"percent",value:c.bulkPriceMethod==="decrease"?-(Number(c.bulkPriceValue)||0):Number(c.bulkPriceValue)||0}:s.price_adjustment={method:p[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const p={set:"set",increase:"add",decrease:"subtract"};s.stock_quantity={method:p[c.bulkStockMethod]||"add",value:Number(c.bulkStockValue)||0}}const i=e.modifiedProductIds||[],l=e.modifiedOptionIds||[],u=e.modifiedProductFields||{},d=e.modifiedOptionFields||{},m=c.bulkSelectedOptionIds||e.selectedOptionIds||[],g=new Set(t),f=[],w=[];function y(p,S,C){if(S._modified||l.includes(C)){const b={product_id:p.id,option_id:S.id},L=d[C]||[];!s.price_adjustment&&L.includes("price_adjustment")&&(b.price_adjustment=S.price_adjustment),!s.price_adjustment&&L.includes("selling_price")&&(b.price_adjustment=S.price_adjustment),!s.stock_quantity&&L.includes("stock_quantity")&&(b.stock_quantity=S.stock_quantity),L.includes("option_name")&&(b.option_name=S.option_name),L.includes("sku")&&(b.sku=S.sku),L.includes("list_price")&&(b.list_price=S.list_price),L.includes("safe_stock_quantity")&&(b.safe_stock_quantity=S.safe_stock_quantity),L.includes("is_default")&&(b.is_default=S.is_default),L.includes("is_active")&&(b.is_active=S.is_active),Object.keys(b).length>2&&w.push(b)}}a.filter(p=>t.includes(p.id)).forEach(p=>{if(p._modified||i.includes(p.id)){const C={id:p.id},A=String(p.id),b=u[A]||[];!n.sales_status&&b.includes("sales_status")&&(C.sales_status=p.sales_status),!n.display_status&&b.includes("display_status")&&(C.display_status=p.display_status),b.includes("name")&&(C.name=p.name),b.includes("list_price")&&(C.list_price=p.list_price),b.includes("selling_price")&&(C.selling_price=p.selling_price),Object.keys(C).length>1&&f.push(C)}(p.options||[]).forEach(C=>{const A=`${p.id}-${C.id}`;y(p,C,A)})}),m.length>0&&a.forEach(p=>{g.has(p.id)||(p.options||[]).forEach(S=>{const C=`${p.id}-${S.id}`;m.includes(C)&&y(p,S,C)})});const _={ids:t};Object.keys(n).length>0&&(_.bulk_changes=n),f.length>0&&(_.items=f),Object.keys(s).length>0&&(_.option_bulk_changes=s),w.length>0&&(_.option_items=w),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/products/bulk-update",_).then(p=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.update_success",{products:p?.data?.products_updated||0,options:p?.data?.options_updated||0})),ot(r),r.dataSource.refetch("products"),r.modal.close()}).catch(p=>{X.error("[bulkUpdateProducts] Error:",p),et(r,p,"sirsoft-ecommerce.admin.product.bulk.update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function Ar(r,c,e,t){const a=r.dataSource.get("products")?.data?.data||[],n={};if(c.bulkPriceMethod&&c.bulkPriceValue!==void 0&&c.bulkPriceValue!==null&&c.bulkPriceValue!==""){const d={fixed:"set",increase:"add",decrease:"subtract"};c.bulkPriceUnit==="percent"?n.price_adjustment={method:"percent",value:c.bulkPriceMethod==="decrease"?-(Number(c.bulkPriceValue)||0):Number(c.bulkPriceValue)||0}:n.price_adjustment={method:d[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const d={set:"set",increase:"add",decrease:"subtract"};n.stock_quantity={method:d[c.bulkStockMethod]||"add",value:Number(c.bulkStockValue)||0}}const s=e.modifiedOptionFields||{},i=e.modifiedOptionIds||[],l=[];a.forEach(d=>{(d.options||[]).forEach(m=>{const g=`${d.id}-${m.id}`,f=m._modified||i.includes(g);if(t.includes(g)&&f){const w={product_id:d.id,option_id:m.id},y=s[g]||[];!n.price_adjustment&&y.includes("price_adjustment")&&(w.price_adjustment=m.price_adjustment),!n.price_adjustment&&y.includes("selling_price")&&(w.price_adjustment=m.price_adjustment),!n.stock_quantity&&y.includes("stock_quantity")&&(w.stock_quantity=m.stock_quantity),y.includes("option_name")&&(w.option_name=m.option_name),y.includes("sku")&&(w.sku=m.sku),y.includes("list_price")&&(w.list_price=m.list_price),y.includes("safe_stock_quantity")&&(w.safe_stock_quantity=m.safe_stock_quantity),y.includes("is_default")&&(w.is_default=m.is_default),y.includes("is_active")&&(w.is_active=m.is_active),Object.keys(w).length>2&&l.push(w)}})});const u={ids:t};Object.keys(n).length>0&&(u.bulk_changes=n),l.length>0&&(u.items=l),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/options/bulk-update",u).then(d=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.option_update_success",{count:d?.data?.options_updated||0})),ot(r),r.dataSource.refetch("products"),r.modal.close()}).catch(d=>{X.error("[bulkUpdateOptions] Error:",d),et(r,d,"sirsoft-ecommerce.admin.product.bulk.option_update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function ot(r){r.state.setLocal({modifiedProductIds:[],modifiedOptionIds:[],modifiedProductFields:{},modifiedOptionFields:{},selectedItems:[],selectedOptionIds:[]}),r.state.set({bulkSalesStatus:null,bulkDisplayStatus:null,bulkPriceCondition:null,bulkStockCondition:null,bulkConfirmData:null,bulkUpdateErrors:[]})}function kr(r,c){const e=tt();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},n=e.dataSource.get("products")?.data?.data||[],s=o.bulkSelectedItems||t.selectedItems||[],i=o.bulkSelectedOptionIds||t.selectedOptionIds||[],l={products:[],options:[],summary:{productCount:0,optionCount:0,hasBulkChanges:!1,hasInlineChanges:!1}},u=!!(o.bulkSalesStatus||o.bulkDisplayStatus||o.bulkPriceCondition||o.bulkStockCondition);l.summary.hasBulkChanges=u;const d=t.modifiedProductIds||[],m=t.modifiedOptionIds||[],g=t.modifiedProductFields||{},f=t.modifiedOptionFields||{},w=new Set(s);function y(_,p,S){const C=[],A=f[p]||[];if(o.bulkPriceCondition&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:o.bulkPriceCondition})||`Price adjustment: ${o.bulkPriceCondition}`),o.bulkStockCondition&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:o.bulkStockCondition})||`Stock: ${o.bulkStockCondition}`),S){if(!o.bulkPriceCondition&&A.includes("price_adjustment")){const b=_.price_adjustment?.toLocaleString?.()??_.price_adjustment;C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:b})||`Price adjustment: ${b}`)}!o.bulkPriceCondition&&A.includes("selling_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:_.selling_price?.toLocaleString?.()??_.selling_price})||`Selling price: ${_.selling_price}`),!o.bulkStockCondition&&A.includes("stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:_.stock_quantity})||`Stock: ${_.stock_quantity}`),A.includes("option_name")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_option_name",{name:Z(_.option_name)})||`Option name: ${Z(_.option_name)}`),A.includes("sku")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sku",{sku:_.sku})||`SKU: ${_.sku}`),A.includes("list_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:_.list_price?.toLocaleString?.()??_.list_price})||`List price: ${_.list_price}`),A.includes("safe_stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_safe_stock",{value:_.safe_stock_quantity})||`Safe stock: ${_.safe_stock_quantity}`),A.includes("is_active")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_is_active",{value:_.is_active?"ON":"OFF"})||`Active: ${_.is_active?"ON":"OFF"}`),l.summary.hasInlineChanges=!0}return C}s.length>0&&n.filter(_=>s.includes(_.id)).forEach(_=>{const p=[],S=_._modified||d.includes(_.id),C=String(_.id),A=g[C]||[];if(o.bulkSalesStatus){const b=Se(e,"sales_status",o.bulkSalesStatus);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:b})||`Sales status: ${b}`)}if(o.bulkDisplayStatus){const b=Se(e,"display_status",o.bulkDisplayStatus);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:b})||`Display status: ${b}`)}if(S){if(!o.bulkSalesStatus&&A.includes("sales_status")){const b=Se(e,"sales_status",_.sales_status);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:b})||`Sales status: ${b}`)}if(!o.bulkDisplayStatus&&A.includes("display_status")){const b=Se(e,"display_status",_.display_status);p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:b})||`Display status: ${b}`)}A.includes("name")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_name_changed")||"Name changed"),A.includes("list_price")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:_.list_price?.toLocaleString?.()||_.list_price})||`List price: ${_.list_price?.toLocaleString?.()||_.list_price}`),A.includes("selling_price")&&p.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:_.selling_price?.toLocaleString?.()||_.selling_price})||`Selling price: ${_.selling_price?.toLocaleString?.()||_.selling_price}`),l.summary.hasInlineChanges=!0}(p.length>0||u||S)&&l.products.push({id:_.id,name:Z(_.name),changes:p.length>0?p.join(", "):S?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"}),(_.options||[]).forEach(b=>{const L=`${_.id}-${b.id}`,R=b._modified||m.includes(L),x=y(b,L,R);(x.length>0||u&&(o.bulkPriceCondition||o.bulkStockCondition)||R)&&l.options.push({productId:_.id,optionId:b.id,productName:Z(_.name),optionName:b.option_name_localized||Z(b.option_name)||b.name||"",changes:x.length>0?x.join(", "):R?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"})})}),i.length>0&&n.forEach(_=>{w.has(_.id)||(_.options||[]).forEach(p=>{const S=`${_.id}-${p.id}`;if(i.includes(S)){const C=p._modified||m.includes(S),A=y(p,S,C);l.options.push({productId:_.id,optionId:p.id,productName:Z(_.name),optionName:p.option_name_localized||Z(p.option_name)||p.name||"",changes:A.length>0?A.join(", "):C?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selected")||"Selected"})}})}),l.summary.productCount=l.products.length,l.summary.optionCount=l.options.length,e.state.set({bulkConfirmData:l}),X.log("[buildConfirmData] Built confirm data:",l)}const M=window.G7Core?.createLogger?.("Ecom:Order")??{log:(...r)=>console.log("[Ecom:Order]",...r),warn:(...r)=>console.warn("[Ecom:Order]",...r),error:(...r)=>console.error("[Ecom:Order]",...r)};function Gr(r){const{currencies:c=[]}=r.params||{},e=[{field:"no",header:"NO",width:"60px",sortable:!1,required:!0},{field:"ordered_at",header:"$t:sirsoft-ecommerce.admin.order.column.order_date",width:"140px",sortable:!0,required:!0},{field:"order_number",header:"$t:sirsoft-ecommerce.admin.order.column.order_number",width:"140px",sortable:!0,required:!0},{field:"product",header:"$t:sirsoft-ecommerce.admin.order.column.product",width:"auto",sortable:!1,required:!0},{field:"order_amount",header:"$t:sirsoft-ecommerce.admin.order.column.order_amount",width:"120px",sortable:!0,required:!1},{field:"order_status",header:"$t:sirsoft-ecommerce.admin.order.column.order_status",width:"100px",sortable:!1,required:!1},{field:"orderer",header:"$t:sirsoft-ecommerce.admin.order.column.orderer",width:"120px",sortable:!1,required:!1},{field:"payment_method",header:"$t:sirsoft-ecommerce.admin.order.column.payment_method",width:"100px",sortable:!1,required:!1},{field:"delivery_method",header:"$t:sirsoft-ecommerce.admin.order.column.delivery_method",width:"100px",sortable:!1,required:!1},{field:"country",header:"$t:sirsoft-ecommerce.admin.order.column.country",width:"80px",sortable:!1,required:!1}],t=c.filter(o=>!o.is_default).map(o=>({field:`order_amount_${o.code.toLowerCase()}`,header:`${o.name} (${o.symbol})`,width:"100px",sortable:!0,required:!1}));return[...e,...t]}function Or(r,c){const e=r.params||{},{target:t="_local",path:o,value:a}=e,n=window.G7Core;if(!n?.state)return M.warn("[toggleArrayValue] G7Core.state를 사용할 수 없습니다."),{success:!1,newValue:[]};const s=t==="_global"?n.state.getGlobal():n.state.getLocal(),i=o.split(".");let l=s;for(const d of i)l=l?.[d];Array.isArray(l)||(l=[]);let u;return l.includes(a)?u=l.filter(d=>d!==a):u=[...l,a],t==="_global"?n.state.setGlobal({[o]:u}):n.state.setLocal({[o]:u}),{success:!0,newValue:u}}function Er(r,c){const e=r.params||{},{filterKey:t,checked:o}=e,a=window.G7Core;if(!a?.state)return M.warn("[toggleVisibleFilter] G7Core.state를 사용할 수 없습니다."),{success:!1,newValue:[]};const s=a.state.getLocal().visibleFilters||[];let i;o?i=s.includes(t)?s:[...s,t]:i=s.filter(l=>l!==t),a.state.setLocal({visibleFilters:i});try{localStorage.setItem("g7_order_filter_visible_filters",JSON.stringify(i))}catch(l){M.warn("[toggleVisibleFilter] LocalStorage 저장 실패:",l)}return{success:!0,newValue:i}}function Dr(r,c){const e=r.params||{},{selectedIds:t=[]}=e,o=window.G7Core;return o?.state?(o.state.setLocal({selectedItems:t,selectAll:!1}),{success:!0}):(M.warn("[syncOrderSelection] G7Core.state를 사용할 수 없습니다."),{success:!1})}function Fr(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.order_number)return M.warn("[handleOrderRowAction] actionId 또는 row.order_number가 없습니다."),{success:!1};switch(t){case"view":a?.navigate?.(`/admin/ecommerce/orders/${o.order_number}`);break;case"edit":a?.navigate?.(`/admin/ecommerce/orders/${o.order_number}`);break;case"status":case"shipping":case"memo":case"delete":a?.state?.setGlobal({orderList:{currentActionOrderId:o.id,currentActionType:t}});break;case"print":window.open(`/admin/ecommerce/orders/${o.order_number}/shipping-label`,"_blank");break;default:return console.warn(`[handleOrderRowAction] 알 수 없는 액션: ${t}`),{success:!1}}return{success:!0,action:t,targetId:o.id}}async function $r(r,c){const e=r.params||{},{actionType:t,orderIds:o,status:a,carrierId:n,trackingNumber:s}=e,i=window.G7Core;if(!o||o.length===0)throw new Error("선택된 주문이 없습니다.");let l="";const u={ids:o};switch(t){case"status":if(!a)throw new Error("변경할 상태를 선택해주세요.");l="/api/modules/sirsoft-ecommerce/admin/orders/bulk",u.order_status=a;break;case"shipping":if(!s&&!n)throw new Error("택배사 또는 운송장번호를 입력해주세요.");l="/api/modules/sirsoft-ecommerce/admin/orders/bulk",n&&(u.carrier_id=n),s&&(u.tracking_number=s);break;default:throw new Error(`알 수 없는 액션 타입: ${t}`)}const d=await i?.api?.call(l,{method:"PATCH",body:u});if(!d?.success)throw new Error(d?.message||"일괄 처리에 실패했습니다.");return{success:!0,successCount:d.data?.updated_count??o.length,failCount:d.data?.fail_count??0,errors:d.data?.errors??[]}}async function Nr(r,c){const e=r.params||{},{downloadType:t,orderIds:o,filters:a}=e,n=window.G7Core,s="/api/modules/sirsoft-ecommerce/admin/orders/export",i={};if(t==="selected"){if(!o||o.length===0)throw new Error("선택된 주문이 없습니다.");i.ids=o}else i.filters=a||{};const l=n?.auth?.getToken(),u=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...l?{Authorization:`Bearer ${l}`}:{}},body:JSON.stringify(i)});if(!u.ok){const y=await u.json().catch(()=>null);throw new Error(y?.message||"엑셀 다운로드에 실패했습니다.")}const d=await u.blob(),m=window.URL.createObjectURL(d),g=document.createElement("a");g.href=m;const f=new Date,w=`${f.getFullYear()}-${String(f.getMonth()+1).padStart(2,"0")}-${String(f.getDate()).padStart(2,"0")}`;return g.download=`orders_${w}.xlsx`,document.body.appendChild(g),g.click(),document.body.removeChild(g),window.URL.revokeObjectURL(m),{success:!0}}function Tr(r,c){const e=r.params||{},{columns:t=[]}=e;try{localStorage.setItem("g7_order_visible_columns",JSON.stringify(t))}catch(o){return M.warn("[saveVisibleColumns] LocalStorage 저장 실패:",o),{success:!1}}return{success:!0}}function Rr(r,c){const t=(r.params||{}).defaultColumns||["order_date","order_number","order_status","order_amount","product","orderer","payment_method","delivery_method","country"];try{const o=localStorage.getItem("g7_order_visible_columns");if(o){const a=JSON.parse(o);if(Array.isArray(a))return a}}catch(o){M.warn("[loadVisibleColumns] LocalStorage 로드 실패:",o)}return t}function Mr(r,c){const e=r.params||{},{id:t,defaultFilters:o=[]}=e,a=window.G7Core;if(!t)return M.warn("[loadVisibleFilters] id 파라미터가 필요합니다."),o;let n;try{const i=a?.AuthManager?.getInstance();i&&(n=i.getUser()?.uuid)}catch{}const s=`g7_filters_${t}${n!==void 0?`_${n}`:""}`;try{const i=localStorage.getItem(s);if(i){const l=JSON.parse(i);if(Array.isArray(l)&&l.length>0)return M.log(`[loadVisibleFilters] localStorage에서 로드: ${s}`,l),l}}catch(i){M.warn("[loadVisibleFilters] LocalStorage 로드 실패:",i)}return M.log(`[loadVisibleFilters] 기본값 사용: ${s}`,o),o}function Hr(r,c){const e=window.G7Core;if(!e)return;const t=e.state.get()||{},o=t.bulkSelectedItems||[],a=t.bulkOrderStatus||"",n=t.bulkCourier||"",s=t.bulkTrackingNumber||"";if(!a){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_status_selected")||"변경할 주문상태를 선택해주세요.");return}if(["shipping_ready","shipping"].includes(a)&&(!n||!s)){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.carrier_required")||"해당 상태로 변경하려면 택배사와 송장번호를 모두 입력해주세요.");return}e.state.set({bulkConfirmData:{selectedItems:o,orderStatus:a,courierId:n,trackingNumber:s}}),e.modal?.open?.("modal_bulk_confirm")}async function qr(r,c){const e=window.G7Core;if(!e)return;const o=(e.state.get()||{}).bulkConfirmData;if(!o||!o.selectedItems?.length){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.error")||"일괄 처리에 실패했습니다.");return}const a={ids:o.selectedItems,order_status:o.orderStatus};o.courierId&&(a.carrier_id=o.courierId),o.trackingNumber&&(a.tracking_number=o.trackingNumber);try{await e.api.patch("/api/modules/sirsoft-ecommerce/admin/orders/bulk",a),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.success")||"일괄 변경이 완료되었습니다."),e.state.setLocal({selectedItems:[],selectAll:!1,bulkOrderStatus:"",bulkCourier:"",bulkTrackingNumber:""}),e.state.set({bulkConfirmData:null}),e.dataSource?.refetch?.("orders"),e.modal?.close?.()}catch(n){M.error("[executeOrderBulkAction] Error:",n);const i=(n?.response?.data||n?.data||{})?.message||n?.message;e.toast?.error?.(i||e.t?.("sirsoft-ecommerce.admin.order.bulk.error")||"일괄 처리에 실패했습니다.")}}const $e=window.G7Core?.createLogger?.("Ecom:ProductList")??{log:(...r)=>console.log("[Ecom:ProductList]",...r),warn:(...r)=>console.warn("[Ecom:ProductList]",...r),error:(...r)=>console.error("[Ecom:ProductList]",...r)};async function Ur(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.id)return $e.warn("[handleProductRowAction] actionId 또는 row.id가 없습니다."),{success:!1};switch(t){case"edit":a?.navigate?.(`/admin/ecommerce/products/${o.product_code}/edit`);break;case"copy":a?.state?.setLocal({productList:{copyTargetProduct:o},ui:{showCopyModal:!0}});break;case"delete":try{const s=(await a?.api?.get(`/api/modules/sirsoft-ecommerce/admin/products/${o.id}/can-delete`))?.data;a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:s?.canDelete??!0,deleteBlockReason:s?.reason??null,relatedData:s?.relatedData??null},ui:{showDeleteModal:!0}})}catch(n){$e.error("[handleProductRowAction] 삭제 가능 여부 확인 실패:",n),a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:!1,deleteBlockReason:"삭제 가능 여부를 확인할 수 없습니다.",relatedData:null},ui:{showDeleteModal:!0}})}break;default:return $e.warn(`[handleProductRowAction] 알 수 없는 액션: ${t}`),{success:!1}}return{success:!0,action:t,targetId:o.id}}const U=window.G7Core?.createLogger?.("Ecom:OrderDetail")??{log:(...r)=>console.log("[Ecom:OrderDetail]",...r),warn:(...r)=>console.warn("[Ecom:OrderDetail]",...r),error:(...r)=>console.error("[Ecom:OrderDetail]",...r)};function rt(){return typeof document>"u"?"":document.querySelector('input[name="batchTrackingNumber"]')?.value?.trim()??""}function Vr(r,c){const e=window.G7Core;if(!e?.state){U.warn("[initOrderDetailForm] G7Core.state를 사용할 수 없습니다.");return}e.state.getLocal();const o=c?.data?.data??e.dataSource?.get?.("order")?.data;if(!o){U.warn("[initOrderDetailForm] order 데이터가 없습니다.");return}e.state.setLocal({form:{recipient_name:o.recipient_name||"",recipient_phone:o.recipient_phone||"",recipient_tel:o.recipient_tel||"",recipient_zipcode:o.recipient_zipcode||"",recipient_address:o.recipient_address||"",recipient_detail_address:o.recipient_detail_address||"",delivery_memo:o.delivery_memo||"",admin_memo:o.admin_memo||""}}),U.log("[initOrderDetailForm] 폼 초기화 완료")}function jr(r,c){const e=window.G7Core,{optionId:t}=r.params||{};if(!e?.state||!t)return;const a=e.state.getLocal().selectedProducts||[];let n;a.includes(t)?n=a.filter(u=>u!==t):n=[...a,t];const i=(e.dataSource?.get?.("order")?.data?.options||[]).length,l=n.length===i&&i>0;e.state.setLocal({selectedProducts:n,selectAll:l})}function Br(r,c){const e=window.G7Core;if(!e?.state)return;const o=e.state.getLocal().selectAll,n=e.dataSource?.get?.("order")?.data?.options||[];if(o)e.state.setLocal({selectedProducts:[],selectAll:!1});else{const s=n.map(i=>i.id);e.state.setLocal({selectedProducts:s,selectAll:!0})}}function Kr(r,c){const e=window.G7Core;if(!e)return;const t=r.params||{},o=t.selectedProducts||[],a=t.batchOrderStatus||"",n=t.batchCarrierId||"",s=t.batchTrackingNumber||rt();if(!a){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_status_selected")||"변경할 주문상태를 선택해주세요.");return}if(o.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_items_selected")||"변경할 상품을 선택해주세요.");return}if(a==="cancelled"){const g=t.cancelPg??!0,f=e.dataSource?.get?.("order")?.data,w=f?.options||[],y=o.map(_=>{const p=w.find(S=>S.id===_);return!p||p.option_status==="cancelled"?null:{id:p.id,product_name:p.product_name,product_option_name:p.product_option_name,thumbnail_url:p.product_snapshot?.thumbnail_url||"",unit_price:p.unit_price,quantity:p.quantity,cancel_quantity:p.quantity,option_status:p.option_status,option_status_label:p.option_status_label||p.option_status}}).filter(Boolean);if(y.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_cancellable_items")||"취소할 수 있는 상품이 없습니다. 이미 취소된 상품은 제외됩니다.");return}if(g){e.state.setLocal({cancelItems:y,cancelReason:"",cancelReasonDetail:"",cancelPg:!0,refundPriority:"pg_first",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null,cancelValidationErrors:null}),e.modal?.open?.("modal_cancel_order");const _=f?.order_number||"";_&&e.dispatch?.({handler:"sirsoft-ecommerce.estimateRefundAmount",params:{orderId:_,cancelItems:y}})}else{const _={};for(const p of y)_[p.id]=p.quantity;e.state.setLocal({bulkConfirmItems:y,changeQuantities:_,batchOrderStatus:a}),e.modal?.open?.("modal_batch_change_confirm")}return}if(["shipping_ready","shipping","delivered"].includes(a)&&(!n||!s)){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.carrier_required")||"해당 상태로 변경하려면 택배사와 송장번호를 모두 입력해주세요.");return}const u=e.dataSource?.get?.("order")?.data?.options||[],d=[],m={};for(const g of o){const f=u.find(w=>w.id===g);f&&(d.push({id:f.id,product_name:f.product_name,product_option_name:f.product_option_name,sku:f.sku,thumbnail_url:f.product_snapshot?.thumbnail_url||"",unit_price:f.unit_price,original_price:f.product_snapshot?.original_price||f.unit_price,quantity:f.quantity,option_status:f.option_status}),m[f.id]=f.quantity)}e.state.setLocal({bulkConfirmItems:d,changeQuantities:m,batchOrderStatus:a,batchCarrierId:n,batchTrackingNumber:s}),e.modal?.open?.("modal_batch_change_confirm")}async function Qr(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t){U.warn("[processOrderDetailBulkChange] orderId가 없습니다.");return}const o=e.state.getLocal?.()??{},a=r.params?.selectedProducts??o.selectedProducts,n=r.params?.batchOrderStatus||o.batchOrderStatus,s=r.params?.batchCarrierId||o.batchCarrierId,i=r.params?.batchTrackingNumber||o.batchTrackingNumber||rt(),l=r.params?.changeQuantities??o.changeQuantities;if(!a||a.length===0){U.warn("[processOrderDetailBulkChange] 선택된 상품이 없습니다.");return}if(!n){U.warn("[processOrderDetailBulkChange] 변경할 상태가 없습니다.");return}const d=e.dataSource?.get?.("order")?.data?.options||[],g={items:a.map(f=>{const w=d.find(y=>y.id===f);return{option_id:f,quantity:l?.[f]??w?.quantity??1}}),status:n};s&&(g.carrier_id=s),i&&(g.tracking_number=i);try{const f=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/options/bulk-status`,g);if(f?.success){const w=e.t,y=f.data?.changed_count??a.length;e.toast?.success?.(w?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_success",{count:y})??`${y}개 옵션의 상태가 변경되었습니다.`),e.modal?.close?.("modal_batch_change_confirm"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}}),e.state.setLocal({selectedProducts:[],selectAll:!1,batchOrderStatus:"",batchCarrierId:"",batchTrackingNumber:"",changeQuantities:{}})}else throw new Error(f?.message||"상태 변경에 실패했습니다.")}catch(f){U.error("[processOrderDetailBulkChange] API 호출 실패:",f);const y=(f?.response?.data||f?.data||{})?.message||f?.message;e.toast?.error?.(y??e.t?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_failed")??"상태 변경에 실패했습니다.")}}async function zr(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t)return;const a=e.state.getLocal().form?.admin_memo??"";try{const n=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}`,{admin_memo:a});if(n?.success)e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.handler.memo_save_success")??"관리자 메모가 저장되었습니다.");else throw new Error(n?.message)}catch(n){U.error("[saveAdminMemo] 저장 실패:",n),e.toast?.error?.(n?.message??"메모 저장에 실패했습니다.")}}function Wr(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o}=r.params||{};if(!t||!o)return;const a=r.params?.value??r.$event?.target?.value,n=parseInt(String(a),10),s=Math.max(1,Math.min(isNaN(n)?o:n,o)),l={...e.state.getLocal().changeQuantities||{}};l[t]=s,e.state.setLocal({changeQuantities:l})}function Yr(r,c){const e=window.G7Core;if(!e?.state){U.warn("[openConfirmDepositModal] G7Core.state 를 사용할 수 없습니다.");return}const t=e.dataSource?.get?.("order")?.data,o=t?.depositor_name??"",a=t?.total_due_amount??0;e.state.setLocal({depositorName:o,depositAmount:a,depositConfirmErrors:null,isConfirmingDeposit:!1,markOrderComplete:!1}),e.modal?.open?.("modal_confirm_deposit")}async function Jr(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t){U.warn("[confirmDeposit] orderId 가 없습니다.");return}const o=e.state.getLocal(),a=r.params?.amount??o.depositAmount,n=r.params?.depositorName??o.depositorName??"",s=r.params?.markOrderComplete??o.markOrderComplete??!1,i=s===!0||s==="true";e.state.setLocal({isConfirmingDeposit:!0,depositConfirmErrors:null});try{const l=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/confirm-deposit`,{amount:a,depositor_name:n,mark_order_complete:i});if(l?.success)e.state.setLocal({isConfirmingDeposit:!1,depositConfirmErrors:null}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.modal.confirm_deposit.confirm_success")??"입금이 확인되어 결제완료 처리되었습니다."),e.modal?.close?.("modal_confirm_deposit"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}});else throw new Error(l?.message||"Deposit confirm failed")}catch(l){U.error("[confirmDeposit] 실패:",l);const u=l?.response?.data||l?.data||{},d=u?.errors?.detail||u?.message||l?.message||"",m=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.confirm_deposit.confirm_failed")??"입금 확인에 실패했습니다.";e.state.setLocal({isConfirmingDeposit:!1,depositConfirmErrors:{amount:[d||m]}}),e.toast?.error?.(d||m)}}const Ne=window.G7Core?.createLogger?.("Ecom:CancelOrder")??{log:(...r)=>console.log("[Ecom:CancelOrder]",...r),warn:(...r)=>console.warn("[Ecom:CancelOrder]",...r),error:(...r)=>console.error("[Ecom:CancelOrder]",...r)};let Q=null;function Xr(){Q&&(clearTimeout(Q),Q=null,Ne.log("[clearCancelOrderTimers] debounce 타이머 정리 완료"))}function Zr(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a)return;const n=r.params?.value??r.$event?.target?.value,s=parseInt(String(n),10),i=Math.max(1,Math.min(isNaN(s)?o:s,o)),u=(e.state.getLocal().cancelItems||[]).map(m=>m.id===t?{...m,cancel_quantity:i}:m);e.state.setLocal({cancelItems:u});const d=window.__g7ActionContext;Q&&clearTimeout(Q),Q=setTimeout(()=>{const m=window.__g7ActionContext;window.__g7ActionContext=d;try{Te(e,a)}finally{window.__g7ActionContext=m}},500)}async function en(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,cancelItems:o}=r.params||{};t&&await Te(e,t,o)}async function Te(r,c,e){const t=r.state.getLocal(),o=e||t.cancelItems||[],a=t.refundPriority||"pg_first";if(o.length===0){r.state.setLocal({refundEstimate:null});return}const n=o.map(s=>({order_option_id:s.id,cancel_quantity:s.cancel_quantity}));r.state.setLocal({refundLoading:!0});try{const s=await r.api.post(`/api/modules/sirsoft-ecommerce/admin/orders/${c}/estimate-refund`,{items:n,refund_priority:a});if(s?.success)r.state.setLocal({refundEstimate:s.data,refundLoading:!1,cancelError:null});else throw new Error(s?.message||"Estimate failed")}catch(s){Ne.error("[estimateRefundAmount] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function tn(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o)return;e.state.setLocal({refundPriority:t});const a=window.__g7ActionContext;Q&&clearTimeout(Q),Q=setTimeout(()=>{const n=window.__g7ActionContext;window.__g7ActionContext=a;try{Te(e,o)}finally{window.__g7ActionContext=n}},300)}async function on(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t)return;const o=e.state.getLocal(),a=r.params?.cancelItems||o.cancelItems||[],n=r.params?.cancelReason||o.cancelReason||"",s=r.params?.cancelReasonDetail||o.cancelReasonDetail||"",i=r.params?.cancelPg??o.cancelPg??!0,l=r.params?.refundPriority||o.refundPriority||"pg_first",u={type:"partial",cancel_pg:i,refund_priority:l,items:a.map(d=>({order_option_id:d.id,cancel_quantity:d.cancel_quantity}))};n&&(u.reason=n),s&&(u.reason_detail=s),e.state.setLocal({isCancelling:!0,cancelError:null,cancelValidationErrors:null});try{const d=await e.api.post(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/cancel`,u);if(d?.success)e.state.setLocal({isCancelling:!1}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_success")??"주문이 취소되었습니다."),e.modal?.close?.("modal_cancel_order"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}}),e.state.setLocal({selectedProducts:[],selectAll:!1,batchOrderStatus:""});else throw new Error(d?.message||"Cancel failed")}catch(d){Ne.error("[executeCancelOrder] 실패:",d);const m=d?.response?.data||d?.data,f=(d?.response?.status||d?.status)===422,w=m?.errors?.detail,y=m?.errors??null,_=w||m?.message||d?.message||"",p=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_failed")??"주문 취소에 실패했습니다.";f?(e.state.setLocal({isCancelling:!1,cancelError:null,cancelValidationErrors:y}),e.toast?.error?.(_||p)):(e.state.setLocal({isCancelling:!1,cancelError:_||p,cancelValidationErrors:null}),e.toast?.error?.(_||p))}}const nt=window.G7Core?.createLogger?.("Ecom:UserCancelOrder")??{log:(...r)=>console.log("[Ecom:UserCancelOrder]",...r),warn:(...r)=>console.warn("[Ecom:UserCancelOrder]",...r),error:(...r)=>console.error("[Ecom:UserCancelOrder]",...r)};let T=null;function ve(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}function rn(){T&&(clearTimeout(T),T=null)}function nn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t}=r.params||{};if(!t)return;const a=[...e.state.getLocal().selectedItemIds||[]],n=a.indexOf(t);n>=0?a.splice(n,1):a.push(t);const i=(e.dataSource?.get?.("order")?.data?.options||[]).filter(u=>u.option_status!=="cancelled").map(u=>u.id),l=i.length>0&&i.every(u=>a.includes(u));e.state.setLocal({selectedItemIds:a,selectAllItems:l})}function an(r,c){const e=window.G7Core;if(!e?.state)return;const t=e.state.getLocal(),a=(e.dataSource?.get?.("order")?.data?.options||[]).filter(s=>s.option_status!=="cancelled").map(s=>s.id),n=!t.selectAllItems;e.state.setLocal({selectedItemIds:n?a:[],selectAllItems:n})}function sn(r,c){const e=window.G7Core;if(!e?.state)return;const t=e.dataSource?.get?.("order")?.data;if(!t)return;const a=e.state.getLocal().selectedItemIds||[];if(a.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_selected_items")??"취소할 상품을 선택해주세요.");return}const s=(t.options||[]).filter(l=>a.includes(l.id)&&l.option_status!=="cancelled").map(l=>({id:l.id,product_name:l.product_name??"",product_option_name:l.product_option_name??"",thumbnail_url:l.product_snapshot?.thumbnail_url||"",unit_price:l.unit_price??0,quantity:l.quantity??1,cancel_quantity:l.quantity??1,option_status:l.option_status??"",selected:!0}));if(s.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_cancellable_items")??"취소 가능한 상품이 없습니다.");return}e.state.setLocal({cancelItems:s,cancelSelectAll:!0,cancelReason:"",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null}),e.modal?.open?.("modal_cancel_order");const i=r.params?.orderId||t.id;(i||t.order_number)&&ce(e,i)}function cn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,orderId:o}=r.params||{};if(!t)return;const n=(e.state.getLocal().cancelItems||[]).map(i=>i.id===t?{...i,selected:!i.selected}:i),s=n.every(i=>i.selected);e.state.setLocal({cancelItems:n,cancelSelectAll:s}),T&&clearTimeout(T),T=setTimeout(()=>{ce(e,o)},500)}function ln(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=e.state.getLocal(),a=!o.cancelSelectAll,n=(o.cancelItems||[]).map(s=>({...s,selected:a}));e.state.setLocal({cancelItems:n,cancelSelectAll:a}),T&&clearTimeout(T),T=setTimeout(()=>{ce(e,t)},500)}function dn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a&&!ve(e))return;const n=r.params?.value??r.$event?.target?.value,s=parseInt(String(n),10),i=Math.max(1,Math.min(isNaN(s)?o:s,o)),u=(e.state.getLocal().cancelItems||[]).map(m=>m.id===t?{...m,cancel_quantity:i}:m);e.state.setLocal({cancelItems:u});const d=window.__g7ActionContext;T&&clearTimeout(T),T=setTimeout(()=>{const m=window.__g7ActionContext;window.__g7ActionContext=d;try{ce(e,a)}finally{window.__g7ActionContext=m}},500)}async function un(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};!t&&!ve(e)||await ce(e,t)}async function ce(r,c){const e=r.state.getLocal(),t=e.cancelItems||[],o=e.refundPriority||"pg_first",a=t.filter(s=>s.selected);if(a.length===0){r.state.setLocal({refundEstimate:null,refundLoading:!1});return}const n=a.map(s=>({order_option_id:s.id,cancel_quantity:s.cancel_quantity}));r.state.setLocal({refundLoading:!0});try{const s=r.state.get?.("_global")?.guestOrderToken,i=r.dataSource?.get?.("order")?.data?.order_number,l=!!(s&&i),u=l?`/api/modules/sirsoft-ecommerce/guest/orders/${i}/estimate-refund`:`/api/modules/sirsoft-ecommerce/user/orders/${c}/estimate-refund`,d=l?{headers:{"X-Guest-Order-Token":s}}:void 0,m=await r.api.post(u,{items:n,refund_priority:o},d);if(m?.success)r.state.setLocal({refundEstimate:m.data,refundLoading:!1,cancelError:null});else throw new Error(m?.message||"Estimate failed")}catch(s){nt.error("[estimateUserRefund] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("mypage.order_detail.cancel_modal.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function mn(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o&&!ve(e))return;e.state.setLocal({refundPriority:t});const a=window.__g7ActionContext;T&&clearTimeout(T),T=setTimeout(()=>{const n=window.__g7ActionContext;window.__g7ActionContext=a;try{ce(e,o)}finally{window.__g7ActionContext=n}},300)}async function pn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t&&!ve(e))return;const o=e.state.getLocal(),n=(o.cancelItems||[]).filter(d=>d.selected),s=r.params?.cancelReason||o.cancelReason||"",i=r.params?.cancelReasonDetail??o.cancelReasonDetail??"",l=r.params?.refundPriority||o.refundPriority||"pg_first";if(n.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_selected_items")??"취소할 상품을 선택해주세요.");return}if(!s){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.reason_required")??"취소 사유를 선택해주세요.");return}const u={reason:s,refund_priority:l,items:n.map(d=>({order_option_id:d.id,cancel_quantity:d.cancel_quantity}))};i&&(u.reason_detail=i),e.state.setLocal({isCancelling:!0,cancelError:null,cancelValidationErrors:null});try{const d=e.state.get?.("_global")?.guestOrderToken,m=e.dataSource?.get?.("order")?.data?.order_number,g=!!(d&&m),f=g?`/api/modules/sirsoft-ecommerce/guest/orders/${m}/cancel`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/cancel`,w=g?{headers:{"X-Guest-Order-Token":d}}:void 0,y=await e.api.post(f,u,w);if(y?.success)e.state.setLocal({isCancelling:!1}),e.toast?.success?.(e.t?.("mypage.order_detail.cancel_success")??"주문이 취소되었습니다."),e.modal?.close?.("modal_cancel_order"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}});else throw new Error(y?.message||"Cancel failed")}catch(d){nt.error("[executeUserCancelOrder] 실패:",d);const m=d?.response?.data||d?.data,g=m?.message||d?.message||"",f=m?.errors??null,w=e.t?.("mypage.order_detail.cancel_modal.cancel_failed")??"주문 취소에 실패했습니다.";e.state.setLocal({isCancelling:!1,cancelError:g||w,cancelValidationErrors:f}),e.toast?.error?.(g||w)}}const at=window.G7Core?.createLogger?.("Ecom:ConfirmOrder")??{log:(...r)=>console.log("[Ecom:ConfirmOrder]",...r),warn:(...r)=>console.warn("[Ecom:ConfirmOrder]",...r),error:(...r)=>console.error("[Ecom:ConfirmOrder]",...r)};function fn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function gn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o}=r.params||{};if(!o||!t&&!fn(e)){at.error("[confirmOrderOption] optionId 또는 식별자 누락");return}e.state.setLocal({isConfirming:!0});try{const a=e.state.get?.("_global")?.guestOrderToken,n=e.dataSource?.get?.("order")?.data?.order_number,s=!!(a&&n),i=s?`/api/modules/sirsoft-ecommerce/guest/orders/${n}/options/${o}/confirm`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/options/${o}/confirm`,l=s?{headers:{"X-Guest-Order-Token":a}}:void 0,u=await e.api.post(i,void 0,l);if(u?.success)e.state.setLocal({isConfirming:!1,confirmTarget:null}),e.modal?.close?.("modal_confirm_purchase"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.toast?.success?.(u.message??e.t?.("sirsoft-ecommerce::messages.order.confirmed")??"구매확정이 완료되었습니다.");else throw new Error(u?.message||"Confirm failed")}catch(a){at.error("[confirmOrderOption] 실패:",a);const n=a?.response?.data?.message||a?.data?.message||a?.message||e.t?.("sirsoft-ecommerce::messages.order.cannot_confirm")||"구매확정에 실패했습니다.";e.state.setLocal({isConfirming:!1}),e.toast?.error?.(n)}}const st=window.G7Core?.createLogger?.("Ecom:ChangeShippingAddress")??{log:(...r)=>console.log("[Ecom:ChangeShippingAddress]",...r),warn:(...r)=>console.warn("[Ecom:ChangeShippingAddress]",...r),error:(...r)=>console.error("[Ecom:ChangeShippingAddress]",...r)};function _n(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function wn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=_n(e);if(!t&&!o){st.error("[changeShippingAddress] orderId 또는 비회원 식별자 누락");return}const a=e.state.get?.("_global")??{},s=(o?"manual":a.changeAddressMode??"saved")==="saved"?{address_id:a.selectedAddressId}:a.editingShippingAddress;e.state.setLocal({isSubmittingAddress:!0});try{const i=a.guestOrderToken,l=e.dataSource?.get?.("order")?.data?.order_number,u=o?`/api/modules/sirsoft-ecommerce/guest/orders/${l}/shipping-address`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/shipping-address`,d=o?{headers:{"X-Guest-Order-Token":i}}:void 0,m=await e.api.put(u,s,d);if(m?.success)e.state.setLocal({isSubmittingAddress:!1}),e.modal?.close?.("changeAddressModal"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.toast?.success?.(m.message??e.t?.("mypage.order_detail.shipping_address_changed")??"배송지가 변경되었습니다.");else throw new Error(m?.message||"Change shipping address failed")}catch(i){st.error("[changeShippingAddress] 실패:",i),e.state.setLocal({isSubmittingAddress:!1});const l=i?.response?.data?.message||i?.data?.message||i?.message||"배송지 변경에 실패했습니다.";e.toast?.error?.(l)}}const Re=window.G7Core?.createLogger?.("Ecom:Review")??{log:(...r)=>console.log("[Ecom:Review]",...r),warn:(...r)=>console.warn("[Ecom:Review]",...r),error:(...r)=>console.error("[Ecom:Review]",...r)};async function yn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o,productId:a}=r.params||{};if(!t||!o||!a){Re.error("[submitReview] orderId, optionId 또는 productId 누락");return}const n=e.state.getLocal(),s=r.params?.rating??n.reviewRating??5,i=(r.params?.content??n.reviewContent??"").trim(),u=(r.params?.images??n.reviewImages??[]).map(d=>d instanceof File?d:d?.file instanceof File||d?.file instanceof Blob||d?.file&&typeof d.file.name=="string"&&d.file.size>0?d.file:d instanceof Blob?d:null).filter(d=>d!==null);if(!s||s<1||s>5){e.toast?.error?.(e.t?.("sirsoft-ecommerce.user.review.validation.rating_required")??"별점을 선택해 주세요.");return}if(i.length<10){e.state.setLocal({reviewError:e.t?.("sirsoft-ecommerce.user.review.validation.content_min")??"리뷰 내용을 최소 10자 이상 입력해 주세요."});return}e.state.setLocal({isSubmittingReview:!0,reviewError:null});try{const d=await e.api.post("/api/modules/sirsoft-ecommerce/user/reviews",{order_option_id:o,product_id:a,rating:s,content:i});if(!d?.success)throw new Error(d?.message||"Review creation failed");const m=d.data?.id;if(m&&u.length>0)for(let g=0;gconsole.log("[Ecom:ShippingPolicyForm]",...r),warn:(...r)=>console.warn("[Ecom:ShippingPolicyForm]",...r),error:(...r)=>console.error("[Ecom:ShippingPolicyForm]",...r)},hn=["fixed","conditional_free","per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],Cn=["conditional_free"],bn=["range_amount","range_quantity","range_weight","range_volume","range_volume_weight"],In=["api"],Sn=["per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],vn={shipping_method:"parcel",custom_shipping_name:null,carrier:null,currency_code:"KRW",charge_policy:"fixed",base_fee:0,free_threshold:null,ranges:null,api_endpoint:null,api_request_fields:null,api_response_fee_field:null,api_config:null,extra_fee_enabled:!1,extra_fee_settings:[],extra_fee_multiply:!1,is_active:!0};function ee(r){return{showBaseFee:hn.includes(r),showFreeThreshold:Cn.includes(r),showRanges:bn.includes(r),showApiSettings:In.includes(r),showUnitValue:Sn.includes(r)}}function G(){const r=window.G7Core;return r?.state?r:(I.warn("G7Core.state not available"),null)}function O(r,c){r.setLocalState?r.setLocalState(c):G()?.state?.setLocal(c)}function E(r){return[...(r.state.getLocal?.()??{}).form?.country_settings??[]]}function Ln(r,c){const e=r.params||{},t=e.policy,o=!!e.isEdit,a=!!e.isCopy;if(e.availableCountries,!G())return;const s={activeCountryTab:0,rangeErrors:{}};if((o||a)&&t){const i=t.country_settings??[],u=i[0]?.charge_policy??"fixed",d=ee(u);Object.assign(s,d),I.log("[initShippingPolicyForm]",o?"Edit":"Copy","mode, countries:",i.length,"first charge_policy:",u)}else{const i=ee("fixed");Object.assign(s,i),I.log("[initShippingPolicyForm] Create mode, default flags")}O(c,s)}function xn(r,c){const t=(r.params||{}).country_code??"";if(!t){I.warn("[addCountrySetting] country_code is empty");return}const o=G();if(!o)return;const a=E(o);if(a.some(u=>u.country_code===t)){I.warn("[addCountrySetting] Duplicate country_code:",t);return}const n={country_code:t,...vn};a.push(n);const s=a.length-1,i=ee(n.charge_policy),l={"form.country_settings":a,activeCountryTab:s,...i};O(c,l),I.log("[addCountrySetting] Added country:",t,"index:",s)}function Pn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){I.warn("[removeCountrySetting] Invalid index:",e.index);return}const o=G();if(!o)return;const a=E(o),n=o.state.getLocal?.()??{};if(t>=a.length){I.warn("[removeCountrySetting] Index out of bounds:",t);return}const s=a[t].country_code;a.splice(t,1);const i=n.activeCountryTab??0;let l=i;a.length===0?l=0:i>=a.length?l=a.length-1:i>t&&(l=i-1);const u={...n.rangeErrors??{}};u[s]=[];const d=a[l],m=ee(d?d.charge_policy:"fixed"),g={"form.country_settings":a,activeCountryTab:l,rangeErrors:u,...m};O(c,g),I.log("[removeCountrySetting] Removed index:",t,"country:",s,"newTab:",l)}function An(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){I.warn("[switchCountryTab] Invalid index:",e.index);return}const o=G();if(!o)return;const a=E(o);if(t>=a.length){I.warn("[switchCountryTab] Index out of bounds:",t);return}const n=a[t],s=ee(n.charge_policy),i={activeCountryTab:t,...s};O(c,i),I.log("[switchCountryTab] Switched to tab:",t,"country:",n.country_code,"charge_policy:",n.charge_policy)}function kn(r,c){const e=r.params||{},t=e.field??"",o=e.value;if(!t){I.warn("[updateCountryField] field is empty");return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=E(a);if(s>=i.length){I.warn("[updateCountryField] Index out of bounds:",s);return}i[s]={...i[s],[t]:o},O(c,{"form.country_settings":i}),I.log("[updateCountryField] Updated",t,"=",o,"at index:",s)}function Gn(r,c){const e=r.params||{},t=e.value??"",o=Number(e.index??0);if(!t){I.warn("[onChargePolicyChange] charge_policy value is empty");return}const a=G();if(!a)return;const n=ee(t);I.log("[onChargePolicyChange]",t,n,"countryIndex:",o);const s=E(a),i=s[o];if(!i){I.warn("[onChargePolicyChange] No country setting at index:",o);return}const l={...i,charge_policy:t};if(n.showBaseFee||(l.base_fee=0),n.showFreeThreshold||(l.free_threshold=null),!n.showRanges&&!n.showUnitValue&&(l.ranges=null),n.showApiSettings||(l.api_endpoint=null,l.api_request_fields=null,l.api_response_fee_field=null,l.api_config=null),n.showRanges&&(!l.ranges||!l.ranges.tiers)){const d=t.replace("range_","");l.ranges={type:d,tiers:[{min:0,max:null,fee:0}]}}n.showUnitValue&&(!l.ranges||!l.ranges.unit_value)&&(l.ranges={type:t,unit_value:1}),s[o]=l;const u={"form.country_settings":s,...n};O(c,u)}function On(r,c){const e=r.params||{},t=Number(e.index??0),o=G();if(!o)return;const a=E(o),n=a[t];if(!n){I.warn("[addRangeTier] No country setting at index:",t);return}const s=[...n.ranges?.tiers??[]],i=s[s.length-1],u={min:i?.max!=null?i.max+1:0,max:null,fee:0};s.push(u),a[t]={...n,ranges:{...n.ranges??{},tiers:s}},O(c,{"form.country_settings":a}),Le(o,c,t,s,n.country_code),I.log("[addRangeTier] Added tier, total:",s.length)}function En(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex);if(isNaN(o)||o<0){I.warn("[removeRangeTier] Invalid tierIndex:",e.tierIndex);return}const a=G();if(!a)return;const n=E(a),s=n[t];if(!s){I.warn("[removeRangeTier] No country setting at index:",t);return}const i=[...s.ranges?.tiers??[]];if(o>=i.length){I.warn("[removeRangeTier] tierIndex out of bounds:",o);return}i.splice(o,1),n[t]={...s,ranges:{...s.ranges??{},tiers:i}},O(c,{"form.country_settings":n}),Le(a,c,t,i,s.country_code),I.log("[removeRangeTier] Removed tier:",o,"remaining:",i.length)}function Dn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex),a=e.field,n=e.value;if(isNaN(o)||o<0){I.warn("[updateRangeTierField] Invalid tierIndex:",e.tierIndex);return}if(!["min","max","fee"].includes(a)){I.warn("[updateRangeTierField] Invalid field:",a);return}const s=G();if(!s)return;const i=E(s),l=i[t];if(!l){I.warn("[updateRangeTierField] No country setting at index:",t);return}const u=[...l.ranges?.tiers??[]];if(o>=u.length){I.warn("[updateRangeTierField] tierIndex out of bounds:",o);return}let d;a==="max"?d=n===""||n===null||n===void 0?null:Number(n):d=Number(n),u[o]={...u[o],[a]:d},i[t]={...l,ranges:{...l.ranges??{},tiers:u}},O(c,{"form.country_settings":i}),Le(s,c,t,u,l.country_code)}function Fn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=G();if(!o)return;const n=E(o)[t];if(!n)return;const s=n.ranges?.tiers??[];Le(o,c,t,s,n.country_code)}function Le(r,c,e,t,o){const n={...(r.state.getLocal?.()??{}).rangeErrors??{}};if(t.length===0){n[o]=[],O(c,{rangeErrors:n});return}const s=new Array(t.length).fill(null).map(()=>({}));let i=!1;const l=r.t;for(let u=0;u=d.max&&(s[u].min=l?.("sirsoft-ecommerce.validation.shipping_policy.ranges.min_less_than_max")??"시작값이 종료값보다 작아야 합니다.",i=!0),u=i.length){I.warn("[removeExtraFeeRow] feeIndex out of bounds:",o);return}i.splice(o,1),n[t]={...s,extra_fee_settings:i},O(c,{"form.country_settings":n}),I.log("[removeExtraFeeRow] Removed index:",o,"remaining:",i.length)}function Tn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=e.settings;if(!o||!Array.isArray(o)){I.warn("[applyExtraFeeTemplate] Invalid settings:",o);return}const a=G();if(!a)return;const n=E(a),s=n[t];if(!s){I.warn("[applyExtraFeeTemplate] No country setting at index:",t);return}n[t]={...s,extra_fee_settings:o},O(c,{"form.country_settings":n});const i=a.t,l=i?.("sirsoft-ecommerce.admin.shipping_policy.form.template_applied")??"템플릿이 적용되었습니다.";a.toast?.success?.(l),I.log("[applyExtraFeeTemplate] Applied template with",o.length,"rows")}function Rn(r,c){const e=r.params||{},t=parseFloat(e.value)||1,o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=E(o),i=s[n];i&&(s[n]={...i,ranges:{...i.ranges??{},type:i.charge_policy,unit_value:t}},O(c,{"form.country_settings":s}),I.log("[updateUnitValue] Updated unit_value:",t))}function Mn(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),a=E(e),n=a[o];if(!n)return;const s=[...n.api_request_fields??[]];s.push(""),a[o]={...n,api_request_fields:s},O(c,{"form.country_settings":a}),I.log("[addApiRequestField] Added field, total:",s.length)}function Hn(r,c){const e=r.params||{},t=Number(e.fieldIndex),o=e.value??"";if(isNaN(t)||t<0){I.warn("[updateApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=E(a),l=i[s];if(!l)return;const u=[...l.api_request_fields??[]];t>=u.length||(u[t]=o,i[s]={...l,api_request_fields:u},O(c,{"form.country_settings":i}),I.log("[updateApiRequestField] Updated index:",t,"value:",o))}function qn(r,c){const e=r.params||{},t=Number(e.fieldIndex);if(isNaN(t)||t<0){I.warn("[removeApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=E(o),i=s[n];if(!i)return;const l=[...i.api_request_fields??[]];t>=l.length||(l.splice(t,1),s[n]={...i,api_request_fields:l},O(c,{"form.country_settings":s}),I.log("[removeApiRequestField] Removed index:",t,"remaining:",l.length))}function Un(r,c){const t=(r.params||{}).field??"";if(!t){I.warn("[toggleApiRequestField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=E(o),i=s[n];if(!i)return;const l=[...i.api_request_fields??[]],u=l.indexOf(t);u>=0?l.splice(u,1):l.push(t);const d=l.length>0?l:null;s[n]={...i,api_request_fields:d},O(c,{"form.country_settings":s}),I.log("[toggleApiRequestField] Toggled:",t,"selected:",d)}function Vn(r,c){const e=r.params||{},t=e.field??"";if(!t){I.warn("[updateApiConfigField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=E(o),i=s[n];if(!i)return;const l={...i.api_config??{},[t]:e.value};s[n]={...i,api_config:l},O(c,{"form.country_settings":s}),I.log("[updateApiConfigField] Updated",t,"at index:",n)}function jn(r,c){const e=r.params||{},t=e.field??"";if(!t){I.warn("[updateApiFieldMap] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=E(o),i=s[n];if(!i)return;const l=e.value??"",u={...i.api_config?.field_map??{}};l.trim()===""?delete u[t]:u[t]=l;const d={...i.api_config??{},field_map:Object.keys(u).length>0?u:null};s[n]={...i,api_config:d},O(c,{"form.country_settings":s}),I.log("[updateApiFieldMap] Mapped",t,"→",l||"(removed)")}function Bn(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),n=E(e)[o];if(n){if(!n.api_endpoint){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.shipping_policy.form.api_test_endpoint_required")??"Endpoint required");return}e.state.set?.({apiTestLoading:!0,apiTestResult:null}),e.dispatch({handler:"apiCall",target:"/api/modules/sirsoft-ecommerce/admin/shipping-policies/test-api-call",auth_required:!0,params:{method:"POST",body:{endpoint:n.api_endpoint,request_fields:n.api_request_fields,config:n.api_config??{},sample:{country_code:n.country_code}}},onSuccess:{handler:"setState",params:{target:"global",apiTestLoading:!1,apiTestResult:"{{response.data}}"}},onError:{handler:"setState",params:{target:"global",apiTestLoading:!1,apiTestResult:"{{error.errors ?? error}}"}}})}}function Kn(r,c){const e=r.params||{},t=Number(e.feeIndex),o=e.field,a=e.value;if(isNaN(t)||t<0){I.warn("[updateExtraFeeField] Invalid feeIndex:",e.feeIndex);return}if(!["zipcode","fee","region"].includes(o)){I.warn("[updateExtraFeeField] Invalid field:",o);return}const n=G();if(!n)return;const s=n.state.getLocal?.()??{},i=Number(s.activeCountryTab??0),l=E(n),u=l[i];if(!u)return;const d=[...u.extra_fee_settings??[]];if(t>=d.length)return;const m=o==="fee"?parseFloat(a)||0:a;d[t]={...d[t],[o]:m},l[i]={...u,extra_fee_settings:d},O(c,{"form.country_settings":l}),I.log("[updateExtraFeeField] Updated index:",t,o,"=",m)}const me=window.G7Core?.createLogger?.("Handler:InitCategory")??{log:(...r)=>console.log("[Handler:InitCategory]",...r),warn:(...r)=>console.warn("[Handler:InitCategory]",...r),error:(...r)=>console.error("[Handler:InitCategory]",...r)};function it(r,c){for(const e of r){if(e.id===c)return e;if(e.children&&e.children.length>0){const t=it(e.children,c);if(t)return t}}return null}function ct(r){return new URLSearchParams(window.location.search).get(r)}function Qn(r){return window.G7Core?.state?.getDataSource?.(r)}async function zn(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function Wn(r,c){const e=window.G7Core,t=ct("id"),o=ct("mode");if(!t){me.log("[initCategoryFromUrl] No id parameter in URL");return}if(!e?.state?.set){me.warn("[initCategoryFromUrl] G7Core.state.set not available");return}const a=await zn("categories",l=>l?.data?.data);if(!a){me.warn("[initCategoryFromUrl] categories data not available after waiting");return}const n=parseInt(t,10),s=it(a,n);if(!s){me.warn("[initCategoryFromUrl] Category not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedCategoryId:s.id,selectedCategory:s,panelMode:i}),me.log("[initCategoryFromUrl] Category initialized from URL:",{id:n,mode:i})}const pe=window.G7Core?.createLogger?.("Handler:InitBrand")??{log:(...r)=>console.log("[Handler:InitBrand]",...r),warn:(...r)=>console.warn("[Handler:InitBrand]",...r),error:(...r)=>console.error("[Handler:InitBrand]",...r)};function lt(r){return new URLSearchParams(window.location.search).get(r)}function Yn(r){return window.G7Core?.state?.getDataSource?.(r)}async function Jn(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function Xn(r,c){const e=window.G7Core,t=lt("id"),o=lt("mode");if(!t){pe.log("[initBrandFromUrl] No id parameter in URL");return}if(!e?.state?.set){pe.warn("[initBrandFromUrl] G7Core.state.set not available");return}const a=await Jn("brands",l=>l?.data?.data);if(!a){pe.warn("[initBrandFromUrl] brands data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){pe.warn("[initBrandFromUrl] Brand not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedBrandId:s.id,selectedBrand:s,panelMode:i}),pe.log("[initBrandFromUrl] Brand initialized from URL:",{id:n,mode:i})}const fe=window.G7Core?.createLogger?.("Handler:InitCommonInfo")??{log:(...r)=>console.log("[Handler:InitCommonInfo]",...r),warn:(...r)=>console.warn("[Handler:InitCommonInfo]",...r),error:(...r)=>console.error("[Handler:InitCommonInfo]",...r)};function dt(r){return new URLSearchParams(window.location.search).get(r)}function Zn(r){return window.G7Core?.state?.getDataSource?.(r)}async function ea(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function ta(r,c){const e=window.G7Core,t=dt("id"),o=dt("mode");if(!t){fe.log("[initCommonInfoFromUrl] No id parameter in URL");return}if(!e?.state?.set){fe.warn("[initCommonInfoFromUrl] G7Core.state.set not available");return}const a=await ea("commonInfos",l=>l?.data);if(!a){fe.warn("[initCommonInfoFromUrl] commonInfos data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){fe.warn("[initCommonInfoFromUrl] Common info not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedId:s.id,selectedItem:s,panelMode:i}),fe.log("[initCommonInfoFromUrl] Common info initialized from URL:",{id:n,mode:i})}const ge=window.G7Core?.createLogger?.("Handler:InitNotice")??{log:(...r)=>console.log("[Handler:InitNotice]",...r),warn:(...r)=>console.warn("[Handler:InitNotice]",...r),error:(...r)=>console.error("[Handler:InitNotice]",...r)};function ut(r){return new URLSearchParams(window.location.search).get(r)}function oa(r){return window.G7Core?.state?.getDataSource?.(r)}async function ra(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function na(r,c){const e=window.G7Core,t=ut("id"),o=ut("mode");if(!t){ge.log("[initNoticeFromUrl] No id parameter in URL");return}if(!e?.state?.set){ge.warn("[initNoticeFromUrl] G7Core.state.set not available");return}const a=await ra("templates",l=>l?.data);if(!a){ge.warn("[initNoticeFromUrl] templates data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){ge.warn("[initNoticeFromUrl] Template not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedTemplateId:s.id,selectedTemplate:s,panelMode:i}),ge.log("[initNoticeFromUrl] Template initialized from URL:",{id:n,mode:i})}const te={updateProductField:Ct,updateOptionField:bt,calculateCurrencyPrices:ae,initPreferredCurrency:kt,initPreferredShippingCountry:Dt,setDateRange:$t,setDefaultOption:Nt,toggleOption:Tt,toggleProductOptions:Be,toggleAllOptionsInRow:Rt,getProductOptionStates:Mt,syncProductSelection:Ht,generateCopyProductCode:qt,copyProduct:Ut,selectCategory:jt,selectCategoryMobile:Bt,addCategoryToSelection:Kt,removeCategoryFromSelection:Qt,getCategoryBreadcrumb:zt,validateCategoryPath:Yt,initCategoryInfosFromProduct:Wt,getBrandName:Jt,getBrandDescription:Xt,updatePrice:oo,calculateTotalOptionStock:ro,validatePriceRelation:no,addOptionInput:yo,removeOptionInput:ho,updateOptionInput:Co,generateOptions:bo,deleteOption:Io,applyOptionAddTool:So,addRequiredItem:vo,updateRequiredItem:Lo,removeRequiredItem:xo,reorderRequiredItems:Po,addAdditionalOption:Ao,updateAdditionalOption:ko,removeAdditionalOption:Go,reorderAdditionalOptions:Oo,clearAdditionalOptions:Eo,addAdditionalOptionValue:Do,updateAdditionalOptionValue:Fo,removeAdditionalOptionValue:$o,uploadImages:Mo,setThumbnail:Ho,reorderImages:qo,updateDescription:Uo,confirmSelectNoticeTemplate:Vo,selectNoticeTemplate:Xe,updateNoticeItem:jo,removeNoticeItem:Bo,reorderNoticeItems:Ko,fillNoticeWithValue:Qo,switchNoticeMode:zo,updateNewTemplateName:Wo,addNoticeItem:Yo,updateNoticeItemName:Jo,saveAsNoticeTemplate:Xo,confirmSaveNoticeTemplate:tr,fillTemplateFieldsWithDetailReference:Zo,fillNoticeItemsWithDetailReference:er,toggleLabel:or,generateProductCode:rr,getShippingPolicyInfo:nr,getCommonInfoContent:ar,updateShoppingIntegration:sr,updateShippingType:ir,updateIdentificationCode:cr,openLabelPeriodModal:lr,saveLabelPeriod:dr,removeLabelPeriod:ur,updateActivityLogSort:mr,updateActivityLogPerPage:pr,setDefaultShippingPolicy:fr,setLabelDatePreset:_r,toggleDefaultShippingPolicy:gr,toggleLabelAssignment:wr,saveLabelSettings:Ir,deleteLabel:br,updateLabelPeriodInline:yr,setLabelDatePresetInline:hr,confirmUncheckLabel:Cr,removeDescriptionLocale:Sr,showAddLocaleModal:vr,addDescriptionLocale:Lr,setDefaultOptionFromGrid:co,addOptionRow:uo,updateFormOptionField:lo,recalculateOptionPriceAdjustments:mo,bulkUpdate:xr,buildConfirmData:kr,buildOrderColumns:Gr,toggleArrayValue:Or,toggleVisibleFilter:Er,syncOrderSelection:Dr,handleOrderRowAction:Fr,processOrderBulkAction:$r,buildOrderBulkConfirmData:Hr,executeOrderBulkAction:qr,downloadOrderExcel:Nr,saveVisibleColumns:Tr,loadVisibleColumns:Rr,loadVisibleFilters:Mr,handleProductRowAction:Ur,initOrderDetailForm:Vr,toggleProductSelection:jr,toggleAllProducts:Br,buildOrderDetailBulkConfirmData:Kr,processOrderDetailBulkChange:Qr,saveAdminMemo:zr,updateChangeQuantity:Wr,openConfirmDepositModal:Yr,confirmDeposit:Jr,initShippingPolicyForm:Ln,addCountrySetting:xn,removeCountrySetting:Pn,switchCountryTab:An,updateCountryField:kn,onChargePolicyChange:Gn,addRangeTier:On,removeRangeTier:En,updateRangeTierField:Dn,validateRangeTiers:Fn,addExtraFeeRow:$n,removeExtraFeeRow:Nn,applyExtraFeeTemplate:Tn,updateUnitValue:Rn,addApiRequestField:Mn,updateApiRequestField:Hn,removeApiRequestField:qn,toggleApiRequestField:Un,updateApiConfigField:Vn,updateApiFieldMap:jn,testShippingApi:Bn,updateExtraFeeField:Kn,updateCancelQuantity:Zr,estimateRefundAmount:en,changeRefundPriority:tn,executeCancelOrder:on,clearCancelOrderTimers:Xr,toggleItemSelection:nn,toggleSelectAllItems:an,initUserCancelItems:sn,toggleUserCancelItem:cn,toggleUserCancelSelectAll:ln,updateUserCancelQuantity:dn,estimateUserRefund:un,changeUserRefundPriority:mn,executeUserCancelOrder:pn,clearUserCancelOrderTimers:rn,confirmOrderOption:gn,changeShippingAddress:wn,submitReview:yn,initCategoryFromUrl:Wn,initBrandFromUrl:Xn,initCommonInfoFromUrl:ta,initNoticeFromUrl:na},V="sirsoft-ecommerce",_e=window.G7Core?.createLogger?.(`Module:${V}`)??{log:(...r)=>console.log(`[Module:${V}]`,...r),warn:(...r)=>console.warn(`[Module:${V}]`,...r),error:(...r)=>console.error(`[Module:${V}]`,...r)};function mt(r=!1){const c=window.G7Core?.getActionDispatcher?.();if(c)Object.entries(te).forEach(([e,t])=>{const o=`${V}.${e}`;c.registerHandler(o,t,{category:"module",source:V})}),_e.log(`${Object.keys(te).length} handler(s) registered:`,Object.keys(te).map(e=>`${V}.${e}`));else if(r){let e=0;const t=50,o=()=>{const a=window.G7Core?.getActionDispatcher?.();a?(Object.entries(te).forEach(([n,s])=>{const i=`${V}.${n}`;a.registerHandler(i,s,{category:"module",source:V})}),_e.log(`${Object.keys(te).length} handler(s) registered:`,Object.keys(te).map(n=>`${V}.${n}`))):(e++,e<=t?(_e.warn(`ActionDispatcher not found, retrying... (${e}/${t})`),setTimeout(o,100)):_e.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};o()}else _e.warn("ActionDispatcher not found, handlers not registered")}function Me(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>mt(!0));else{const r=!!window.G7Core?.getActionDispatcher?.();mt(!r)}}return Me(),typeof window<"u"&&(window.__SirsoftEcommerce={identifier:V,handlers:Object.keys(te),initModule:Me}),xe.initModule=Me,Object.defineProperty(xe,Symbol.toStringTag,{value:"Module"}),xe}({}); +var SirsoftEcommerce=function(Ae){"use strict";const Lt={KRW:0,JPY:0},xt={KRW:"₩",USD:"$",JPY:"¥",CNY:"元",EUR:"€",GBP:"£"},Pt={KRW:1e3,JPY:100};function At(r){if(!r)return 1;const c=r.base_unit??Pt[r.code]??1;return Math.max(1,c)}function kt(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function de(r,c){const e=typeof c=="string"?{code:c}:c,t=e.code,o=e.symbol&&e.symbol.length>0?e.symbol:xt[t]??"",a=e.decimal_places??Lt[t]??2,n=r.toLocaleString(void 0,{minimumFractionDigits:a,maximumFractionDigits:a});return o?t==="KRW"||o==="₩"||o==="원"?n+"원":o+n:n+" "+t}function Ot(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function ue(r,c){const e=Ot(),t=c||e.find(o=>o.is_default)?.code;return t?de(r,e.find(o=>o.code===t)??t):r.toLocaleString()}function se(r,c){const{basePrice:e,currencies:t}=r,o=parseFloat(String(e))||0;if(!t||!Array.isArray(t)||t.length===0)return{};const a=t.find(i=>i.is_default),n=At(a),s={};for(const i of t){const l=i.code;if(i.is_default??!1)s[l]={price:o,formatted:de(o,i),is_default:!0,editable:!0};else if(i.exchange_rate&&i.exchange_rate>0){const d=o/n*i.exchange_rate,m=kt(d,i.rounding_unit||"0.01",i.rounding_method||"round");s[l]={price:m,formatted:de(m,i),is_default:!1,editable:!1,exchange_rate:i.exchange_rate}}}return s}const z=window.G7Core?.createLogger?.("Ecom:UpdateProduct")??{log:(...r)=>console.log("[Ecom:UpdateProduct]",...r),warn:(...r)=>console.warn("[Ecom:UpdateProduct]",...r),error:(...r)=>console.error("[Ecom:UpdateProduct]",...r)};function Gt(r,c){const e=r.params||{},{productId:t,field:o,dataSourceId:a="products"}=e;let{value:n}=e;if(n&&typeof n=="object"&&n.target!==void 0&&(n=n.target?.value,z.log("[updateProductField] Extracted value from event:",n)),!t||!o){z.warn("[updateProductField] Missing required params:",{productId:t,field:o});return}if(n===void 0){z.warn("[updateProductField] Value is undefined, skipping update");return}const s=window.G7Core;if(!s?.dataSource?.get||!s?.dataSource?.set){z.warn("[updateProductField] G7Core.dataSource API is not available");return}const i=s.dataSource.get(a);if(!i){console.warn(`[updateProductField] DataSource '${a}' not found`);return}const l=i.data?.data||[];if(!Array.isArray(l)||l.length===0){z.warn("[updateProductField] Products data is empty or invalid");return}const u=s.state.get()||{},d=u.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let p=[...u._local?.productFieldErrors||[]];const g=l.map(C=>{if(String(C.id)===String(t)){const x=C[o];if(typeof n=="object"&&n!==null?JSON.stringify(x)===JSON.stringify(n):String(x)===String(n))return z.log(`[updateProductField] No change for product ${t}.${o}, skipping`),C;let S=n;const N=parseFloat(n)||0;if(o==="selling_price"){const O=parseFloat(C.list_price)||0;O>0&&N>O?(p.some(Q=>Q.productId===String(t)&&Q.field===o)||p.push({productId:String(t),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),S=O):p=p.filter($=>!($.productId===String(t)&&$.field===o))}else p=p.filter(O=>!(O.productId===String(t)&&O.field===o));const L={...C,[o]:S,_modified:!0};return o==="selling_price"&&d&&Array.isArray(d)&&(L.multi_currency_selling_price=se({basePrice:S,currencies:d}),z.log(`[updateProductField] Recalculated multi_currency_selling_price for product ${t}`)),L}return C});s.dataSource.set(a,{...i,data:{...i.data,data:g}});const w=s.state.getLocal()||{},y=new Set(w.modifiedProductIds||[]);y.add(t);const _={...w.modifiedProductFields||{}},f=String(t),b=new Set(_[f]||[]);b.add(o),_[f]=Array.from(b),s.state.setLocal({modifiedProductIds:Array.from(y),modifiedProductFields:_,productFieldErrors:p}),z.log(`[updateProductField] Updated product ${t}.${o} =`,n)}const B=window.G7Core?.createLogger?.("Ecom:UpdateOption")??{log:(...r)=>console.log("[Ecom:UpdateOption]",...r),warn:(...r)=>console.warn("[Ecom:UpdateOption]",...r),error:(...r)=>console.error("[Ecom:UpdateOption]",...r)};function Et(r,c){const e=r.params||{},{productId:t,optionId:o,field:a,value:n,dataSourceId:s="products"}=e;if(!t||!o||!a){B.warn("[updateOptionField] Missing required params:",{productId:t,optionId:o,field:a});return}const i=window.G7Core;if(!i?.dataSource?.get||!i?.dataSource?.set){B.warn("[updateOptionField] G7Core.dataSource API is not available");return}const l=i.dataSource.get(s);if(!l){console.warn(`[updateOptionField] DataSource '${s}' not found`);return}const u=l.data?.data||[];if(!Array.isArray(u)||u.length===0){B.warn("[updateOptionField] Products data is empty or invalid");return}const d=i.state.get()||{},m=d.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let g=[...d._local?.optionFieldErrors||[]],w=!1,y=!1;const _=u.map(N=>{if(String(N.id)===String(t)){const L=N.options||[],O=L.findIndex(D=>String(D.id)===String(o));if(O===-1)return B.warn("[updateOptionField] Option not found:",o),N;y=!0;const $=L[O],Q=$[a];if(typeof n=="object"&&n!==null?JSON.stringify(Q)===JSON.stringify(n):String(Q)===String(n))return B.log(`[updateOptionField] No change for option ${o}.${a}, skipping`),N;let J=n;const Pe=parseFloat(n)||0;if(a==="selling_price"){const D=parseFloat($.list_price)||0;D>0&&Pe>D?(g.some(he=>he.productId===String(t)&&he.optionId===String(o)&&he.field===a)||g.push({productId:String(t),optionId:String(o),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),J=D):g=g.filter(H=>!(H.productId===String(t)&&H.optionId===String(o)&&H.field===a))}else g=g.filter(D=>!(D.productId===String(t)&&D.optionId===String(o)&&D.field===a));const re=[...L],ne={...re[O],[a]:J,_modified:!0};let ae=null;if(a==="selling_price"){const D=parseFloat(J)||0;if($.is_default===!0)ae=D,ne.price_adjustment=0;else{const H=parseFloat(N.selling_price)||0;ne.price_adjustment=D-H}ne.price_adjustment_formatted=(ne.price_adjustment>=0?"+":"")+ne.price_adjustment.toLocaleString()+"원"}if(a==="selling_price"&&m&&Array.isArray(m)&&(ne.multi_currency_selling_price=se({basePrice:J,currencies:m}),B.log(`[updateOptionField] Recalculated multi_currency_selling_price for option ${o}`)),re[O]=ne,ae!==null)for(let D=0;DH.is_active===!1?D:D+(parseInt(H.stock_quantity,10)||0),0),Ke=Qe,B.log(`[updateOptionField] Updated product stock to option sum: ${Ke}`));const ze={...N,options:re,stock_quantity:Ke,option_stock_sum:Qe,_modified:!0};return ae!==null&&(ze.selling_price=ae,m&&Array.isArray(m)&&(ze.multi_currency_selling_price=se({basePrice:ae,currencies:m})),w=!0,B.log(`[updateOptionField] Reverse-synced product ${t} selling_price to ${ae} from default option`)),ze}return N});if(!y)return;i.dataSource.set(s,{...l,data:{...l.data,data:_}});const f=i.state.getLocal()||{},b=new Set(f.modifiedOptionIds||[]),C=`${t}-${o}`;b.add(C);const x={...f.modifiedOptionFields||{}},P=new Set(x[C]||[]);P.add(a),x[C]=Array.from(P);const S={modifiedOptionIds:Array.from(b),modifiedOptionFields:x,optionFieldErrors:g};if(w){const N=new Set(f.modifiedProductIds||[]);N.add(t);const L={...f.modifiedProductFields||{}},O=String(t),$=new Set(L[O]||[]);$.add("selling_price"),L[O]=Array.from($),S.modifiedProductIds=Array.from(N),S.modifiedProductFields=L}i.state.setLocal(S),B.log(`[updateOptionField] Updated option ${t}/${o}.${a} =`,n)}const Ft="g7_preferred_currency",Dt="g7_locale",Ye=window.G7Core?.createLogger?.("Handler:InitPreferredCurrency")??{log:(...r)=>console.log("[Handler:InitPreferredCurrency]",...r),warn:(...r)=>console.warn("[Handler:InitPreferredCurrency]",...r),error:(...r)=>console.error("[Handler:InitPreferredCurrency]",...r)};function $t(r){const c=window.G7Core;c?.state?.set?c.state.set(r):Ye.warn("G7Core.state.set not available")}function Nt(){try{return localStorage.getItem(Dt)}catch{return null}}function We(){try{const e=(window.G7Core?.state?.get?.()||{})?.modules?.["sirsoft-ecommerce"]?.language_currency;return Array.isArray(e?.currencies)?e.currencies:[]}catch{return[]}}function Tt(r){if(!r)return null;const c=r.toLowerCase().split("-")[0].split("_")[0];if(!c)return null;const t=We().filter(o=>(Array.isArray(o?.locales)?o.locales:[]).some(n=>String(n).toLowerCase()===c));return t.length===1?t[0]?.code??null:t.length>1?t.find(a=>a?.is_default===!0)?.code??null:null}function Rt(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Mt(r,c){const e=r?.params?.defaultCurrency||We().find(l=>l?.is_default)?.code||"",t=l=>typeof l=="string"&&/^[A-Z]{3}$/.test(l),o=Rt(),a=r?.params?.accountCurrency??o?.ecommerce_preferred_currency;let n=null;try{n=localStorage.getItem(Ft)}catch{}const s=a?null:Tt(Nt()),i=t(a)?a:t(n)?n:t(s)?s:t(e)?e:"KRW";$t({preferredCurrency:i}),Ye.log("Preferred currency initialized:",i)}const Ht="g7_preferred_shipping_country",Je=window.G7Core?.createLogger?.("Handler:InitPreferredShippingCountry")??{log:(...r)=>console.log("[Handler:InitPreferredShippingCountry]",...r),warn:(...r)=>console.warn("[Handler:InitPreferredShippingCountry]",...r),error:(...r)=>console.error("[Handler:InitPreferredShippingCountry]",...r)};function qt(r){const c=window.G7Core;c?.state?.set?c.state.set(r):Je.warn("G7Core.state.set not available")}function Ut(){try{const r=window.G7Config;return r?.user?r.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}function Vt(r,c){const e=r?.params?.defaultCountry||"KR",t=i=>typeof i=="string"&&/^[A-Z]{2}$/.test(i),o=Ut(),a=r?.params?.accountCountry??o?.ecommerce_preferred_shipping_country;let n=null;try{n=localStorage.getItem(Ht)}catch{}const s=t(a)?a:t(n)?n:t(e)?e:"KR";qt({preferredShippingCountry:s}),Je.log("Preferred shipping country initialized:",s)}function X(r){const c=r.getFullYear(),e=String(r.getMonth()+1).padStart(2,"0"),t=String(r.getDate()).padStart(2,"0");return`${c}-${e}-${t}`}function jt(r){const c=new Date,e=X(c);let t;switch(r){case"today":t=e;break;case"3days":{const o=new Date(c);o.setDate(o.getDate()-2),t=X(o);break}case"week":{const o=new Date(c);o.setDate(o.getDate()-6),t=X(o);break}case"month":{const o=new Date(c);o.setMonth(o.getMonth()-1),t=X(o);break}case"3months":{const o=new Date(c);o.setMonth(o.getMonth()-3),t=X(o);break}case"6months":{const o=new Date(c);o.setMonth(o.getMonth()-6),t=X(o);break}case"1year":{const o=new Date(c);o.setFullYear(o.getFullYear()-1),t=X(o);break}case"all":default:return t="",{startDate:"",endDate:""}}return{startDate:t,endDate:e}}function Bt(r){const{preset:c}=r.params||{preset:"today"},{startDate:e,endDate:t}=jt(c);return{startDate:e,endDate:t,preset:c}}const ie=window.G7Core?.createLogger?.("Ecom:DefaultOption")??{log:(...r)=>console.log("[Ecom:DefaultOption]",...r),warn:(...r)=>console.warn("[Ecom:DefaultOption]",...r),error:(...r)=>console.error("[Ecom:DefaultOption]",...r)};function Kt(r,c){const e=r.params||{},{productId:t,optionId:o,dataSourceId:a="products"}=e;if(!t||!o){ie.warn("[setDefaultOption] Missing required params:",{productId:t,optionId:o});return}const n=window.G7Core;if(!n?.dataSource?.get||!n?.dataSource?.set){ie.warn("[setDefaultOption] G7Core.dataSource API is not available");return}const s=n.dataSource.get(a);if(!s){console.warn(`[setDefaultOption] DataSource '${a}' not found`);return}const i=s.data?.data||[];if(!Array.isArray(i)||i.length===0){ie.warn("[setDefaultOption] Products data is empty or invalid");return}const u=(n.state.get()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies,d=i.map(w=>{if(String(w.id)!==String(t))return w;const y=w.options||[];if(y.length===0)return w;const _=y.find(P=>String(P.id)===String(o));if(!_)return ie.warn("[setDefaultOption] Option not found:",o),w;const f=y.map(P=>{const S=String(P.id)===String(o);return{...P,is_default:S,_modified:S?!0:P._modified}}),b=_.list_price,C=_.selling_price;let x=w.multi_currency_selling_price;return u&&Array.isArray(u)&&(x=se({basePrice:C,currencies:u})),ie.log(`[setDefaultOption] Set option ${o} as default for product ${t}. Syncing prices: list_price=${b}, selling_price=${C}`),{...w,options:f,list_price:b,selling_price:C,multi_currency_selling_price:x,_modified:!0}});n.dataSource.set(a,{...s,data:{...s.data,data:d}});const m=n.state.getLocal()||{},p=new Set(m.modifiedProductIds||[]);p.add(String(t));const g=new Set(m.modifiedOptionIds||[]);g.add(`${t}-${o}`),n.state.setLocal({modifiedProductIds:Array.from(p),modifiedOptionIds:Array.from(g)}),ie.log(`[setDefaultOption] Option ${o} set as default for product ${t}`)}const ke=window.G7Core?.createLogger?.("Ecom:OptionSelect")??{log:(...r)=>console.log("[Ecom:OptionSelect]",...r),warn:(...r)=>console.warn("[Ecom:OptionSelect]",...r),error:(...r)=>console.error("[Ecom:OptionSelect]",...r)},me=(r,c)=>`${r}-${c}`,Oe=r=>r.split("-")[0];function Qt(r,c){const e=r.params||{},{productId:t,optionId:o,checked:a}=e,n=window.G7Core;if(!n?.state){ke.warn("[toggleOption] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=me(t,o);let d;a?i.includes(u)?d=i:d=[...i,u]:d=i.filter(f=>f!==u);const w=(n.dataSource?.get("products")?.data?.data||[]).find(f=>String(f.id)===String(t))?.options||[];let y=[...l];const _=Number(t);w.length>0&&(w.map(C=>me(t,C.id)).filter(C=>d.includes(C)).length===w.length?y.includes(_)||y.push(_):y=y.filter(C=>C!==_)),n.state.setLocal({selectedOptionIds:d,selectedItems:y})}function Xe(r,c){const e=r.params||{},{productId:t,options:o,checked:a}=e,n=window.G7Core;if(!n?.state){ke.warn("[toggleProductOptions] G7Core.state를 사용할 수 없습니다.");return}const s=n.state.getLocal(),i=s.selectedOptionIds||[],l=s.selectedItems||[],u=(o||[]).map(g=>me(t,g.id));let d,m=[...l];const p=Number(t);a?(d=[...i.filter(w=>Oe(w)!==String(t)),...u],m.includes(p)||m.push(p)):(d=i.filter(g=>Oe(g)!==String(t)),m=m.filter(g=>g!==p)),n.state.setLocal({selectedOptionIds:d,selectedItems:m})}function zt(r,c){return Xe(r)}function Yt(r,c){const e=r.params||{products:[],selectedOptionIds:[]},{products:t,selectedOptionIds:o}=e,a=o||[];if(!t||!Array.isArray(t))return{};const n={};for(const s of t){const i=String(s.id),l=s.options||[];if(l.length===0){n[i]="none";continue}const d=l.map(m=>me(i,m.id)).filter(m=>a.includes(m)).length;d===0?n[i]="none":d===l.length?n[i]="all":n[i]="some"}return n}function Wt(r,c){const e=r.params||{},{selectedIds:t}=e,o=window.G7Core;if(!o?.state){ke.warn("[syncProductSelection] G7Core.state를 사용할 수 없습니다.");return}const a=o.state.getLocal(),n=a.selectedItems||[],s=a.selectedOptionIds||[],l=o.dataSource?.get("products")?.data?.data||[],u=t.filter(p=>!n.includes(p)),d=n.filter(p=>!t.includes(p));let m=[...s];for(const p of u){const g=l.find(w=>Number(w.id)===p);if(g?.options)for(const w of g.options){const y=me(p,w.id);m.includes(y)||m.push(y)}}for(const p of d)m=m.filter(g=>Oe(g)!==String(p));o.state.setLocal({selectedItems:t,selectedOptionIds:m})}const Ce=window.G7Core?.createLogger?.("Ecom:CopyCode")??{log:(...r)=>console.log("[Ecom:CopyCode]",...r),warn:(...r)=>console.warn("[Ecom:CopyCode]",...r),error:(...r)=>console.error("[Ecom:CopyCode]",...r)};async function Jt(r,c){const e=window.G7Core;if(!e?.state||!e?.api){Ce.warn("[generateCopyProductCode] G7Core API is not available");return}try{const t=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/generate-code");if(!t?.data?.product_code)throw new Error("Invalid API response");const o=t.data.product_code;if((window.__g7LayoutContextStack||[]).length>0&&e.state.setParentLocal)e.state.setParentLocal({"ui.copyOptions.newProductCode":o}),Ce.log(`[generateCopyProductCode] Generated (parent): ${o}`);else{const s=e.state.getLocal()||{};e.state.setLocal({ui:{...s.ui,copyOptions:{...s.ui?.copyOptions||{},newProductCode:o}}}),Ce.log(`[generateCopyProductCode] Generated (local): ${o}`)}}catch(t){Ce.error("[generateCopyProductCode] Failed:",t),e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code.")}}const Ze=window.G7Core?.createLogger?.("Ecom:CopyProduct")??{log:(...r)=>console.log("[Ecom:CopyProduct]",...r),warn:(...r)=>console.warn("[Ecom:CopyProduct]",...r),error:(...r)=>console.error("[Ecom:CopyProduct]",...r)};async function Xt(r,c){const e=window.G7Core;if(!e?.state){Ze.warn("[copyProduct] G7Core API is not available");return}const t=e.state.getLocal()||{},a=(e.state.getParent?.()||{})._local||{},n=a.ui?.copyOptions||t.ui?.copyOptions||{},s=t.form?.product_code||a.form?.product_code||a.productList?.copyTargetProduct?.product_code||t.productList?.copyTargetProduct?.product_code;if(!s){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.copy_source_not_found")??"Could not find source product information.");return}e.modal?.close?.();const i={copy_id:s,copy_images:n.images??!0?"1":"0",copy_options:n.options??!0?"1":"0",copy_categories:n.categories??!0?"1":"0",copy_sales_info:n.sales_info??!0?"1":"0",copy_description:n.description??!0?"1":"0",copy_notice:n.notice??!0?"1":"0",copy_common_info:n.common_info??!0?"1":"0",copy_other_info:n.other_info??!0?"1":"0",copy_shipping:n.shipping??!0?"1":"0",copy_seo:n.seo??!1?"1":"0",copy_identification:n.identification??!0?"1":"0"},u=`/admin/ecommerce/products/create?${new URLSearchParams(i).toString()}`;Ze.log("[copyProduct] Navigating to:",u),e.navigate?e.navigate(u):c.navigate?c.navigate(u):window.location.href=u}const be=window.G7Core?.createLogger?.("Ecom:ExpandedOptions")??{log:(...r)=>console.log("[Ecom:ExpandedOptions]",...r),warn:(...r)=>console.warn("[Ecom:ExpandedOptions]",...r),error:(...r)=>console.error("[Ecom:ExpandedOptions]",...r)},Zt="/api/modules/sirsoft-ecommerce/admin/products/options",Ge=100,Ee=new Set;function eo(r,c){const t=r?.dataSource?.get?.(c)?.data?.data;return Array.isArray(t)?t:[]}function et(r,c,e){const t=r?.dataSource?.get?.(c),o=t?.data?.data;if(!t||!Array.isArray(o)){be.warn("[loadExpandedOptions] DataSource not available for merge:",c);return}const a=o.map(n=>{const s=String(n?.id);return Object.prototype.hasOwnProperty.call(e,s)?{...n,options:e[s]}:n});r.dataSource.set(c,{...t,data:{...t.data,data:a}})}function to(r,c){const e=r?.state?.getLocal?.()||{},t=e.selectedItems||[];if(t.length===0)return;const o=new Set(t.map(s=>String(s))),a=new Set(e.selectedOptionIds||[]);let n=!1;Object.entries(c).forEach(([s,i])=>{o.has(s)&&i.forEach(l=>{const u=`${s}-${l?.id}`;a.has(u)||(a.add(u),n=!0)})}),n&&r.state.setLocal({selectedOptionIds:Array.from(a)})}function Fe(r,c){const e=r?.state?.getLocal?.()||{},t=c(e.optionsLoadingIds||[],e.optionsErrorIds||[]);r.state.setLocal({optionsLoadingIds:t.loading,optionsErrorIds:t.errored})}function De(r,c){const e=r.params||{},t=e.dataSourceId||"products",o=e.force===!0,a=window.G7Core;if(!a?.dataSource?.get||!a?.dataSource?.set||!a?.state?.setLocal){be.warn("[loadExpandedOptions] G7Core dataSource/state API is not available");return}Array.isArray(e.expandedRows)&&a.state.setLocal({expandedRows:e.expandedRows});const n=Array.isArray(e.productIds)?e.productIds:e.productId!==void 0&&e.productId!==null?[e.productId]:Array.isArray(e.expandedRows)?e.expandedRows:a.state.getLocal?.()?.expandedRows||[];if(!Array.isArray(n)||n.length===0)return;const s=eo(a,t),i=new Map(s.map(d=>[String(d?.id),d])),l=[],u={};n.forEach(d=>{const m=String(d),p=i.get(m);if(p&&!(!o&&Array.isArray(p.options))){if(Number(p.options_total_count)===0){u[m]=[];return}Ee.has(m)||l.push(m)}}),Object.keys(u).length>0&&et(a,t,u),l.length!==0&&(l.length>Ge&&(be.warn(`[loadExpandedOptions] Requested ${l.length} products, truncating to ${Ge}`),l.length=Ge),l.forEach(d=>Ee.add(d)),Fe(a,(d,m)=>({loading:Array.from(new Set([...d,...l])),errored:m.filter(p=>!l.includes(p))})),a.api.get(Zt,{params:{product_ids:l.map(d=>Number(d))}}).then(d=>{const p=(d?.data||{}).options||{},g={};l.forEach(w=>{const y=p[w];g[w]=Array.isArray(y)?y:[]}),et(a,t,g),to(a,g),Fe(a,(w,y)=>({loading:w.filter(_=>!l.includes(_)),errored:y.filter(_=>!l.includes(_))}))}).catch(d=>{be.error("[loadExpandedOptions] Failed to load options:",d),Fe(a,(p,g)=>({loading:p.filter(w=>!l.includes(w)),errored:Array.from(new Set([...g,...l]))}));const m=d?.response?.data?.message||a.t?.("sirsoft-ecommerce.admin.product.table.options.load_failed")||"Failed to load product options.";a.toast?.error?.(m)}).finally(()=>{l.forEach(d=>Ee.delete(d))}))}function oo(r,c={}){const e=r?.state?.getLocal?.()?.expandedRows||[];!Array.isArray(e)||e.length===0||De({params:{productIds:e,force:c.force===!0,dataSourceId:c.dataSourceId||"products"}})}function ro(r,c){const e=r.params||{};De({params:{productId:e.productId,productIds:e.productIds,force:!0,dataSourceId:e.dataSourceId}})}const A=window.G7Core?.createLogger?.("Ecom:Category")??{log:(...r)=>console.log("[Ecom:Category]",...r),warn:(...r)=>console.warn("[Ecom:Category]",...r),error:(...r)=>console.error("[Ecom:Category]",...r)};function tt(r,c,e,t=[]){for(const o of r){const a=o.name?.[e]||o.name?.ko||"";if(o.id===c)return[...t,a].join(" > ");if(o.children&&o.children.length>0){const n=tt(o.children,c,e,[...t,a]);if(n)return n}}return null}function no(r,c,e){return r.map(t=>{if(t.breadcrumb)return{id:t.id,breadcrumb:t.breadcrumb};const o=tt(c,t.id,e);if(o)return{id:t.id,breadcrumb:o};const a=t.name?.[e]||t.name?.ko||t.name_localized||"";return{id:t.id,breadcrumb:a}})}function ao(r,c){const e=r.params||{},t=e.depth,o=e.category;if(t===void 0||!o){A.warn("[selectCategory] Missing depth or category param");return}const a=c.isolatedContext;if(a){const u=a.state.selectedCategories||[null,null,null,null];if(u[t]?.id===o.id){A.log(`[selectCategory] Same category already selected at depth ${t}, skipping`);return}const d=u.map((m,p)=>pdf0?t+2:p;a.mergeState({selectedCategories:g,categoryStep:y}),A.log(`[selectCategoryMobile] Selected category at depth ${t} (isolated):`,o.id,"nextStep:",y);return}const n=window.G7Core;if(!n?.state){A.warn("[selectCategoryMobile] G7Core.state API is not available");return}const s=n.state.getLocal()||{},l=(s.ui?.selectedCategories||[null,null,null,null]).map((m,p)=>p0?t+2:s.ui?.categoryStep;n.state.setLocal({ui:{...s.ui,selectedCategories:l,categoryStep:d}}),A.log(`[selectCategoryMobile] Selected category at depth ${t}:`,o.id,"nextStep:",d)}function io(r,c){A.log("[addCategoryToSelection] START");const e=window.G7Core;if(!e?.state){A.warn("[addCategoryToSelection] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.locale?.current?.()||"ko",a=c.isolatedContext,n=a?a.state.selectedCategories||[]:t.ui?.selectedCategories||[];A.log("[addCategoryToSelection] Current state:",{selectedCats:n.map(f=>f?.id??null),currentCategoryIds:t.form?.category_ids,currentSelectedCategoryInfos:t.selectedCategoryInfos,usingIsolatedContext:!!a});const s=n.filter(Boolean).pop();if(!s){A.warn("[addCategoryToSelection] No category selected");return}A.log("[addCategoryToSelection] Last selected category:",s.id,s.name);const i=t.form?.category_ids||[];if(i.length>=5){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.category.messages.max_5")??"You can select up to 5 categories.");return}if(i.includes(s.id)){A.log("[addCategoryToSelection] Duplicate detected:",s.id,"in",i),e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.category.messages.already_selected")??"This category is already selected.");return}const l=n.filter(Boolean).map(f=>f.name?.[o]||f.name?.ko||"").join(" > "),u=[...i,s.id],d=t.form?.primary_category_id||s.id,p=(e.state.getDataSources?.()||{}).categories?.data?.data||[];let g=t.selectedCategoryInfos||[];const w=t.form?.categories||[];g.length===0&&w.length>0&&(A.log("[addCategoryToSelection] Initializing from form.categories:",w.length,"items"),g=no(w,p,o));const y=[...g,{id:s.id,breadcrumb:l}];A.log("[addCategoryToSelection] Before setLocal:",{newCategoryIds:u,primaryCategoryId:d,newInfos:y});try{e.state.setLocal({form:{...t.form,category_ids:u,primary_category_id:d},selectedCategoryInfos:y,hasChanges:!0}),A.log("[addCategoryToSelection] setLocal SUCCESS")}catch(f){A.error("[addCategoryToSelection] setLocal FAILED:",f)}const _=e.state.getLocal()||{};A.log("[addCategoryToSelection] After setLocal:",{category_ids:_.form?.category_ids,selectedCategoryInfos:_.selectedCategoryInfos}),A.log(`[addCategoryToSelection] Added category ${s.id} with breadcrumb: ${l}. Total: ${u.length}`)}function co(r,c){const t=(r.params||{}).categoryId;if(!t){A.warn("[removeCategoryFromSelection] Missing categoryId param");return}const o=window.G7Core;if(!o?.state){A.warn("[removeCategoryFromSelection] G7Core.state API is not available");return}const a=o.state.getLocal()||{},s=(a.form?.category_ids||[]).filter(d=>d!==t);let i=a.form?.primary_category_id;i===t&&(i=s[0]||null);const u=(a.selectedCategoryInfos||[]).filter(d=>d.id!==t);o.state.setLocal({form:{...a.form,category_ids:s,primary_category_id:i},selectedCategoryInfos:u,hasChanges:!0}),A.log(`[removeCategoryFromSelection] Removed category ${t}. Remaining: ${s.length}`)}function lo(r,c){const e=r.params||{},t=e.categoryId,o=e.categories;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=(l,u,d=[])=>{for(const m of l){const p=m.name?.[n]||m.name?.ko||"";if(m.id===u)return[...d,p];if(m.children&&m.children.length>0){const g=s(m.children,u,[...d,p]);if(g)return g}}return null},i=s(o,t);return i?i.join(" > "):""}function uo(r,c){A.log("[initCategoryInfosFromProduct] START");const e=window.G7Core;if(!e?.state){A.warn("[initCategoryInfosFromProduct] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.locale?.current?.()||"ko",n=(e.state.getDataSources?.()||{}).categories?.data?.data||[],s=t.form?.categories||[],i=t.form?.category_ids||[];if(A.log("[initCategoryInfosFromProduct] Product categories:",s),A.log("[initCategoryInfosFromProduct] Category IDs:",i),A.log("[initCategoryInfosFromProduct] Categories tree available:",n.length>0),i.length===0){A.log("[initCategoryInfosFromProduct] No categories to initialize");return}const l=(d,m,p=[])=>{for(const g of d){const w=g.name?.[o]||g.name?.ko||"";if(g.id===m)return[...p,w];if(g.children&&g.children.length>0){const y=l(g.children,m,[...p,w]);if(y)return y}}return null},u=[];for(const d of i){const m=l(n,d);if(m)u.push({id:d,breadcrumb:m.join(" > ")});else{const p=s.find(g=>g.id===d);if(p){const g=p.name?.[o]||p.name?.ko||p.name_localized||"";u.push({id:d,breadcrumb:g})}}}A.log("[initCategoryInfosFromProduct] Generated selectedCategoryInfos:",u),e.state.setLocal({selectedCategoryInfos:u}),A.log("[initCategoryInfosFromProduct] DONE")}function mo(r,c){const e={category1:"",category2:"",category3:"",category4:""},t=r.params||{},o=t.categoryId1?String(t.categoryId1):"",a=t.categoryId2?String(t.categoryId2):"",n=t.categoryId3?String(t.categoryId3):"",s=t.categoryId4?String(t.categoryId4):"",i=t.categories;if(!i||!Array.isArray(i)||i.length===0)return A.warn("[validateCategoryPath] No categories data provided"),e;if(o){const l=i.find(u=>String(u.id)===o);if(l&&l.depth===0&&(e.category1=o,a&&l.children)){const u=l.children.find(d=>String(d.id)===a);if(u&&u.depth===1&&(e.category2=a,n&&u.children)){const d=u.children.find(m=>String(m.id)===n);if(d&&d.depth===2&&(e.category3=n,s&&d.children)){const m=d.children.find(p=>String(p.id)===s);m&&m.depth===3&&(e.category4=s)}}}}return A.log("[validateCategoryPath] Result:",e),e}function po(r,c){const e=r.params||{},t=e.brandId,o=e.brands;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?s.name?.[n]??s.name?.ko??"":""}function fo(r,c){const e=r.params||{},t=e.brandId,o=e.brands;if(!t||!o||!Array.isArray(o))return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?.description?.[n]??s?.description?.ko??""}const Z=window.G7Core?.createLogger?.("Ecom:Price")??{log:(...r)=>console.log("[Ecom:Price]",...r),warn:(...r)=>console.warn("[Ecom:Price]",...r),error:(...r)=>console.error("[Ecom:Price]",...r)},go={KRW:1e3,JPY:100};function _o(r,c){const t=r.find(o=>o.code===c)?.base_unit??go[c]??1;return Math.max(1,t)}function wo(r,c,e,t){const o=r/t*c;switch(e){case"ceil":return Math.ceil(o);case"floor":return Math.floor(o);case"round":default:return Math.round(o)}}function yo(r,c){const e=r.params||{},t=e.field,o=e.currency,a=e.value,n=e.autoFill;if(!t||!o||a===void 0){Z.warn("[updatePrice] Missing required params: field, currency, or value");return}const s=window.G7Core;if(!s?.state){Z.warn("[updatePrice] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=c.datasources?.currencies?.data||{},u=l?.list||[],d=l?.default_currency||u.find(y=>y?.is_default)?.code||"",m=l?.rounding_method||"round",p={...i.form?.multi_currency_prices||{}};if(p[o]={...p[o]||{},[t]:a},n&&o===d){const y=_o(u,d);u.forEach(_=>{if(_.code!==d){const f=_.exchange_rate??1,b=wo(a,f,m,y);p[_.code]={...p[_.code]||{},[t]:b}}})}const g={list:"list_price",selling:"selling_price"},w=o===d?a:i.form?.[g[t]];s.state.setLocal({form:{...i.form,multi_currency_prices:p,[g[t]]:w},hasChanges:!0}),Z.log(`[updatePrice] Updated ${t} price for ${o}: ${a}`)}function ho(r,c){const t=(r.params||{}).options;if(!t||!Array.isArray(t)||t.length===0)return 0;const o=t.reduce((a,n)=>n.is_active===!1?a:a+(n.stock_quantity||0),0);return Z.log(`[calculateTotalOptionStock] Total stock: ${o}`),o}function Co(r,c){const e=window.G7Core;if(!e?.state){Z.warn("[validatePriceRelation] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=parseFloat(String(t.form?.list_price))||0,a=parseFloat(String(t.form?.selling_price))||0,n=a>o&&o>0,s=e.t?.("sirsoft-ecommerce.admin.product.validation.selling_price_exceeds_list_price")??"판매가는 정가보다 클 수 없습니다.",i={...t.errors||{}};n?i.selling_price=[s]:delete i.selling_price;const l=Object.keys(i).length>0;e.state.setLocal({validation:{...t.validation,price_error:n},errors:l?i:null}),n?(e.toast?.warning?.(s),Z.warn(`[validatePriceRelation] Selling price (${a}) exceeds list price (${o})`)):Z.log("[validatePriceRelation] Price validation passed")}const T=window.G7Core?.createLogger?.("Ecom:ProductOption")??{log:(...r)=>console.log("[Ecom:ProductOption]",...r),warn:(...r)=>console.warn("[Ecom:ProductOption]",...r),error:(...r)=>console.error("[Ecom:ProductOption]",...r)};function bo(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ot(){return bo().reduce((c,e)=>({...c,[e]:""}),{})}function Ie(r){return(r??[]).filter(c=>c.is_active!==!1).reduce((c,e)=>c+(parseInt(String(e.stock_quantity),10)||0),0)}const Io={KRW:1e3,JPY:100};function $e(){const e=((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).find(o=>o.is_default);if(!e)return 1;const t=e.base_unit??Io[e.code]??1;return Math.max(1,t)}function So(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function Ne(r,c,e){const t=c.exchange_rate||0;if(t<=0)return{price:0,formatted:de(0,c)};const o=e??$e(),a=r/o*t,n=So(a,c.rounding_unit||"0.01",c.rounding_method||"round"),s=c.decimal_places??2,i=parseFloat(n.toFixed(s));return{price:i,formatted:de(i,c)}}function Te(r,c,e){return r.map(t=>{const o=parseFloat(String(t.price_adjustment))||0,a=c+o,n={...t,selling_price:a};if(e.length>0){const s=$e(),i={};e.forEach(l=>{i[l.code]=Ne(a,l,s)}),n.multi_currency_selling_price=i}return n})}function Re(){return((window.G7Core?.state?.get?.()||{}).modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(c=>!c.is_default&&c.exchange_rate)}function vo(r,c){const e=r.params||{},t=window.G7Core;if(!t?.state){T.warn("[setDefaultOption] G7Core.state API is not available");return}const o=t.state.getLocal()||{},a=[...o.form?.options??[]],n=e.index,s=n!=null?typeof n=="string"?parseInt(n,10):n:-1,i=e.optionCode??a[s]?.option_code;if(!i){T.warn("[setDefaultOption] Missing optionCode/index param",{index:n});return}let l=a.map(m=>({...m,is_default:m.option_code===i}));const u=l.find(m=>m.is_default===!0),d=u?parseFloat(String(u.selling_price))||parseFloat(String(o.form?.selling_price))||0:parseFloat(String(o.form?.selling_price))||0;if(u){u.price_adjustment=0;const m=Re();l=Te(l,d,m)}t.state.setLocal({form:{...o.form,options:l,selling_price:d},hasChanges:!0}),T.log(`[setDefaultOption] Set default option to ${i}, synced product selling_price to ${d}`)}function Lo(r,c){const e=r.params||{},t=typeof e.index=="string"?parseInt(e.index,10):e.index,o=e.field,a=e.value;if(t==null||!o){T.warn("[updateFormOptionField] Missing required params:",{index:t,field:o});return}const n=window.G7Core;if(!n?.state){T.warn("[updateFormOptionField] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.options??[]];if(t<0||t>=i.length){T.warn("[updateFormOptionField] Index out of bounds:",{index:t,length:i.length});return}const l=["regular_price","sale_price","list_price","selling_price","stock_quantity","safe_stock_quantity","weight","volume","mileage_value"];let u=a;if(l.includes(o)&&(u=parseFloat(a)||0),i[t]={...i[t],[o]:u},o==="selling_price"){const d=parseFloat(String(u))||0,m=Re();if(i[t].is_default===!0){i[t].price_adjustment=0;const g=Te(i,d,m);n.state.setLocal({form:{...s.form,options:g,selling_price:d},hasChanges:!0}),T.log(`[updateFormOptionField] Reverse-synced product selling_price to ${d} from default option`);return}const p=parseFloat(String(s.form?.selling_price))||0;if(i[t].price_adjustment=d-p,m.length>0){const g=$e(),w={};m.forEach(y=>{w[y.code]=Ne(d,y,g)}),i[t].multi_currency_selling_price=w,T.log(`[updateFormOptionField] Recalculated multi_currency_selling_price for option at index ${t}:`,m.map(y=>y.code))}}if((o==="stock_quantity"||o==="is_active")&&i.length>0){const d=Ie(i);n.state.setLocal({form:{...s.form,options:i,stock_quantity:d},hasChanges:!0}),T.log(`[updateFormOptionField] Updated product stock_quantity to ${d} (sum of active options)`);return}n.state.setLocal({form:{...s.form,options:i},hasChanges:!0}),T.log(`[updateFormOptionField] Updated options[${t}].${o} =`,u)}function xo(r,c){const e=window.G7Core;if(!e?.state){T.warn("[addOptionRow] G7Core.state API is not available");return}const o=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,a=e.state.getLocal()||{},n=[...a.form?.options??[]],s=a.form?.option_groups??[],i=`OPT-${Date.now()}`,l=s.map(p=>({key:p.name,value:ot()})),u={id:null,option_code:i,option_name:ot(),option_values:l,is_default:n.length===0,regular_price:0,sale_price:0,list_price:0,selling_price:0,price_adjustment:0,multi_currency_selling_price:{},sku:"",stock_quantity:o,safe_stock_quantity:0,weight:0,volume:0,mileage_value:0,mileage_type:"percent",is_active:!0},d=[...n,u],m=Ie(d);e.state.setLocal({form:{...a.form,options:d,stock_quantity:m,has_options:d.length>0},hasChanges:!0}),T.log(`[addOptionRow] Added new option row: ${i}, product stock_quantity = ${m}`)}function Po(r,c){const e=window.G7Core;if(!e?.state){T.warn("[recalculateOptionPriceAdjustments] G7Core.state API is not available");return}const t=r.params||{},o=e.state.getLocal()||{},a=t.newSellingPrice!==void 0?parseFloat(String(t.newSellingPrice))||0:parseFloat(String(o.form?.selling_price))||0,n=[...o.form?.options??[]];if(n.length===0){T.log("[recalculateOptionPriceAdjustments] No options to recalculate");return}const s=Re(),i=Te(n,a,s);e.state.setLocal({form:{...o.form,options:i}}),T.log(`[recalculateOptionPriceAdjustments] Recalculated selling_price for ${n.length} options based on product selling_price: ${a}`)}const h=window.G7Core?.createLogger?.("Ecom:Option")??{log:(...r)=>console.log("[Ecom:Option]",...r),warn:(...r)=>console.warn("[Ecom:Option]",...r),error:(...r)=>console.error("[Ecom:Option]",...r)};function Me(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function ce(){return window.G7Core?.config?.("app.locale")??Me()[0]??"ko"}function q(){return Me().reduce((c,e)=>({...c,[e]:""}),{})}function rt(r){return{id:null,name:q(),price_adjustment:0,is_default:r===0,is_active:!0,allow_custom_text:!1,sort_order:r}}function Ao(r,c){return typeof r=="string"?r:r[c]??r.ko??Object.values(r)[0]??""}function nt(r){return r&&typeof r=="object"&&!Array.isArray(r)}function ko(r){return Array.isArray(r)?r:r&&typeof r=="object"?Object.values(r):[]}function Oo(r){return r.reduce((c,e)=>{const t=[];return c.forEach(o=>{e.forEach(a=>{t.push([...o,a])})}),t},[[]])}function Go(r){const c=Me(),e={};for(const t of c){const o=r.map(a=>Ao(a,t));e[t]=o.join("/")}return e}function Eo(r,c){return c.map((e,t)=>({key:typeof e.name=="string"?{...q(),[ce()]:e.name}:e.name,value:nt(r[t])?r[t]:{...q(),[ce()]:r[t]}}))}function Fo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addOptionInput] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.ui?.optionInputs??[];if(o.length>=3){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.input_max_3")??"You can add up to 3 option inputs.");return}e.state.setLocal({ui:{...t.ui,optionInputs:[...o,{name:q(),values:[]}]}}),h.log(`[addOptionInput] Added option input. Total: ${o.length+1}`)}function Do(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeOptionInput] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeOptionInput] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.ui?.optionInputs??[]];n.splice(t,1),o.state.setLocal({ui:{...a.ui,optionInputs:n}}),h.log(`[removeOptionInput] Removed option input at index ${t}`)}function $o(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.value;if(t===void 0||!o){h.warn("[updateOptionInput] Missing required params");return}const n=window.G7Core;if(!n?.state){h.warn("[updateOptionInput] G7Core.state API is not available");return}h.log(`[updateOptionInput] Called with index=${t}, field="${o}" (type: ${typeof o}), value=`,a);const s=n.state.getLocal()||{},i=[...s.ui?.optionInputs??[]];if(h.log(`[updateOptionInput] Current inputs[${t}]:`,i[t]),i[t]||(h.log(`[updateOptionInput] inputs[${t}] not found, initializing with empty structure`),i[t]={name:q(),values:[]}),o==="name"){const l=ce(),u=i[t].name;h.log(`[updateOptionInput] Processing 'name' field - defaultLocale: ${l}, existingName:`,u,`type: ${typeof u}`);let d;a&&typeof a=="object"&&!Array.isArray(a)?(d=a,h.log("[updateOptionInput] Using multilingual value directly:",d)):(d={...u&&typeof u=="object"&&!Array.isArray(u)?u:q(),[l]:a},h.log("[updateOptionInput] Setting single locale value:",d)),i[t]={...i[t],name:d}}else h.log(`[updateOptionInput] Processing non-name field '${o}'`),i[t]={...i[t],[o]:a};h.log(`[updateOptionInput] Final inputs[${t}]:`,i[t]),n.state.setLocal({ui:{...s.ui,optionInputs:i}})}function No(r,c){const e=window.G7Core;if(!e?.state){h.warn("[generateOptions] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=e.state.get()||{},a=t.ui?.optionInputs??[],n=ce(),s=f=>f.name?typeof f.name=="string"?f.name:f.name[n]??"":"",i=a.map(f=>({...f,values:ko(f.values)})),l=i.filter(f=>s(f)&&f.values&&f.values.length>0);if(h.log(`[generateOptions] Validating inputs - defaultLocale: ${n}, normalizedInputs:`,i.map(f=>({name:f.name,nameType:typeof f.name,extractedName:s(f),values:f.values,valuesIsArray:Array.isArray(f.values)}))),h.log(`[generateOptions] Valid inputs count: ${l.length}`),l.length===0){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.name_value_required")??"Please enter option name and values.");return}const u=t.form?.options??[],d=r.params?.skipConfirm===!0;if(u.length>0&&!d){e.modal?.open?.("modal_confirm_regenerate");return}const p=r.params?.isCreate===!0||r.params?.isCreate==="true"?1:0,g=parseFloat(String(t.form?.list_price))||0,w=parseFloat(String(t.form?.selling_price))||0,y=t.form?.multi_currency_selling_price,_=(o.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies||[]).filter(f=>!f.is_default&&f.exchange_rate);try{h.log("[generateOptions] Step 1: Creating optionGroups...");const f=l.map((L,O)=>(h.log(`[generateOptions] Processing input[${O}]:`,{name:L.name,values:L.values,valuesType:typeof L.values,valuesIsArray:Array.isArray(L.values)}),{name:typeof L.name=="string"?{...q(),[n]:L.name}:L.name,values:L.values.map($=>nt($)?$:{...q(),[n]:$})})),C=Oo(l.map(L=>L.values)).map((L,O)=>{const $=Eo(L,l),Q=Go(L),It=`OPT-${String(O+1).padStart(3,"0")}`;let J={};return y&&typeof y=="object"&&Object.keys(y).length>0?J=JSON.parse(JSON.stringify(y)):w>0&&_.length>0&&_.forEach(Pe=>{J[Pe.code]=Ne(w,Pe)}),{id:null,option_code:It,option_values:$,option_name:Q,list_price:g,selling_price:w,price_adjustment:0,stock_quantity:p,safe_stock_quantity:0,is_default:O===0,is_active:!0,sku:"",multi_currency_selling_price:J}}),x=t.form?.options??[],P=L=>L.option_name?typeof L.option_name=="string"?L.option_name:L.option_name[n]??L.option_name.ko??"":"",S=C.map(L=>{const O=P(L),$=x.find(Q=>P(Q)===O);return $?{...L,...$,id:$.id}:L}),N=Ie(S);e.state.setLocal({form:{...t.form,option_groups:f,options:S,has_options:S.length>0,stock_quantity:N},hasChanges:!0}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.generated",{count:S.length})??`${S.length} options have been generated.`),h.log(`[generateOptions] Generated ${S.length} options`)}catch(f){throw h.error("[generateOptions] Error occurred:",f),h.error("[generateOptions] Error message:",f.message),h.error("[generateOptions] Error stack:",f.stack),f}}function To(r,c){const t=(r.params||{}).rowIndex;if(t===void 0){h.warn("[deleteOption] Missing rowIndex param");return}const o=window.G7Core;if(!o?.state){h.warn("[deleteOption] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.options??[]];n.splice(t,1);const s=Ie(n),i=n.length>0;o.state.setLocal({form:{...a.form,options:n,has_options:i,...i?{stock_quantity:s}:{}},hasChanges:!0}),h.log(`[deleteOption] Deleted option at index ${t}, product stock_quantity = ${s}`)}function Ro(r,c){const e=r.params||{},t=e.optionName,o=e.applyToAll,a=e.inheritPrice,n=window.G7Core;if(!n?.state){h.warn("[applyOptionAddTool] G7Core.state API is not available");return}if(!t?.trim()){n.toast?.warning?.(n.t?.("sirsoft-ecommerce.admin.product.options.messages.name_required")??"Please enter option name.");return}const s=n.state.getLocal()||{},l=[...s.form?.options??[]].map(u=>{const d={...u};return o&&(d.option_values={...u.option_values,[t]:""}),a&&(d.price_adjustment=u.price_adjustment??0),d});n.state.setLocal({form:{...s.form,options:l},ui:{...s.ui,optionAddToolName:"",optionAddToolApplyToAll:!1,optionAddToolInheritPrice:!1},hasChanges:!0}),n.toast?.success?.(n.t?.("sirsoft-ecommerce.admin.product.options.messages.add_tool_applied")??"Option add tool has been applied.")}function Mo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addRequiredItem] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.required_items??[],a={id:null,sort_order:o.length,option_name:{ko:"",en:""},description:{ko:"",en:""}};e.state.setLocal({form:{...t.form,required_items:[...o,a]},hasChanges:!0}),h.log(`[addRequiredItem] Added required item. Total: ${o.length+1}`)}function Ho(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.locale,n=e.value;if(t===void 0||!o||!a){h.warn("[updateRequiredItem] Missing required params");return}const s=window.G7Core;if(!s?.state){h.warn("[updateRequiredItem] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=[...i.form?.required_items??[]];l[t]={...l[t],[o]:{...l[t][o],[a]:n}},s.state.setLocal({form:{...i.form,required_items:l},hasChanges:!0})}function qo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeRequiredItem] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeRequiredItem] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.required_items??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,required_items:n},hasChanges:!0}),h.log(`[removeRequiredItem] Removed required item at index ${t}`)}function Uo(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){h.warn("[reorderRequiredItems] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){h.warn("[reorderRequiredItems] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.required_items??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,required_items:s},hasChanges:!0}),h.log(`[reorderRequiredItems] Moved item from ${t} to ${o}`)}function Vo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[addAdditionalOption] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.additional_options??[];if(o.length>=5){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_max_5")??"You can add up to 5 additional options.");return}const a=`temp_${Date.now()}_${Math.random().toString(36).substring(2,9)}`;e.state.setLocal({form:{...t.form,additional_options:[...o,{id:a,name:q(),is_required:!1,sort_order:o.length,values:[rt(0)]}]},hasChanges:!0}),h.log(`[addAdditionalOption] Added additional option. Total: ${o.length+1}`)}function jo(r,c){const e=r.params||{},t=e.index,o=e.field,a=e.value;if(t===void 0||!o){h.warn("[updateAdditionalOption] Missing required params");return}const n=window.G7Core;if(!n?.state){h.warn("[updateAdditionalOption] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.additional_options??[]];if(!i[t]){h.warn("[updateAdditionalOption] Option not found at index",t);return}if(o==="name")if(a&&typeof a=="object"&&!Array.isArray(a))i[t]={...i[t],name:a};else{const l=ce(),u=i[t].name,d=u&&typeof u=="object"&&!Array.isArray(u)?u:q();i[t]={...i[t],name:{...d,[l]:a}}}else i[t]={...i[t],[o]:a};n.state.setLocal({form:{...s.form,additional_options:i},hasChanges:!0}),h.log(`[updateAdditionalOption] Updated options[${t}].${o}`)}function Bo(r,c){const t=(r.params||{}).index;if(t===void 0){h.warn("[removeAdditionalOption] Missing index param");return}const o=window.G7Core;if(!o?.state){h.warn("[removeAdditionalOption] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.additional_options??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,additional_options:n},hasChanges:!0}),h.log(`[removeAdditionalOption] Removed additional option at index ${t}`)}function Ko(r,c){const e=r.params||{},t=parseInt(String(e.oldIndex),10),o=parseInt(String(e.newIndex),10);if(isNaN(t)||isNaN(o)||t===o)return;const a=window.G7Core;if(!a?.state){h.warn("[reorderAdditionalOptions] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.additional_options??[]];if(t<0||t>=s.length||o<0||o>=s.length)return;const[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,additional_options:s},ui:{...n.ui,dragIndex:null},hasChanges:!0}),h.log(`[reorderAdditionalOptions] Moved item from ${t} to ${o}`)}function Qo(r,c){const e=window.G7Core;if(!e?.state){h.warn("[clearAdditionalOptions] G7Core.state API is not available");return}const t=e.state.getLocal()||{};e.state.setLocal({form:{...t.form,additional_options:[]},hasChanges:!0}),h.log("[clearAdditionalOptions] Cleared all additional options.")}function He(r){if(!r)return[];const c=r.values;return Array.isArray(c)?c:c&&typeof c=="object"?Object.values(c):[]}function zo(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex;if(t==null||isNaN(t)){h.warn("[addAdditionalOptionValue] Missing groupIndex param");return}const o=window.G7Core;if(!o?.state){h.warn("[addAdditionalOptionValue] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.additional_options??[]];if(!n[t]){h.warn("[addAdditionalOptionValue] Group not found at index",t);return}const s=[...He(n[t])];if(s.length>=20){o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_value_max_20")??"You can add up to 20 choices per group.");return}s.push(rt(s.length)),n[t]={...n[t],values:s},o.state.setLocal({form:{...a.form,additional_options:n},hasChanges:!0}),h.log(`[addAdditionalOptionValue] Added value to group ${t}. Total: ${s.length}`)}function Yo(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex,o=typeof e.valueIndex=="string"?parseInt(e.valueIndex,10):e.valueIndex,a=e.field,n=e.value;if(t===void 0||o===void 0||!a){h.warn("[updateAdditionalOptionValue] Missing required params");return}const s=window.G7Core;if(!s?.state){h.warn("[updateAdditionalOptionValue] G7Core.state API is not available");return}const i=s.state.getLocal()||{},l=[...i.form?.additional_options??[]];if(!l[t]){h.warn("[updateAdditionalOptionValue] Group not found at index",t);return}const u=[...He(l[t])];if(!u[o]){h.warn("[updateAdditionalOptionValue] Value not found at index",o);return}if(a==="name")if(n&&typeof n=="object"&&!Array.isArray(n))u[o]={...u[o],name:n};else{const d=ce(),m=u[o].name,p=m&&typeof m=="object"&&!Array.isArray(m)?m:q();u[o]={...u[o],name:{...p,[d]:n}}}else if(a==="price_adjustment"){let d=parseFloat(String(n));(isNaN(d)||d<0)&&(d=0),u[o]={...u[o],price_adjustment:d}}else if(a==="is_default")n===!0||n==="true"?u.forEach((m,p)=>{m.is_default=p===o}):u[o].is_default=!1;else if(a==="is_active"||a==="allow_custom_text"){const d=n===!0||n==="true";u[o]={...u[o],[a]:d}}else u[o]={...u[o],[a]:n};l[t]={...l[t],values:u},s.state.setLocal({form:{...i.form,additional_options:l},hasChanges:!0}),h.log(`[updateAdditionalOptionValue] Updated group ${t} value ${o} field ${a}`)}function Wo(r,c){const e=r.params||{},t=typeof e.groupIndex=="string"?parseInt(e.groupIndex,10):e.groupIndex,o=typeof e.valueIndex=="string"?parseInt(e.valueIndex,10):e.valueIndex;if(t===void 0||o===void 0){h.warn("[removeAdditionalOptionValue] Missing required params");return}const a=window.G7Core;if(!a?.state){h.warn("[removeAdditionalOptionValue] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.additional_options??[]];if(!s[t]){h.warn("[removeAdditionalOptionValue] Group not found at index",t);return}const i=[...He(s[t])];if(i.length<=1){a.toast?.warning?.(a.t?.("sirsoft-ecommerce.admin.product.options.messages.additional_value_min_1")??"At least one choice is required per group.");return}i.splice(o,1),i.forEach((l,u)=>{l.sort_order=u}),i.some(l=>l.is_default)||(i[0].is_default=!0),s[t]={...s[t],values:i},a.state.setLocal({form:{...n.form,additional_options:s},hasChanges:!0}),h.log(`[removeAdditionalOptionValue] Removed value ${o} from group ${t}`)}const j=window.G7Core?.createLogger?.("Ecom:Image")??{log:(...r)=>console.log("[Ecom:Image]",...r),warn:(...r)=>console.warn("[Ecom:Image]",...r),error:(...r)=>console.error("[Ecom:Image]",...r)},Jo=["image/jpeg","image/png","image/gif","image/webp"],Xo=10*1024*1024,Zo=20;async function er(r,c){const t=(r.params||{}).files;if(!t||t.length===0){j.warn("[uploadImages] No files provided");return}const o=window.G7Core;if(!o?.state||!o?.api){j.warn("[uploadImages] G7Core.state or G7Core.api is not available");return}const n=(o.state.getLocal()||{}).form?.images??[],s=Zo-n.length;t.length>s&&o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.max_count",{max:s})??`You can add up to ${s} images.`);const i=Array.from(t).slice(0,s);for(const l of i){if(!Jo.includes(l.type)){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.unsupported_format",{name:l.name})??`${l.name}: Unsupported format.`);continue}if(l.size>Xo){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.size_exceeded",{name:l.name})??`${l.name}: Exceeds 10MB.`);continue}const u=o.state.getLocal()||{};o.state.setLocal({ui:{...u.ui,uploadProgress:{...u.ui?.uploadProgress||{},[l.name]:0}}});try{const d=new FormData;d.append("file",l),d.append("collection","products");const m=await o.api.upload("/api/admin/media/upload",d,{onProgress:b=>{const C=o.state.getLocal()||{};o.state.setLocal({ui:{...C.ui,uploadProgress:{...C.ui?.uploadProgress||{},[l.name]:b}}})}}),p=o.state.getLocal()||{},g=p.form?.images??[],w={...m.data,is_thumbnail:g.length===0,sort_order:g.length};o.state.setLocal({form:{...p.form,images:[...g,w]},hasChanges:!0});const y=o.state.getLocal()||{},{[l.name]:_,...f}=y.ui?.uploadProgress||{};o.state.setLocal({ui:{...y.ui,uploadProgress:f}}),j.log(`[uploadImages] Uploaded ${l.name}`)}catch(d){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.images.messages.upload_failed",{name:l.name})??`${l.name}: Upload failed`);const m=o.state.getLocal()||{},{[l.name]:p,...g}=m.ui?.uploadProgress||{};o.state.setLocal({ui:{...m.ui,uploadProgress:g}}),j.error(`[uploadImages] Failed to upload ${l.name}:`,d)}}}function tr(r,c){const t=(r.params||{}).index;if(t===void 0){j.warn("[setThumbnail] Missing index param");return}const o=window.G7Core;if(!o?.state){j.warn("[setThumbnail] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=(a.form?.images??[]).map((s,i)=>({...s,is_thumbnail:i===t}));o.state.setLocal({form:{...a.form,images:n},hasChanges:!0}),j.log(`[setThumbnail] Set thumbnail to image at index ${t}`)}function or(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){j.warn("[reorderImages] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){j.warn("[reorderImages] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.images??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,images:s},hasChanges:!0}),j.log(`[reorderImages] Moved image from ${t} to ${o}`)}const at=window.G7Core?.createLogger?.("Ecom:Description")??{log:(...r)=>console.log("[Ecom:Description]",...r),warn:(...r)=>console.warn("[Ecom:Description]",...r),error:(...r)=>console.error("[Ecom:Description]",...r)};function rr(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){at.warn("[updateDescription] Missing locale param");return}const a=window.G7Core;if(!a?.state){at.warn("[updateDescription] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,description:{...n.form?.description,[t]:o}},hasChanges:!0})}const k=window.G7Core?.createLogger?.("Ecom:Notice")??{log:(...r)=>console.log("[Ecom:Notice]",...r),warn:(...r)=>console.warn("[Ecom:Notice]",...r),error:(...r)=>console.error("[Ecom:Notice]",...r)};function pe(r=""){const e=window.G7Core?.locale?.supported?.()??["ko","en"],t={};for(const o of e)t[o]=r;return t}function nr(r,c){const t=(r.params||{}).templateId,o=window.G7Core;if(!o?.state){k.warn("[confirmSelectNoticeTemplate] G7Core.state API is not available");return}const n=(o.state.getLocal()||{}).form?.notice_items??[],s=n.length>0&&n.some(i=>Object.values(i.content||{}).some(u=>u&&u.trim()!==""));if(!t||!s){st(r);return}o.state.setGlobal({pendingNoticeTemplateId:t}),o.modal?.open?.("notice_template_confirm_modal"),k.log(`[confirmSelectNoticeTemplate] Showing confirm modal for template: ${t}`)}function st(r,c){const t=(r.params||{}).templateId,o=window.G7Core;if(!o?.state){k.warn("[selectNoticeTemplate] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=o.dataSource?.get?.("notice_templates"),s=n?.data?.data??n?.data??[];if(!t){o.state.setLocal({form:{...a.form,notice_items:[]},ui:{...a.ui,selectedNoticeTemplateId:null},hasChanges:!0});return}const i=s.find(d=>d.id===t);if(!i){k.warn(`[selectNoticeTemplate] Template not found: ${t}`);return}const l=a.form?.notice_items??[],u=(i.fields??[]).map((d,m)=>{const p=JSON.stringify(d.name),g=l.find(w=>JSON.stringify(w.name)===p);return{key:`field_${m}`,name:d.name,content:g?.content??d.content??pe(),sort_order:m,is_custom:!1}});o.state.setLocal({form:{...a.form,notice_items:u},ui:{...a.ui,selectedNoticeTemplateId:t},hasChanges:!0}),k.log(`[selectNoticeTemplate] Selected template: ${t}`)}function ar(r,c){const e=r.params||{},t=e.index,o=e.locale,a=e.value;if(t===void 0||!o){k.warn("[updateNoticeItem] Missing required params");return}const n=window.G7Core;if(!n?.state){k.warn("[updateNoticeItem] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.notice_items??[]];i[t]={...i[t],content:{...i[t].content,[o]:a}},n.state.setLocal({form:{...s.form,notice_items:i},hasChanges:!0})}function sr(r,c){const t=(r.params||{}).index;if(t===void 0){k.warn("[removeNoticeItem] Missing index param");return}const o=window.G7Core;if(!o?.state){k.warn("[removeNoticeItem] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.notice_items??[]];n.splice(t,1),n.forEach((s,i)=>{s.sort_order=i}),o.state.setLocal({form:{...a.form,notice_items:n},hasChanges:!0}),k.log(`[removeNoticeItem] Removed notice item at index ${t}`)}function ir(r,c){const e=r.params||{},t=e.oldIndex,o=e.newIndex;if(t===void 0||o===void 0){k.warn("[reorderNoticeItems] Missing oldIndex or newIndex param");return}const a=window.G7Core;if(!a?.state){k.warn("[reorderNoticeItems] G7Core.state API is not available");return}const n=a.state.getLocal()||{},s=[...n.form?.notice_items??[]],[i]=s.splice(t,1);s.splice(o,0,i),s.forEach((l,u)=>{l.sort_order=u}),a.state.setLocal({form:{...n.form,notice_items:s},hasChanges:!0}),k.log(`[reorderNoticeItems] Moved item from ${t} to ${o}`)}function cr(r,c){const t=(r.params||{}).value,o=window.G7Core;if(!o?.state){k.warn("[fillNoticeWithValue] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=a.ui?.noticeLocale??"ko",s=(a.form?.notice_items??[]).map(i=>({...i,content:{...i.content,[n]:i.content?.[n]||t}}));o.state.setLocal({form:{...a.form,notice_items:s},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.notice.messages.filled")??"Empty fields have been filled.")}function lr(r,c){const t=(r.params||{}).mode;if(!t){k.warn("[switchNoticeMode] Missing mode param");return}const o=window.G7Core;if(!o?.state){k.warn("[switchNoticeMode] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,noticeMode:t}}),k.log(`[switchNoticeMode] Switched to ${t} mode`)}function dr(r,c){const e=r.params||{},t=e.locale,o=e.value;if(!t){k.warn("[updateNewTemplateName] Missing locale param");return}const a=window.G7Core;if(!a?.state){k.warn("[updateNewTemplateName] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,new_template_name:{...n.form?.new_template_name,[t]:o}}})}function ur(r,c){const e=window.G7Core;if(!e?.state){k.warn("[addNoticeItem] G7Core.state API is not available");return}const t=e.state.getLocal()||{},o=t.form?.notice_items??[],a={key:`custom_${Date.now()}`,name:pe(),content:pe(),sort_order:o.length,is_custom:!0};e.state.setLocal({form:{...t.form,notice_items:[...o,a]},hasChanges:!0}),k.log(`[addNoticeItem] Added new notice item. Total: ${o.length+1}`)}function mr(r,c){const e=r.params||{},t=e.index,o=e.locale,a=e.value;if(t===void 0||!o){k.warn("[updateNoticeItemName] Missing required params");return}const n=window.G7Core;if(!n?.state){k.warn("[updateNoticeItemName] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.notice_items??[]];i[t]={...i[t],name:{...i[t].name,[o]:a}},n.state.setLocal({form:{...s.form,notice_items:i},hasChanges:!0})}function pr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[saveAsNoticeTemplate] G7Core.state API is not available");return}if(((e.state.getLocal()||{}).form?.notice_items??[]).length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.no_items")??"No items to save.");return}e.state.set?.({"ui.saveTemplateData":{name:pe(),category_id:null,is_default:!1}}),e.dispatch?.({handler:"openModal",target:"modal_save_template"})}const Se={ko:"상세설명참조",en:"See detail description"};function fr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[fillTemplateFieldsWithDetailReference] G7Core.state API is not available");return}const a=((r.params||{}).form||{}).fields??[];if(a.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product_notice_template.messages.no_fields_to_change")??"변경할 항목이 없습니다.");return}const n=e.locale?.supported?.()??["ko","en"],s={};for(const l of n)s[l]=Se[l]??Se.en??"See detail description";const i=a.map(l=>({...l,content:{...s}}));e.state.setLocal({"form.fields":i}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product_notice_template.messages.all_fields_changed_to_detail_ref")??"모든 항목이 '상세설명참조'로 변경되었습니다."),k.log("[fillTemplateFieldsWithDetailReference] All fields updated to detail reference")}function gr(r,c){const e=window.G7Core;if(!e?.state){k.warn("[fillNoticeItemsWithDetailReference] G7Core.state API is not available");return}const a=((r.params||{}).form||{}).notice_items??[];if(a.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.no_items_to_change")??"변경할 항목이 없습니다.");return}const n=e.locale?.supported?.()??["ko","en"],s={};for(const l of n)s[l]=Se[l]??Se.en??"See detail description";const i=a.map(l=>({...l,content:{...s}}));e.state.setLocal({"form.notice_items":i}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.all_items_changed_to_detail_ref")??"모든 항목이 '상세설명참조'로 변경되었습니다."),k.log("[fillNoticeItemsWithDetailReference] All notice items updated to detail reference")}async function _r(r,c){const e=window.G7Core;if(!e?.state||!e?.api){k.warn("[confirmSaveNoticeTemplate] G7Core.state or G7Core.api is not available");return}const t=e.state.getLocal()||{},o=e.state.get?.()?.ui?.saveTemplateData??{},a=o.name;if(!a?.ko?.trim()){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_name_required")??"Please enter template name.");return}try{const n=t.form?.notice_items??[],s=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/notice-templates",{name:a,is_active:o.is_default??!1,fields:n.map(i=>({name:i.name,content:i.content}))});e.state.set?.({"ui.saveTemplateData":{name:pe(),category_id:null,is_default:!1}}),e.state.setLocal({ui:{...t.ui,noticeMode:"existing"},hasChanges:!0}),e.dispatch?.({handler:"closeModal"}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_saved")??"Template has been saved."),e.datasources?.refresh?.("notice_templates"),k.log(`[confirmSaveNoticeTemplate] Template saved: ${s.data.id}`)}catch(n){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.notice.messages.template_save_error")??"Failed to save template."),k.error("[confirmSaveNoticeTemplate] Failed to save template:",n)}}const v=window.G7Core?.createLogger?.("Ecom:Misc")??{log:(...r)=>console.log("[Ecom:Misc]",...r),warn:(...r)=>console.warn("[Ecom:Misc]",...r),error:(...r)=>console.error("[Ecom:Misc]",...r)};function wr(r,c){const t=(r.params||{}).labelId;if(!t){v.warn("[toggleLabel] Missing labelId param");return}const o=window.G7Core;if(!o?.state){v.warn("[toggleLabel] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=a.form?.labels??[],s=n.includes(t)?n.filter(i=>i!==t):[...n,t];o.state.setLocal({form:{...a.form,labels:s},hasChanges:!0}),v.log(`[toggleLabel] Toggled label ${t}. Current labels: ${s.length}`)}async function yr(r,c){const e=window.G7Core;if(!e?.state||!e?.api){v.warn("[generateProductCode] G7Core.state or G7Core.api is not available");return}try{const t=await e.api.post("/api/modules/sirsoft-ecommerce/admin/products/generate-code"),o=e.state.getLocal()||{};e.state.setLocal({form:{...o.form,product_code:t.data.product_code}}),v.log(`[generateProductCode] Generated: ${t.data.product_code}`)}catch(t){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.product.messages.code_generate_error")??"Failed to generate product code."),v.error("[generateProductCode] Failed:",t)}}function hr(r,c){const e=r.params||{},t=e.policyId,o=e.field,a=e.policies;if(!t||!o||!a)return"-";const n=a.find(s=>s.id===t);return n?n[o]??"-":"-"}function Cr(r,c){const e=r.params||{},t=e.templateId,o=e.templates;if(!t||!o)return"";const n=window.G7Core?.locale?.current?.()||"ko",s=o.find(i=>i.id===t);return s?.content?.[n]??s?.content?.ko??""}function br(r,c){const e=r.params||{},t=e.platform,o=e.field,a=e.value;if(!t||!o){v.warn("[updateShoppingIntegration] Missing required params");return}const n=window.G7Core;if(!n?.state){v.warn("[updateShoppingIntegration] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=s.form?.shopping_integrations??{naver:{enabled:!1,sync_interval:"manual"},daum:{enabled:!1,sync_interval:"manual"},facebook:{enabled:!1,sync_interval:"manual"},google:{enabled:!1,sync_interval:"manual"}};n.state.setLocal({form:{...s.form,shopping_integrations:{...i,[t]:{...i[t],[o]:a}}},hasChanges:!0}),v.log(`[updateShoppingIntegration] Updated ${t}.${o} to ${a}`)}function Ir(r,c){const t=(r.params||{}).type;if(!t){v.warn("[updateShippingType] Missing type param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateShippingType] G7Core.state API is not available");return}const a=o.state.getLocal()||{};let n=0,s=0;t==="fixed_paid"?n=3e3:t==="threshold_free"&&(s=3e4),o.state.setLocal({form:{...a.form,shipping_type:t,shipping_fee:t==="fixed_free"?0:a.form?.shipping_fee??n,free_shipping_threshold:t==="threshold_free"?a.form?.free_shipping_threshold??s:0},hasChanges:!0}),v.log(`[updateShippingType] Updated shipping type to ${t}`)}function Sr(r,c){const e=r.params||{},t=e.field,o=e.value;if(!t){v.warn("[updateIdentificationCode] Missing field param");return}const a=window.G7Core;if(!a?.state){v.warn("[updateIdentificationCode] G7Core.state API is not available");return}const n=a.state.getLocal()||{};a.state.setLocal({form:{...n.form,[t]:o},hasChanges:!0})}function vr(r,c){const e=r.params||{},t=e.labelId,o=e.labelName;if(!t){v.warn("[openLabelPeriodModal] Missing labelId param");return}const a=window.G7Core;if(!a?.state){v.warn("[openLabelPeriodModal] G7Core.state API is not available");return}const n=a.state.getLocal()||{},i=(n.form?.labels??[]).find(l=>l.id===t);a.state.setLocal({ui:{...n.ui,showLabelPeriodModal:!0,labelPeriodModalData:{labelId:t,labelName:o,startDate:i?.start_date??null,endDate:i?.end_date??null}}})}function Lr(r,c){const e=r.params||{},t=e.labelId,o=e.startDate,a=e.endDate;if(!t){v.warn("[saveLabelPeriod] Missing labelId param");return}const n=window.G7Core;if(!n?.state){v.warn("[saveLabelPeriod] G7Core.state API is not available");return}const s=n.state.getLocal()||{},i=[...s.form?.labels??[]],l=i.findIndex(u=>u.id===t);l>=0?i[l]={...i[l],start_date:o,end_date:a}:i.push({id:t,start_date:o,end_date:a}),n.state.setLocal({form:{...s.form,labels:i},ui:{...s.ui,showLabelPeriodModal:!1,labelPeriodModalData:null},hasChanges:!0}),n.toast?.success?.(n.t?.("sirsoft-ecommerce.admin.product.messages.label_period_saved")??"Label period has been saved."),v.log(`[saveLabelPeriod] Saved period for label ${t}`)}function xr(r,c){const t=(r.params||{}).labelId;if(!t){v.warn("[removeLabelPeriod] Missing labelId param");return}const o=window.G7Core;if(!o?.state){v.warn("[removeLabelPeriod] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=[...a.form?.labels??[]],s=n.findIndex(i=>i.id===t);s>=0&&(n[s]={...n[s],start_date:null,end_date:null}),o.state.setLocal({form:{...a.form,labels:n},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.messages.label_period_removed")??"Label period has been removed."),v.log(`[removeLabelPeriod] Removed period for label ${t}`)}function Pr(r,c){const t=(r.params||{}).sort;if(!t){v.warn("[updateActivityLogSort] Missing sort param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateActivityLogSort] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,activityLogSort:t}}),o.datasources?.refresh?.("activity_logs",{params:{sort:t==="latest"?"desc":"asc",per_page:a.ui?.activityLogPerPage??10}}),v.log(`[updateActivityLogSort] Updated sort to ${t}`)}function Ar(r,c){const t=(r.params||{}).perPage;if(!t){v.warn("[updateActivityLogPerPage] Missing perPage param");return}const o=window.G7Core;if(!o?.state){v.warn("[updateActivityLogPerPage] G7Core.state API is not available");return}const a=o.state.getLocal()||{};o.state.setLocal({ui:{...a.ui,activityLogPerPage:t}}),o.datasources?.refresh?.("activity_logs",{params:{sort:a.ui?.activityLogSort==="oldest"?"asc":"desc",per_page:t}}),v.log(`[updateActivityLogPerPage] Updated perPage to ${t}`)}function kr(r,c){const e=window.G7Core;if(!e?.state){v.warn("[setDefaultShippingPolicy] G7Core.state API is not available");return}const t=e.state.getLocal()||{};if(t.form?.shipping_policy_id){v.log("[setDefaultShippingPolicy] Shipping policy already set, skipping");return}if(t.ui?.useDefaultShippingPolicy===!1){v.log("[setDefaultShippingPolicy] useDefaultShippingPolicy is OFF, skipping");return}const o=e.dataSource?.get?.("shipping_policies"),a=o?.data?.data??o?.data??[];if(a.length===0){v.log("[setDefaultShippingPolicy] No shipping policies found");return}const n=a.find(s=>s.is_default===!0);n?(e.state.setLocal({form:{...t.form,shipping_policy_id:n.id}}),v.log(`[setDefaultShippingPolicy] Set default shipping policy: ${n.id} (${n.name})`)):v.log("[setDefaultShippingPolicy] No default shipping policy found")}function Or(r,c){const e=window.G7Core;if(!e?.state){v.warn("[toggleDefaultShippingPolicy] G7Core.state API is not available");return}const t=r.params?.checked??!1,o=e.state.getLocal()||{},a={...o.ui,useDefaultShippingPolicy:t};if(t){const n=e.dataSource?.get?.("shipping_policies"),i=(n?.data?.data??n?.data??[]).find(l=>l.is_default===!0);e.state.setLocal({ui:a,form:{...o.form,shipping_policy_id:i?.id??null}}),i?v.log(`[toggleDefaultShippingPolicy] ON - Selected default policy: ${i.id}`):v.log("[toggleDefaultShippingPolicy] ON - No default policy found")}else e.state.setLocal({ui:a}),v.log("[toggleDefaultShippingPolicy] OFF - Keeping current shipping policy")}function Gr(r,c){const e=window.G7Core;if(!e?.state){v.warn("[setLabelDatePreset] G7Core.state API is not available");return}const t=r.params?.preset;if(!t){v.warn("[setLabelDatePreset] Missing preset param");return}const o=new Date,a=i=>{const l=i.getFullYear(),u=String(i.getMonth()+1).padStart(2,"0"),d=String(i.getDate()).padStart(2,"0");return`${l}-${u}-${d}`},n=a(o);let s=null;switch(t){case"7d":{const i=new Date(o);i.setDate(i.getDate()+7),s=a(i);break}case"14d":{const i=new Date(o);i.setDate(i.getDate()+14),s=a(i);break}case"30d":{const i=new Date(o);i.setDate(i.getDate()+30),s=a(i);break}case"permanent":s=null;break;default:v.warn(`[setLabelDatePreset] Unknown preset: ${t}`);return}e.state.setGlobal({"labelFormData.start_date":n,"labelFormData.end_date":s}),v.log(`[setLabelDatePreset] Applied preset '${t}': ${n} ~ ${s??"permanent"}`)}const Er=(r,c)=>{const e=window.G7Core,o=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],a=Number(r.params?.labelId);if(!a)return;const n=[...o];if(n.find(l=>l.label_id===a)){e?.state?.setLocal?.({"ui.lastClickedLabelId":a});return}const i=[...n,{label_id:a,started_at:null,ended_at:null}];e?.state?.setLocal?.({"form.label_assignments":i,"ui.lastClickedLabelId":a,hasChanges:!0})},Fr=(r,c)=>{const e=window.G7Core,o=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],a=Number(r.params?.labelId),n=r.params?.field,s=r.params?.value;if(!a||!n)return;const i=[...o],l=i.findIndex(u=>u.label_id===a);l<0||(i[l]={...i[l],[n]:s||null},e?.state?.setLocal?.({"form.label_assignments":i,hasChanges:!0}))},Dr=(r,c)=>{const e=window.G7Core,t=e?.state?.getLocal?.()||{},o=t.form?.label_assignments??[],a=t.ui?.lastClickedLabelId,n=r.params?.preset;if(!a||!n)return;const s=[...o],i=s.findIndex(p=>p.label_id===a);if(i<0)return;const l=new Date,u=p=>p.toISOString().split("T")[0],d=u(l);let m=null;switch(n){case"7d":m=u(new Date(l.getTime()+7*24*60*60*1e3));break;case"14d":m=u(new Date(l.getTime()+14*24*60*60*1e3));break;case"30d":m=u(new Date(l.getTime()+30*24*60*60*1e3));break;case"permanent":m=null;break}s[i]={...s[i],started_at:d,ended_at:m},e?.state?.setLocal?.({"form.label_assignments":s,hasChanges:!0})},$r=(r,c)=>{const e=window.G7Core,o=(e?.state?.get()||{}).labelToUncheckId;if(!o)return;const i=[...(e?.state?.getParent?.()?._local||{}).form?.label_assignments??[]].filter(l=>l.label_id!==o);e?.state?.setParentLocal?.({"form.label_assignments":i,hasChanges:!0}),e?.state?.setGlobal?.({labelToUncheckId:null}),e?.modal?.close?.()},Nr=async(r,c)=>{const e=window.G7Core,t=e?.state?.get()||{},a=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],n=t.labelToDeleteId;if(n){e?.state?.setGlobal?.({isDeletingLabel:!0});try{const s=await e.api.delete(`/api/modules/sirsoft-ecommerce/admin/product-labels/${n}`);if(!s?.success)throw new Error(s?.message||"Failed to delete label");const l=[...a].filter(u=>u.label_id!==n);e?.state?.setLocal?.({"form.label_assignments":l,"ui.lastClickedLabelId":null}),await e?.dataSource?.refetch?.("product_labels"),e?.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.product.labels.delete_success")??"Label deleted."),e?.modal?.close?.()}catch(s){e?.toast?.error?.(s?.response?.data?.message||s?.message||e.t?.("sirsoft-ecommerce.admin.product.labels.delete_error")||"Failed to delete label.")}finally{e?.state?.setGlobal?.({isDeletingLabel:!1,labelToDeleteId:null})}}},Tr=async(r,c)=>{const e=window.G7Core,t=e?.state?.get()||{},a=(e?.state?.getLocal?.()||{}).form?.label_assignments??[],n=t.editingLabelId,s=t.labelFormData;if(s?.name?.ko){e?.state?.setGlobal?.({isSavingLabel:!0});try{if(n){const i=await e.api.put(`/api/modules/sirsoft-ecommerce/admin/product-labels/${n}`,{name:s.name,color:s.color});if(!i?.success)throw new Error(i?.message||"Failed to update label");const u=[...a].map(d=>d.label_id===n?{...d,started_at:s.started_at||null,ended_at:s.ended_at||null}:d);e?.state?.setLocal?.({"form.label_assignments":u,hasChanges:!0})}else{const i=await e.api.post("/api/modules/sirsoft-ecommerce/admin/product-labels",{name:s.name,color:s.color||"#6B7280"});if(!i?.success)throw new Error(i?.message||"Failed to create label");const l=Number(i?.data?.id);if(l){const d=[...[...a],{label_id:l,started_at:null,ended_at:null}];e?.state?.setLocal?.({"form.label_assignments":d,"ui.lastClickedLabelId":l,hasChanges:!0})}}await e?.dataSource?.refetch?.("product_labels"),e?.toast?.success?.(n?e.t?.("sirsoft-ecommerce.admin.product.labels.save_success")??"Label saved.":e.t?.("sirsoft-ecommerce.admin.product.labels.create_success")??"Label created."),e?.modal?.close?.()}catch(i){e?.toast?.error?.(i?.response?.data?.message||i?.message||e.t?.("sirsoft-ecommerce.admin.product.labels.save_error")||"Failed to save label.")}finally{e?.state?.setGlobal?.({isSavingLabel:!1})}}},ee=window.G7Core?.createLogger?.("Ecom:DescriptionLocale")??{log:(...r)=>console.log("[Ecom:DescriptionLocale]",...r),warn:(...r)=>console.warn("[Ecom:DescriptionLocale]",...r),error:(...r)=>console.error("[Ecom:DescriptionLocale]",...r)};function it(){return window.G7Core?.config?.("app.supported_locales")??["ko","en"]}function Rr(r,c){const t=(r.params||{}).locale;if(!t){ee.warn("[removeDescriptionLocale] Missing locale param");return}const o=window.G7Core;if(!o?.state){ee.warn("[removeDescriptionLocale] G7Core.state API is not available");return}const a=o?.state?.get?.("_global.settings.general.language")??o?.config?.("app.fallback_locale")??"ko";if(t===a){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.default_locale_cannot_delete")??"Default language cannot be deleted.");return}const n=o.state.getLocal()||{},s=it(),i={...n.form?.description};delete i[t];const l={...n.form?.description_mobile};delete l[t];const u=(n.ui?.descriptionActiveLocales??s).filter(d=>d!==t);o.state.setLocal({form:{...n.form,description:i,description_mobile:l},ui:{...n.ui,descriptionActiveLocales:u,descriptionLocale:u[0]??"ko"},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_removed",{locale:t.toUpperCase()})??`${t.toUpperCase()} language has been removed.`),ee.log(`[removeDescriptionLocale] Removed locale: ${t}`)}function Mr(r,c){const e=window.G7Core;if(!e?.state){ee.warn("[showAddLocaleModal] G7Core.state API is not available");return}const t=e.state.getLocal()||{};e.state.setLocal({ui:{...t.ui,showAddLocaleModal:!0}})}function Hr(r,c){const t=(r.params||{}).locale;if(!t){ee.warn("[addDescriptionLocale] Missing locale param");return}const o=window.G7Core;if(!o?.state){ee.warn("[addDescriptionLocale] G7Core.state API is not available");return}const a=o.state.getLocal()||{},n=it(),s=a.ui?.descriptionActiveLocales??["ko"];if(s.includes(t)){o.toast?.warning?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_already_added")??"This language is already added.");return}if(!n.includes(t)){o.toast?.error?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_unsupported")??"Unsupported language.");return}o.state.setLocal({form:{...a.form,description:{...a.form?.description,[t]:""},description_mobile:{...a.form?.description_mobile,[t]:""}},ui:{...a.ui,descriptionActiveLocales:[...s,t],descriptionLocale:t,showAddLocaleModal:!1},hasChanges:!0}),o.toast?.success?.(o.t?.("sirsoft-ecommerce.admin.product.description_editor.messages.locale_added",{locale:t.toUpperCase()})??`${t.toUpperCase()} language has been added.`),ee.log(`[addDescriptionLocale] Added locale: ${t}`)}const Y=window.G7Core?.createLogger?.("Ecom:BulkUpdate")??{log:(...r)=>console.log("[Ecom:BulkUpdate]",...r),warn:(...r)=>console.warn("[Ecom:BulkUpdate]",...r),error:(...r)=>console.error("[Ecom:BulkUpdate]",...r)};function ct(r,c,e){const t=c?.response?.data??{},o=t?.message||c?.message||"",a=t?.errors,n=a&&typeof a=="object"?Object.values(a).map(i=>Array.isArray(i)?i[0]:String(i)):[],s=o||r.t(e);r.toast.error(s),r.state.set({bulkUpdateErrors:n.length>0?n:s?[s]:[]})}function W(r){return typeof r=="string"?r:r&&typeof r=="object"?r.ko||r.en||JSON.stringify(r):String(r??"")}function ve(r,c,e){return e?r.t(`sirsoft-ecommerce.enums.${c}.${e}`)||e:String(e??"")}function lt(){const r=window.G7Core;return r||(Y.error("[bulkUpdate] G7Core is not available"),null)}function qr(r,c){const e=lt();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},a=o.bulkSelectedItems||t.selectedItems||[],n=o.bulkSelectedOptionIds||t.selectedOptionIds||[];Y.log("[bulkUpdate] globalState.bulkSelectedItems:",o.bulkSelectedItems),Y.log("[bulkUpdate] localState.selectedItems:",t.selectedItems),Y.log("[bulkUpdate] Final selectedItems:",a),a.length>0?Ur(e,o,t,a):n.length>0?Vr(e,o,t,n):e.toast.warning(e.t("sirsoft-ecommerce.admin.product.bulk.no_selection"))}function Ur(r,c,e,t){const a=r.dataSource.get("products")?.data?.data||[],n={};c.bulkSalesStatus&&(n.sales_status=c.bulkSalesStatus),c.bulkDisplayStatus&&(n.display_status=c.bulkDisplayStatus);const s={};if(c.bulkPriceMethod&&c.bulkPriceValue!==void 0&&c.bulkPriceValue!==null&&c.bulkPriceValue!==""){const f={fixed:"set",increase:"add",decrease:"subtract"};c.bulkPriceUnit==="percent"?s.price_adjustment={method:"percent",value:c.bulkPriceMethod==="decrease"?-(Number(c.bulkPriceValue)||0):Number(c.bulkPriceValue)||0}:s.price_adjustment={method:f[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const f={set:"set",increase:"add",decrease:"subtract"};s.stock_quantity={method:f[c.bulkStockMethod]||"add",value:Number(c.bulkStockValue)||0}}const i=e.modifiedProductIds||[],l=e.modifiedOptionIds||[],u=e.modifiedProductFields||{},d=e.modifiedOptionFields||{},m=c.bulkSelectedOptionIds||e.selectedOptionIds||[],p=new Set(t),g=[],w=[];function y(f,b,C){if(b._modified||l.includes(C)){const P={product_id:f.id,option_id:b.id},S=d[C]||[];!s.price_adjustment&&S.includes("price_adjustment")&&(P.price_adjustment=b.price_adjustment),!s.price_adjustment&&S.includes("selling_price")&&(P.price_adjustment=b.price_adjustment),!s.stock_quantity&&S.includes("stock_quantity")&&(P.stock_quantity=b.stock_quantity),S.includes("option_name")&&(P.option_name=b.option_name),S.includes("sku")&&(P.sku=b.sku),S.includes("list_price")&&(P.list_price=b.list_price),S.includes("safe_stock_quantity")&&(P.safe_stock_quantity=b.safe_stock_quantity),S.includes("is_default")&&(P.is_default=b.is_default),S.includes("is_active")&&(P.is_active=b.is_active),Object.keys(P).length>2&&w.push(P)}}a.filter(f=>t.includes(f.id)).forEach(f=>{if(f._modified||i.includes(f.id)){const C={id:f.id},x=String(f.id),P=u[x]||[];!n.sales_status&&P.includes("sales_status")&&(C.sales_status=f.sales_status),!n.display_status&&P.includes("display_status")&&(C.display_status=f.display_status),P.includes("name")&&(C.name=f.name),P.includes("list_price")&&(C.list_price=f.list_price),P.includes("selling_price")&&(C.selling_price=f.selling_price),Object.keys(C).length>1&&g.push(C)}(f.options||[]).forEach(C=>{const x=`${f.id}-${C.id}`;y(f,C,x)})}),m.length>0&&a.forEach(f=>{p.has(f.id)||(f.options||[]).forEach(b=>{const C=`${f.id}-${b.id}`;m.includes(C)&&y(f,b,C)})});const _={ids:t};Object.keys(n).length>0&&(_.bulk_changes=n),g.length>0&&(_.items=g),Object.keys(s).length>0&&(_.option_bulk_changes=s),w.length>0&&(_.option_items=w),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/products/bulk-update",_).then(f=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.update_success",{products:f?.data?.products_updated||0,options:f?.data?.options_updated||0})),ut(r),dt(r),r.modal.close()}).catch(f=>{Y.error("[bulkUpdateProducts] Error:",f),ct(r,f,"sirsoft-ecommerce.admin.product.bulk.update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function Vr(r,c,e,t){const a=r.dataSource.get("products")?.data?.data||[],n={};if(c.bulkPriceMethod&&c.bulkPriceValue!==void 0&&c.bulkPriceValue!==null&&c.bulkPriceValue!==""){const d={fixed:"set",increase:"add",decrease:"subtract"};c.bulkPriceUnit==="percent"?n.price_adjustment={method:"percent",value:c.bulkPriceMethod==="decrease"?-(Number(c.bulkPriceValue)||0):Number(c.bulkPriceValue)||0}:n.price_adjustment={method:d[c.bulkPriceMethod]||"add",value:Number(c.bulkPriceValue)||0}}if(c.bulkStockMethod&&c.bulkStockValue!==void 0&&c.bulkStockValue!==null&&c.bulkStockValue!==""){const d={set:"set",increase:"add",decrease:"subtract"};n.stock_quantity={method:d[c.bulkStockMethod]||"add",value:Number(c.bulkStockValue)||0}}const s=e.modifiedOptionFields||{},i=e.modifiedOptionIds||[],l=[];a.forEach(d=>{(d.options||[]).forEach(m=>{const p=`${d.id}-${m.id}`,g=m._modified||i.includes(p);if(t.includes(p)&&g){const w={product_id:d.id,option_id:m.id},y=s[p]||[];!n.price_adjustment&&y.includes("price_adjustment")&&(w.price_adjustment=m.price_adjustment),!n.price_adjustment&&y.includes("selling_price")&&(w.price_adjustment=m.price_adjustment),!n.stock_quantity&&y.includes("stock_quantity")&&(w.stock_quantity=m.stock_quantity),y.includes("option_name")&&(w.option_name=m.option_name),y.includes("sku")&&(w.sku=m.sku),y.includes("list_price")&&(w.list_price=m.list_price),y.includes("safe_stock_quantity")&&(w.safe_stock_quantity=m.safe_stock_quantity),y.includes("is_default")&&(w.is_default=m.is_default),y.includes("is_active")&&(w.is_active=m.is_active),Object.keys(w).length>2&&l.push(w)}})});const u={ids:t};Object.keys(n).length>0&&(u.bulk_changes=n),l.length>0&&(u.items=l),r.state.set({isProcessing:!0,bulkUpdateErrors:[]}),r.api.patch("/api/modules/sirsoft-ecommerce/admin/options/bulk-update",u).then(d=>{r.toast.success(r.t("sirsoft-ecommerce.admin.product.bulk.option_update_success",{count:d?.data?.options_updated||0})),ut(r),dt(r),r.modal.close()}).catch(d=>{Y.error("[bulkUpdateOptions] Error:",d),ct(r,d,"sirsoft-ecommerce.admin.product.bulk.option_update_error")}).finally(()=>{r.state.set({isProcessing:!1})})}function dt(r){Promise.resolve(r.dataSource.refetch("products")).then(()=>{oo(r,{force:!0})}).catch(c=>{Y.error("[bulkUpdate] Failed to refetch products:",c)})}function ut(r){r.state.setLocal({modifiedProductIds:[],modifiedOptionIds:[],modifiedProductFields:{},modifiedOptionFields:{},selectedItems:[],selectedOptionIds:[]}),r.state.set({bulkSalesStatus:null,bulkDisplayStatus:null,bulkPriceCondition:null,bulkStockCondition:null,bulkConfirmData:null,bulkUpdateErrors:[]})}function jr(r,c){const e=lt();if(!e)return;const t=e.state.getLocal()||{},o=e.state.get()||{},n=e.dataSource.get("products")?.data?.data||[],s=o.bulkSelectedItems||t.selectedItems||[],i=o.bulkSelectedOptionIds||t.selectedOptionIds||[],l={products:[],options:[],summary:{productCount:0,optionCount:0,hasBulkChanges:!1,hasInlineChanges:!1}},u=!!(o.bulkSalesStatus||o.bulkDisplayStatus||o.bulkPriceCondition||o.bulkStockCondition);l.summary.hasBulkChanges=u;const d=t.modifiedProductIds||[],m=t.modifiedOptionIds||[],p=t.modifiedProductFields||{},g=t.modifiedOptionFields||{},w=new Set(s);function y(_,f,b){const C=[],x=g[f]||[];if(o.bulkPriceCondition&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:o.bulkPriceCondition})||`Price adjustment: ${o.bulkPriceCondition}`),o.bulkStockCondition&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:o.bulkStockCondition})||`Stock: ${o.bulkStockCondition}`),b){if(!o.bulkPriceCondition&&x.includes("price_adjustment")){const P=_.price_adjustment?.toLocaleString?.()??_.price_adjustment;C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_price_adjustment_inline",{value:P})||`Price adjustment: ${P}`)}!o.bulkPriceCondition&&x.includes("selling_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:_.selling_price?.toLocaleString?.()??_.selling_price})||`Selling price: ${_.selling_price}`),!o.bulkStockCondition&&x.includes("stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_stock_inline",{value:_.stock_quantity})||`Stock: ${_.stock_quantity}`),x.includes("option_name")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_option_name",{name:W(_.option_name)})||`Option name: ${W(_.option_name)}`),x.includes("sku")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sku",{sku:_.sku})||`SKU: ${_.sku}`),x.includes("list_price")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:ue(Number(_.list_price??0))})||`List price: ${_.list_price}`),x.includes("safe_stock_quantity")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_safe_stock",{value:_.safe_stock_quantity})||`Safe stock: ${_.safe_stock_quantity}`),x.includes("is_active")&&C.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_is_active",{value:_.is_active?"ON":"OFF"})||`Active: ${_.is_active?"ON":"OFF"}`),l.summary.hasInlineChanges=!0}return C}s.length>0&&n.filter(_=>s.includes(_.id)).forEach(_=>{const f=[],b=_._modified||d.includes(_.id),C=String(_.id),x=p[C]||[];if(o.bulkSalesStatus){const S=ve(e,"sales_status",o.bulkSalesStatus);f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:S})||`Sales status: ${S}`)}if(o.bulkDisplayStatus){const S=ve(e,"display_status",o.bulkDisplayStatus);f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:S})||`Display status: ${S}`)}if(b){if(!o.bulkSalesStatus&&x.includes("sales_status")){const S=ve(e,"sales_status",_.sales_status);f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_sales_status",{status:S})||`Sales status: ${S}`)}if(!o.bulkDisplayStatus&&x.includes("display_status")){const S=ve(e,"display_status",_.display_status);f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_display_status",{status:S})||`Display status: ${S}`)}x.includes("name")&&f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_name_changed")||"Name changed"),x.includes("list_price")&&f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_list_price",{price:ue(Number(_.list_price??0))})||`List price: ${_.list_price?.toLocaleString?.()||_.list_price}`),x.includes("selling_price")&&f.push(e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selling_price",{price:ue(Number(_.selling_price??0))})||`Selling price: ${_.selling_price?.toLocaleString?.()||_.selling_price}`),l.summary.hasInlineChanges=!0}(f.length>0||u||b)&&l.products.push({id:_.id,name:W(_.name),changes:f.length>0?f.join(", "):b?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"});const P=u&&!!(o.bulkPriceCondition||o.bulkStockCondition);if(Array.isArray(_.options))_.options.forEach(S=>{const N=`${_.id}-${S.id}`,L=S._modified||m.includes(N),O=y(S,N,L);(O.length>0||P||L)&&l.options.push({productId:_.id,optionId:S.id,productName:W(_.name),optionName:S.option_name_localized||W(S.option_name)||S.name||"",changes:O.length>0?O.join(", "):L?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"})});else if(P){const S=Number(_.options_total_count)||0;S>0&&l.options.push({aggregate:!0,productId:_.id,optionId:0,optionCount:S,productName:W(_.name),optionName:"",changes:y({},`${_.id}-*`,!1).join(", ")||e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_bulk_applied")||"Bulk changes applied"})}}),i.length>0&&n.forEach(_=>{w.has(_.id)||(_.options||[]).forEach(f=>{const b=`${_.id}-${f.id}`;if(i.includes(b)){const C=f._modified||m.includes(b),x=y(f,b,C);l.options.push({productId:_.id,optionId:f.id,productName:W(_.name),optionName:f.option_name_localized||W(f.option_name)||f.name||"",changes:x.length>0?x.join(", "):C?e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_inline_modified")||"Inline modified":e.t("sirsoft-ecommerce.admin.product.messages.bulk_summary_selected")||"Selected"})}})}),l.summary.productCount=l.products.length,l.summary.optionCount=l.options.reduce((_,f)=>_+(f.aggregate?f.optionCount||0:1),0),e.state.set({bulkConfirmData:l}),Y.log("[buildConfirmData] Built confirm data:",l)}const M=window.G7Core?.createLogger?.("Ecom:Order")??{log:(...r)=>console.log("[Ecom:Order]",...r),warn:(...r)=>console.warn("[Ecom:Order]",...r),error:(...r)=>console.error("[Ecom:Order]",...r)};function Br(r){const{currencies:c=[]}=r.params||{},e=[{field:"no",header:"NO",width:"60px",sortable:!1,required:!0},{field:"ordered_at",header:"$t:sirsoft-ecommerce.admin.order.column.order_date",width:"140px",sortable:!0,required:!0},{field:"order_number",header:"$t:sirsoft-ecommerce.admin.order.column.order_number",width:"140px",sortable:!0,required:!0},{field:"product",header:"$t:sirsoft-ecommerce.admin.order.column.product",width:"auto",sortable:!1,required:!0},{field:"order_amount",header:"$t:sirsoft-ecommerce.admin.order.column.order_amount",width:"120px",sortable:!0,required:!1},{field:"order_status",header:"$t:sirsoft-ecommerce.admin.order.column.order_status",width:"100px",sortable:!1,required:!1},{field:"orderer",header:"$t:sirsoft-ecommerce.admin.order.column.orderer",width:"120px",sortable:!1,required:!1},{field:"payment_method",header:"$t:sirsoft-ecommerce.admin.order.column.payment_method",width:"100px",sortable:!1,required:!1},{field:"delivery_method",header:"$t:sirsoft-ecommerce.admin.order.column.delivery_method",width:"100px",sortable:!1,required:!1},{field:"country",header:"$t:sirsoft-ecommerce.admin.order.column.country",width:"80px",sortable:!1,required:!1}],t=c.filter(o=>!o.is_default).map(o=>({field:`order_amount_${o.code.toLowerCase()}`,header:`${o.name} (${o.symbol})`,width:"100px",sortable:!0,required:!1}));return[...e,...t]}function Kr(r,c){const e=r.params||{},{target:t="_local",path:o,value:a}=e,n=window.G7Core;if(!n?.state)return M.warn("[toggleArrayValue] G7Core.state를 사용할 수 없습니다."),{success:!1,newValue:[]};const s=t==="_global"?n.state.getGlobal():n.state.getLocal(),i=o.split(".");let l=s;for(const d of i)l=l?.[d];Array.isArray(l)||(l=[]);let u;return l.includes(a)?u=l.filter(d=>d!==a):u=[...l,a],t==="_global"?n.state.setGlobal({[o]:u}):n.state.setLocal({[o]:u}),{success:!0,newValue:u}}function Qr(r,c){const e=r.params||{},{filterKey:t,checked:o}=e,a=window.G7Core;if(!a?.state)return M.warn("[toggleVisibleFilter] G7Core.state를 사용할 수 없습니다."),{success:!1,newValue:[]};const s=a.state.getLocal().visibleFilters||[];let i;o?i=s.includes(t)?s:[...s,t]:i=s.filter(l=>l!==t),a.state.setLocal({visibleFilters:i});try{localStorage.setItem("g7_order_filter_visible_filters",JSON.stringify(i))}catch(l){M.warn("[toggleVisibleFilter] LocalStorage 저장 실패:",l)}return{success:!0,newValue:i}}function zr(r,c){const e=r.params||{},{selectedIds:t=[]}=e,o=window.G7Core;return o?.state?(o.state.setLocal({selectedItems:t,selectAll:!1}),{success:!0}):(M.warn("[syncOrderSelection] G7Core.state를 사용할 수 없습니다."),{success:!1})}function Yr(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.order_number)return M.warn("[handleOrderRowAction] actionId 또는 row.order_number가 없습니다."),{success:!1};switch(t){case"view":a?.navigate?.(`/admin/ecommerce/orders/${o.order_number}`);break;case"edit":a?.navigate?.(`/admin/ecommerce/orders/${o.order_number}`);break;case"status":case"shipping":case"memo":case"delete":a?.state?.setGlobal({orderList:{currentActionOrderId:o.id,currentActionType:t}});break;case"print":window.open(`/admin/ecommerce/orders/${o.order_number}/shipping-label`,"_blank");break;default:return console.warn(`[handleOrderRowAction] 알 수 없는 액션: ${t}`),{success:!1}}return{success:!0,action:t,targetId:o.id}}async function Wr(r,c){const e=r.params||{},{actionType:t,orderIds:o,status:a,carrierId:n,trackingNumber:s}=e,i=window.G7Core;if(!o||o.length===0)throw new Error("선택된 주문이 없습니다.");let l="";const u={ids:o};switch(t){case"status":if(!a)throw new Error("변경할 상태를 선택해주세요.");l="/api/modules/sirsoft-ecommerce/admin/orders/bulk",u.order_status=a;break;case"shipping":if(!s&&!n)throw new Error("택배사 또는 운송장번호를 입력해주세요.");l="/api/modules/sirsoft-ecommerce/admin/orders/bulk",n&&(u.carrier_id=n),s&&(u.tracking_number=s);break;default:throw new Error(`알 수 없는 액션 타입: ${t}`)}const d=await i?.api?.call(l,{method:"PATCH",body:u});if(!d?.success)throw new Error(d?.message||"일괄 처리에 실패했습니다.");return{success:!0,successCount:d.data?.updated_count??o.length,failCount:d.data?.fail_count??0,errors:d.data?.errors??[]}}async function Jr(r,c){const e=r.params||{},{downloadType:t,orderIds:o,filters:a}=e,n=window.G7Core,s="/api/modules/sirsoft-ecommerce/admin/orders/export",i={};if(t==="selected"){if(!o||o.length===0)throw new Error("선택된 주문이 없습니다.");i.ids=o}else i.filters=a||{};const l=n?.auth?.getToken(),u=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json",...l?{Authorization:`Bearer ${l}`}:{}},body:JSON.stringify(i)});if(!u.ok){const y=await u.json().catch(()=>null);throw new Error(y?.message||"엑셀 다운로드에 실패했습니다.")}const d=await u.blob(),m=window.URL.createObjectURL(d),p=document.createElement("a");p.href=m;const g=new Date,w=`${g.getFullYear()}-${String(g.getMonth()+1).padStart(2,"0")}-${String(g.getDate()).padStart(2,"0")}`;return p.download=`orders_${w}.xlsx`,document.body.appendChild(p),p.click(),document.body.removeChild(p),window.URL.revokeObjectURL(m),{success:!0}}function Xr(r,c){const e=r.params||{},{columns:t=[]}=e;try{localStorage.setItem("g7_order_visible_columns",JSON.stringify(t))}catch(o){return M.warn("[saveVisibleColumns] LocalStorage 저장 실패:",o),{success:!1}}return{success:!0}}function Zr(r,c){const t=(r.params||{}).defaultColumns||["order_date","order_number","order_status","order_amount","product","orderer","payment_method","delivery_method","country"];try{const o=localStorage.getItem("g7_order_visible_columns");if(o){const a=JSON.parse(o);if(Array.isArray(a))return a}}catch(o){M.warn("[loadVisibleColumns] LocalStorage 로드 실패:",o)}return t}function en(r,c){const e=r.params||{},{id:t,defaultFilters:o=[]}=e,a=window.G7Core;if(!t)return M.warn("[loadVisibleFilters] id 파라미터가 필요합니다."),o;let n;try{const i=a?.AuthManager?.getInstance();i&&(n=i.getUser()?.uuid)}catch{}const s=`g7_filters_${t}${n!==void 0?`_${n}`:""}`;try{const i=localStorage.getItem(s);if(i){const l=JSON.parse(i);if(Array.isArray(l)&&l.length>0)return M.log(`[loadVisibleFilters] localStorage에서 로드: ${s}`,l),l}}catch(i){M.warn("[loadVisibleFilters] LocalStorage 로드 실패:",i)}return M.log(`[loadVisibleFilters] 기본값 사용: ${s}`,o),o}function tn(r,c){const e=window.G7Core;if(!e)return;const t=e.state.get()||{},o=t.bulkSelectedItems||[],a=t.bulkOrderStatus||"",n=t.bulkCourier||"",s=t.bulkTrackingNumber||"";if(!a){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_status_selected")||"변경할 주문상태를 선택해주세요.");return}if(["shipping_ready","shipping"].includes(a)&&(!n||!s)){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.carrier_required")||"해당 상태로 변경하려면 택배사와 송장번호를 모두 입력해주세요.");return}e.state.set({bulkConfirmData:{selectedItems:o,orderStatus:a,courierId:n,trackingNumber:s}}),e.modal?.open?.("modal_bulk_confirm")}async function on(r,c){const e=window.G7Core;if(!e)return;const o=(e.state.get()||{}).bulkConfirmData;if(!o||!o.selectedItems?.length){e.toast?.error?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.error")||"일괄 처리에 실패했습니다.");return}const a={ids:o.selectedItems,order_status:o.orderStatus};o.courierId&&(a.carrier_id=o.courierId),o.trackingNumber&&(a.tracking_number=o.trackingNumber);try{await e.api.patch("/api/modules/sirsoft-ecommerce/admin/orders/bulk",a),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.success")||"일괄 변경이 완료되었습니다."),e.state.setLocal({selectedItems:[],selectAll:!1,bulkOrderStatus:"",bulkCourier:"",bulkTrackingNumber:""}),e.state.set({bulkConfirmData:null}),e.dataSource?.refetch?.("orders"),e.modal?.close?.()}catch(n){M.error("[executeOrderBulkAction] Error:",n);const i=(n?.response?.data||n?.data||{})?.message||n?.message;e.toast?.error?.(i||e.t?.("sirsoft-ecommerce.admin.order.bulk.error")||"일괄 처리에 실패했습니다.")}}const qe=window.G7Core?.createLogger?.("Ecom:ProductList")??{log:(...r)=>console.log("[Ecom:ProductList]",...r),warn:(...r)=>console.warn("[Ecom:ProductList]",...r),error:(...r)=>console.error("[Ecom:ProductList]",...r)};async function rn(r,c){const e=r.params||{},{actionId:t,row:o}=e,a=window.G7Core;if(!t||!o?.id)return qe.warn("[handleProductRowAction] actionId 또는 row.id가 없습니다."),{success:!1};switch(t){case"edit":a?.navigate?.(`/admin/ecommerce/products/${o.product_code}/edit`);break;case"copy":a?.state?.setLocal({productList:{copyTargetProduct:o},ui:{showCopyModal:!0}});break;case"delete":try{const s=(await a?.api?.get(`/api/modules/sirsoft-ecommerce/admin/products/${o.id}/can-delete`))?.data;a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:s?.canDelete??!0,deleteBlockReason:s?.reason??null,relatedData:s?.relatedData??null},ui:{showDeleteModal:!0}})}catch(n){qe.error("[handleProductRowAction] 삭제 가능 여부 확인 실패:",n),a?.state?.setLocal({productList:{deleteTargetProduct:o,canDelete:!1,deleteBlockReason:"삭제 가능 여부를 확인할 수 없습니다.",relatedData:null},ui:{showDeleteModal:!0}})}break;default:return qe.warn(`[handleProductRowAction] 알 수 없는 액션: ${t}`),{success:!1}}return{success:!0,action:t,targetId:o.id}}const U=window.G7Core?.createLogger?.("Ecom:OrderDetail")??{log:(...r)=>console.log("[Ecom:OrderDetail]",...r),warn:(...r)=>console.warn("[Ecom:OrderDetail]",...r),error:(...r)=>console.error("[Ecom:OrderDetail]",...r)};function mt(){return typeof document>"u"?"":document.querySelector('input[name="batchTrackingNumber"]')?.value?.trim()??""}function nn(r,c){const e=window.G7Core;if(!e?.state){U.warn("[initOrderDetailForm] G7Core.state를 사용할 수 없습니다.");return}e.state.getLocal();const o=c?.data?.data??e.dataSource?.get?.("order")?.data;if(!o){U.warn("[initOrderDetailForm] order 데이터가 없습니다.");return}e.state.setLocal({form:{recipient_name:o.recipient_name||"",recipient_phone:o.recipient_phone||"",recipient_tel:o.recipient_tel||"",recipient_zipcode:o.recipient_zipcode||"",recipient_address:o.recipient_address||"",recipient_detail_address:o.recipient_detail_address||"",delivery_memo:o.delivery_memo||"",admin_memo:o.admin_memo||""}}),U.log("[initOrderDetailForm] 폼 초기화 완료")}function an(r,c){const e=window.G7Core,{optionId:t}=r.params||{};if(!e?.state||!t)return;const a=e.state.getLocal().selectedProducts||[];let n;a.includes(t)?n=a.filter(u=>u!==t):n=[...a,t];const i=(e.dataSource?.get?.("order")?.data?.options||[]).length,l=n.length===i&&i>0;e.state.setLocal({selectedProducts:n,selectAll:l})}function sn(r,c){const e=window.G7Core;if(!e?.state)return;const o=e.state.getLocal().selectAll,n=e.dataSource?.get?.("order")?.data?.options||[];if(o)e.state.setLocal({selectedProducts:[],selectAll:!1});else{const s=n.map(i=>i.id);e.state.setLocal({selectedProducts:s,selectAll:!0})}}function cn(r,c){const e=window.G7Core;if(!e)return;const t=r.params||{},o=t.selectedProducts||[],a=t.batchOrderStatus||"",n=t.batchCarrierId||"",s=t.batchTrackingNumber||mt();if(!a){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_status_selected")||"변경할 주문상태를 선택해주세요.");return}if(o.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_items_selected")||"변경할 상품을 선택해주세요.");return}if(a==="cancelled"){const p=t.cancelPg??!0,g=e.dataSource?.get?.("order")?.data,w=g?.options||[],y=g?.currency_snapshot?.base_currency??null,_=o.map(f=>{const b=w.find(x=>x.id===f);if(!b||b.option_status==="cancelled")return null;const C=b.quantity??1;return{id:b.id,product_name:b.product_name,product_option_name:b.product_option_name,thumbnail_url:b.product_snapshot?.thumbnail_url||"",unit_price:b.unit_price,unit_price_formatted:b.unit_price_formatted??ue(Number(b.unit_price??0),y),quantity:C,subtotal_formatted:ue(Number(b.unit_price??0)*C,y),cancel_quantity:C,option_status:b.option_status,option_status_label:b.option_status_label||b.option_status}}).filter(Boolean);if(_.length===0){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.no_cancellable_items")||"취소할 수 있는 상품이 없습니다. 이미 취소된 상품은 제외됩니다.");return}if(p){e.state.setLocal({cancelItems:_,cancelReason:"",cancelReasonDetail:"",cancelPg:!0,refundPriority:"pg_first",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null,cancelValidationErrors:null}),e.modal?.open?.("modal_cancel_order");const f=g?.order_number||"";f&&e.dispatch?.({handler:"sirsoft-ecommerce.estimateRefundAmount",params:{orderId:f,cancelItems:_}})}else{const f={};for(const b of _)f[b.id]=b.quantity;e.state.setLocal({bulkConfirmItems:_,changeQuantities:f,batchOrderStatus:a}),e.modal?.open?.("modal_batch_change_confirm")}return}if(["shipping_ready","shipping","delivered"].includes(a)&&(!n||!s)){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.order.bulk.carrier_required")||"해당 상태로 변경하려면 택배사와 송장번호를 모두 입력해주세요.");return}const u=e.dataSource?.get?.("order")?.data?.options||[],d=[],m={};for(const p of o){const g=u.find(w=>w.id===p);g&&(d.push({id:g.id,product_name:g.product_name,product_option_name:g.product_option_name,sku:g.sku,thumbnail_url:g.product_snapshot?.thumbnail_url||"",unit_price:g.unit_price,original_price:g.product_snapshot?.original_price||g.unit_price,quantity:g.quantity,option_status:g.option_status}),m[g.id]=g.quantity)}e.state.setLocal({bulkConfirmItems:d,changeQuantities:m,batchOrderStatus:a,batchCarrierId:n,batchTrackingNumber:s}),e.modal?.open?.("modal_batch_change_confirm")}async function ln(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t){U.warn("[processOrderDetailBulkChange] orderId가 없습니다.");return}const o=e.state.getLocal?.()??{},a=r.params?.selectedProducts??o.selectedProducts,n=r.params?.batchOrderStatus||o.batchOrderStatus,s=r.params?.batchCarrierId||o.batchCarrierId,i=r.params?.batchTrackingNumber||o.batchTrackingNumber||mt(),l=r.params?.changeQuantities??o.changeQuantities;if(!a||a.length===0){U.warn("[processOrderDetailBulkChange] 선택된 상품이 없습니다.");return}if(!n){U.warn("[processOrderDetailBulkChange] 변경할 상태가 없습니다.");return}const d=e.dataSource?.get?.("order")?.data?.options||[],p={items:a.map(g=>{const w=d.find(y=>y.id===g);return{option_id:g,quantity:l?.[g]??w?.quantity??1}}),status:n};s&&(p.carrier_id=s),i&&(p.tracking_number=i);try{const g=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/options/bulk-status`,p);if(g?.success){const w=e.t,y=g.data?.changed_count??a.length;e.toast?.success?.(w?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_success",{count:y})??`${y}개 옵션의 상태가 변경되었습니다.`),e.modal?.close?.("modal_batch_change_confirm"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}}),e.state.setLocal({selectedProducts:[],selectAll:!1,batchOrderStatus:"",batchCarrierId:"",batchTrackingNumber:"",changeQuantities:{}})}else throw new Error(g?.message||"상태 변경에 실패했습니다.")}catch(g){U.error("[processOrderDetailBulkChange] API 호출 실패:",g);const y=(g?.response?.data||g?.data||{})?.message||g?.message;e.toast?.error?.(y??e.t?.("sirsoft-ecommerce.admin.order.detail.handler.bulk_change_failed")??"상태 변경에 실패했습니다.")}}async function dn(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t)return;const a=e.state.getLocal().form?.admin_memo??"";try{const n=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}`,{admin_memo:a});if(n?.success)e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.handler.memo_save_success")??"관리자 메모가 저장되었습니다.");else throw new Error(n?.message)}catch(n){U.error("[saveAdminMemo] 저장 실패:",n),e.toast?.error?.(n?.message??"메모 저장에 실패했습니다.")}}function un(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o}=r.params||{};if(!t||!o)return;const a=r.params?.value??r.$event?.target?.value,n=parseInt(String(a),10),s=Math.max(1,Math.min(isNaN(n)?o:n,o)),l={...e.state.getLocal().changeQuantities||{}};l[t]=s,e.state.setLocal({changeQuantities:l})}function mn(r,c){const e=window.G7Core;if(!e?.state){U.warn("[openConfirmDepositModal] G7Core.state 를 사용할 수 없습니다.");return}const t=e.dataSource?.get?.("order")?.data,o=t?.depositor_name??"",a=t?.total_due_charge_amount??t?.total_due_amount??0;e.state.setLocal({depositorName:o,depositAmount:a,depositConfirmErrors:null,isConfirmingDeposit:!1,markOrderComplete:!1}),e.modal?.open?.("modal_confirm_deposit")}async function pn(r,c){const e=window.G7Core,{orderId:t}=r.params||{};if(!e?.state||!t){U.warn("[confirmDeposit] orderId 가 없습니다.");return}const o=e.state.getLocal(),a=r.params?.amount??o.depositAmount,n=r.params?.depositorName??o.depositorName??"",s=r.params?.markOrderComplete??o.markOrderComplete??!1,i=s===!0||s==="true";e.state.setLocal({isConfirmingDeposit:!0,depositConfirmErrors:null});try{const l=await e.api.patch(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/confirm-deposit`,{amount:a,depositor_name:n,mark_order_complete:i});if(l?.success)e.state.setLocal({isConfirmingDeposit:!1,depositConfirmErrors:null}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.modal.confirm_deposit.confirm_success")??"입금이 확인되어 결제완료 처리되었습니다."),e.modal?.close?.("modal_confirm_deposit"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}});else throw new Error(l?.message||"Deposit confirm failed")}catch(l){U.error("[confirmDeposit] 실패:",l);const u=l?.response?.data||l?.data||{},d=u?.errors?.detail||u?.message||l?.message||"",m=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.confirm_deposit.confirm_failed")??"입금 확인에 실패했습니다.";e.state.setLocal({isConfirmingDeposit:!1,depositConfirmErrors:{amount:[d||m]}}),e.toast?.error?.(d||m)}}const Ue=window.G7Core?.createLogger?.("Ecom:CancelOrder")??{log:(...r)=>console.log("[Ecom:CancelOrder]",...r),warn:(...r)=>console.warn("[Ecom:CancelOrder]",...r),error:(...r)=>console.error("[Ecom:CancelOrder]",...r)};let K=null;function fn(){K&&(clearTimeout(K),K=null,Ue.log("[clearCancelOrderTimers] debounce 타이머 정리 완료"))}function gn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a)return;const n=r.params?.value??r.$event?.target?.value,s=parseInt(String(n),10),i=Math.max(1,Math.min(isNaN(s)?o:s,o)),u=(e.state.getLocal().cancelItems||[]).map(m=>m.id===t?{...m,cancel_quantity:i}:m);e.state.setLocal({cancelItems:u});const d=window.__g7ActionContext;K&&clearTimeout(K),K=setTimeout(()=>{const m=window.__g7ActionContext;window.__g7ActionContext=d;try{Ve(e,a)}finally{window.__g7ActionContext=m}},500)}async function _n(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,cancelItems:o}=r.params||{};t&&await Ve(e,t,o)}async function Ve(r,c,e){const t=r.state.getLocal(),o=e||t.cancelItems||[],a=t.refundPriority||"pg_first";if(o.length===0){r.state.setLocal({refundEstimate:null});return}const n=o.map(s=>({order_option_id:s.id,cancel_quantity:s.cancel_quantity}));r.state.setLocal({refundLoading:!0});try{const s=await r.api.post(`/api/modules/sirsoft-ecommerce/admin/orders/${c}/estimate-refund`,{items:n,refund_priority:a});if(s?.success)r.state.setLocal({refundEstimate:s.data,refundLoading:!1,cancelError:null});else throw new Error(s?.message||"Estimate failed")}catch(s){Ue.error("[estimateRefundAmount] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function wn(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o)return;e.state.setLocal({refundPriority:t});const a=window.__g7ActionContext;K&&clearTimeout(K),K=setTimeout(()=>{const n=window.__g7ActionContext;window.__g7ActionContext=a;try{Ve(e,o)}finally{window.__g7ActionContext=n}},300)}async function yn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t)return;const o=e.state.getLocal(),a=r.params?.cancelItems||o.cancelItems||[],n=r.params?.cancelReason||o.cancelReason||"",s=r.params?.cancelReasonDetail||o.cancelReasonDetail||"",i=r.params?.cancelPg??o.cancelPg??!0,l=r.params?.refundPriority||o.refundPriority||"pg_first",u={type:"partial",cancel_pg:i,refund_priority:l,items:a.map(m=>({order_option_id:m.id,cancel_quantity:m.cancel_quantity}))};n&&(u.reason=n),s&&(u.reason_detail=s);const d={bank_code:o.refundBankCode||"",account_number:o.refundBankAccount||"",holder:o.refundBankHolder||""};(d.bank_code||d.account_number||d.holder)&&(u.refund_bank=d),e.state.setLocal({isCancelling:!0,cancelError:null,cancelValidationErrors:null});try{const m=await e.api.post(`/api/modules/sirsoft-ecommerce/admin/orders/${t}/cancel`,u);if(m?.success)e.state.setLocal({isCancelling:!1}),e.toast?.success?.(e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_success")??"주문이 취소되었습니다."),e.modal?.close?.("modal_cancel_order"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order_logs"}}),e.state.setLocal({selectedProducts:[],selectAll:!1,batchOrderStatus:""});else throw new Error(m?.message||"Cancel failed")}catch(m){Ue.error("[executeCancelOrder] 실패:",m);const p=m?.response?.data||m?.data,w=(m?.response?.status||m?.status)===422,y=p?.errors?.detail,_=p?.errors??null,f=y||p?.message||m?.message||"",b=e.t?.("sirsoft-ecommerce.admin.order.detail.modal.cancel.cancel_failed")??"주문 취소에 실패했습니다.";w?(e.state.setLocal({isCancelling:!1,cancelError:null,cancelValidationErrors:_}),e.toast?.error?.(f||b)):(e.state.setLocal({isCancelling:!1,cancelError:f||b,cancelValidationErrors:null}),e.toast?.error?.(f||b))}}const pt=window.G7Core?.createLogger?.("Ecom:UserCancelOrder")??{log:(...r)=>console.log("[Ecom:UserCancelOrder]",...r),warn:(...r)=>console.warn("[Ecom:UserCancelOrder]",...r),error:(...r)=>console.error("[Ecom:UserCancelOrder]",...r)};let R=null;function Le(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}function hn(){R&&(clearTimeout(R),R=null)}function Cn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t}=r.params||{};if(!t)return;const a=[...e.state.getLocal().selectedItemIds||[]],n=a.indexOf(t);n>=0?a.splice(n,1):a.push(t);const i=(e.dataSource?.get?.("order")?.data?.options||[]).filter(u=>u.option_status!=="cancelled").map(u=>u.id),l=i.length>0&&i.every(u=>a.includes(u));e.state.setLocal({selectedItemIds:a,selectAllItems:l})}function bn(r,c){const e=window.G7Core;if(!e?.state)return;const t=e.state.getLocal(),a=(e.dataSource?.get?.("order")?.data?.options||[]).filter(s=>s.option_status!=="cancelled").map(s=>s.id),n=!t.selectAllItems;e.state.setLocal({selectedItemIds:n?a:[],selectAllItems:n})}function In(r,c){const e=window.G7Core;if(!e?.state)return;const t=e.dataSource?.get?.("order")?.data;if(!t)return;const a=e.state.getLocal().selectedItemIds||[];if(a.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_selected_items")??"취소할 상품을 선택해주세요.");return}const s=(t.options||[]).filter(l=>a.includes(l.id)&&l.option_status!=="cancelled").map(l=>({id:l.id,product_name:l.product_name??"",product_option_name:l.product_option_name??"",thumbnail_url:l.product_snapshot?.thumbnail_url||"",unit_price:l.unit_price??0,quantity:l.quantity??1,cancel_quantity:l.quantity??1,option_status:l.option_status??"",selected:!0}));if(s.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_cancellable_items")??"취소 가능한 상품이 없습니다.");return}e.state.setLocal({cancelItems:s,cancelSelectAll:!0,cancelReason:"",refundEstimate:null,refundLoading:!1,isCancelling:!1,cancelError:null}),e.modal?.open?.("modal_cancel_order");const i=r.params?.orderId||t.id;(i||t.order_number)&&le(e,i)}function Sn(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,orderId:o}=r.params||{};if(!t)return;const n=(e.state.getLocal().cancelItems||[]).map(i=>i.id===t?{...i,selected:!i.selected}:i),s=n.every(i=>i.selected);e.state.setLocal({cancelItems:n,cancelSelectAll:s}),R&&clearTimeout(R),R=setTimeout(()=>{le(e,o)},500)}function vn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=e.state.getLocal(),a=!o.cancelSelectAll,n=(o.cancelItems||[]).map(s=>({...s,selected:a}));e.state.setLocal({cancelItems:n,cancelSelectAll:a}),R&&clearTimeout(R),R=setTimeout(()=>{le(e,t)},500)}function Ln(r,c){const e=window.G7Core;if(!e?.state)return;const{optionId:t,maxQuantity:o,orderId:a}=r.params||{};if(!t||!o||!a&&!Le(e))return;const n=r.params?.value??r.$event?.target?.value,s=parseInt(String(n),10),i=Math.max(1,Math.min(isNaN(s)?o:s,o)),u=(e.state.getLocal().cancelItems||[]).map(m=>m.id===t?{...m,cancel_quantity:i}:m);e.state.setLocal({cancelItems:u});const d=window.__g7ActionContext;R&&clearTimeout(R),R=setTimeout(()=>{const m=window.__g7ActionContext;window.__g7ActionContext=d;try{le(e,a)}finally{window.__g7ActionContext=m}},500)}async function xn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};!t&&!Le(e)||await le(e,t)}async function le(r,c){const e=r.state.getLocal(),t=e.cancelItems||[],o=e.refundPriority||"pg_first",a=t.filter(s=>s.selected);if(a.length===0){r.state.setLocal({refundEstimate:null,refundLoading:!1});return}const n=a.map(s=>({order_option_id:s.id,cancel_quantity:s.cancel_quantity}));r.state.setLocal({refundLoading:!0});try{const s=r.state.get?.("_global")?.guestOrderToken,i=r.dataSource?.get?.("order")?.data?.order_number,l=!!(s&&i),u=l?`/api/modules/sirsoft-ecommerce/guest/orders/${i}/estimate-refund`:`/api/modules/sirsoft-ecommerce/user/orders/${c}/estimate-refund`,d=l?{headers:{"X-Guest-Order-Token":s}}:void 0,m=await r.api.post(u,{items:n,refund_priority:o},d);if(m?.success)r.state.setLocal({refundEstimate:m.data,refundLoading:!1,cancelError:null});else throw new Error(m?.message||"Estimate failed")}catch(s){pt.error("[estimateUserRefund] 실패:",s),r.state.setLocal({refundLoading:!1,refundEstimate:null}),r.toast?.error?.(r.t?.("mypage.order_detail.cancel_modal.estimate_failed")??"환불 예상금액 계산에 실패했습니다.")}}function Pn(r,c){const e=window.G7Core;if(!e?.state)return;const{priority:t,orderId:o}=r.params||{};if(!t||!o&&!Le(e))return;e.state.setLocal({refundPriority:t});const a=window.__g7ActionContext;R&&clearTimeout(R),R=setTimeout(()=>{const n=window.__g7ActionContext;window.__g7ActionContext=a;try{le(e,o)}finally{window.__g7ActionContext=n}},300)}async function An(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{};if(!t&&!Le(e))return;const o=e.state.getLocal(),n=(o.cancelItems||[]).filter(d=>d.selected),s=r.params?.cancelReason||o.cancelReason||"",i=r.params?.cancelReasonDetail??o.cancelReasonDetail??"",l=r.params?.refundPriority||o.refundPriority||"pg_first";if(n.length===0){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.no_selected_items")??"취소할 상품을 선택해주세요.");return}if(!s){e.toast?.warning?.(e.t?.("mypage.order_detail.cancel_modal.reason_required")??"취소 사유를 선택해주세요.");return}const u={reason:s,refund_priority:l,items:n.map(d=>({order_option_id:d.id,cancel_quantity:d.cancel_quantity}))};i&&(u.reason_detail=i),e.state.setLocal({isCancelling:!0,cancelError:null,cancelValidationErrors:null});try{const d=e.state.get?.("_global")?.guestOrderToken,m=e.dataSource?.get?.("order")?.data?.order_number,p=!!(d&&m),g=p?`/api/modules/sirsoft-ecommerce/guest/orders/${m}/cancel`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/cancel`,w=p?{headers:{"X-Guest-Order-Token":d}}:void 0,y=await e.api.post(g,u,w);if(y?.success)e.state.setLocal({isCancelling:!1}),e.toast?.success?.(e.t?.("mypage.order_detail.cancel_success")??"주문이 취소되었습니다."),e.modal?.close?.("modal_cancel_order"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}});else throw new Error(y?.message||"Cancel failed")}catch(d){pt.error("[executeUserCancelOrder] 실패:",d);const m=d?.response?.data||d?.data,p=m?.message||d?.message||"",g=m?.errors??null,w=e.t?.("mypage.order_detail.cancel_modal.cancel_failed")??"주문 취소에 실패했습니다.";e.state.setLocal({isCancelling:!1,cancelError:p||w,cancelValidationErrors:g}),e.toast?.error?.(p||w)}}const ft=window.G7Core?.createLogger?.("Ecom:ConfirmOrder")??{log:(...r)=>console.log("[Ecom:ConfirmOrder]",...r),warn:(...r)=>console.warn("[Ecom:ConfirmOrder]",...r),error:(...r)=>console.error("[Ecom:ConfirmOrder]",...r)};function kn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function On(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o}=r.params||{};if(!o||!t&&!kn(e)){ft.error("[confirmOrderOption] optionId 또는 식별자 누락");return}e.state.setLocal({isConfirming:!0});try{const a=e.state.get?.("_global")?.guestOrderToken,n=e.dataSource?.get?.("order")?.data?.order_number,s=!!(a&&n),i=s?`/api/modules/sirsoft-ecommerce/guest/orders/${n}/options/${o}/confirm`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/options/${o}/confirm`,l=s?{headers:{"X-Guest-Order-Token":a}}:void 0,u=await e.api.post(i,void 0,l);if(u?.success)e.state.setLocal({isConfirming:!1,confirmTarget:null}),e.modal?.close?.("modal_confirm_purchase"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.toast?.success?.(u.message??e.t?.("sirsoft-ecommerce::messages.order.confirmed")??"구매확정이 완료되었습니다.");else throw new Error(u?.message||"Confirm failed")}catch(a){ft.error("[confirmOrderOption] 실패:",a);const n=a?.response?.data?.message||a?.data?.message||a?.message||e.t?.("sirsoft-ecommerce::messages.order.cannot_confirm")||"구매확정에 실패했습니다.";e.state.setLocal({isConfirming:!1}),e.toast?.error?.(n)}}const gt=window.G7Core?.createLogger?.("Ecom:ChangeShippingAddress")??{log:(...r)=>console.log("[Ecom:ChangeShippingAddress]",...r),warn:(...r)=>console.warn("[Ecom:ChangeShippingAddress]",...r),error:(...r)=>console.error("[Ecom:ChangeShippingAddress]",...r)};function Gn(r){const c=r?.state?.get?.("_global")?.guestOrderToken,e=r?.dataSource?.get?.("order")?.data?.order_number;return!!(c&&e)}async function En(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t}=r.params||{},o=Gn(e);if(!t&&!o){gt.error("[changeShippingAddress] orderId 또는 비회원 식별자 누락");return}const a=e.state.get?.("_global")??{},s=(o?"manual":a.changeAddressMode??"saved")==="saved"?{address_id:a.selectedAddressId}:a.editingShippingAddress;e.state.setLocal({isSubmittingAddress:!0});try{const i=a.guestOrderToken,l=e.dataSource?.get?.("order")?.data?.order_number,u=o?`/api/modules/sirsoft-ecommerce/guest/orders/${l}/shipping-address`:`/api/modules/sirsoft-ecommerce/user/orders/${t}/shipping-address`,d=o?{headers:{"X-Guest-Order-Token":i}}:void 0,m=await e.api.put(u,s,d);if(m?.success)e.state.setLocal({isSubmittingAddress:!1}),e.modal?.close?.("changeAddressModal"),e.dispatch?.({handler:"refetchDataSource",params:{dataSourceId:"order"}}),e.toast?.success?.(m.message??e.t?.("mypage.order_detail.shipping_address_changed")??"배송지가 변경되었습니다.");else throw new Error(m?.message||"Change shipping address failed")}catch(i){gt.error("[changeShippingAddress] 실패:",i),e.state.setLocal({isSubmittingAddress:!1});const l=i?.response?.data?.message||i?.data?.message||i?.message||"배송지 변경에 실패했습니다.";e.toast?.error?.(l)}}const je=window.G7Core?.createLogger?.("Ecom:Review")??{log:(...r)=>console.log("[Ecom:Review]",...r),warn:(...r)=>console.warn("[Ecom:Review]",...r),error:(...r)=>console.error("[Ecom:Review]",...r)};async function Fn(r,c){const e=window.G7Core;if(!e?.state)return;const{orderId:t,optionId:o,productId:a}=r.params||{};if(!t||!o||!a){je.error("[submitReview] orderId, optionId 또는 productId 누락");return}const n=e.state.getLocal(),s=r.params?.rating??n.reviewRating??5,i=(r.params?.content??n.reviewContent??"").trim(),u=(r.params?.images??n.reviewImages??[]).map(d=>d instanceof File?d:d?.file instanceof File||d?.file instanceof Blob||d?.file&&typeof d.file.name=="string"&&d.file.size>0?d.file:d instanceof Blob?d:null).filter(d=>d!==null);if(!s||s<1||s>5){e.toast?.error?.(e.t?.("sirsoft-ecommerce.user.review.validation.rating_required")??"별점을 선택해 주세요.");return}if(i.length<10){e.state.setLocal({reviewError:e.t?.("sirsoft-ecommerce.user.review.validation.content_min")??"리뷰 내용을 최소 10자 이상 입력해 주세요."});return}e.state.setLocal({isSubmittingReview:!0,reviewError:null});try{const d=await e.api.post("/api/modules/sirsoft-ecommerce/user/reviews",{order_option_id:o,product_id:a,rating:s,content:i});if(!d?.success)throw new Error(d?.message||"Review creation failed");const m=d.data?.id;if(m&&u.length>0)for(let p=0;pconsole.log("[Ecom:ShippingPolicyForm]",...r),warn:(...r)=>console.warn("[Ecom:ShippingPolicyForm]",...r),error:(...r)=>console.error("[Ecom:ShippingPolicyForm]",...r)},Dn=["fixed","conditional_free","per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],$n=["conditional_free"],Nn=["range_amount","range_quantity","range_weight","range_volume","range_volume_weight"],Tn=["api"],Rn=["per_quantity","per_weight","per_volume","per_volume_weight","per_amount"],Mn={shipping_method:"parcel",custom_shipping_name:null,carrier:null,currency_code:"KRW",charge_policy:"fixed",base_fee:0,free_threshold:null,ranges:null,api_endpoint:null,api_request_fields:null,api_response_fee_field:null,api_config:null,extra_fee_enabled:!1,extra_fee_settings:[],extra_fee_multiply:!1,is_active:!0};function te(r){return{showBaseFee:Dn.includes(r),showFreeThreshold:$n.includes(r),showRanges:Nn.includes(r),showApiSettings:Tn.includes(r),showUnitValue:Rn.includes(r)}}function G(){const r=window.G7Core;return r?.state?r:(I.warn("G7Core.state not available"),null)}function E(r,c){r.setLocalState?r.setLocalState(c):G()?.state?.setLocal(c)}function F(r){return[...(r.state.getLocal?.()??{}).form?.country_settings??[]]}function Hn(r,c){const e=r.params||{},t=e.policy,o=!!e.isEdit,a=!!e.isCopy;if(e.availableCountries,!G())return;const s={activeCountryTab:0,rangeErrors:{}};if((o||a)&&t){const i=t.country_settings??[],u=i[0]?.charge_policy??"fixed",d=te(u);Object.assign(s,d),I.log("[initShippingPolicyForm]",o?"Edit":"Copy","mode, countries:",i.length,"first charge_policy:",u)}else{const i=te("fixed");Object.assign(s,i),I.log("[initShippingPolicyForm] Create mode, default flags")}E(c,s)}function qn(r,c){const t=(r.params||{}).country_code??"";if(!t){I.warn("[addCountrySetting] country_code is empty");return}const o=G();if(!o)return;const a=F(o);if(a.some(u=>u.country_code===t)){I.warn("[addCountrySetting] Duplicate country_code:",t);return}const n={country_code:t,...Mn};a.push(n);const s=a.length-1,i=te(n.charge_policy),l={"form.country_settings":a,activeCountryTab:s,...i};E(c,l),I.log("[addCountrySetting] Added country:",t,"index:",s)}function Un(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){I.warn("[removeCountrySetting] Invalid index:",e.index);return}const o=G();if(!o)return;const a=F(o),n=o.state.getLocal?.()??{};if(t>=a.length){I.warn("[removeCountrySetting] Index out of bounds:",t);return}const s=a[t].country_code;a.splice(t,1);const i=n.activeCountryTab??0;let l=i;a.length===0?l=0:i>=a.length?l=a.length-1:i>t&&(l=i-1);const u={...n.rangeErrors??{}};u[s]=[];const d=a[l],m=te(d?d.charge_policy:"fixed"),p={"form.country_settings":a,activeCountryTab:l,rangeErrors:u,...m};E(c,p),I.log("[removeCountrySetting] Removed index:",t,"country:",s,"newTab:",l)}function Vn(r,c){const e=r.params||{},t=Number(e.index);if(isNaN(t)||t<0){I.warn("[switchCountryTab] Invalid index:",e.index);return}const o=G();if(!o)return;const a=F(o);if(t>=a.length){I.warn("[switchCountryTab] Index out of bounds:",t);return}const n=a[t],s=te(n.charge_policy),i={activeCountryTab:t,...s};E(c,i),I.log("[switchCountryTab] Switched to tab:",t,"country:",n.country_code,"charge_policy:",n.charge_policy)}function jn(r,c){const e=r.params||{},t=e.field??"",o=e.value;if(!t){I.warn("[updateCountryField] field is empty");return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=F(a);if(s>=i.length){I.warn("[updateCountryField] Index out of bounds:",s);return}i[s]={...i[s],[t]:o},E(c,{"form.country_settings":i}),I.log("[updateCountryField] Updated",t,"=",o,"at index:",s)}function Bn(r,c){const e=r.params||{},t=e.value??"",o=Number(e.index??0);if(!t){I.warn("[onChargePolicyChange] charge_policy value is empty");return}const a=G();if(!a)return;const n=te(t);I.log("[onChargePolicyChange]",t,n,"countryIndex:",o);const s=F(a),i=s[o];if(!i){I.warn("[onChargePolicyChange] No country setting at index:",o);return}const l={...i,charge_policy:t};if(n.showBaseFee||(l.base_fee=0),n.showFreeThreshold||(l.free_threshold=null),!n.showRanges&&!n.showUnitValue&&(l.ranges=null),n.showApiSettings||(l.api_endpoint=null,l.api_request_fields=null,l.api_response_fee_field=null,l.api_config=null),n.showRanges&&(!l.ranges||!l.ranges.tiers)){const d=t.replace("range_","");l.ranges={type:d,tiers:[{min:0,max:null,fee:0}]}}n.showUnitValue&&(!l.ranges||!l.ranges.unit_value)&&(l.ranges={type:t,unit_value:1}),s[o]=l;const u={"form.country_settings":s,...n};E(c,u)}function Kn(r,c){const e=r.params||{},t=Number(e.index??0),o=G();if(!o)return;const a=F(o),n=a[t];if(!n){I.warn("[addRangeTier] No country setting at index:",t);return}const s=[...n.ranges?.tiers??[]],i=s[s.length-1],u={min:i?.max!=null?i.max+1:0,max:null,fee:0};s.push(u),a[t]={...n,ranges:{...n.ranges??{},tiers:s}},E(c,{"form.country_settings":a}),xe(o,c,t,s,n.country_code),I.log("[addRangeTier] Added tier, total:",s.length)}function Qn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex);if(isNaN(o)||o<0){I.warn("[removeRangeTier] Invalid tierIndex:",e.tierIndex);return}const a=G();if(!a)return;const n=F(a),s=n[t];if(!s){I.warn("[removeRangeTier] No country setting at index:",t);return}const i=[...s.ranges?.tiers??[]];if(o>=i.length){I.warn("[removeRangeTier] tierIndex out of bounds:",o);return}i.splice(o,1),n[t]={...s,ranges:{...s.ranges??{},tiers:i}},E(c,{"form.country_settings":n}),xe(a,c,t,i,s.country_code),I.log("[removeRangeTier] Removed tier:",o,"remaining:",i.length)}function zn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=Number(e.tierIndex),a=e.field,n=e.value;if(isNaN(o)||o<0){I.warn("[updateRangeTierField] Invalid tierIndex:",e.tierIndex);return}if(!["min","max","fee"].includes(a)){I.warn("[updateRangeTierField] Invalid field:",a);return}const s=G();if(!s)return;const i=F(s),l=i[t];if(!l){I.warn("[updateRangeTierField] No country setting at index:",t);return}const u=[...l.ranges?.tiers??[]];if(o>=u.length){I.warn("[updateRangeTierField] tierIndex out of bounds:",o);return}let d;a==="max"?d=n===""||n===null||n===void 0?null:Number(n):d=Number(n),u[o]={...u[o],[a]:d},i[t]={...l,ranges:{...l.ranges??{},tiers:u}},E(c,{"form.country_settings":i}),xe(s,c,t,u,l.country_code)}function Yn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=G();if(!o)return;const n=F(o)[t];if(!n)return;const s=n.ranges?.tiers??[];xe(o,c,t,s,n.country_code)}function xe(r,c,e,t,o){const n={...(r.state.getLocal?.()??{}).rangeErrors??{}};if(t.length===0){n[o]=[],E(c,{rangeErrors:n});return}const s=new Array(t.length).fill(null).map(()=>({}));let i=!1;const l=r.t;for(let u=0;u=d.max&&(s[u].min=l?.("sirsoft-ecommerce.validation.shipping_policy.ranges.min_less_than_max")??"시작값이 종료값보다 작아야 합니다.",i=!0),u=i.length){I.warn("[removeExtraFeeRow] feeIndex out of bounds:",o);return}i.splice(o,1),n[t]={...s,extra_fee_settings:i},E(c,{"form.country_settings":n}),I.log("[removeExtraFeeRow] Removed index:",o,"remaining:",i.length)}function Xn(r,c){const e=r.params||{},t=Number(e.countryIndex??0),o=e.settings;if(!o||!Array.isArray(o)){I.warn("[applyExtraFeeTemplate] Invalid settings:",o);return}const a=G();if(!a)return;const n=F(a),s=n[t];if(!s){I.warn("[applyExtraFeeTemplate] No country setting at index:",t);return}n[t]={...s,extra_fee_settings:o},E(c,{"form.country_settings":n});const i=a.t,l=i?.("sirsoft-ecommerce.admin.shipping_policy.form.template_applied")??"템플릿이 적용되었습니다.";a.toast?.success?.(l),I.log("[applyExtraFeeTemplate] Applied template with",o.length,"rows")}function Zn(r,c){const e=r.params||{},t=parseFloat(e.value)||1,o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];i&&(s[n]={...i,ranges:{...i.ranges??{},type:i.charge_policy,unit_value:t}},E(c,{"form.country_settings":s}),I.log("[updateUnitValue] Updated unit_value:",t))}function ea(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),a=F(e),n=a[o];if(!n)return;const s=[...n.api_request_fields??[]];s.push(""),a[o]={...n,api_request_fields:s},E(c,{"form.country_settings":a}),I.log("[addApiRequestField] Added field, total:",s.length)}function ta(r,c){const e=r.params||{},t=Number(e.fieldIndex),o=e.value??"";if(isNaN(t)||t<0){I.warn("[updateApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const a=G();if(!a)return;const n=a.state.getLocal?.()??{},s=Number(n.activeCountryTab??0),i=F(a),l=i[s];if(!l)return;const u=[...l.api_request_fields??[]];t>=u.length||(u[t]=o,i[s]={...l,api_request_fields:u},E(c,{"form.country_settings":i}),I.log("[updateApiRequestField] Updated index:",t,"value:",o))}function oa(r,c){const e=r.params||{},t=Number(e.fieldIndex);if(isNaN(t)||t<0){I.warn("[removeApiRequestField] Invalid fieldIndex:",e.fieldIndex);return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=[...i.api_request_fields??[]];t>=l.length||(l.splice(t,1),s[n]={...i,api_request_fields:l},E(c,{"form.country_settings":s}),I.log("[removeApiRequestField] Removed index:",t,"remaining:",l.length))}function ra(r,c){const t=(r.params||{}).field??"";if(!t){I.warn("[toggleApiRequestField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=[...i.api_request_fields??[]],u=l.indexOf(t);u>=0?l.splice(u,1):l.push(t);const d=l.length>0?l:null;s[n]={...i,api_request_fields:d},E(c,{"form.country_settings":s}),I.log("[toggleApiRequestField] Toggled:",t,"selected:",d)}function na(r,c){const e=r.params||{},t=e.field??"";if(!t){I.warn("[updateApiConfigField] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l={...i.api_config??{},[t]:e.value};s[n]={...i,api_config:l},E(c,{"form.country_settings":s}),I.log("[updateApiConfigField] Updated",t,"at index:",n)}function aa(r,c){const e=r.params||{},t=e.field??"";if(!t){I.warn("[updateApiFieldMap] Missing field param");return}const o=G();if(!o)return;const a=o.state.getLocal?.()??{},n=Number(a.activeCountryTab??0),s=F(o),i=s[n];if(!i)return;const l=e.value??"",u={...i.api_config?.field_map??{}};l.trim()===""?delete u[t]:u[t]=l;const d={...i.api_config??{},field_map:Object.keys(u).length>0?u:null};s[n]={...i,api_config:d},E(c,{"form.country_settings":s}),I.log("[updateApiFieldMap] Mapped",t,"→",l||"(removed)")}function sa(r,c){const e=G();if(!e)return;const t=e.state.getLocal?.()??{},o=Number(t.activeCountryTab??0),n=F(e)[o];if(n){if(!n.api_endpoint){e.toast?.warning?.(e.t?.("sirsoft-ecommerce.admin.shipping_policy.form.api_test_endpoint_required")??"Endpoint required");return}e.state.set?.({apiTestLoading:!0,apiTestResult:null}),e.dispatch({handler:"apiCall",target:"/api/modules/sirsoft-ecommerce/admin/shipping-policies/test-api-call",auth_required:!0,params:{method:"POST",body:{endpoint:n.api_endpoint,request_fields:n.api_request_fields,config:n.api_config??{},sample:{country_code:n.country_code}}},onSuccess:{handler:"setState",params:{target:"global",apiTestLoading:!1,apiTestResult:"{{response.data}}"}},onError:{handler:"setState",params:{target:"global",apiTestLoading:!1,apiTestResult:"{{error.errors ?? error}}"}}})}}function ia(r,c){const e=r.params||{},t=Number(e.feeIndex),o=e.field,a=e.value;if(isNaN(t)||t<0){I.warn("[updateExtraFeeField] Invalid feeIndex:",e.feeIndex);return}if(!["zipcode","fee","region"].includes(o)){I.warn("[updateExtraFeeField] Invalid field:",o);return}const n=G();if(!n)return;const s=n.state.getLocal?.()??{},i=Number(s.activeCountryTab??0),l=F(n),u=l[i];if(!u)return;const d=[...u.extra_fee_settings??[]];if(t>=d.length)return;const m=o==="fee"?parseFloat(a)||0:a;d[t]={...d[t],[o]:m},l[i]={...u,extra_fee_settings:d},E(c,{"form.country_settings":l}),I.log("[updateExtraFeeField] Updated index:",t,o,"=",m)}const fe=window.G7Core?.createLogger?.("Handler:InitCategory")??{log:(...r)=>console.log("[Handler:InitCategory]",...r),warn:(...r)=>console.warn("[Handler:InitCategory]",...r),error:(...r)=>console.error("[Handler:InitCategory]",...r)};function _t(r,c){for(const e of r){if(e.id===c)return e;if(e.children&&e.children.length>0){const t=_t(e.children,c);if(t)return t}}return null}function wt(r){return new URLSearchParams(window.location.search).get(r)}function ca(r){return window.G7Core?.state?.getDataSource?.(r)}async function la(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function da(r,c){const e=window.G7Core,t=wt("id"),o=wt("mode");if(!t){fe.log("[initCategoryFromUrl] No id parameter in URL");return}if(!e?.state?.set){fe.warn("[initCategoryFromUrl] G7Core.state.set not available");return}const a=await la("categories",l=>l?.data?.data);if(!a){fe.warn("[initCategoryFromUrl] categories data not available after waiting");return}const n=parseInt(t,10),s=_t(a,n);if(!s){fe.warn("[initCategoryFromUrl] Category not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedCategoryId:s.id,selectedCategory:s,panelMode:i}),fe.log("[initCategoryFromUrl] Category initialized from URL:",{id:n,mode:i})}const ge=window.G7Core?.createLogger?.("Handler:InitBrand")??{log:(...r)=>console.log("[Handler:InitBrand]",...r),warn:(...r)=>console.warn("[Handler:InitBrand]",...r),error:(...r)=>console.error("[Handler:InitBrand]",...r)};function yt(r){return new URLSearchParams(window.location.search).get(r)}function ua(r){return window.G7Core?.state?.getDataSource?.(r)}async function ma(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function pa(r,c){const e=window.G7Core,t=yt("id"),o=yt("mode");if(!t){ge.log("[initBrandFromUrl] No id parameter in URL");return}if(!e?.state?.set){ge.warn("[initBrandFromUrl] G7Core.state.set not available");return}const a=await ma("brands",l=>l?.data?.data);if(!a){ge.warn("[initBrandFromUrl] brands data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){ge.warn("[initBrandFromUrl] Brand not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedBrandId:s.id,selectedBrand:s,panelMode:i}),ge.log("[initBrandFromUrl] Brand initialized from URL:",{id:n,mode:i})}const _e=window.G7Core?.createLogger?.("Handler:InitCommonInfo")??{log:(...r)=>console.log("[Handler:InitCommonInfo]",...r),warn:(...r)=>console.warn("[Handler:InitCommonInfo]",...r),error:(...r)=>console.error("[Handler:InitCommonInfo]",...r)};function ht(r){return new URLSearchParams(window.location.search).get(r)}function fa(r){return window.G7Core?.state?.getDataSource?.(r)}async function ga(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function _a(r,c){const e=window.G7Core,t=ht("id"),o=ht("mode");if(!t){_e.log("[initCommonInfoFromUrl] No id parameter in URL");return}if(!e?.state?.set){_e.warn("[initCommonInfoFromUrl] G7Core.state.set not available");return}const a=await ga("commonInfos",l=>l?.data);if(!a){_e.warn("[initCommonInfoFromUrl] commonInfos data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){_e.warn("[initCommonInfoFromUrl] Common info not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedId:s.id,selectedItem:s,panelMode:i}),_e.log("[initCommonInfoFromUrl] Common info initialized from URL:",{id:n,mode:i})}const we=window.G7Core?.createLogger?.("Handler:InitNotice")??{log:(...r)=>console.log("[Handler:InitNotice]",...r),warn:(...r)=>console.warn("[Handler:InitNotice]",...r),error:(...r)=>console.error("[Handler:InitNotice]",...r)};function Ct(r){return new URLSearchParams(window.location.search).get(r)}function wa(r){return window.G7Core?.state?.getDataSource?.(r)}async function ya(r,c,e=30,t=100){for(let o=0;o0)return n;await new Promise(s=>setTimeout(s,t))}return null}async function ha(r,c){const e=window.G7Core,t=Ct("id"),o=Ct("mode");if(!t){we.log("[initNoticeFromUrl] No id parameter in URL");return}if(!e?.state?.set){we.warn("[initNoticeFromUrl] G7Core.state.set not available");return}const a=await ya("templates",l=>l?.data);if(!a){we.warn("[initNoticeFromUrl] templates data not available after waiting");return}const n=parseInt(t,10),s=a.find(l=>l.id===n);if(!s){we.warn("[initNoticeFromUrl] Template not found with id:",n);return}const i=o==="edit"?"edit":"view";e.state.set({selectedTemplateId:s.id,selectedTemplate:s,panelMode:i}),we.log("[initNoticeFromUrl] Template initialized from URL:",{id:n,mode:i})}const oe={updateProductField:Gt,updateOptionField:Et,calculateCurrencyPrices:se,initPreferredCurrency:Mt,initPreferredShippingCountry:Vt,setDateRange:Bt,setDefaultOption:Kt,toggleOption:Qt,toggleProductOptions:Xe,toggleAllOptionsInRow:zt,getProductOptionStates:Yt,syncProductSelection:Wt,loadExpandedOptions:De,retryExpandedOptions:ro,generateCopyProductCode:Jt,copyProduct:Xt,selectCategory:ao,selectCategoryMobile:so,addCategoryToSelection:io,removeCategoryFromSelection:co,getCategoryBreadcrumb:lo,validateCategoryPath:mo,initCategoryInfosFromProduct:uo,getBrandName:po,getBrandDescription:fo,updatePrice:yo,calculateTotalOptionStock:ho,validatePriceRelation:Co,addOptionInput:Fo,removeOptionInput:Do,updateOptionInput:$o,generateOptions:No,deleteOption:To,applyOptionAddTool:Ro,addRequiredItem:Mo,updateRequiredItem:Ho,removeRequiredItem:qo,reorderRequiredItems:Uo,addAdditionalOption:Vo,updateAdditionalOption:jo,removeAdditionalOption:Bo,reorderAdditionalOptions:Ko,clearAdditionalOptions:Qo,addAdditionalOptionValue:zo,updateAdditionalOptionValue:Yo,removeAdditionalOptionValue:Wo,uploadImages:er,setThumbnail:tr,reorderImages:or,updateDescription:rr,confirmSelectNoticeTemplate:nr,selectNoticeTemplate:st,updateNoticeItem:ar,removeNoticeItem:sr,reorderNoticeItems:ir,fillNoticeWithValue:cr,switchNoticeMode:lr,updateNewTemplateName:dr,addNoticeItem:ur,updateNoticeItemName:mr,saveAsNoticeTemplate:pr,confirmSaveNoticeTemplate:_r,fillTemplateFieldsWithDetailReference:fr,fillNoticeItemsWithDetailReference:gr,toggleLabel:wr,generateProductCode:yr,getShippingPolicyInfo:hr,getCommonInfoContent:Cr,updateShoppingIntegration:br,updateShippingType:Ir,updateIdentificationCode:Sr,openLabelPeriodModal:vr,saveLabelPeriod:Lr,removeLabelPeriod:xr,updateActivityLogSort:Pr,updateActivityLogPerPage:Ar,setDefaultShippingPolicy:kr,setLabelDatePreset:Gr,toggleDefaultShippingPolicy:Or,toggleLabelAssignment:Er,saveLabelSettings:Tr,deleteLabel:Nr,updateLabelPeriodInline:Fr,setLabelDatePresetInline:Dr,confirmUncheckLabel:$r,removeDescriptionLocale:Rr,showAddLocaleModal:Mr,addDescriptionLocale:Hr,setDefaultOptionFromGrid:vo,addOptionRow:xo,updateFormOptionField:Lo,recalculateOptionPriceAdjustments:Po,bulkUpdate:qr,buildConfirmData:jr,buildOrderColumns:Br,toggleArrayValue:Kr,toggleVisibleFilter:Qr,syncOrderSelection:zr,handleOrderRowAction:Yr,processOrderBulkAction:Wr,buildOrderBulkConfirmData:tn,executeOrderBulkAction:on,downloadOrderExcel:Jr,saveVisibleColumns:Xr,loadVisibleColumns:Zr,loadVisibleFilters:en,handleProductRowAction:rn,initOrderDetailForm:nn,toggleProductSelection:an,toggleAllProducts:sn,buildOrderDetailBulkConfirmData:cn,processOrderDetailBulkChange:ln,saveAdminMemo:dn,updateChangeQuantity:un,openConfirmDepositModal:mn,confirmDeposit:pn,initShippingPolicyForm:Hn,addCountrySetting:qn,removeCountrySetting:Un,switchCountryTab:Vn,updateCountryField:jn,onChargePolicyChange:Bn,addRangeTier:Kn,removeRangeTier:Qn,updateRangeTierField:zn,validateRangeTiers:Yn,addExtraFeeRow:Wn,removeExtraFeeRow:Jn,applyExtraFeeTemplate:Xn,updateUnitValue:Zn,addApiRequestField:ea,updateApiRequestField:ta,removeApiRequestField:oa,toggleApiRequestField:ra,updateApiConfigField:na,updateApiFieldMap:aa,testShippingApi:sa,updateExtraFeeField:ia,updateCancelQuantity:gn,estimateRefundAmount:_n,changeRefundPriority:wn,executeCancelOrder:yn,clearCancelOrderTimers:fn,toggleItemSelection:Cn,toggleSelectAllItems:bn,initUserCancelItems:In,toggleUserCancelItem:Sn,toggleUserCancelSelectAll:vn,updateUserCancelQuantity:Ln,estimateUserRefund:xn,changeUserRefundPriority:Pn,executeUserCancelOrder:An,clearUserCancelOrderTimers:hn,confirmOrderOption:On,changeShippingAddress:En,submitReview:Fn,initCategoryFromUrl:da,initBrandFromUrl:pa,initCommonInfoFromUrl:_a,initNoticeFromUrl:ha},V="sirsoft-ecommerce",ye=window.G7Core?.createLogger?.(`Module:${V}`)??{log:(...r)=>console.log(`[Module:${V}]`,...r),warn:(...r)=>console.warn(`[Module:${V}]`,...r),error:(...r)=>console.error(`[Module:${V}]`,...r)};function bt(r=!1){const c=window.G7Core?.getActionDispatcher?.();if(c)Object.entries(oe).forEach(([e,t])=>{const o=`${V}.${e}`;c.registerHandler(o,t,{category:"module",source:V})}),ye.log(`${Object.keys(oe).length} handler(s) registered:`,Object.keys(oe).map(e=>`${V}.${e}`));else if(r){let e=0;const t=50,o=()=>{const a=window.G7Core?.getActionDispatcher?.();a?(Object.entries(oe).forEach(([n,s])=>{const i=`${V}.${n}`;a.registerHandler(i,s,{category:"module",source:V})}),ye.log(`${Object.keys(oe).length} handler(s) registered:`,Object.keys(oe).map(n=>`${V}.${n}`))):(e++,e<=t?(ye.warn(`ActionDispatcher not found, retrying... (${e}/${t})`),setTimeout(o,100)):ye.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};o()}else ye.warn("ActionDispatcher not found, handlers not registered")}function Be(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>bt(!0));else{const r=!!window.G7Core?.getActionDispatcher?.();bt(!r)}}return Be(),typeof window<"u"&&(window.__SirsoftEcommerce={identifier:V,handlers:Object.keys(oe),initModule:Be}),Ae.initModule=Be,Object.defineProperty(Ae,Symbol.toStringTag,{value:"Module"}),Ae}({}); +//# sourceMappingURL=module.iife.js.map diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/README.md b/modules/_bundled/sirsoft-ecommerce/docs/api/README.md index 593b69e1..f7b8c9bd 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/README.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/README.md @@ -4,7 +4,7 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 33 · **엔드포인트 수**: 231 +- **문서 수**: 33 · **엔드포인트 수**: 239 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | @@ -19,19 +19,19 @@ | [currency.md](currency.md) | `currency` | 2 | | [dashboard.md](dashboard.md) | `dashboard` | 4 | | [extra-fee-templates.md](extra-fee-templates.md) | `extra-fee-templates` | 10 | -| [guest.md](guest.md) | `guest` | 5 | +| [guest.md](guest.md) | `guest` | 7 | | [inquiries.md](inquiries.md) | `inquiries` | 10 | | [mileage.md](mileage.md) | `mileage` | 3 | | [mileage-transactions.md](mileage-transactions.md) | `mileage-transactions` | 5 | | [options.md](options.md) | `options` | 3 | -| [orders.md](orders.md) | `orders` | 22 | +| [orders.md](orders.md) | `orders` | 27 | | [payments.md](payments.md) | `payments` | 1 | | [presets.md](presets.md) | `presets` | 4 | | [product-common-infos.md](product-common-infos.md) | `product-common-infos` | 6 | | [product-image.md](product-image.md) | `product-image` | 1 | | [product-labels.md](product-labels.md) | `product-labels` | 6 | | [product-notice-templates.md](product-notice-templates.md) | `product-notice-templates` | 7 | -| [products.md](products.md) | `products` | 30 | +| [products.md](products.md) | `products` | 31 | | [promotion-coupons.md](promotion-coupons.md) | `promotion-coupons` | 9 | | [review-image.md](review-image.md) | `review-image` | 1 | | [reviews.md](reviews.md) | `reviews` | 12 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md index dd125472..91c9fa62 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Addresses 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -42,7 +42,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| addresses | object | `{"data":[{"id":263,"user_id":"a1e0a91a-fba6-491c-a53e-728…` | 회원 본인 소유 배송지 컬렉션 (`data[]` 배송지 항목 배열 + `abilities.can_create` — UserAddressCollection 파생) | +| addresses | object | `{"data":[],"abilities":{"can_create":true}}` | 회원 본인 소유 배송지 컬렉션 (`data[]` 배송지 항목 배열 + `abilities.can_create` — UserAddressCollection 파생) | **응답 예시** @@ -56,39 +56,7 @@ HTTP/1.1 200 "message": "배송지 목록을 조회했습니다.", "data": { "addresses": { - "data": [ - { - "id": 1, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 배송지", - "recipient_name": "원진수", - "recipient_phone": "010-4988-7051", - "country_code": "KR", - "country_name": { - "ko": "한국", - "en": "South Korea" - }, - "zipcode": "27058", - "address": "충청남도 남양주시 테헤란로 6114", - "address_detail": "Quidem natus iste.", - "address_line_1": null, - "address_line_2": null, - "city": null, - "state": null, - "postal_code": null, - "is_default": false, - "is_domestic": true, - "is_international": false, - "full_address": "충청남도 남양주시 테헤란로 6114 Quidem natus iste.", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_update": true, - "can_delete": true, - "can_set_default": true - } - } - ], + "data": [], "abilities": { "can_create": true } @@ -174,7 +142,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| address | object | `{"id":9,"user_id":"a234c2b1-cde8-437f-b28b-23323be2b98d",…` | 기본주소 (국내) | +| address | object | `{"id":99,"user_id":"a26219fc-94a0-4f63-9404-04c2a6ac99e4"…` | 기본주소 (국내) | **응답 예시** @@ -188,8 +156,8 @@ HTTP/1.1 201 "message": "배송지가 추가되었습니다.", "data": { "address": { - "id": 9, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "id": 99, + "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", "name": "실측 예시값", "recipient_name": "실측 예시값", "recipient_phone": "실측 예시값", @@ -210,8 +178,8 @@ HTTP/1.1 201 "is_domestic": true, "is_international": false, "full_address": "실측 예시값 실측 예시값", - "created_at": "2026-07-08 15:00:36", - "updated_at": "2026-07-08 15:00:36", + "created_at": "2026-08-05 09:46:56", + "updated_at": "2026-08-05 09:46:56", "abilities": { "can_update": true, "can_delete": false, @@ -249,7 +217,7 @@ HTTP/1.1 201 **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/user/addresses/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/user/addresses/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -257,23 +225,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('sirsoft-ecommerce::messages.address.deleted')` 를 데이터 인수 없이 호출하므로 `data` 는 `null` 입니다._ **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "배송지가 삭제되었습니다.", - "data": null -} -``` + **에러 응답** @@ -302,7 +258,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/user/addresses/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/user/addresses/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -310,57 +266,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| address | object | `{"id":1,"user_id":"a234c2b1-cde8-437f-b28b-23323be2b98d",…` | 기본주소 (국내) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "배송지 정보를 조회했습니다.", - "data": { - "address": { - "id": 1, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 배송지", - "recipient_name": "원진수", - "recipient_phone": "010-4988-7051", - "country_code": "KR", - "country_name": { - "ko": "한국", - "en": "South Korea" - }, - "zipcode": "27058", - "address": "충청남도 남양주시 테헤란로 6114", - "address_detail": "Quidem natus iste.", - "address_line_1": null, - "address_line_2": null, - "city": null, - "state": null, - "postal_code": null, - "is_default": false, - "is_domestic": true, - "is_international": false, - "full_address": "충청남도 남양주시 테헤란로 6114 Quidem natus iste.", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_update": true, - "can_delete": true, - "can_set_default": true - } - } - } -} -``` + **에러 응답** @@ -407,7 +317,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/user/addresses/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/user/addresses/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -435,65 +345,19 @@ Content-Type: application/json **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| address | object | `{"id":1,"user_id":"a234c2b1-cde8-437f-b28b-23323be2b98d",…` | 기본주소 (국내) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "배송지가 수정되었습니다.", - "data": { - "address": { - "id": 1, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "실측 예시값", - "recipient_name": "실측 예시값", - "recipient_phone": "실측 예시값", - "country_code": "KR", - "country_name": { - "ko": "한국", - "en": "South Korea" - }, - "zipcode": "실측 예시값", - "address": "실측 예시값", - "address_detail": "실측 예시값", - "address_line_1": null, - "address_line_2": null, - "city": "실측 예시값", - "state": null, - "postal_code": null, - "is_default": true, - "is_domestic": true, - "is_international": false, - "full_address": "실측 예시값 실측 예시값", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:36", - "abilities": { - "can_update": true, - "can_delete": false, - "can_set_default": false - } - } - } -} -``` + **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -515,7 +379,7 @@ HTTP/1.1 200 **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/user/addresses/1/default HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/user/addresses/{id}/default HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -523,23 +387,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만). 컨트롤러가 `ResponseHelper::success('sirsoft-ecommerce::messages.address.default_set')` 를 데이터 인수 없이 호출하므로 `data` 는 `null` 입니다. 변경된 목록은 배송지 목록 조회(`GET /user/addresses`)로 재조회합니다._ **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "기본 배송지로 설정되었습니다.", - "data": null -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md b/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md index d26992e3..e168d6d8 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Brands 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -32,7 +32,7 @@ | sort | query | string | 아니오 | `name_asc`, `name_desc`, `created_asc`, `created_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | | sort_by | query | string | 아니오 | `name`, `sort_order` | 정렬 기준 필드명 | | sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | -| locale | query | string | 아니오 | `ko`, `en`, `fr`, `ja` | 로케일 코드 (표시 언어/지역) | +| locale | query | string | 아니오 | `ko`, `en` | 로케일 코드 (표시 언어/지역) | **요청 예시** @@ -49,21 +49,21 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `43` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | integer | `127` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | -| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | +| number | integer | `42` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `20` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"ASUS","en":"ASUS"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `ASUS` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| slug | string | `asus` | URL 친화 식별자 (slug) | +| url | string | `asus` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | | website | string | `https://www.asus.com` | 브랜드 공식 웹사이트 URL | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| sort_order | integer | `20` | 표시 정렬 순서 값 (작을수록 우선) | | is_active | boolean | `true` | active 여부 | | icon | string | `tag` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| created_at | string | `2026-07-30 23:35:46` | 생성 일시 | +| updated_at | string | `2026-07-30 23:35:46` | 최종 수정 일시 | | creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | | updater | array | `[]` | 수정자 정보 객체 (id/name — updater 관계 파생, 로드 시에만 포함) | -| products_count | integer | `0` | products 개수 (집계) | +| products_count | integer | `1` | products 개수 (집계) | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -79,21 +79,46 @@ HTTP/1.1 200 "data": { "data": [ { - "number": 1, - "id": 1, + "number": 42, + "id": 20, "name": { - "ko": "API 문서 샘플 브랜드", - "en": "API Doc Sample Brand" + "ko": "ASUS", + "en": "ASUS" }, - "localized_name": "API 문서 샘플 브랜드", - "slug": "apidoc-sample-brand", - "url": "apidoc-sample-brand", - "website": null, - "sort_order": 0, + "localized_name": "ASUS", + "slug": "asus", + "url": "asus", + "website": "https://www.asus.com", + "sort_order": 20, "is_active": true, "icon": "tag", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", + "created_at": "2026-07-30 23:35:46", + "updated_at": "2026-07-30 23:35:46", + "creator": [], + "updater": [], + "products_count": 1, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + { + "number": 41, + "id": 26, + "name": { + "ko": "CJ제일제당", + "en": "CJ CheilJedang" + }, + "localized_name": "CJ제일제당", + "slug": "cj", + "url": "cj", + "website": "https://www.cj.co.kr", + "sort_order": 26, + "is_active": true, + "icon": "tag", + "created_at": "2026-07-30 23:35:46", + "updated_at": "2026-07-30 23:35:46", "creator": [], "updater": [], "products_count": 0, @@ -102,7 +127,8 @@ HTTP/1.1 200 "can_update": true, "can_delete": true } - } + }, + "... (총 42건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -166,11 +192,60 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`BrandResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 브랜드의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 브랜드 이름 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (표시용 문자열) | +| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | +| website | string \| null | `null` | 브랜드 공식 웹사이트 URL (미입력 시 null) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | 활성 여부 | +| icon | string | `tag` | SortableMenuList 컴포넌트용 아이콘 식별자 (고정값 `tag`) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| products_count | integer | `0` | 브랜드에 연결된 상품 수 (생성 직후이므로 0) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 생성 직후에는 `creator`/`updater` 관계가 로드되지 않으므로(`whenLoaded`) 응답에서 생략됩니다. **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "브랜드가 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 브랜드", + "en": "API Doc Sample Brand" + }, + "localized_name": "API 문서 샘플 브랜드", + "slug": "apidoc-sample-brand", + "url": "apidoc-sample-brand", + "website": null, + "sort_order": 0, + "is_active": true, + "icon": "tag", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "products_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -200,7 +275,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/brands/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/brands/{brand} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -208,29 +283,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| brand_id | integer | `1` | brand 식별자 (연관 리소스 참조) | -| products_count | integer | `0` | products 개수 (집계) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "브랜드가 삭제되었습니다.", - "data": { - "brand_id": 1, - "products_count": 0 - } -} -``` + **에러 응답** @@ -267,7 +324,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/brands/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/brands/{brand} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -286,11 +343,60 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`BrandResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 브랜드의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 브랜드 이름 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (표시용 문자열) | +| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | +| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | +| website | string \| null | `https://www.asus.com` | 브랜드 공식 웹사이트 URL (미입력 시 null) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | 활성 여부 | +| icon | string | `tag` | SortableMenuList 컴포넌트용 아이콘 식별자 (고정값 `tag`) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 15:00:16` | 최종 수정 일시 (이번 수정 시각으로 갱신) | +| products_count | integer | `0` | 브랜드에 연결된 상품 수 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 수정 직후에는 `creator`/`updater` 관계가 로드되지 않으므로(`whenLoaded`) 응답에서 생략됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "브랜드가 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 브랜드", + "en": "API Doc Sample Brand" + }, + "localized_name": "API 문서 샘플 브랜드", + "slug": "apidoc-sample-brand", + "url": "apidoc-sample-brand", + "website": "https://www.asus.com", + "sort_order": 0, + "is_active": true, + "icon": "tag", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:16", + "products_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -298,8 +404,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -321,7 +427,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/brands/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/brands/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -329,58 +435,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | -| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | -| website | null | `null` | 브랜드 웹사이트 URL | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| is_active | boolean | `true` | active 여부 | -| icon | string | `tag` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| products_count | integer | `0` | products 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "브랜드 정보를 조회했습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 브랜드", - "en": "API Doc Sample Brand" - }, - "localized_name": "API 문서 샘플 브랜드", - "slug": "apidoc-sample-brand", - "url": "apidoc-sample-brand", - "website": null, - "sort_order": 0, - "is_active": true, - "icon": "tag", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "products_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -410,7 +469,7 @@ HTTP/1.1 200 **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/brands/1/toggle-status HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/brands/{id}/toggle-status HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -418,58 +477,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 브랜드","en":"API Doc Sample Brand"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 브랜드` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| slug | string | `apidoc-sample-brand` | URL 친화 식별자 (slug) | -| url | string | `apidoc-sample-brand` | SortableMenuItem 표시용 URL (slug 값을 그대로 노출) | -| website | null | `null` | 브랜드 웹사이트 URL | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| is_active | boolean | `false` | active 여부 | -| icon | string | `tag` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:16` | 최종 수정 일시 | -| products_count | integer | `0` | products 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "sirsoft-ecommerce::messages.brands.status_changed", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 브랜드", - "en": "API Doc Sample Brand" - }, - "localized_name": "API 문서 샘플 브랜드", - "slug": "apidoc-sample-brand", - "url": "apidoc-sample-brand", - "website": null, - "sort_order": 0, - "is_active": false, - "icon": "tag", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:16", - "products_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md index bbfd7d86..326e199a 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/cart.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Cart 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -42,17 +42,34 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `2` | 실제로 삭제된 장바구니 아이템 건수 (`CartService::deleteItems()` 반환값. 성공 메시지의 `:deleted_count` 치환값과 동일) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "2개 상품이 삭제되었습니다.", + "data": { + "deleted_count": 2 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`ids` 누락/빈 배열, 존재하지 않는 장바구니 ID — `error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 삭제 처리 중 예외 발생 (`messages.cart.delete_failed`) | @@ -88,10 +105,10 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| items | array | `[{"id":962,"product_id":201,"product_option_id":1086,"qua…` | 장바구니 라인 아이템 목록 (상품·옵션·수량 등 — CartItemResource 파생) | -| item_ids | array | `[962]` | item 식별자 배열 (연관 리소스 참조) | -| item_count | integer | `1` | item 개수 (집계) | -| calculation | object | `{"items":[{"product_id":201,"product_option_id":1086,"pro…` | 선택 아이템 기준 금액 계산 결과 (상품 소계·할인·배송비 등 — OrderCalculationResult 파생) | +| items | array | `[]` | 장바구니 라인 아이템 목록 (상품·옵션·수량 등 — CartItemResource 파생) | +| item_ids | array | `[]` | item 식별자 배열 (연관 리소스 참조) | +| item_count | integer | `0` | item 개수 (집계) | +| calculation | object | `{"items":[],"summary":{"subtotal":0,"subtotal_formatted":…` | 선택 아이템 기준 금액 계산 결과 (상품 소계·할인·배송비 등 — OrderCalculationResult 파생) | | has_unshippable_items | boolean | `false` | unshippable items 여부 | | selected_shipping_country | string | `KR` | 배송비 계산에 적용된 배송 국가 코드 (ResolveShippingCountry 해석 결과) | @@ -113,39 +130,40 @@ HTTP/1.1 200 "items": [], "summary": { "subtotal": 0, - "subtotal_formatted": "0원", + "subtotal_formatted": "¥0", "product_coupon_discount": 0, - "product_coupon_discount_formatted": "0원", + "product_coupon_discount_formatted": "¥0", "code_discount": 0, - "code_discount_formatted": "0원", + "code_discount_formatted": "¥0", "order_coupon_discount": 0, - "order_coupon_discount_formatted": "0원", + "order_coupon_discount_formatted": "¥0", "total_coupon_discount": 0, - "total_coupon_discount_formatted": "0원", + "total_coupon_discount_formatted": "¥0", "total_discount": 0, - "discount_formatted": "0원", + "discount_formatted": "¥0", "base_shipping_total": 0, "extra_shipping_total": 0, "total_shipping": 0, "total_shipping_fee": 0, - "shipping_fee_formatted": "0원", + "shipping_fee_formatted": "¥0", "shipping_discount": 0, - "shipping_discount_formatted": "0원", + "shipping_discount_formatted": "¥0", "taxable_amount": 0, "tax_free_amount": 0, + "vat_amount": 0, "points_earning": 0, "total_mileage": 0, "mileage_formatted": "0P", "points_used": 0, - "points_used_formatted": "0원", + "points_used_formatted": "¥0", "payment_amount": 0, - "payment_amount_formatted": "0원", + "payment_amount_formatted": "¥0", "final_amount": 0, - "final_amount_formatted": "0원", + "final_amount_formatted": "¥0", "coupon_discount": 0, - "coupon_discount_formatted": "0원", + "coupon_discount_formatted": "¥0", "order_discount": 0, - "order_discount_formatted": "0원" + "order_discount_formatted": "¥0" }, "promotions": { "coupon_issue_ids": [], @@ -215,17 +233,108 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[{"id":962,"product_id":201,"quantity":1, …}]` | 이번 요청으로 담긴 장바구니 아이템 목록 (CartItemResource — 아래 "장바구니 아이템 필드" 참조) | +| cart_count | integer | `3` | 담기 후 장바구니에 들어있는 전체 아이템 수 (`CartService::bulkAddToCart()` 반환값) | + +> 추가옵션 추가금(`price_adjustment`)은 쇼핑몰 **기본 통화** 기준 값입니다. 기본 통화가 아닌 통화로 표시할 때는 `multi_currency_price_adjustment` 의 해당 통화 값을 사용하세요 — 통화코드별로 `{ price, formatted, is_default, exchange_rate }` 를 담으며 `formatted` 에는 부호(`+`/`-`)가 붙습니다. 상품가·옵션가의 `multi_currency_*` 와 같은 형태입니다. + +> 담기 응답의 아이템은 목록 조회(`GET /cart`)가 돌려주는 아이템과 **동일한 필드·동일한 값**을 가집니다. 추가옵션을 선택해 담았다면 `additional_options`·`additional_options_total` 이 채워지고 그 금액이 `subtotal` 에 반영되므로, 담기 직후 목록을 다시 조회하지 않고 이 응답만으로 화면을 갱신해도 됩니다. + +**장바구니 아이템 필드** (`items[]` — CartItemResource) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `962` | 장바구니 아이템 ID (수량/옵션 변경·삭제 시 path 파라미터) | +| product_id | integer | `201` | 담긴 상품 ID | +| product_option_id | integer | `1086` | 선택된 상품 옵션 ID | +| quantity | integer | `1` | 구매 수량 | +| additional_options | array | `[]` | 선택된 추가옵션 해석 결과 (항목별 `additional_option_id`, `value_id`, `group_name`, `name`, `price_adjustment`, `price_adjustment_formatted`, `multi_currency_price_adjustment`, `custom_text`) | +| additional_options_total | integer | `0` | 추가옵션 단위 추가금 합계 (수량 미곱셈, 기본 통화 기준) | +| created_at | string | `2026-07-14 10:00:00` | 담은 일시 (사용자 타임존 포맷) | +| updated_at | string | `2026-07-14 10:00:00` | 최종 수정 일시 (사용자 타임존 포맷) | +| product | object\|null | `{"id":201,"name":"티셔츠", …}` | 상품 요약 (`id`, `name`, `product_code`, `thumbnail_url`, `sales_status`, `display_status`) | +| product_option | object\|null | `{"id":1086,"selling_price":19000, …}` | 옵션 요약 (`id`, `option_code`, `option_name`, `option_name_localized`, `option_values`, `option_values_localized`, `list_price`, `list_price_formatted`, `multi_currency_list_price`, `selling_price`, `selling_price_formatted`, `multi_currency_selling_price`, `discount_rate`, `stock_quantity`, `is_active`) | +| available | boolean | `true` | 현재 구매 가능 여부 (`Product::isPurchasable()`) | +| unavailable_reason | string\|null | `sold_out` | 구매 불가 사유 (판매상태 `suspended`/`sold_out`/`coming_soon` 또는 전시상태 `hidden`. 구매 가능 시 `null`) | +| is_shippable_to_selected_country | boolean | `true` | 선택된 배송 국가로 배송 가능한지 (배송정책 기준) | +| selected_shipping_country | string | `KR` | 판정에 사용된 배송 국가 코드 | +| subtotal | integer | `19000` | 표시 소계 = (옵션 판매가 + 추가옵션 단위 합계) × 수량 (옵션이 로드된 경우에만 존재) | +| subtotal_formatted | string | `19,000원` | 소계 통화 포맷 문자열 | +| multi_currency_subtotal | object | `{"KRW":19000}` | 통화별 환산 소계 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "장바구니에 상품이 추가되었습니다.", + "data": { + "items": [ + { + "id": 962, + "product_id": 201, + "product_option_id": 1086, + "quantity": 1, + "additional_options": [], + "additional_options_total": 0, + "created_at": "2026-07-14 10:00:00", + "updated_at": "2026-07-14 10:00:00", + "product": { + "id": 201, + "name": "티셔츠", + "product_code": "P-201", + "thumbnail_url": null, + "sales_status": "on_sale", + "display_status": "visible" + }, + "product_option": { + "id": 1086, + "option_code": "OPT-1086", + "option_name": "색상/사이즈", + "option_name_localized": "색상/사이즈", + "option_values": ["BLACK", "L"], + "option_values_localized": ["블랙", "L"], + "list_price": 25000, + "list_price_formatted": "25,000원", + "multi_currency_list_price": {"KRW": 25000}, + "selling_price": 19000, + "selling_price_formatted": "19,000원", + "multi_currency_selling_price": {"KRW": 19000}, + "discount_rate": 24.0, + "stock_quantity": 50, + "is_active": true + }, + "available": true, + "unavailable_reason": null, + "is_shippable_to_selected_country": true, + "selected_shipping_country": "KR", + "subtotal": 19000, + "subtotal_formatted": "19,000원", + "multi_currency_subtotal": {"KRW": 19000} + } + ], + "cart_count": 3 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 400 | Bad Request | 비회원인데 `X-Cart-Key` 헤더가 없거나 형식(`ck_`+32자 영숫자)이 아닌 경우 | +| 403 | Forbidden | 타인 소유 장바구니 항목 조작 (`CartOperationException: access_denied`) | +| 404 | Not Found | 대상 장바구니 항목 없음 (`CartOperationException: item_not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패 (`error.errors`) 또는 재고 부족·판매 중지·구매 대상 제한·구매 수량 한도 위반 (`CartUnavailableException` — `error` 에 `code: cart_unavailable`, `unavailable_items`, `has_stock_issue`, `has_status_issue`, `has_restriction_issue`, `has_min_qty_issue`, `has_max_qty_issue`), 옵션 없음/타상품 옵션 (`CartOperationException: option_not_found`/`invalid_option`) | +| 500 | Internal Server Error | 담기 처리 중 예외 발생 (`messages.cart.add_failed`) | @@ -277,7 +386,10 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 비회원인데 `X-Cart-Key` 헤더가 없거나 형식(`ck_`+32자 영숫자)이 아닌 경우 (`CartKeyRequest` 검증) | +| 500 | Internal Server Error | 삭제 처리 중 예외 발생 (`messages.cart.delete_failed`) | @@ -313,7 +425,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| count | integer | `1` | 장바구니에 담긴 아이템 개수 (`selected_ids` 지정 시 해당 항목만 집계) | +| count | integer | `0` | 장바구니에 담긴 아이템 개수 (`selected_ids` 지정 시 해당 항목만 집계) | **응답 예시** @@ -367,7 +479,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| cart_key | string | `ck_QBQT80EINHHXLO8HYHntMpmUZrPujPka` | 비회원 장바구니 식별 키 (`ck_` + 32자 영숫자. 이후 요청의 `X-Cart-Key` 헤더에 실어 자신의 장바구니를 식별) | +| cart_key | string | `ck_F2M15LD5Wu9UQsOwvOVrfp1UeECn3q6f` | 비회원 장바구니 식별 키 (`ck_` + 32자 영숫자. 이후 요청의 `X-Cart-Key` 헤더에 실어 자신의 장바구니를 식별) | **응답 예시** @@ -380,14 +492,16 @@ HTTP/1.1 200 "success": true, "message": "장바구니 키가 발급되었습니다.", "data": { - "cart_key": "ck_QBQT80EINHHXLO8HYHntMpmUZrPujPka" + "cart_key": "ck_F2M15LD5Wu9UQsOwvOVrfp1UeECn3q6f" } } ``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 키 발급 처리 중 예외 발생 (`messages.cart.key_issue_failed` — "장바구니 키 발급에 실패했습니다.") | @@ -415,15 +529,36 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| merged_count | integer | `2` | 비회원 장바구니에서 회원 계정으로 옮겨진 아이템 건수 (`CartService::mergeGuestCartToUser()` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 상품이 장바구니로 병합되었습니다.", + "data": { + "merged_count": 2 + } +} +``` + +> `messages.cart.merged` 는 `:count` 플레이스홀더를 갖지만 컨트롤러가 messageParams 를 전달하지 않으므로 응답 메시지에는 치환되지 않은 원문이 그대로 실립니다. 화면 표기는 `data.merged_count` 를 사용하십시오. **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 비로그인 상태로 호출(`auth` 오류 — "로그인이 필요합니다."), `X-Cart-Key` 헤더 누락 또는 형식(`ck_`+32자 영숫자) 불일치 (`MergeGuestCartRequest` 검증) | +| 500 | Internal Server Error | 병합 처리 중 예외 발생 (`messages.cart.merge_failed`) | @@ -491,39 +626,40 @@ HTTP/1.1 200 "items": [], "summary": { "subtotal": 0, - "subtotal_formatted": "0원", + "subtotal_formatted": "¥0", "product_coupon_discount": 0, - "product_coupon_discount_formatted": "0원", + "product_coupon_discount_formatted": "¥0", "code_discount": 0, - "code_discount_formatted": "0원", + "code_discount_formatted": "¥0", "order_coupon_discount": 0, - "order_coupon_discount_formatted": "0원", + "order_coupon_discount_formatted": "¥0", "total_coupon_discount": 0, - "total_coupon_discount_formatted": "0원", + "total_coupon_discount_formatted": "¥0", "total_discount": 0, - "discount_formatted": "0원", + "discount_formatted": "¥0", "base_shipping_total": 0, "extra_shipping_total": 0, "total_shipping": 0, "total_shipping_fee": 0, - "shipping_fee_formatted": "0원", + "shipping_fee_formatted": "¥0", "shipping_discount": 0, - "shipping_discount_formatted": "0원", + "shipping_discount_formatted": "¥0", "taxable_amount": 0, "tax_free_amount": 0, + "vat_amount": 0, "points_earning": 0, "total_mileage": 0, "mileage_formatted": "0P", "points_used": 0, - "points_used_formatted": "0원", + "points_used_formatted": "¥0", "payment_amount": 0, - "payment_amount_formatted": "0원", + "payment_amount_formatted": "¥0", "final_amount": 0, - "final_amount_formatted": "0원", + "final_amount_formatted": "¥0", "coupon_discount": 0, - "coupon_discount_formatted": "0원", + "coupon_discount_formatted": "¥0", "order_discount": 0, - "order_discount_formatted": "0원" + "order_discount_formatted": "¥0" }, "promotions": { "coupon_issue_ids": [], @@ -582,17 +718,30 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "장바구니에서 상품이 삭제되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 타인 소유 장바구니 항목 삭제 시도 (`CartOperationException: access_denied`) | +| 404 | Not Found | `id` 에 해당하는 장바구니 항목이 없는 경우 (`CartOperationException: item_not_found`) | +| 422 | Unprocessable Entity | 비회원인데 `X-Cart-Key` 헤더가 없거나 형식(`ck_`+32자 영숫자)이 아닌 경우 (`DeleteCartItemRequest` 검증) | +| 500 | Internal Server Error | 삭제 처리 중 예외 발생 (`messages.cart.delete_failed`) | @@ -611,7 +760,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | --- | --- | --- | --- | --- | --- | | id | path | string | 예 | — | 대상 리소스의 식별자 | | product_option_id | body | integer | 예 | — | product option 식별자 | -| quantity | body | integer | 예 | min 1, max 9999 | 변경할 구매 수량 (1~9999) | +| quantity | body | integer | 예 | min 1, max 99 | 변경할 구매 수량 (1 ~ 관리자 설정 상한, 기본 99) | | additional_option_selections | body | array | 아니오 | — | 추가 옵션 재선택 목록 (항목별 additional_option_id/value_id, 직접입력 custom_text — 미전달 시 기존 선택 유지) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.change_option_validation_rules`). @@ -636,18 +785,79 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| item | object | `{"id":962,"product_option_id":1090, …}` | 옵션·수량이 반영된 장바구니 아이템 1건 (CartItemResource — 필드 구성은 POST `/cart` 의 "장바구니 아이템 필드" 표와 동일) | + +> 값도 목록 조회와 동일합니다. 재선택한 추가옵션이 `additional_options` 에 실리고 그 금액이 `subtotal` 에 반영되므로, 이 응답만으로 해당 행을 갱신해도 금액이 어긋나지 않습니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "옵션이 변경되었습니다.", + "data": { + "item": { + "id": 962, + "product_id": 201, + "product_option_id": 1090, + "quantity": 2, + "additional_options": [], + "additional_options_total": 0, + "created_at": "2026-07-14 10:00:00", + "updated_at": "2026-07-14 10:12:00", + "product": { + "id": 201, + "name": "티셔츠", + "product_code": "P-201", + "thumbnail_url": null, + "sales_status": "on_sale", + "display_status": "visible" + }, + "product_option": { + "id": 1090, + "option_code": "OPT-1090", + "option_name": "색상/사이즈", + "option_name_localized": "색상/사이즈", + "option_values": ["WHITE", "M"], + "option_values_localized": ["화이트", "M"], + "list_price": 25000, + "list_price_formatted": "25,000원", + "multi_currency_list_price": {"KRW": 25000}, + "selling_price": 19000, + "selling_price_formatted": "19,000원", + "multi_currency_selling_price": {"KRW": 19000}, + "discount_rate": 24.0, + "stock_quantity": 40, + "is_active": true + }, + "available": true, + "unavailable_reason": null, + "is_shippable_to_selected_country": true, + "selected_shipping_country": "KR", + "subtotal": 38000, + "subtotal_formatted": "38,000원", + "multi_currency_subtotal": {"KRW": 38000} + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 타인 소유 장바구니 항목 조작 (`CartOperationException: access_denied`) | +| 404 | Not Found | `id` 에 해당하는 장바구니 항목이 없는 경우 (`CartOperationException: item_not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패(`error.errors`), 옵션 없음/타상품 옵션 지정 (`CartOperationException: option_not_found`/`invalid_option`), 재고 부족·판매 중지·구매 대상 제한·구매 수량 한도 위반 (`CartUnavailableException` — `error.code = cart_unavailable`, `unavailable_items`, `has_stock_issue`, `has_status_issue`, `has_restriction_issue`, `has_min_qty_issue`, `has_max_qty_issue`) | +| 500 | Internal Server Error | 옵션 변경 처리 중 예외 발생 (`messages.cart.update_failed`) | @@ -665,7 +875,7 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | id | path | string | 예 | — | 대상 리소스의 식별자 | -| quantity | body | integer | 예 | min 1, max 9999 | 변경할 구매 수량 (1~9999) | +| quantity | body | integer | 예 | min 1, max 99 | 변경할 구매 수량 (1 ~ 관리자 설정 상한, 기본 99) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.cart.update_quantity_validation_rules`). @@ -685,21 +895,110 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[{"id":962,"quantity":2, …}]` | 수량 반영 후의 장바구니 전체 아이템 목록 (CartItemResource — 필드 구성은 POST `/cart` 의 "장바구니 아이템 필드" 표와 동일. 프론트가 refetch 없이 화면을 갱신하도록 전체를 함께 반환) | +| item_count | integer | `1` | 장바구니 아이템 개수 | +| calculation | object | `{"items":[…],"summary":{…},"promotions":{…},"validation_errors":[]}` | 선택 아이템 기준 금액 계산 결과 (GET `/cart` 의 `calculation` 과 동일 구조 — 소계·할인·배송비·적립·최종 결제금액 등) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "수량이 변경되었습니다.", + "data": { + "items": [ + { + "id": 962, + "product_id": 201, + "product_option_id": 1086, + "quantity": 2, + "additional_options": [], + "additional_options_total": 0, + "created_at": "2026-07-14 10:00:00", + "updated_at": "2026-07-14 10:15:00", + "product": { + "id": 201, + "name": "티셔츠", + "product_code": "P-201", + "thumbnail_url": null, + "sales_status": "on_sale", + "display_status": "visible" + }, + "product_option": { + "id": 1086, + "option_code": "OPT-1086", + "option_name": "색상/사이즈", + "option_name_localized": "색상/사이즈", + "option_values": ["BLACK", "L"], + "option_values_localized": ["블랙", "L"], + "list_price": 25000, + "list_price_formatted": "25,000원", + "multi_currency_list_price": {"KRW": 25000}, + "selling_price": 19000, + "selling_price_formatted": "19,000원", + "multi_currency_selling_price": {"KRW": 19000}, + "discount_rate": 24.0, + "stock_quantity": 50, + "is_active": true + }, + "available": true, + "unavailable_reason": null, + "is_shippable_to_selected_country": true, + "selected_shipping_country": "KR", + "subtotal": 38000, + "subtotal_formatted": "38,000원", + "multi_currency_subtotal": {"KRW": 38000} + } + ], + "item_count": 1, + "calculation": { + "items": [], + "summary": { + "subtotal": 38000, + "subtotal_formatted": "38,000원", + "total_discount": 0, + "discount_formatted": "0원", + "total_shipping_fee": 0, + "shipping_fee_formatted": "0원", + "payment_amount": 38000, + "payment_amount_formatted": "38,000원", + "final_amount": 38000, + "final_amount_formatted": "38,000원" + }, + "promotions": { + "coupon_issue_ids": [], + "item_coupons": [], + "discount_code": null, + "product_promotions": {"coupons": [], "discount_codes": [], "events": []}, + "order_promotions": {"coupons": [], "discount_codes": [], "events": []} + }, + "validation_errors": [] + } + } +} +``` + +> `calculation.summary` 는 GET `/cart` 응답과 동일한 전체 키 집합(`product_coupon_discount`, `code_discount`, `taxable_amount`, `points_earning` 등)을 포함합니다. 위 예시는 대표 키만 발췌한 것입니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 타인 소유 장바구니 항목 조작 (`CartOperationException: access_denied`) | +| 404 | Not Found | `id` 에 해당하는 장바구니 항목이 없는 경우 (`CartOperationException: item_not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패(`quantity` 누락/범위 초과 — `error.errors`) 또는 재고 부족·판매 중지·구매 수량 한도 위반 (`CartUnavailableException` — `error.code = cart_unavailable`, `unavailable_items`, `has_stock_issue`, `has_status_issue`, `has_restriction_issue`, `has_min_qty_issue`, `has_max_qty_issue`) | +| 500 | Internal Server Error | 수량 변경 처리 중 예외 발생 (`messages.cart.update_failed`) | -**설명** 장바구니 아이템(`id`)의 수량만 변경합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@updateQuantity`가 `CartService::updateQuantity()`로 수량을 반영한 뒤 프론트가 별도 refetch 없이 화면을 갱신할 수 있도록 `index`와 동일한 전체 목록·계산 결과(`items`·`calculation`)를 함께 반환합니다. 수량은 1~9999 범위이며, 재고 부족·판매 중지·구매수량 한도 위반은 사유별 422, 항목/권한 문제는 404/403으로 매핑됩니다. 장바구니의 수량 증감(+/-) 컨트롤에 사용합니다. +**설명** 장바구니 아이템(`id`)의 수량만 변경합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CartController@updateQuantity`가 `CartService::updateQuantity()`로 수량을 반영한 뒤 프론트가 별도 refetch 없이 화면을 갱신할 수 있도록 `index`와 동일한 전체 목록·계산 결과(`items`·`calculation`)를 함께 반환합니다. 수량 상한은 관리자 환경설정의 장바구니 수량 상한(기본 99)을 따르며, 재고 부족·판매 중지·구매수량 한도 위반은 사유별 422, 항목/권한 문제는 404/403으로 매핑됩니다. 장바구니의 수량 증감(+/-) 컨트롤에 사용합니다. diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md index 098a31c3..a0e2d144 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Categories 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -49,12 +49,12 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `87` | 기본 키 (내부 식별자) | +| id | integer | `29` | 기본 키 (내부 식별자) | | name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | | description | object | `{"ko":"다양한 스타일의 의류 제품","en":"Various styles of clothing p…` | 설명 (다국어 필드는 로케일별 값 객체) | | localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | -| path | string | `87` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path (조상 조회·하위 일괄 선택에 사용) | +| path | string | `29` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path (조상 조회·하위 일괄 선택에 사용) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | | is_active | boolean | `true` | active 여부 | @@ -63,10 +63,10 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | icon | string | `folder` | 아이콘 식별자 (아이콘 클래스/이름) | | meta_title | null | `null` | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목, 미설정 시 null) | | meta_description | null | `null` | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약, 미설정 시 null) | -| created_at | string | `2026-06-15 02:24:00` | 생성 일시 | -| updated_at | string | `2026-06-15 02:24:00` | 최종 수정 일시 | +| created_at | string | `2026-07-30 14:35:46` | 생성 일시 | +| updated_at | string | `2026-07-30 14:35:46` | 최종 수정 일시 | | images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | -| products_count | integer | `22` | products 개수 (집계) | +| products_count | integer | `24` | products 개수 (집계) | | children_count | integer | `0` | children 개수 (집계) | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -83,34 +83,70 @@ HTTP/1.1 200 "data": { "data": [ { - "id": 1, + "id": 29, "name": { - "ko": "API 문서 샘플 카테고리", - "en": "API Doc Sample Category" + "ko": "의류", + "en": "Clothing" }, - "description": null, - "localized_name": "API 문서 샘플 카테고리", + "description": { + "ko": "다양한 스타일의 의류 제품", + "en": "Various styles of clothing products" + }, + "localized_name": "의류", "parent_id": null, - "path": "0", + "path": "29", "depth": 0, "sort_order": 0, "is_active": true, - "slug": "apidoc-sample-category", - "url": "apidoc-sample-category", + "slug": "clothing", + "url": "clothing", "icon": "folder", "meta_title": null, "meta_description": null, - "created_at": "2026-07-08 01:44:49", - "updated_at": "2026-07-08 01:44:49", + "created_at": "2026-07-30 14:35:46", + "updated_at": "2026-07-30 14:35:46", "images": [], - "products_count": 0, + "products_count": 24, "children_count": 0, "abilities": { "can_create": true, "can_update": true, "can_delete": true } - } + }, + { + "id": 38, + "name": { + "ko": "전자기기", + "en": "Electronics" + }, + "description": { + "ko": "최신 전자제품과 기기", + "en": "Latest electronic products and devices" + }, + "localized_name": "전자기기", + "parent_id": null, + "path": "38", + "depth": 0, + "sort_order": 1, + "is_active": true, + "slug": "electronics", + "url": "electronics", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-30 14:35:47", + "updated_at": "2026-07-30 14:35:47", + "images": [], + "products_count": 15, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 8건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -182,11 +218,72 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CategoryResource`). 생성 직후 `fresh(['images'])` 로 재조회하므로 `images` 관계만 포함되고 `parent`/`children` 키는 나타나지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object\|null | `null` | 설명 (다국어 필드는 로케일별 값 객체, 미설정 시 null) | +| localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 | +| parent_id | integer\|null | `null` | 상위 카테고리 ID (최상위면 null) | +| path | string | `1` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (미지정 시 형제 마지막 순번 자동 부여) | +| is_active | boolean | `true` | 활성 여부 | +| slug | string | `clothing` | URL 친화 식별자 (slug) | +| url | string | `clothing` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | +| icon | string | `folder` | SortableMenuList 표시용 아이콘 (리소스에서 고정값 `folder`) | +| meta_title | string\|null | `null` | SEO 메타 제목 (미설정 시 null) | +| meta_description | string\|null | `null` | SEO 메타 설명 (미설정 시 null) | +| created_at | string | `2026-07-08 01:44:49` | 생성 일시 (`Y-m-d H:i:s`) | +| updated_at | string | `2026-07-08 01:44:49` | 최종 수정 일시 (`Y-m-d H:i:s`) | +| images | array | `[]` | 카테고리 이미지 배열 (각 항목: id/hash/original_filename/mime_type/size/size_formatted/download_url/order/is_image/alt_text). `temp_key` 로 연결된 임시 이미지가 여기에 포함됨 | +| products_count | integer | `0` | 연결된 상품 개수 (집계) | +| children_count | integer | `0` | 하위 카테고리 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "카테고리가 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "description": null, + "localized_name": "API 문서 샘플 카테고리", + "parent_id": null, + "path": "1", + "depth": 0, + "sort_order": 0, + "is_active": true, + "slug": "apidoc-sample-category", + "url": "apidoc-sample-category", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 01:44:49", + "images": [], + "products_count": 0, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -215,6 +312,8 @@ Content-Type: application/json | temp_key | body | string | 아니오 | max 64 | 사전 업로드한 임시 이미지를 이 카테고리에 연결하기 위한 FileUploader temp_key | | collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | +| alt_text.ko | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `ko` 로케일 값 | +| alt_text.en | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `en` 로케일 값 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category-image.filter_upload_validation_rules`). @@ -243,25 +342,70 @@ Content-Disposition: form-data; name="collection" ------G7ExampleBoundary Content-Disposition: form-data; name="alt_text" +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text.ko" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text.en" + 예시값 ------G7ExampleBoundary-- ``` **응답 필드** (`data` 내부) - +_단건 응답: FileUploader 컴포넌트가 `response.data?.data` 형식을 기대하므로 컨트롤러가 `data` 안에 한 번 더 `data` 객체를 감싸 반환합니다. 아래는 `data.data` 객체의 필드입니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 업로드된 카테고리 이미지의 기본 키 | +| hash | string | `9f2c1b8e4a...` | 이미지 고유 해시 (다운로드 URL 식별자) | +| original_filename | string | `banner.png` | 업로드 당시의 원본 파일명 | +| mime_type | string | `image/png` | 파일 MIME 타입 | +| size | integer | `102400` | 파일 크기 (바이트) | +| size_formatted | string | `100 KB` | 사람이 읽기 쉬운 형식으로 변환한 파일 크기 | +| download_url | string | `/api/modules/sirsoft-ecommerce/admin/categories/images/download/9f2c1b8e4a...` | 이미지 다운로드 URL | +| order | integer | `1` | 이미지 표시 순서 (`sort_order`, 미설정 시 1) | +| is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.category_images.uploaded", + "data": { + "data": { + "id": 1, + "hash": "9f2c1b8e4a7d5c0361b2e8f4a9d7c015", + "original_filename": "banner.png", + "mime_type": "image/png", + "size": 102400, + "size_formatted": "100 KB", + "download_url": "/api/modules/sirsoft-ecommerce/admin/categories/images/download/9f2c1b8e4a7d5c0361b2e8f4a9d7c015", + "order": 1, + "is_image": true + } + } +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.category_images.uploaded` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 에 `category_images` 섹션이 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 파일 저장 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `file` 은 필수이며 `jpeg,png,jpg,gif,svg,webp` 이미지만 허용, 최대 10MB | @@ -300,19 +444,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.category_images.reordered" +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.category_images.reordered` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 에 `category_images` 섹션이 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 순서 갱신 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `order` 는 1건 이상 필수이며 각 항목에 정수 `id` 와 0 이상의 정수 `order` 가 필요 | @@ -334,7 +490,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/categories/images/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/categories/images/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -342,19 +498,31 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.category_images.deleted" +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.category_images.deleted` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 에 `category_images` 섹션이 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 이미지 삭제 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | path 의 이미지 `id` 에 해당하는 카테고리 이미지가 없는 경우 (`messages.category_images.not_found`) | @@ -397,23 +565,35 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.categories.order_updated" +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.categories.order_updated` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 의 `categories` 섹션에 `order_updated` 키가 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 순서 갱신 트랜잭션 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `parent_menus` / `child_menus` 중 최소 하나가 필요하며, 각 항목의 `id` 는 실제 존재하는 카테고리여야 하고 `order` 는 0 이상의 정수 | -**설명** 카테고리 트리 전체의 배치(부모-자식 관계와 정렬 순서)를 일괄 갱신합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, SortableMenuList 컴포넌트가 보내는 `parent_menus`/`child_menus` 형식을 컨트롤러가 `{id, parent_id, sort_order}` 목록으로 변환해 `CategoryService::reorder()`(트랜잭션)에 전달합니다. `parent_id`가 바뀐 항목은 depth와 materialized path가 함께 재계산됩니다. 관리자 카테고리 관리 화면에서 드래그 앤 드롭으로 계층 구조를 재정렬할 때 사용합니다. +**설명** 카테고리 트리 전체의 배치(부모-자식 관계와 정렬 순서)를 일괄 갱신합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, SortableMenuList 컴포넌트가 보내는 `parent_menus`/`child_menus` 형식을 컨트롤러가 `{id, parent_id, sort_order}` 목록으로 변환해 `CategoryService::reorder()`(트랜잭션)에 전달합니다. `parent_id`가 바뀐 항목은 depth와 materialized path가 함께 재계산됩니다. 관리자 카테고리 관리 화면에서 드래그 앤 드롭으로 계층 구조를 재정렬할 때 사용합니다. `child_menus`의 각 항목은 지정한 부모의 조상이 될 수 없습니다 — 순환이 만들어지는 이동은 422(`child_menus.{parentId}.{index}.id`)로 거절됩니다. ### GET /api/modules/sirsoft-ecommerce/admin/categories/tree @@ -441,12 +621,12 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `87` | 기본 키 (내부 식별자) | +| id | integer | `29` | 기본 키 (내부 식별자) | | name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | | description | object | `{"ko":"다양한 스타일의 의류 제품","en":"Various styles of clothing p…` | 설명 (다국어 필드는 로케일별 값 객체) | | localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | -| path | string | `87` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path (조상 조회·하위 일괄 선택에 사용) | +| path | string | `29` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path (조상 조회·하위 일괄 선택에 사용) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | | is_active | boolean | `true` | active 여부 | @@ -455,12 +635,12 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | icon | string | `folder` | 아이콘 식별자 (아이콘 클래스/이름) | | meta_title | null | `null` | SEO 메타 제목 (검색엔진/소셜 공유 표시 제목, 미설정 시 null) | | meta_description | null | `null` | SEO 메타 설명 (검색엔진/소셜 공유 표시 요약, 미설정 시 null) | -| created_at | string | `2026-06-15 02:24:00` | 생성 일시 | -| updated_at | string | `2026-06-15 02:24:00` | 최종 수정 일시 | +| created_at | string | `2026-07-30 14:35:46` | 생성 일시 | +| updated_at | string | `2026-07-30 14:35:46` | 최종 수정 일시 | | parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | -| children | array | `[{"id":88,"name":{"ko":"남성","en":"Men"},"description":{"k…` | 하위 항목 배열 (계층 트리 — children 관계 파생) | +| children | array | `[{"id":30,"name":{"ko":"남성","en":"Men"},"description":{"k…` | 하위 항목 배열 (계층 트리 — children 관계 파생) | | images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | -| products_count | integer | `22` | products 개수 (집계) | +| products_count | integer | `24` | products 개수 (집계) | | children_count | integer | `2` | children 개수 (집계) | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -477,36 +657,34 @@ HTTP/1.1 200 "data": { "data": [ { - "id": 1, + "id": 29, "name": { - "ko": "API 문서 샘플 카테고리", - "en": "API Doc Sample Category" + "ko": "의류", + "en": "Clothing" }, - "description": null, - "localized_name": "API 문서 샘플 카테고리", + "description": { + "ko": "다양한 스타일의 의류 제품", + "en": "Various styles of clothing products" + }, + "localized_name": "의류", "parent_id": null, - "path": "0", - "depth": 0, - "sort_order": 0, - "is_active": true, - "slug": "apidoc-sample-category", - "url": "apidoc-sample-category", - "icon": "folder", - "meta_title": null, - "meta_description": null, - "created_at": "2026-07-08 01:44:49", - "updated_at": "2026-07-08 01:44:49", - "parent": null, - "children": [], - "images": [], - "products_count": 0, - "children_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } + "...": "(17개 키 생략, 총 22개)" + }, + { + "id": 38, + "name": { + "ko": "전자기기", + "en": "Electronics" + }, + "description": { + "ko": "최신 전자제품과 기기", + "en": "Latest electronic products and devices" + }, + "localized_name": "전자기기", + "parent_id": null, + "...": "(17개 키 생략, 총 22개)" + }, + "... (총 8건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -544,6 +722,8 @@ HTTP/1.1 200 | temp_key | body | string | 아니오 | max 64 | 사전 업로드한 임시 이미지를 이 카테고리에 연결하기 위한 FileUploader temp_key | | collection | body | string | 아니오 | max 100 | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | +| alt_text.ko | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `ko` 로케일 값 | +| alt_text.en | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `en` 로케일 값 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.category-image.filter_upload_validation_rules`). @@ -572,26 +752,71 @@ Content-Disposition: form-data; name="collection" ------G7ExampleBoundary Content-Disposition: form-data; name="alt_text" +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text.ko" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text.en" + 예시값 ------G7ExampleBoundary-- ``` **응답 필드** (`data` 내부) - +_단건 응답: FileUploader 컴포넌트가 `response.data?.data` 형식을 기대하므로 컨트롤러가 `data` 안에 한 번 더 `data` 객체를 감싸 반환합니다. 아래는 `data.data` 객체의 필드입니다 (임시 업로드 엔드포인트와 동일 형식, 다만 이미지가 path 의 `categoryId` 에 즉시 귀속됩니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 업로드된 카테고리 이미지의 기본 키 | +| hash | string | `9f2c1b8e4a...` | 이미지 고유 해시 (다운로드 URL 식별자) | +| original_filename | string | `banner.png` | 업로드 당시의 원본 파일명 | +| mime_type | string | `image/png` | 파일 MIME 타입 | +| size | integer | `102400` | 파일 크기 (바이트) | +| size_formatted | string | `100 KB` | 사람이 읽기 쉬운 형식으로 변환한 파일 크기 | +| download_url | string | `/api/modules/sirsoft-ecommerce/admin/categories/images/download/9f2c1b8e4a...` | 이미지 다운로드 URL | +| order | integer | `1` | 이미지 표시 순서 (`sort_order`, 미설정 시 1) | +| is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.category_images.uploaded", + "data": { + "data": { + "id": 2, + "hash": "1c47e9a5b60d38f27ea4c9d015b7e8f3", + "original_filename": "banner.png", + "mime_type": "image/png", + "size": 102400, + "size_formatted": "100 KB", + "download_url": "/api/modules/sirsoft-ecommerce/admin/categories/images/download/1c47e9a5b60d38f27ea4c9d015b7e8f3", + "order": 1, + "is_image": true + } + } +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.category_images.uploaded` 키를 사용하는데, 현재 모듈 `lang/ko/messages.php` 에 `category_images` 섹션이 정의되어 있지 않아 번역되지 않은 키 문자열이 그대로 반환됩니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 파일 저장 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `file` 은 필수이며 `jpeg,png,jpg,gif,svg,webp` 이미지만 허용, 최대 10MB | @@ -613,7 +838,7 @@ Content-Disposition: form-data; name="alt_text" **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/categories/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/categories/{category} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -621,27 +846,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| category_id | integer | `1` | category 식별자 (연관 리소스 참조) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "카테고리가 삭제되었습니다.", - "data": { - "category_id": 1 - } -} -``` + **에러 응답** @@ -681,7 +890,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/categories/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/categories/{category} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -705,24 +914,86 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CategoryResource`). 수정 직후 `fresh(['images'])` 로 재조회하므로 `images` 관계만 포함되고 `parent`/`children` 키는 나타나지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| description | object\|null | `null` | 설명 (다국어 필드는 로케일별 값 객체, 미설정 시 null) | +| localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 | +| parent_id | integer\|null | `null` | 상위 카테고리 ID (변경 시 depth/path 재계산) | +| path | string | `1` | 조상부터 자기 자신까지의 ID를 `/`로 이은 materialized path | +| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| is_active | boolean | `true` | 활성 여부 | +| slug | string | `clothing` | URL 친화 식별자 (slug) | +| url | string | `clothing` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | +| icon | string | `folder` | SortableMenuList 표시용 아이콘 (리소스에서 고정값 `folder`) | +| meta_title | string\|null | `null` | SEO 메타 제목 (미설정 시 null) | +| meta_description | string\|null | `null` | SEO 메타 설명 (미설정 시 null) | +| created_at | string | `2026-07-08 01:44:49` | 생성 일시 (`Y-m-d H:i:s`) | +| updated_at | string | `2026-07-08 06:00:17` | 최종 수정 일시 (`Y-m-d H:i:s`) | +| images | array | `[]` | 카테고리 이미지 배열 (각 항목: id/hash/original_filename/mime_type/size/size_formatted/download_url/order/is_image/alt_text) | +| products_count | integer | `0` | 연결된 상품 개수 (집계) | +| children_count | integer | `0` | 하위 카테고리 개수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "카테고리가 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 카테고리", + "en": "API Doc Sample Category" + }, + "description": null, + "localized_name": "API 문서 샘플 카테고리", + "parent_id": null, + "path": "1", + "depth": 0, + "sort_order": 0, + "is_active": true, + "slug": "apidoc-sample-category", + "url": "apidoc-sample-category", + "icon": "folder", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-08 01:44:49", + "updated_at": "2026-07-08 06:00:17", + "images": [], + "products_count": 0, + "children_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 대상 카테고리가 없거나 수정 트랜잭션 중 예외 발생 (컨트롤러가 예외를 잡아 `exceptions.operation_failed` 로 응답) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -**설명** 기존 카테고리(path의 `category`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `CategoryService::updateCategory()`가 검증된 데이터로 갱신한 뒤 `CategoryResource`를 반환합니다. `name`과 `slug`는 필수이고, `parent_id`를 변경하면 계층 위치(path/depth)가 재계산됩니다. `temp_key`로 임시 업로드한 이미지를 이 시점에 연결할 수 있으며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. `sirsoft-ecommerce.category.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. +**설명** 기존 카테고리(path의 `category`)를 수정합니다. `auth:sanctum` + `sirsoft-ecommerce.categories.update` 권한이 필요하며, `CategoryService::updateCategory()`가 검증된 데이터로 갱신한 뒤 `CategoryResource`를 반환합니다. `name`과 `slug`는 필수이고, `parent_id`를 변경하면 계층 위치(path/depth)가 재계산됩니다. `temp_key`로 임시 업로드한 이미지를 이 시점에 연결할 수 있으며, 대상이 없거나 처리 중 예외가 발생하면 404 또는 400을 반환합니다. `sirsoft-ecommerce.category.update_validation_rules` 필터로 확장이 검증 규칙을 추가할 수 있습니다. `parent_id`에는 자기 자신이나 자신의 하위 카테고리를 지정할 수 없습니다 — 순환이 만들어지는 요청은 422(`parent_id`)로 거절됩니다. ### GET /api/modules/sirsoft-ecommerce/admin/categories/{id} @@ -740,7 +1011,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/categories/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/categories/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -748,76 +1019,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 카테고리","en":"API Doc Sample Category"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 카테고리` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | -| path | string | `0` | Materialized Path: 1/5/23 | -| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| is_active | boolean | `true` | active 여부 | -| slug | string | `apidoc-sample-category` | URL 친화 식별자 (slug) | -| url | string | `apidoc-sample-category` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | -| icon | string | `folder` | 아이콘 식별자 (아이콘 클래스/이름) | -| meta_title | null | `null` | SEO 제목 (다국어 JSON) | -| meta_description | null | `null` | SEO 설명 (다국어 JSON) | -| created_at | string | `2026-07-08 01:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 01:44:49` | 최종 수정 일시 | -| parent | null | `null` | 상위 항목 객체 (parent 관계 파생) | -| children | array | `[]` | 하위 항목 배열 (계층 트리 — children 관계 파생) | -| images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | -| products_count | integer | `0` | products 개수 (집계) | -| children_count | integer | `0` | children 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "카테고리 정보를 조회했습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 카테고리", - "en": "API Doc Sample Category" - }, - "description": null, - "localized_name": "API 문서 샘플 카테고리", - "parent_id": null, - "path": "0", - "depth": 0, - "sort_order": 0, - "is_active": true, - "slug": "apidoc-sample-category", - "url": "apidoc-sample-category", - "icon": "folder", - "meta_title": null, - "meta_description": null, - "created_at": "2026-07-08 01:44:49", - "updated_at": "2026-07-08 01:44:49", - "parent": null, - "children": [], - "images": [], - "products_count": 0, - "children_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -847,7 +1053,7 @@ HTTP/1.1 200 **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/categories/1/toggle-status HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/categories/{id}/toggle-status HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -855,72 +1061,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 카테고리","en":"API Doc Sample Category"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 카테고리` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | -| path | string | `0` | Materialized Path: 1/5/23 | -| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| is_active | boolean | `false` | active 여부 | -| slug | string | `apidoc-sample-category` | URL 친화 식별자 (slug) | -| url | string | `apidoc-sample-category` | SortableMenuItem 표시용 URL (slug 값을 그대로 사용) | -| icon | string | `folder` | 아이콘 식별자 (아이콘 클래스/이름) | -| meta_title | null | `null` | SEO 제목 (다국어 JSON) | -| meta_description | null | `null` | SEO 설명 (다국어 JSON) | -| created_at | string | `2026-07-08 01:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 06:00:17` | 최종 수정 일시 | -| images | array | `[]` | 카테고리 이미지 배열 (images 관계 로드 시 — id/hash/download_url/alt_text 등) | -| products_count | integer | `0` | products 개수 (집계) | -| children_count | integer | `0` | children 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "sirsoft-ecommerce::messages.categories.status_changed", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 카테고리", - "en": "API Doc Sample Category" - }, - "description": null, - "localized_name": "API 문서 샘플 카테고리", - "parent_id": null, - "path": "0", - "depth": 0, - "sort_order": 0, - "is_active": false, - "slug": "apidoc-sample-category", - "url": "apidoc-sample-category", - "icon": "folder", - "meta_title": null, - "meta_description": null, - "created_at": "2026-07-08 01:44:49", - "updated_at": "2026-07-08 06:00:17", - "images": [], - "products_count": 0, - "children_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -957,14 +1102,14 @@ Accept: application/json | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `87` | 기본 키 (내부 식별자) | +| id | integer | `29` | 기본 키 (내부 식별자) | | name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | | name_localized | string | `의류` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | slug | string | `clothing` | URL 친화 식별자 (slug) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | -| products_count | integer | `22` | products 개수 (집계) | -| children | array | `[{"id":88,"name":{"ko":"남성","en":"Men"},"name_localized":…` | 하위 항목 배열 (계층 트리 — children 관계 파생) | +| products_count | integer | `24` | products 개수 (집계) | +| children | array | `[{"id":30,"name":{"ko":"남성","en":"Men"},"name_localized":…` | 하위 항목 배열 (계층 트리 — children 관계 파생) | **응답 예시** @@ -978,25 +1123,70 @@ HTTP/1.1 200 "message": "카테고리 목록을 조회했습니다.", "data": [ { - "id": 1, + "id": 29, "name": { - "ko": "API 문서 샘플 카테고리", - "en": "API Doc Sample Category" + "ko": "의류", + "en": "Clothing" }, - "name_localized": "API 문서 샘플 카테고리", - "slug": "apidoc-sample-category", + "name_localized": "의류", + "slug": "clothing", "depth": 0, - "parent_id": null, - "products_count": 0, - "children": [] - } + "...": "(3개 키 생략, 총 8개)" + }, + { + "id": 38, + "name": { + "ko": "전자기기", + "en": "Electronics" + }, + "name_localized": "전자기기", + "slug": "electronics", + "depth": 0, + "...": "(3개 키 생략, 총 8개)" + }, + { + "id": 46, + "name": { + "ko": "가구", + "en": "Furniture" + }, + "name_localized": "가구", + "slug": "furniture", + "depth": 0, + "...": "(3개 키 생략, 총 8개)" + }, + { + "id": 51, + "name": { + "ko": "식품", + "en": "Food" + }, + "name_localized": "식품", + "slug": "food", + "depth": 0, + "...": "(3개 키 생략, 총 8개)" + }, + { + "id": 56, + "name": { + "ko": "스포츠", + "en": "Sports" + }, + "name_localized": "스포츠", + "slug": "sports", + "depth": 0, + "...": "(3개 키 생략, 총 8개)" + }, + "... (총 8건 중 5건 표시)" ] } ``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 카테고리 트리 조회 중 예외가 발생한 경우 (컨트롤러가 예외를 잡아 `messages.categories.fetch_failed` 로 응답) | @@ -1018,65 +1208,18 @@ _대표 에러 없음 (공개 조회). **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "sirsoft-ecommerce::messages.categories.fetch_success", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 카테고리", - "en": "API Doc Sample Category" - }, - "name_localized": "API 문서 샘플 카테고리", - "description": null, - "description_localized": null, - "slug": "apidoc-sample-category", - "depth": 0, - "parent_id": null, - "products_count": 0, - "breadcrumb": [ - { - "id": 1, - "name": "API 문서 샘플 카테고리", - "slug": "apidoc-sample-category" - } - ], - "images": [], - "children": [] - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md index 32468e36..a7081ce3 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Category Image 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -39,20 +39,46 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투(`data`)를 반환하지 않습니다. 성공 시 이미지 원본 바이너리를 `StreamedResponse` 로 스트리밍합니다 (`CategoryImageService::download()` → `StorageInterface::response()`)._ + +| 응답 요소 | 값 | 용도/설명 | +| --- | --- | --- | +| 본문 | 이미지 원본 바이너리 | `category_images.path` 에 저장된 파일을 스토리지에서 스트리밍 | +| `Content-Type` 헤더 | `image/jpeg` (레코드의 `mime_type` 값) | 업로드 시 저장된 MIME 타입을 그대로 사용 | +| `Cache-Control` 헤더 | `public, max-age=31536000` | 브라우저/CDN 1년 캐싱 | +| `Content-Disposition` 헤더 | 원본 파일명(`original_filename`) 포함 | 다운로드 시 표시될 파일명 | **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: image/jpeg +Cache-Control: public, max-age=31536000 +Content-Disposition: inline; filename="category-main.jpg" + +<이미지 바이너리 데이터> +``` + +실패 시에는 JSON 봉투로 응답합니다. + +```json +{ + "success": false, + "message": "카테고리 이미지를 찾을 수 없습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 400 | Bad Request | 스트리밍 처리 중 예외 발생 (`exceptions.operation_failed` → "작업 처리 중 오류가 발생했습니다.") | +| 404 | Not Found | `hash` 에 해당하는 카테고리 이미지 레코드가 없거나, 레코드는 있으나 스토리지에 실제 파일이 없는 경우 (`CategoryImageService::download()` 가 `null` 반환) | **설명** 공개 API로, 해시(`hash`)로 식별되는 카테고리 이미지 원본 파일을 스트리밍 서빙합니다. 인증이 필요 없으며(`PublicBaseController`), `CategoryImageController@download`가 `CategoryImageService::download()`를 호출해 리포지토리에서 해시로 이미지를 찾고 `StorageInterface::response()`로 `StreamedResponse`를 반환합니다. 응답에는 저장된 `mime_type`과 `Cache-Control: public, max-age=31536000`(1년) 헤더가 부여되어 브라우저/CDN 캐싱에 최적화됩니다. 해시에 해당하는 레코드가 없거나 스토리지에 실제 파일이 없으면 404를, 그 외 처리 오류 시 400 에러 응답을 반환합니다. +> **서빙 방식**: 이미지 본문은 `StorageInterface::response()`(내부 `readStream`) 로 청크 스트리밍되며 파일 전체를 메모리에 적재하지 않습니다. 관리자용 카테고리 이미지 서빙(`CategoryController::downloadImage`)도 동일하게 `CategoryImageService::download()` 스트리밍 경로를 공유합니다(전체 메모리 적재 `Storage::get()` 미사용). + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md index 93186507..f6a0af26 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Checkout 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(raw HTTP) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -38,15 +38,30 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만) — `CheckoutController@destroy` 는 `ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.checkout.deleted')` 를 데이터 인수 없이 호출합니다._ **응답 예시** - + + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문서가 삭제되었습니다.", + "data": null +} +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | 삭제할 임시 주문이 없음 (`TempOrderService::deleteTempOrder()` 가 false 반환 — 이미 만료·미존재). 메시지: `임시 주문 정보를 찾을 수 없습니다.` | +| 500 | Internal Server Error | 삭제 처리 중 예외 발생. 메시지: `주문서 삭제에 실패했습니다.` | @@ -80,22 +95,263 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CheckoutDataService::buildResponseData()` 가 구성)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| temp_order_id | integer | `1` | 임시 주문 ID (주문 확정 시 사용) | +| items | array | `[...]` | 주문 상품 목록 (`CheckoutItemResource` — 아래 "items[] 항목" 표) | +| calculation | object | `{...}` | 주문 계산 결과 (`OrderCalculationResult::toArray()` — 아래 "calculation" 표) | +| promotions | object | `{"item_coupons":{},"order_coupon_issue_id":null,"shipping_coupon_issue_id":null}` | 현재 임시 주문에 적용된 쿠폰 선택 상태 | +| use_points | integer | `0` | 현재 사용 설정된 마일리지 포인트 | +| shipping_address | object\|null | `{"country_code":"KR","zipcode":"06234","region":null,"city":null,"address":null}` | 배송비 계산에 사용된 배송 주소 (미전달 시 `null`) | +| expires_at | string | `2026-07-14T12:34:56+09:00` | 임시 주문 만료 시각 (ISO 8601) | +| available_coupons | array | `[]` | 주문금액/배송비 대상 사용 가능 쿠폰 목록 (비회원은 빈 배열) | +| mileage | object\|null | `{...}` | 마일리지 잔액 정보 (비회원은 `null` — 아래 "mileage" 표) | +| validation_errors | array | `[]` | 조건 위반으로 자동 제외된 쿠폰의 사유 목록 (min_amount / per_user_limit / not_combinable 등) | +| has_unshippable_items | boolean | `false` | 선택된 배송국가로 배송 불가한 상품이 1개라도 있으면 `true` (주문하기 차단 플래그) | +| selected_shipping_country | string | `KR` | 현재 선택된 배송 국가 코드 | +| free_shipping | object | `{...}` | 무료배송 기준액 정보 (아래 "free_shipping" 표) | +| unavailable_items | array | `[]` | 구매 불가 상품 목록 (존재할 때만 포함) | +| has_stock_issue | boolean | `false` | 구매 불가 사유에 재고 부족이 포함되면 `true` (`unavailable_items` 존재 시에만 포함) | +| has_status_issue | boolean | `false` | 구매 불가 사유에 판매 상태 문제가 포함되면 `true` (`unavailable_items` 존재 시에만 포함) | + +`items[]` 항목 (`CheckoutItemResource::toArray()`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer\|null | `1` | 장바구니 아이템 ID (바로구매는 `null`) | +| product_id | integer | `1` | 상품 ID | +| product_option_id | integer | `1` | 상품 옵션 ID | +| quantity | integer | `1` | 주문 수량 | +| additional_options | array | `[]` | 선택한 추가 옵션 (`group_name` / `name` / `price_adjustment`) | +| additional_options_total | integer | `0` | 추가 옵션 단가 합계 | +| product | object\|null | `{...}` | 상품 정보 (이름·썸네일 등) | +| product_option | object\|null | `{...}` | 옵션 정보 (옵션명·판매가 등) | +| is_shippable_to_selected_country | boolean | `true` | 선택된 배송국가로 이 상품이 배송 가능한지 | +| subtotal | integer | `10000` | 표시 소계 = (옵션 판매가 + 추가옵션 단가 합계) × 수량 | +| subtotal_formatted | string | `10,000원` | 소계 통화 포맷 | +| multi_currency_subtotal | object | `{"KRW":{...}}` | 소계 다통화 환산 | +| unit_price | integer | `10000` | 계산 기준 단가 | +| unit_price_formatted | string | `10,000원` | 단가 통화 포맷 | +| product_coupon_discount_amount | integer | `0` | 상품 쿠폰 할인액 | +| product_coupon_discount_formatted | string | `0원` | 상품 쿠폰 할인액 포맷 | +| code_discount_amount | integer | `0` | 할인코드 할인액 | +| code_discount_formatted | string | `0원` | 할인코드 할인액 포맷 | +| order_coupon_discount_share | integer | `0` | 주문 쿠폰 할인의 이 라인 분담액 | +| order_coupon_discount_share_formatted | string | `0원` | 분담액 포맷 | +| total_discount | integer | `0` | 이 라인의 총 할인액 | +| total_discount_formatted | string | `0원` | 총 할인액 포맷 | +| points_used_share | integer | `0` | 사용 마일리지의 이 라인 분담액 | +| points_used_share_formatted | string | `0원` | 분담액 포맷 | +| points_earning | integer | `0` | 이 라인의 적립 예정 마일리지 | +| final_amount | integer | `10000` | 이 라인의 최종 금액 | +| final_amount_formatted | string | `10,000원` | 최종 금액 포맷 | +| multi_currency_unit_price | object | `{"KRW":{"amount":10000,"formatted":"10,000원"}}` | 단가 다통화 환산 | +| multi_currency_product_coupon_discount | object | `{"KRW":{"amount":0,"formatted":"0원"}}` | 상품 쿠폰 할인 다통화 환산 | +| multi_currency_total_discount | object | `{"KRW":{"amount":0,"formatted":"0원"}}` | 총 할인 다통화 환산 | +| multi_currency_final_amount | object | `{"KRW":{"amount":10000,"formatted":"10,000원"}}` | 최종 금액 다통화 환산 | +| available_coupons | array | `[]` | 이 상품에 적용 가능한 상품 쿠폰 목록 | +| disabled_coupon_ids | array | `[]` | 다른 상품 라인에서 이미 소진되어(per_user_limit) 이 라인에서는 선택 불가한 쿠폰 발급 ID | + +`calculation` (`OrderCalculationResult::toArray()`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| items | array | `[...]` | 라인별 계산 결과 | +| summary | object | `{...}` | 합계 정보 (`Summary::toArray()` — 아래 표) | +| promotions | object | `{...}` | 실제 적용된 프로모션 요약 (상품/주문 프로모션 분리 + `coupon_issue_ids` / `item_coupons` / `discount_code` 평탄 키) | +| validation_errors | array | `[]` | 쿠폰 검증 오류 목록 | +| metadata | object | `{}` | 플러그인 확장용 메타데이터 (비어 있으면 생략) | + +`calculation.summary` (`Summary::toArray()`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| subtotal | integer | `10000` | 상품 금액 소계 (할인 전) | +| subtotal_formatted | string | `10,000원` | 소계 통화 포맷 | +| product_coupon_discount | integer | `0` | 상품/카테고리 쿠폰 할인 합계 | +| product_coupon_discount_formatted | string | `0원` | 상품 쿠폰 할인 포맷 | +| code_discount | integer | `0` | 할인코드 할인 합계 | +| code_discount_formatted | string | `0원` | 할인코드 할인 포맷 | +| order_coupon_discount | integer | `0` | 주문 쿠폰 할인 합계 | +| order_coupon_discount_formatted | string | `0원` | 주문 쿠폰 할인 포맷 | +| total_coupon_discount | integer | `0` | 상품 + 주문 쿠폰 할인 합계 | +| total_coupon_discount_formatted | string | `0원` | 쿠폰 할인 합계 포맷 | +| total_discount | integer | `0` | 총 할인금액 | +| discount_formatted | string | `0원` | 총 할인금액 포맷 | +| base_shipping_total | integer | `3000` | 기본 배송비 합계 | +| extra_shipping_total | integer | `0` | 추가 배송비(도서산간) 합계 | +| total_shipping | integer | `3000` | 총 배송비 (기본 + 추가) | +| total_shipping_fee | integer | `3000` | 총 배송비 (별칭 키) | +| shipping_fee_formatted | string | `3,000원` | 총 배송비 포맷 | +| shipping_discount | integer | `0` | 배송비 할인 | +| shipping_discount_formatted | string | `0원` | 배송비 할인 포맷 | +| taxable_amount | integer | `10000` | 과세 금액 합계 | +| tax_free_amount | integer | `0` | 면세 금액 합계 | +| points_earning | integer | `0` | 적립 예정 마일리지 합계 | +| total_mileage | integer | `0` | 적립 예정 마일리지 (별칭 키) | +| mileage_formatted | string | `0P` | 적립 예정 마일리지 포맷 | +| points_used | integer | `0` | 사용 마일리지 합계 | +| points_used_formatted | string | `0원` | 사용 마일리지 차감액 통화 포맷 | +| payment_amount | integer | `13000` | 결제금액 (마일리지 사용 전) | +| payment_amount_formatted | string | `13,000원` | 결제금액 포맷 | +| final_amount | integer | `13000` | 최종 지불금액 | +| final_amount_formatted | string | `13,000원` | 최종 지불금액 포맷 | +| selected_payment_currency | string | `KRW` | 선택된 결제 통화 (설정된 경우에만 포함) | +| multi_currency | object | `{"KRW":{...}}` | 합계 금액의 다통화 환산 (설정된 경우에만 포함) | +| coupon_discount | integer | `0` | 상품 쿠폰 할인 (deprecated — 하위 호환) | +| coupon_discount_formatted | string | `0원` | 상품 쿠폰 할인 포맷 (deprecated) | +| order_discount | integer | `0` | 주문 쿠폰 할인 (deprecated — 하위 호환) | +| order_discount_formatted | string | `0원` | 주문 쿠폰 할인 포맷 (deprecated) | + +`mileage` (`UserMileageService::getBalance()` + 체크아웃 추가 키, 비회원은 `null`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| enabled | boolean | `true` | 마일리지 적립 기능 활성화 여부 | +| available | integer | `0` | 사용 가능 마일리지 잔액 | +| pending | integer | `0` | 적립 대기 중인 마일리지 | +| expiring_soon | integer | `0` | 곧 소멸 예정 마일리지 | +| expiring_date | string\|null | `2026-08-31` | 소멸 예정일 (없으면 `null`) | +| total_earned | integer | `0` | 누적 적립 마일리지 | +| total_used | integer | `0` | 누적 사용 마일리지 | +| by_currency | object | `{}` | 통화별 마일리지 내역 | +| max_usable | integer | `0` | 이번 주문에서 사용 가능한 최대 마일리지 | +| usable | boolean | `true` | 마일리지 "사용" 가능 여부 (기본 통화 사용 규칙 미설정 시 `false`) | + +`free_shipping` (`CheckoutDataService::buildFreeShippingInfo()`): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| enabled | boolean | `false` | 무료배송 기준액 기능 활성화 여부 | +| threshold_base | integer | `50000` | 무료배송 기준액 (기본 통화 정수) | +| threshold_multi_currency | object | `{"KRW":{...}}` | 기준액의 결제통화 환산 (기준액 0 이면 `[]`) | +| remaining_base | integer | `40000` | 무료배송까지 남은 금액 (기본 통화) | +| remaining_multi_currency | object | `{"KRW":{...}}` | 남은 금액의 결제통화 환산 (남은 금액 0 이면 `[]`) | **응답 예시** - +```json +{ + "success": true, + "message": "주문서 정보를 조회했습니다.", + "data": { + "temp_order_id": 1, + "items": [ + { + "id": 1, + "product_id": 1, + "product_option_id": 1, + "quantity": 1, + "additional_options": [], + "additional_options_total": 0, + "product": { "id": 1, "name": "샘플 상품" }, + "product_option": { "id": 1, "name": "기본 옵션" }, + "is_shippable_to_selected_country": true, + "subtotal": 10000, + "subtotal_formatted": "10,000원", + "multi_currency_subtotal": {}, + "unit_price": 10000, + "unit_price_formatted": "10,000원", + "product_coupon_discount_amount": 0, + "product_coupon_discount_formatted": "0원", + "code_discount_amount": 0, + "code_discount_formatted": "0원", + "order_coupon_discount_share": 0, + "order_coupon_discount_share_formatted": "0원", + "total_discount": 0, + "total_discount_formatted": "0원", + "points_used_share": 0, + "points_used_share_formatted": "0원", + "points_earning": 0, + "final_amount": 10000, + "final_amount_formatted": "10,000원", + "available_coupons": [], + "disabled_coupon_ids": [] + } + ], + "calculation": { + "items": [], + "summary": { + "subtotal": 10000, + "subtotal_formatted": "10,000원", + "total_discount": 0, + "discount_formatted": "0원", + "base_shipping_total": 3000, + "extra_shipping_total": 0, + "total_shipping": 3000, + "shipping_fee_formatted": "3,000원", + "points_earning": 0, + "points_used": 0, + "points_used_formatted": "0원", + "payment_amount": 13000, + "payment_amount_formatted": "13,000원", + "final_amount": 13000, + "final_amount_formatted": "13,000원" + }, + "promotions": { + "coupon_issue_ids": [], + "item_coupons": {}, + "discount_code": null + }, + "validation_errors": [] + }, + "promotions": { + "item_coupons": {}, + "order_coupon_issue_id": null, + "shipping_coupon_issue_id": null + }, + "use_points": 0, + "shipping_address": { + "country_code": "KR", + "zipcode": "06234", + "region": null, + "city": null, + "address": null + }, + "expires_at": "2026-07-14T12:34:56+09:00", + "available_coupons": [], + "mileage": { + "enabled": true, + "available": 0, + "pending": 0, + "expiring_soon": 0, + "expiring_date": null, + "total_earned": 0, + "total_used": 0, + "by_currency": {}, + "max_usable": 0, + "usable": true + }, + "validation_errors": [], + "has_unshippable_items": false, + "selected_shipping_country": "KR", + "free_shipping": { + "enabled": false, + "threshold_base": 0, + "threshold_multi_currency": [], + "remaining_base": 0, + "remaining_multi_currency": [] + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 404 | Not Found | 임시 주문이 만료되었거나 존재하지 않음. 메시지: `임시 주문 정보를 찾을 수 없습니다.` | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 조회/재계산 중 예외 발생 | **설명** 현재 유효한 임시 주문을 조회하면서 최신 가격으로 실시간 재계산해 주문서 페이지 데이터를 반환합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@show`가 `TempOrderService::getTempOrderWithCalculation()`으로 재계산하고 `CheckoutDataService::buildResponseData()`가 쿠폰·마일리지·상품·구매불가 상품 정보를 포함해 응답을 구성합니다. 쿼리로 `country_code`/`zipcode`/`region` 등 배송 주소를 전달하면 해당 주소 기준 배송비가 계산되며, 우편번호 없이 배송국가만으로도 미리보기 배송비를 산출합니다. 임시 주문이 만료·미존재면 404를 반환합니다. +응답의 `mileage_info` 에는 잔액(`available`)·사용 가능 최대(`max_usable`)·사용 가능 여부(`usable`)와 함께 사용 정책 `usage_policy`(`min_use_amount`, `use_unit`, `max_use_amount`)가 포함됩니다. `max_usable` 과 `usage_policy.max_use_amount` 는 저장 API 가 판정하는 기준(마일리지 차감 전 결제금액)과 동일한 기준으로 산출되므로, 입력 UI 는 이 값을 그대로 상·하한으로 사용하면 됩니다. 조회는 사용 한도를 초과한 임시 주문이 있어도 200 을 반환합니다(차단은 저장 시점에서만). + ### POST /api/modules/sirsoft-ecommerce/checkout @@ -108,7 +364,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | item_ids | body | array | 아니오 | min 1 | item 식별자 배열 | -| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/option_values/quantity, item_ids와 택일) | +| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/product_option_id/quantity, item_ids와 택일) | | coupon_issue_ids | body | array | 아니오 | — | coupon issue 식별자 배열 | | use_points | body | integer | 아니오 | min 0 | 사용할 마일리지(적립금) 포인트 | @@ -139,21 +395,58 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CheckoutController@store` 가 직접 조립 — 성공 시 HTTP 201)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| temp_order_id | integer | `1` | 생성된 임시 주문 ID (이후 조회/수정/주문 확정에 사용) | +| calculation | object | `{...}` | 주문 계산 결과 (`OrderCalculationResult::toArray()` — `items` / `summary` / `promotions` / `validation_errors`. 상세는 GET /checkout 의 calculation 표 참조) | +| expires_at | string | `2026-07-14T12:34:56+09:00` | 임시 주문 만료 시각 (ISO 8601) | **응답 예시** - +```json +{ + "success": true, + "message": "주문서가 생성되었습니다.", + "data": { + "temp_order_id": 1, + "calculation": { + "items": [], + "summary": { + "subtotal": 10000, + "subtotal_formatted": "10,000원", + "total_discount": 0, + "total_shipping": 3000, + "payment_amount": 13000, + "final_amount": 13000, + "final_amount_formatted": "13,000원" + }, + "promotions": { + "coupon_issue_ids": [], + "item_coupons": {}, + "discount_code": null + }, + "validation_errors": [] + }, + "expires_at": "2026-07-14T12:34:56+09:00" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 400 | Bad Request | 장바구니가 비어 있음. 메시지: `장바구니가 비어있습니다.` | +| 422 | Unprocessable Entity | 보유 잔액을 초과하는 마일리지 사용 요청 (`error.code: mileage_exceeds_balance`). 메시지: `주문서 생성에 실패했습니다.` | +| 500 | Internal Server Error | 임시 주문 생성 중 예외 발생. 메시지: `주문서 생성에 실패했습니다.` | -**설명** 장바구니에서 선택한 아이템으로 임시 주문을 생성해 주문서 작성 단계로 진입합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@store`가 `direct_items`가 있으면 `TempOrderService::createTempOrderFromDirectItems()`(바로 구매, 장바구니 미경유), 없으면 `item_ids`로 `createTempOrderFromSelectedItems()`(장바구니 경유)를 호출합니다. 응답에는 임시 주문 ID·계산 결과·만료 시각(`expires_at`)이 포함됩니다. 재고 부족·판매 중지·구매 제한 상품이 있으면 400(cart_unavailable), 보유 잔액을 넘는 마일리지 사용은 422, 빈 장바구니는 400을 반환합니다. +**설명** 장바구니에서 선택한 아이템으로 임시 주문을 생성해 주문서 작성 단계로 진입합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@store`가 `direct_items`가 있으면 `TempOrderService::createTempOrderFromDirectItems()`(바로 구매, 장바구니 미경유), 없으면 `item_ids`로 `createTempOrderFromSelectedItems()`(장바구니 경유)를 호출합니다. 응답에는 임시 주문 ID·계산 결과·만료 시각(`expires_at`)이 포함됩니다. 재고 부족·판매 중지·구매 제한 상품이 있으면 400(cart_unavailable), 빈 장바구니는 400을 반환합니다. + +마일리지 사용은 보유 잔액뿐 아니라 관리자 설정 사용 정책(최소 사용액 `min_use_amount`, 사용 단위 `use_unit`, 최대 한도 `max_use_percent`/`max_use_value`)까지 서버에서 강제하며, 위반 시 422 를 반환합니다. 한도 초과 응답 메시지에는 사용 가능한 최대 금액이 함께 안내됩니다. 판정 기준 금액은 마일리지 차감 전 결제금액이며, 비회원의 `use_points` 는 예외 없이 0 으로 처리됩니다. ### PUT /api/modules/sirsoft-ecommerce/checkout @@ -200,21 +493,97 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. GET `/api/modules/sirsoft-ecommerce/checkout` 과 동일한 구조입니다 (`CheckoutDataService::buildResponseData()` 를 그대로 사용)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| temp_order_id | integer | `1` | 임시 주문 ID | +| items | array | `[...]` | 재계산된 주문 상품 목록 (`CheckoutItemResource` — GET 문서의 `items[]` 표와 동일) | +| calculation | object | `{...}` | 재계산된 주문 계산 결과 (`items` / `summary` / `promotions` / `validation_errors`) | +| promotions | object | `{"item_coupons":{},"order_coupon_issue_id":null,"shipping_coupon_issue_id":null}` | 반영된 쿠폰 선택 상태 (미전송 필드는 기존 값 유지) | +| use_points | integer | `0` | 반영된 마일리지 사용 포인트 | +| shipping_address | object\|null | `{"country_code":"KR","zipcode":"06234"}` | 배송비 계산에 사용된 배송 주소 | +| expires_at | string | `2026-07-14T12:34:56+09:00` | 임시 주문 만료 시각 (ISO 8601) | +| available_coupons | array | `[]` | 주문금액/배송비 대상 사용 가능 쿠폰 (비회원은 빈 배열) | +| mileage | object\|null | `{...}` | 마일리지 잔액 정보 (비회원은 `null`) | +| validation_errors | array | `[]` | 조건 위반으로 자동 제외된 쿠폰의 사유 목록 | +| has_unshippable_items | boolean | `false` | 선택된 배송국가로 배송 불가한 상품 존재 여부 | +| selected_shipping_country | string | `KR` | 현재 선택된 배송 국가 코드 | +| free_shipping | object | `{...}` | 무료배송 기준액 정보 (`enabled` / `threshold_base` / `threshold_multi_currency` / `remaining_base` / `remaining_multi_currency`) | **응답 예시** - +```json +{ + "success": true, + "message": "주문서가 수정되었습니다.", + "data": { + "temp_order_id": 1, + "items": [], + "calculation": { + "items": [], + "summary": { + "subtotal": 10000, + "subtotal_formatted": "10,000원", + "total_discount": 1000, + "discount_formatted": "1,000원", + "total_shipping": 3000, + "shipping_fee_formatted": "3,000원", + "points_used": 0, + "payment_amount": 12000, + "final_amount": 12000, + "final_amount_formatted": "12,000원" + }, + "promotions": { + "coupon_issue_ids": [10], + "item_coupons": {}, + "discount_code": null + }, + "validation_errors": [] + }, + "promotions": { + "item_coupons": {}, + "order_coupon_issue_id": 10, + "shipping_coupon_issue_id": null + }, + "use_points": 0, + "shipping_address": { + "country_code": "KR", + "zipcode": "06234" + }, + "expires_at": "2026-07-14T12:34:56+09:00", + "available_coupons": [], + "mileage": { + "enabled": true, + "available": 0, + "max_usable": 0, + "usable": true + }, + "validation_errors": [], + "has_unshippable_items": false, + "selected_shipping_country": "KR", + "free_shipping": { + "enabled": false, + "threshold_base": 0, + "threshold_multi_currency": [], + "remaining_base": 0, + "remaining_multi_currency": [] + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | 임시 주문이 만료되었거나 존재하지 않음 (`TempOrderNotFoundException`). 메시지: `임시 주문 정보를 찾을 수 없습니다.` | +| 422 | Unprocessable Entity | 보유 잔액을 초과하는 마일리지 사용 요청 (`error.code: mileage_exceeds_balance`). 메시지: `주문서 수정에 실패했습니다.` | +| 500 | Internal Server Error | 재계산 중 예외 발생. 메시지: `주문서 수정에 실패했습니다.` | -**설명** 주문서 작성 중 쿠폰·마일리지·배송 주소가 변경될 때 임시 주문 금액을 재계산합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@update`가 전송된 프로모션 필드(`item_coupons`·`order_coupon_issue_id`·`shipping_coupon_issue_id`)와 `use_points`만 반영하고 미전송 필드는 `TempOrderService::updateTempOrder()`에서 기존 값을 유지합니다. `zipcode`/`country_code`로 배송 주소를 함께 넘기면 배송비가 다시 계산됩니다. 임시 주문이 만료·미존재면 404, 보유 잔액 초과 마일리지는 422를 반환합니다. +**설명** 주문서 작성 중 쿠폰·마일리지·배송 주소가 변경될 때 임시 주문 금액을 재계산합니다. `optional.sanctum`으로 회원/비회원 모두 접근하며, `CheckoutController@update`가 전송된 프로모션 필드(`item_coupons`·`order_coupon_issue_id`·`shipping_coupon_issue_id`)와 `use_points`만 반영하고 미전송 필드는 `TempOrderService::updateTempOrder()`에서 기존 값을 유지합니다. `zipcode`/`country_code`로 배송 주소를 함께 넘기면 배송비가 다시 계산됩니다. 임시 주문이 만료·미존재면 404, 보유 잔액 초과 또는 사용 정책(최소 사용액·사용 단위·최대 한도) 위반 마일리지는 422를 반환합니다. ### POST /api/modules/sirsoft-ecommerce/checkout/extend @@ -238,15 +607,30 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| expires_at | string | `2026-07-14T13:04:56+09:00` | 연장된 임시 주문 만료 시각 (ISO 8601) | **응답 예시** - +```json +{ + "success": true, + "message": "주문서 유효시간이 연장되었습니다.", + "data": { + "expires_at": "2026-07-14T13:04:56+09:00" + } +} +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | 연장할 임시 주문이 이미 만료되었거나 존재하지 않음 (`TempOrderService::extendExpiration()` 이 null 반환). 메시지: `임시 주문 정보를 찾을 수 없습니다.` | +| 500 | Internal Server Error | 연장 처리 중 예외 발생. 메시지: `주문서 유효시간 연장에 실패했습니다.` | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md index c6568004..9a6b948d 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Claim Reasons 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -25,12 +25,17 @@ **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) | +| fault_type | query | string | 아니오 | — | | +| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/claim-reasons HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/claim-reasons?type=%EC%98%88%EC%8B%9C%EA%B0%92&fault_type=%EC%98%88%EC%8B%9C%EA%B0%92&is_active=1&search=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -42,19 +47,19 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `8` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| number | integer | `7` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | | id | integer | `1` | 기본 키 (내부 식별자) | -| type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| type | string | `refund` | 클레임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | | code | string | `order_mistake` | 사유 식별 코드 (같은 type 내 고유, 영문 소문자/숫자/`_`) | -| name | object | `{"ko":"주문 실수","en":"Order Mistake","ja":"注文ミス"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name | object | `{"ko":"주문 실수","en":"Order Mistake"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | | localized_name | string | `주문 실수` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | fault_type | string | `customer` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | | fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | is_user_selectable | boolean | `true` | user selectable 여부 | | is_active | boolean | `true` | active 여부 | | sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-05-27 15:20:43` | 생성 일시 | -| updated_at | string | `2026-06-27 00:49:51` | 최종 수정 일시 | +| created_at | string | `2026-07-30 17:36:00` | 생성 일시 | +| updated_at | string | `2026-07-30 17:36:00` | 최종 수정 일시 | | creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | | updater | array | `[]` | 최종 수정자 정보 객체 (id/name — updater 관계 로드 시) | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -68,11 +73,11 @@ HTTP/1.1 200 ```json { "success": true, - "message": "클래임 사유 목록을 조회했습니다.", + "message": "클레임 사유 목록을 조회했습니다.", "data": { "data": [ { - "number": 8, + "number": 7, "id": 1, "type": "refund", "code": "order_mistake", @@ -86,8 +91,8 @@ HTTP/1.1 200 "is_user_selectable": true, "is_active": true, "sort_order": 0, - "created_at": "2026-07-08 10:43:32", - "updated_at": "2026-07-08 10:43:32", + "created_at": "2026-07-30 17:36:00", + "updated_at": "2026-07-30 17:36:00", "creator": [], "updater": [], "abilities": { @@ -97,22 +102,22 @@ HTTP/1.1 200 } }, { - "number": 7, - "id": 8, + "number": 6, + "id": 2, "type": "refund", - "code": "apidoc_sample", + "code": "changed_mind", "name": { - "ko": "API 문서 샘플 사유", - "en": "API Doc Sample Reason" + "ko": "단순 변심", + "en": "Changed Mind" }, - "localized_name": "API 문서 샘플 사유", + "localized_name": "단순 변심", "fault_type": "customer", "fault_type_label": "고객 귀책", "is_user_selectable": true, "is_active": true, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", + "sort_order": 1, + "created_at": "2026-07-30 17:36:00", + "updated_at": "2026-07-30 17:36:00", "creator": [], "updater": [], "abilities": { @@ -121,7 +126,7 @@ HTTP/1.1 200 "can_delete": true } }, - "... (총 8건 중 2건 표시)" + "... (총 7건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -138,10 +143,11 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -**설명** 관리자가 클래임(반품/교환/환불) 사유 마스터 목록을 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `type`(기본 refund)·`is_active`·`fault_type`·`search` 쿼리로 필터링할 수 있습니다. `ClaimReasonService::getAllReasons()`가 조회하고 `ClaimReasonCollection`으로 반환하며, 각 항목은 다국어 사유명(`name`)과 귀책 구분(`fault_type`: customer·seller·carrier), 사용자 노출 여부(`is_user_selectable`)를 포함합니다. 환경설정의 클래임 사유 관리 화면에서 사용됩니다. +**설명** 관리자가 클레임(반품/교환/환불) 사유 마스터 목록을 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `type`(기본 refund)·`is_active`·`fault_type`·`search` 쿼리로 필터링할 수 있습니다. `ClaimReasonService::getAllReasons()`가 조회하고 `ClaimReasonCollection`으로 반환하며, 각 항목은 다국어 사유명(`name`)과 귀책 구분(`fault_type`: customer·seller·carrier), 사용자 노출 여부(`is_user_selectable`)를 포함합니다. 환경설정의 클레임 사유 관리 화면에서 사용됩니다. ### POST /api/modules/sirsoft-ecommerce/admin/claim-reasons @@ -154,7 +160,7 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| type | body | string | 예 | — | 클래임 사유 유형 (ClaimReasonTypeEnum — 현재 `refund`(환불/취소)) | +| type | body | string | 예 | — | 클레임 사유 유형 (ClaimReasonTypeEnum — 현재 `refund`(환불/취소)) | | code | body | string | 예 | max 50 | 사유 식별 코드 (영문 소문자/숫자/`_`, 같은 type 내에서 고유) | | name | body | array | 예 | — | 대상의 이름/명칭 | | fault_type | body | string | 예 | — | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | @@ -186,131 +192,50 @@ Content-Type: application/json **응답 필드** (`data` 내부) - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | - - - -**설명** 관리자가 새 클래임 사유를 생성합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 유형(`type`)·고유 코드(`code`)·다국어 사유명(`name`)·귀책 구분(`fault_type`)을 필수로 받고 사용자 노출 여부·활성 여부·정렬 순서를 선택 입력합니다. `ClaimReasonService::createReason()`이 저장하고 생성된 사유를 201로 반환합니다. `code` 는 유형 내에서 고유해야 하며 회원 취소/반품 화면의 사유 선택지로 활용됩니다. - - -### GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/active - -- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.active` -- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@active` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` - -**요청 파라미터** - -_요청 파라미터 없음._ - -**요청 예시** - -```http -GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/active HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - -_목록 응답: `data.data[]` 배열 항목의 필드._ +_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`)._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `8` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | integer | `1` | 기본 키 (내부 식별자) | +| id | integer | `8` | 생성된 클래임 사유의 기본 키 (내부 식별자) | | type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | -| code | string | `order_mistake` | 사유 식별 코드 (같은 type 내 고유, 영문 소문자/숫자/`_`) | -| name | object | `{"ko":"주문 실수","en":"Order Mistake","ja":"注文ミス"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `주문 실수` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| code | string | `apidoc_sample` | 사유 식별 코드 (같은 type 내 고유) | +| name | object | `{"ko":"API 문서 샘플 사유","en":"API Doc Sample Reason"}` | 다국어 사유명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 사유` | `name` 의 현재 로케일 해석 값 | | fault_type | string | `customer` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | -| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| is_user_selectable | boolean | `true` | user selectable 여부 | -| is_active | boolean | `true` | active 여부 | +| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (Enum label 파생) | +| is_user_selectable | boolean | `true` | 회원 취소/반품 신청 화면에서 선택지로 노출할지 여부 | +| is_active | boolean | `true` | 활성 여부 (false 면 선택지에서 제외) | | sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-05-27 15:20:43` | 생성 일시 | -| updated_at | string | `2026-06-27 00:49:51` | 최종 수정 일시 | -| creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | -| updater | array | `[]` | 최종 수정자 정보 객체 (id/name — updater 관계 로드 시) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (`sirsoft-ecommerce.settings.update` 권한 기반) | **응답 예시** ```http -HTTP/1.1 200 +HTTP/1.1 201 ``` ```json { "success": true, - "message": "클래임 사유 목록을 조회했습니다.", + "message": "클래임 사유가 등록되었습니다.", "data": { - "data": [ - { - "number": 8, - "id": 1, - "type": "refund", - "code": "order_mistake", - "name": { - "ko": "주문 실수", - "en": "Order Mistake" - }, - "localized_name": "주문 실수", - "fault_type": "customer", - "fault_type_label": "고객 귀책", - "is_user_selectable": true, - "is_active": true, - "sort_order": 0, - "created_at": "2026-07-08 10:43:32", - "updated_at": "2026-07-08 10:43:32", - "creator": [], - "updater": [], - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - }, - { - "number": 7, - "id": 8, - "type": "refund", - "code": "apidoc_sample", - "name": { - "ko": "API 문서 샘플 사유", - "en": "API Doc Sample Reason" - }, - "localized_name": "API 문서 샘플 사유", - "fault_type": "customer", - "fault_type_label": "고객 귀책", - "is_user_selectable": true, - "is_active": true, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "creator": [], - "updater": [], - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - }, - "... (총 8건 중 2건 표시)" - ], + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유", + "en": "API Doc Sample Reason" + }, + "localized_name": "API 문서 샘플 사유", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", "abilities": { "can_create": true, "can_update": true, @@ -325,29 +250,30 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 같은 `type` 내에 동일한 `code` 가 이미 존재하면 `code` 필드 검증 실패 | -**설명** 활성화된 클래임 사유만 추려 Select 옵션용으로 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `type` 쿼리(기본 refund)로 유형을 지정하면 `ClaimReasonService::getActiveReasons()`가 `is_active=true` 인 사유만 반환합니다. 관리자 화면에서 환불/취소 처리 시 사유 드롭다운을 채우는 용도로, 목록(index)과 달리 필터 없이 활성 사유만 내려주는 점이 다릅니다. +**설명** 관리자가 새 클레임 사유를 생성합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 유형(`type`)·고유 코드(`code`)·다국어 사유명(`name`)·귀책 구분(`fault_type`)을 필수로 받고 사용자 노출 여부·활성 여부·정렬 순서를 선택 입력합니다. `ClaimReasonService::createReason()`이 저장하고 생성된 사유를 201로 반환합니다. `code` 는 유형 내에서 고유해야 하며 회원 취소/반품 화면의 사유 선택지로 활용됩니다. -### DELETE /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} - -- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.destroy` -- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@destroy` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` +### GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/active + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.active` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@active` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` **요청 파라미터** | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| id | path | string | 예 | — | 대상 리소스의 식별자 | +| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) | **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/claim-reasons/8 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -405,7 +331,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/8 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -501,7 +427,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/claim-reasons/8 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -522,20 +448,68 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `8` | 수정된 클래임 사유의 기본 키 (내부 식별자) | +| type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| code | string | `apidoc_sample` | 사유 식별 코드 (같은 type 내 고유) | +| name | object | `{"ko":"API 문서 샘플 사유(수정)","en":"API Doc Sample Reason (Updated)"}` | 다국어 사유명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 사유(수정)` | `name` 의 현재 로케일 해석 값 | +| fault_type | string | `seller` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| fault_type_label | string | `판매자 귀책` | `fault_type` 값의 사람이 읽는 라벨 (Enum label 파생) | +| is_user_selectable | boolean | `true` | 회원 취소/반품 신청 화면에서 선택지로 노출할지 여부 | +| is_active | boolean | `true` | 활성 여부 (false 면 선택지에서 제외) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:18` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (`sirsoft-ecommerce.settings.update` 권한 기반) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클래임 사유가 수정되었습니다.", + "data": { + "id": 8, + "type": "refund", + "code": "apidoc_sample", + "name": { + "ko": "API 문서 샘플 사유(수정)", + "en": "API Doc Sample Reason (Updated)" + }, + "localized_name": "API 문서 샘플 사유(수정)", + "fault_type": "seller", + "fault_type_label": "판매자 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:18", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 대상 사유가 없거나 갱신에 실패한 경우 (서비스 예외 → `exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 같은 `type` 내 다른 사유가 동일한 `code` 를 이미 사용 중이면 `code` 필드 검증 실패 | @@ -557,7 +531,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/claim-reasons/8/toggle-status HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id}/toggle-status HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -658,17 +632,17 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | --- | --- | --- | --- | | number | integer | `7` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | | id | integer | `1` | 기본 키 (내부 식별자) | -| type | string | `refund` | 클래임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| type | string | `refund` | 클레임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | | code | string | `order_mistake` | 사유 식별 코드 (같은 type 내 고유, 영문 소문자/숫자/`_`) | -| name | object | `{"ko":"주문 실수","en":"Order Mistake","ja":"注文ミス"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name | object | `{"ko":"주문 실수","en":"Order Mistake"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | | localized_name | string | `주문 실수` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | fault_type | string | `customer` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | | fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | is_user_selectable | boolean | `true` | user selectable 여부 | | is_active | boolean | `true` | active 여부 | | sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-05-27 15:20:43` | 생성 일시 | -| updated_at | string | `2026-06-27 00:49:51` | 최종 수정 일시 | +| created_at | string | `2026-07-30 17:36:00` | 생성 일시 | +| updated_at | string | `2026-07-30 17:36:00` | 최종 수정 일시 | | creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | | updater | array | `[]` | 최종 수정자 정보 객체 (id/name — updater 관계 로드 시) | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -682,7 +656,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "클래임 사유 목록을 조회했습니다.", + "message": "클레임 사유 목록을 조회했습니다.", "data": { "data": [ { @@ -700,8 +674,8 @@ HTTP/1.1 200 "is_user_selectable": true, "is_active": true, "sort_order": 0, - "created_at": "2026-07-08 10:43:32", - "updated_at": "2026-07-08 10:43:32", + "created_at": "2026-07-30 17:36:00", + "updated_at": "2026-07-30 17:36:00", "creator": [], "updater": [], "abilities": { @@ -712,21 +686,21 @@ HTTP/1.1 200 }, { "number": 6, - "id": 8, + "id": 2, "type": "refund", - "code": "apidoc_sample", + "code": "changed_mind", "name": { - "ko": "API 문서 샘플 사유", - "en": "API Doc Sample Reason" + "ko": "단순 변심", + "en": "Changed Mind" }, - "localized_name": "API 문서 샘플 사유", + "localized_name": "단순 변심", "fault_type": "customer", "fault_type_label": "고객 귀책", "is_user_selectable": true, "is_active": true, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", + "sort_order": 1, + "created_at": "2026-07-30 17:36:00", + "updated_at": "2026-07-30 17:36:00", "creator": [], "updater": [], "abilities": { @@ -750,10 +724,330 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -**설명** 회원(및 선택적으로 비회원)이 주문 취소/반품 신청 화면에서 선택할 수 있는 클래임 사유 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근)과 `permission:sirsoft-ecommerce.user-orders.cancel` 권한이 적용되며, `ClaimReasonService::getUserSelectableReasons()`가 활성이면서 `is_user_selectable=true` 인 사유만 반환합니다. 관리 전용 목록과 달리 사용자에게 공개 가능한 사유만 내려주는 사용자향 엔드포인트입니다. +**설명** 활성화된 클레임 사유만 추려 Select 옵션용으로 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `type` 쿼리(기본 refund)로 유형을 지정하면 `ClaimReasonService::getActiveReasons()`가 `is_active=true` 인 사유만 반환합니다. 관리자 화면에서 환불/취소 처리 시 사유 드롭다운을 채우는 용도로, 목록(index)과 달리 필터 없이 활성 사유만 내려주는 점이 다릅니다. + + +### DELETE /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.destroy` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 클레임 사유 1건을 삭제합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 대상이 없으면 404 를 반환하고 존재하면 `ClaimReasonService::deleteReason()`이 삭제합니다. 이미 사용 중인 사유 등 삭제 불가 상황에서는 서비스가 던진 예외 메시지를 그대로 사용해 400 으로 응답하므로, 관리자에게 삭제 실패 사유가 노출됩니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 클레임 사유 1건의 상세 정보를 조회합니다. `permission:sirsoft-ecommerce.settings.read` 권한이 필요하며, `ClaimReasonService::getReason()`이 대상을 조회해 `ClaimReasonResource`로 반환합니다. 사유 편집 폼을 열 때 기존 값(다국어 사유명·코드·귀책 구분·활성/노출 설정 등)을 채우는 용도로 사용되며, 해당 사유가 없으면 404 를 반환합니다. + + +### PUT /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.update` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| type | body | string | 예 | — | 클레임 사유 유형 (ClaimReasonTypeEnum — 현재 `refund`(환불/취소)) | +| code | body | string | 예 | max 50 | 사유 식별 코드 (영문 소문자/숫자/`_`, 같은 type 내에서 고유) | +| name | body | array | 예 | — | 대상의 이름/명칭 | +| fault_type | body | string | 예 | — | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| is_user_selectable | body | boolean | 아니오 | — | user selectable 여부 | +| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) | +| sort_order | body | integer | 아니오 | min 0 | 표시 정렬 순서 값 (작을수록 우선) | + +**요청 예시** + +```http +PUT /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "type": "예시값", + "code": "예시값", + "name": [ + "예시 이름" + ], + "fault_type": "예시값", + "is_user_selectable": true, + "is_active": true, + "sort_order": 1 +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 기존 클레임 사유를 수정합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하고 대상은 경로 `id` 로 지정하며, 생성과 동일한 필드(유형·코드·다국어 사유명·귀책 구분·노출/활성/정렬)를 받아 `ClaimReasonService::updateReason()`이 갱신하고 갱신된 사유를 반환합니다. 대상이 없거나 갱신 실패 시 400 오류로 응답합니다. + + +### PATCH /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id}/toggle-status + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.claim-reasons.toggle-status` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@toggleStatus` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.settings.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +PATCH /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id}/toggle-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 클레임 사유의 활성 상태를 켜고 끄는 토글을 수행합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, `ClaimReasonService::toggleStatus()`가 대상 사유의 `is_active` 값을 반전시켜 저장하고 갱신된 사유를 반환합니다. 사유를 삭제하지 않고 일시적으로 회원 선택지에서 감추거나 다시 노출할 때 사용합니다. + + +### GET /api/modules/sirsoft-ecommerce/user/claim-reasons + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.claim-reasons.index` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ClaimReasonController@userSelectableReasons` +- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근) + `permission:sirsoft-ecommerce.user-orders.cancel` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/claim-reasons?type=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) +``` + +**응답 필드** (`data` 내부) + +_목록 응답: `data.data[]` 배열 항목의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `6` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1` | 기본 키 (내부 식별자) | +| type | string | `refund` | 클레임 사유 유형 (ClaimReasonTypeEnum — `refund`(환불/취소)) | +| code | string | `order_mistake` | 사유 식별 코드 (같은 type 내 고유, 영문 소문자/숫자/`_`) | +| name | object | `{"ko":"주문 실수","en":"Order Mistake"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `주문 실수` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| fault_type | string | `customer` | 귀책 구분 (ClaimReasonFaultTypeEnum — `customer`(고객)/`seller`(판매자)/`carrier`(배송사)) | +| fault_type_label | string | `고객 귀책` | `fault_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| is_user_selectable | boolean | `true` | user selectable 여부 | +| is_active | boolean | `true` | active 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-30 17:36:00` | 생성 일시 | +| updated_at | string | `2026-07-30 17:36:00` | 최종 수정 일시 | +| creator | array | `[]` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| updater | array | `[]` | 최종 수정자 정보 객체 (id/name — updater 관계 로드 시) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "클레임 사유 목록을 조회했습니다.", + "data": { + "data": [ + { + "number": 6, + "id": 1, + "type": "refund", + "code": "order_mistake", + "name": { + "ko": "주문 실수", + "en": "Order Mistake" + }, + "localized_name": "주문 실수", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-30 17:36:00", + "updated_at": "2026-07-30 17:36:00", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + { + "number": 5, + "id": 2, + "type": "refund", + "code": "changed_mind", + "name": { + "ko": "단순 변심", + "en": "Changed Mind" + }, + "localized_name": "단순 변심", + "fault_type": "customer", + "fault_type_label": "고객 귀책", + "is_user_selectable": true, + "is_active": true, + "sort_order": 1, + "created_at": "2026-07-30 17:36:00", + "updated_at": "2026-07-30 17:36:00", + "creator": [], + "updater": [], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 6건 중 2건 표시)" + ], + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 회원(및 선택적으로 비회원)이 주문 취소/반품 신청 화면에서 선택할 수 있는 클레임 사유 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근)과 `permission:sirsoft-ecommerce.user-orders.cancel` 권한이 적용되며, `ClaimReasonService::getUserSelectableReasons()`가 활성이면서 `is_user_selectable=true` 인 사유만 반환합니다. 관리 전용 목록과 달리 사용자에게 공개 가능한 사유만 내려주는 사용자향 엔드포인트입니다. diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md index e1abac71..e6d58135 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Coupons 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -47,7 +47,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| coupons | object | `{"data":[{"id":8611,"coupon_id":156,"user_id":"a1e0a91a-f…` | 회원이 발급받은 쿠폰(발급 내역) 페이지네이션 객체 (`data[]` 발급 건 + `pagination` — CouponIssueCollection 직렬화, 쿠폰 정의가 아닌 회원별 발급 건) | +| coupons | object | `{"data":[{"id":2512,"coupon_id":107,"user_id":"a26219fc-9…` | 회원이 발급받은 쿠폰(발급 내역) 페이지네이션 객체 (`data[]` 발급 건 + `pagination` — CouponIssueCollection 직렬화, 쿠폰 정의가 아닌 회원별 발급 건) | **응답 예시** @@ -61,15 +61,56 @@ HTTP/1.1 200 "message": "쿠폰 목록을 조회했습니다.", "data": { "coupons": { - "data": [], + "data": [ + { + "id": 2512, + "coupon_id": 107, + "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", + "coupon_code": "DL-ORG8PPAH", + "status": "used", + "...": "(12개 키 생략, 총 17개)" + }, + { + "id": 2513, + "coupon_id": 61, + "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", + "coupon_code": "DR-WC3RLEDB", + "status": "used", + "...": "(12개 키 생략, 총 17개)" + }, + { + "id": 2514, + "coupon_id": 62, + "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", + "coupon_code": "DR-VHQFNYRR", + "status": "used", + "...": "(12개 키 생략, 총 17개)" + }, + { + "id": 2515, + "coupon_id": 63, + "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", + "coupon_code": "DR-4QRXNXAI", + "status": "used", + "...": "(12개 키 생략, 총 17개)" + }, + { + "id": 2516, + "coupon_id": 64, + "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", + "coupon_code": "DR-IT4CCTSM", + "status": "used", + "...": "(12개 키 생략, 총 17개)" + }, + "... (총 25건 중 5건 표시)" + ], "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 2, "per_page": 25, - "total": 0, - "from": null, - "to": null, - "has_more_pages": false + "total": 41, + "from": 1, + "...": "(2개 키 생략, 총 7개)" } } } @@ -117,7 +158,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| coupons | array | `[{"id":4702,"coupon_id":106,"user_id":1,"coupon_code":nul…` | 현재 장바구니 상품에 적용 가능한 보유 쿠폰(발급 건) 배열 (상품/카테고리 범위·최소 주문금액·유효기간을 만족해 주문에 곧바로 선택 가능한 후보만) | +| coupons | array | `[{"id":2534,"coupon_id":85,"user_id":1209,"coupon_code":"…` | 현재 장바구니 상품에 적용 가능한 보유 쿠폰(발급 건) 배열 (상품/카테고리 범위·최소 주문금액·유효기간을 만족해 주문에 곧바로 선택 가능한 후보만) | **응답 예시** @@ -130,7 +171,49 @@ HTTP/1.1 200 "success": true, "message": "사용 가능한 쿠폰 목록을 조회했습니다.", "data": { - "coupons": [] + "coupons": [ + { + "id": 2534, + "coupon_id": 85, + "user_id": 1209, + "coupon_code": "DR-PDZ7HT4G", + "status": "available", + "...": "(8개 키 생략, 총 13개)" + }, + { + "id": 2542, + "coupon_id": 94, + "user_id": 1209, + "coupon_code": "DR-ROQPOOED", + "status": "available", + "...": "(8개 키 생략, 총 13개)" + }, + { + "id": 2552, + "coupon_id": 106, + "user_id": 1209, + "coupon_code": "DR-XBLQUABC", + "status": "available", + "...": "(8개 키 생략, 총 13개)" + }, + { + "id": 2529, + "coupon_id": 79, + "user_id": 1209, + "coupon_code": "DR-90IO2SWI", + "status": "available", + "...": "(8개 키 생략, 총 13개)" + }, + { + "id": 2532, + "coupon_id": 83, + "user_id": 1209, + "coupon_code": "DR-FEKYVAFT", + "status": "available", + "...": "(8개 키 생략, 총 13개)" + }, + "... (총 27건 중 5건 표시)" + ] } } ``` @@ -176,44 +259,44 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `157` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 쿠폰","en":"API Doc Sample Coupon"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| id | integer | `107` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"카테고리 배송비 3,000원 할인","en":"Category Shipping 3,000 …` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | | description | object | `{"ko":"특정 카테고리 배송비 할인","en":"Shipping discount on categor…` | 설명 (다국어 필드는 로케일별 값 객체) | -| target_type | string | `order_amount` | 할인 적용 대상 (product_amount=상품금액, order_amount=주문금액, shipping_fee=배송비) | +| target_type | string | `shipping_fee` | 할인 적용 대상 (product_amount=상품금액, order_amount=주문금액, shipping_fee=배송비) | | discount_type | string | `fixed` | 혜택 유형 (fixed=정액 할인, rate=정률(%) 할인) | -| discount_value | string | `1000.00` | 혜택값 (정액이면 할인 금액, 정률이면 할인율 %) | +| discount_value | string | `3000.00` | 혜택값 (정액이면 할인 금액, 정률이면 할인율 %) | | discount_max_amount | string | `3000.00` | 정률 할인 시 최대 할인 금액 상한 (없으면 null) | -| min_order_amount | string | `0.00` | 쿠폰 적용 최소 주문금액 (미만 주문에는 사용 불가) | +| min_order_amount | string | `20000.00` | 쿠폰 적용 최소 주문금액 (미만 주문에는 사용 불가) | | issue_method | string | `download` | 발급 방법 (direct=직접발급, download=다운로드, auto=자동발급) | | issue_condition | string | `manual` | 발급 조건 (manual=수동, signup=회원가입, first_purchase=첫구매, birthday=생일) | | issue_status | string | `issuing` | 발급 상태 (issuing=발급중, stopped=발급중단) | | total_quantity | integer | `300` | 총 발급 수량 상한 (null=무제한) | -| issued_count | integer | `0` | issued 개수 (집계) | +| issued_count | integer | `1` | issued 개수 (집계) | | per_user_limit | integer | `1` | 회원 1인당 발급 제한 수량 | | valid_type | string | `period` | 유효기간 유형 (period=기간지정, days_from_issue=발급일로부터 N일) | | valid_days | integer | `14` | 발급일로부터 유효한 일수 (valid_type=days_from_issue 인 경우) | -| valid_from | string | `2026-06-08T02:24:18.000000Z` | 유효기간 시작일 (쿠폰 사용 가능 시작 시각) | -| valid_to | string | `2026-08-07T02:24:18.000000Z` | 유효기간 종료일 (쿠폰 사용 가능 종료 시각) | -| issue_from | string | `2026-06-08T02:24:18.000000Z` | 발급기간 시작일 (다운로드 가능 시작 시각) | -| issue_to | string | `2026-07-15T02:24:18.000000Z` | 발급기간 종료일 (다운로드 가능 종료 시각) | -| is_combinable | boolean | `false` | combinable 여부 | -| target_scope | string | `all` | 적용 범위 (all=전체 상품, products=특정 상품, categories=특정 카테고리) | -| created_by | integer | `1` | 쿠폰 등록자(관리자) 식별자 (users 참조, 삭제 시 null) | -| created_at | string | `2026-07-07T05:47:31.000000Z` | 생성 일시 | -| updated_at | string | `2026-07-07T05:47:31.000000Z` | 최종 수정 일시 | +| valid_from | string | `2026-07-23T14:36:17.000000Z` | 유효기간 시작일 (쿠폰 사용 가능 시작 시각) | +| valid_to | string | `2026-09-21T14:36:17.000000Z` | 유효기간 종료일 (쿠폰 사용 가능 종료 시각) | +| issue_from | string | `2026-07-23T14:36:17.000000Z` | 발급기간 시작일 (다운로드 가능 시작 시각) | +| issue_to | string | `2026-08-29T14:36:17.000000Z` | 발급기간 종료일 (다운로드 가능 종료 시각) | +| is_combinable | boolean | `true` | combinable 여부 | +| target_scope | string | `categories` | 적용 범위 (all=전체 상품, products=특정 상품, categories=특정 카테고리) | +| created_by | null | `null` | 쿠폰 등록자(관리자) 식별자 (users 참조, 삭제 시 null) | +| created_at | string | `2026-07-30T14:36:17.000000Z` | 생성 일시 | +| updated_at | string | `2026-07-31T12:51:54.000000Z` | 최종 수정 일시 | | deleted_at | null | `null` | 소프트 삭제 일시 (미삭제 시 null) | -| is_downloaded | boolean | `false` | downloaded 여부 | -| user_issued_count | integer | `0` | user issued 개수 (집계) | -| coupon_id | integer | `157` | coupon 식별자 (연관 리소스 참조) | -| localized_name | string | `API 문서 샘플 쿠폰` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| target_type_short_label | string | `주문` | `target_type_short` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| valid_period_formatted | string | `-` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| min_order_amount_formatted | string | `0원` | `min_order_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| remaining_quantity | integer | `300` | 잔여 발급 가능 수량 (total_quantity − issued_count, 무제한이면 null) | -| benefit_formatted | string | `1,000원 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_downloaded | boolean | `true` | downloaded 여부 | +| user_issued_count | integer | `1` | user issued 개수 (집계) | +| coupon_id | integer | `107` | coupon 식별자 (연관 리소스 참조) | +| localized_name | string | `카테고리 배송비 3,000원 할인` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| target_type_short_label | string | `배송비` | `target_type_short` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| valid_period_formatted | string | `2026-07-23 ~ 2026-09-21` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| min_order_amount_formatted | string | `¥20,000` | `min_order_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| remaining_quantity | integer | `299` | 잔여 발급 가능 수량 (total_quantity − issued_count, 무제한이면 null) | +| benefit_formatted | string | `¥3,000 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | included_products | array | `[]` | 적용 대상 포함 상품 목록 (target_scope=products 시 이 상품에만 적용) | | excluded_products | array | `[]` | 적용 제외 상품 목록 (해당 상품은 쿠폰 적용에서 제외) | -| included_categories | array | `[]` | 적용 대상 포함 카테고리 목록 (target_scope=categories 시 이 카테고리에만 적용) | +| included_categories | array | `[{"id":32,"name":{"ko":"바지","en":"Pants"},"description":{…` | 적용 대상 포함 카테고리 목록 (target_scope=categories 시 이 카테고리에만 적용) | | excluded_categories | array | `[]` | 적용 제외 카테고리 목록 (해당 카테고리는 쿠폰 적용에서 제외) | **응답 예시** @@ -230,49 +313,151 @@ HTTP/1.1 200 "current_page": 1, "data": [ { - "id": 1, + "id": 107, "name": { - "ko": "API 문서 샘플 쿠폰", - "en": "API Doc Sample Coupon" + "ko": "카테고리 배송비 3,000원 할인", + "en": "Category Shipping 3,000 KRW Off" }, - "description": null, - "target_type": "order_amount", + "description": { + "ko": "특정 카테고리 배송비 할인", + "en": "Shipping discount on categories" + }, + "target_type": "shipping_fee", "discount_type": "fixed", - "discount_value": "1000.00", + "discount_value": "3000.00", "discount_max_amount": null, - "min_order_amount": "0.00", + "min_order_amount": "20000.00", "issue_method": "download", "issue_condition": "manual", "issue_status": "issuing", - "total_quantity": null, - "issued_count": 0, + "total_quantity": 300, + "issued_count": 1, "per_user_limit": 1, "valid_type": "period", "valid_days": null, + "valid_from": "2026-07-23T14:36:17.000000Z", + "valid_to": "2026-09-21T14:36:17.000000Z", + "issue_from": "2026-07-23T14:36:17.000000Z", + "issue_to": "2026-08-29T14:36:17.000000Z", + "is_combinable": true, + "target_scope": "categories", + "created_by": null, + "created_at": "2026-07-30T14:36:17.000000Z", + "updated_at": "2026-07-31T12:51:54.000000Z", + "deleted_at": null, + "is_downloaded": true, + "user_issued_count": 1, + "coupon_id": 107, + "localized_name": "카테고리 배송비 3,000원 할인", + "target_type_short_label": "배송비", + "valid_period_formatted": "2026-07-23 ~ 2026-09-21", + "min_order_amount_formatted": "¥20,000", + "remaining_quantity": 299, + "benefit_formatted": "¥3,000 할인", + "included_products": [], + "excluded_products": [], + "included_categories": [ + { + "id": 32, + "name": { + "ko": "바지", + "en": "Pants" + }, + "description": { + "ko": "다양한 스타일의 바지", + "en": "Various styles of pants" + }, + "parent_id": 30, + "path": "29/30/32", + "depth": 2, + "sort_order": 1, + "is_active": true, + "slug": "men-pants", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-30T14:35:47.000000Z", + "updated_at": "2026-07-30T14:35:47.000000Z", + "pivot": { + "coupon_id": 107, + "category_id": 32 + } + }, + { + "id": 42, + "name": { + "ko": "노트북", + "en": "Laptops" + }, + "description": { + "ko": "노트북 컴퓨터", + "en": "Laptop computers" + }, + "parent_id": 38, + "path": "38/42", + "depth": 1, + "sort_order": 1, + "is_active": true, + "slug": "laptops", + "meta_title": null, + "meta_description": null, + "created_at": "2026-07-30T14:35:47.000000Z", + "updated_at": "2026-07-30T14:35:47.000000Z", + "pivot": { + "coupon_id": 107, + "category_id": 42 + } + } + ], + "excluded_categories": [] + }, + { + "id": 103, + "name": { + "ko": "배송비 50% 할인", + "en": "Shipping 50% Off" + }, + "description": { + "ko": "배송비 반값 할인", + "en": "50% off shipping fee" + }, + "target_type": "shipping_fee", + "discount_type": "rate", + "discount_value": "50.00", + "discount_max_amount": "3000.00", + "min_order_amount": "20000.00", + "issue_method": "download", + "issue_condition": "manual", + "issue_status": "issuing", + "total_quantity": 1000, + "issued_count": 62, + "per_user_limit": 1, + "valid_type": "days_from_issue", + "valid_days": 14, "valid_from": null, "valid_to": null, - "issue_from": null, - "issue_to": null, - "is_combinable": false, + "issue_from": "2026-07-25T14:36:17.000000Z", + "issue_to": "2026-09-28T14:36:17.000000Z", + "is_combinable": true, "target_scope": "all", "created_by": null, - "created_at": "2026-07-08T01:44:49.000000Z", - "updated_at": "2026-07-08T01:44:49.000000Z", + "created_at": "2026-07-30T14:36:17.000000Z", + "updated_at": "2026-07-31T12:52:41.000000Z", "deleted_at": null, - "is_downloaded": false, - "user_issued_count": 0, - "coupon_id": 1, - "localized_name": "API 문서 샘플 쿠폰", - "target_type_short_label": "주문", - "valid_period_formatted": "-", - "min_order_amount_formatted": "0원", - "remaining_quantity": null, - "benefit_formatted": "1,000원 할인", + "is_downloaded": true, + "user_issued_count": 1, + "coupon_id": 103, + "localized_name": "배송비 50% 할인", + "target_type_short_label": "배송비", + "valid_period_formatted": "발급일로부터 14일", + "min_order_amount_formatted": "¥20,000", + "remaining_quantity": 938, + "benefit_formatted": "50.00% 할인 (최대 ¥3,000)", "included_products": [], "excluded_products": [], "included_categories": [], "excluded_categories": [] - } + }, + "... (총 11건 중 2건 표시)" ], "first_page_url": "https://api.example.com/api/modules/sirsoft-ecommerce/user/coupons/downloadable?page=1", "from": 1, @@ -302,8 +487,8 @@ HTTP/1.1 200 "path": "https://api.example.com/api/modules/sirsoft-ecommerce/user/coupons/downloadable", "per_page": 25, "prev_page_url": null, - "to": 1, - "total": 1 + "to": 11, + "total": 11 } } ``` @@ -349,19 +534,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 쿠폰 발급 내역 `CouponIssue` 모델 직렬화 — 전용 Resource 를 거치지 않고 모델의 컬럼/캐스트가 그대로 노출됩니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `8611` | 발급 내역 ID (기본 키) | +| coupon_id | integer | `157` | 발급된 쿠폰 정의(마스터)의 식별자 | +| user_id | integer | `1` | 발급받은 회원 식별자 | +| coupon_code | string | `DL-8QK2ZX4M` | 발급 시 생성된 쿠폰 코드 (다운로드 발급은 `DL-` + 8자리 랜덤 대문자) | +| status | string | `available` | 발급 건 상태 (available=사용가능, used=사용완료, expired=만료, cancelled=취소) | +| issued_at | string | `2026-07-14T02:24:18.000000Z` | 발급일시 (발급 처리 시각) | +| expired_at | string \| null | `2026-08-07T02:24:18.000000Z` | 만료일시 (valid_type=period 이면 쿠폰의 `valid_to`, days_from_issue 이면 발급일 + `valid_days`, 둘 다 아니면 null) | +| used_at | null | `null` | 사용일시 (발급 직후에는 항상 null) | +| order_id | null | `null` | 사용된 주문 식별자 (발급 직후에는 항상 null) | +| discount_amount | string \| null | `null` | 실제 적용된 할인 금액 (사용 전에는 null, decimal(15,2)) | +| created_at | string | `2026-07-14T02:24:18.000000Z` | 생성 일시 | +| updated_at | string | `2026-07-14T02:24:18.000000Z` | 최종 수정 일시 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "쿠폰이 다운로드되었습니다.", + "data": { + "coupon_id": 157, + "user_id": 1, + "coupon_code": "DL-8QK2ZX4M", + "status": "available", + "issued_at": "2026-07-14T02:24:18.000000Z", + "expired_at": "2026-08-07T02:24:18.000000Z", + "updated_at": "2026-07-14T02:24:18.000000Z", + "created_at": "2026-07-14T02:24:18.000000Z", + "id": 8611 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | 쿠폰 다운로드 제한 횟수를 초과했습니다. | 회원 1인당 발급 제한(`per_user_limit`) 을 이미 채운 경우 | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | `coupon_id` 가 정수가 아니거나 존재하지 않는 쿠폰인 경우 (`error.errors` 에 필드별 메시지) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md b/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md index c471ab7c..e34ae463 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/currency.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Currency 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -42,7 +42,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| preferred_currency | null | `null` | 회원이 저장한 선호 결제 통화 코드 (미설정 시 `null`) | +| preferred_currency | string | `KRW` | 회원이 저장한 선호 결제 통화 코드 (미설정 시 `null`) | **응답 예시** @@ -99,18 +99,35 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| preferred_currency | string | `USD` | 저장된 선호 결제 통화 코드 (요청의 `currency` 값이 그대로 반환됨) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "결제 통화가 변경되었습니다.", + "data": { + "preferred_currency": "USD" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | `currency` 누락("결제 통화를 선택해 주세요.") 또는 등록되지 않은/환율 미설정 통화 지정("등록된 통화만 선택할 수 있습니다.") | +| 500 | Internal Server Error | 통화 저장 중 예외 발생 시 ("결제 통화 변경에 실패했습니다.") | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/dashboard.md b/modules/_bundled/sirsoft-ecommerce/docs/api/dashboard.md index c9a97e48..7e90c3db 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/dashboard.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/dashboard.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Dashboard 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -111,9 +111,9 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| items | array | `[{"id":1,"product_id":320,"inquirable_id":79762,"product_…` | 미답변 상품문의 목록 (최신순, PendingInquiryResource — 문의 id/상품/작성자/게시판 글 id 등) | -| total | integer | `1` | 전체 개수 (집계) | -| board_slug | null | `null` | 문의가 저장된 연동 게시판의 slug (관리자 문의 상세 링크용, 미연동 시 null) | +| items | array | `[{"id":60,"product_id":1732,"inquirable_id":284,"product_…` | 미답변 상품문의 목록 (최신순, PendingInquiryResource — 문의 id/상품/작성자/게시판 글 id 등) | +| total | integer | `22` | 전체 개수 (집계) | +| board_slug | string | `inquiry` | 문의가 저장된 연동 게시판의 slug (관리자 문의 상세 링크용, 미연동 시 null) | **응답 예시** @@ -128,16 +128,48 @@ HTTP/1.1 200 "data": { "items": [ { - "id": 1, - "product_id": 1, - "inquirable_id": 59404, - "product_name": "iste et inventore", - "author_name": "API 문서 샘플 사용자", - "created_at": "2026-07-08 10:44:49" + "id": 60, + "product_id": 1732, + "inquirable_id": 284, + "product_name": "면 손수건 3매입 #1", + "author_name": "", + "created_at": "2026-07-31 21:41:56" + }, + { + "id": 59, + "product_id": 1732, + "inquirable_id": 283, + "product_name": "면 손수건 3매입 #1", + "author_name": "", + "created_at": "2026-07-31 21:41:54" + }, + { + "id": 58, + "product_id": 1732, + "inquirable_id": 282, + "product_name": "면 손수건 3매입 #1", + "author_name": "", + "created_at": "2026-07-31 21:41:50" + }, + { + "id": 21, + "product_id": 1732, + "inquirable_id": 225, + "product_name": "면 손수건 3매입 #1", + "author_name": "최고관리자", + "created_at": "2026-07-30 21:20:03" + }, + { + "id": 23, + "product_id": 1732, + "inquirable_id": 227, + "product_name": "면 손수건 3매입 #1", + "author_name": "최고관리자", + "created_at": "2026-07-28 21:06:03" } ], - "total": 1, - "board_slug": null + "total": 22, + "board_slug": "inquiry" } } ``` @@ -178,6 +210,8 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) + + | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | id | integer | `99` | 기본 키 (내부 식별자) | @@ -197,16 +231,7 @@ HTTP/1.1 200 { "success": true, "message": "대시보드 데이터를 조회했습니다.", - "data": [ - { - "id": 1, - "product_id": 1, - "product_name": "API 문서 샘플 상품", - "rating": 5, - "author_name": "API 문서 샘플 사용자", - "created_at": "2026-07-08 10:44:49" - } - ] + "data": [] } ``` @@ -248,7 +273,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| days | array | `[{"date":"2026-07-01","sales_quantity":0,"sales_amount":0…` | 일자별 판매 집계 배열 (각 항목 `{date, sales_quantity, sales_amount}` — 그래프 막대 데이터) | +| days | array | `[{"date":"2026-07-30","sales_quantity":0,"sales_amount":0…` | 일자별 판매 집계 배열 (각 항목 `{date, sales_quantity, sales_amount}` — 그래프 막대 데이터) | | total_quantity | integer | `0` | 표시 기간 판매 수량 합계 | | total_sales | integer | `0` | 표시 기간 순매출 합계 (기본 통화 자릿수로 라운딩) | | quantity_change | null | `null` | 직전 동일 기간 대비 판매 수량 증감율(%) (직전 합계 0 이면 null) | @@ -269,37 +294,37 @@ HTTP/1.1 200 "data": { "days": [ { - "date": "2026-07-02", + "date": "2026-07-30", "sales_quantity": 0, "sales_amount": 0 }, { - "date": "2026-07-03", + "date": "2026-07-31", "sales_quantity": 0, "sales_amount": 0 }, { - "date": "2026-07-04", + "date": "2026-08-01", "sales_quantity": 0, "sales_amount": 0 }, { - "date": "2026-07-05", + "date": "2026-08-02", "sales_quantity": 0, "sales_amount": 0 }, { - "date": "2026-07-06", + "date": "2026-08-03", "sales_quantity": 0, "sales_amount": 0 }, { - "date": "2026-07-07", + "date": "2026-08-04", "sales_quantity": 0, "sales_amount": 0 }, { - "date": "2026-07-08", + "date": "2026-08-05", "sales_quantity": 0, "sales_amount": 0 } diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md b/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md index 82062633..d114266a 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Extra Fee Templates 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -50,9 +50,9 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `37` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | integer | `109` | 기본 키 (내부 식별자) | -| zipcode | string | `00000` | 추가배송비를 적용할 우편번호 (단일 또는 `-` 로 이은 범위) | +| number | integer | `36` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `399` | 기본 키 (내부 식별자) | +| zipcode | string | `15654` | 추가배송비를 적용할 우편번호 (단일 또는 `-` 로 이은 범위) | | fee | integer | `3000` | 해당 우편번호에 부과할 추가 배송비 (상점 기본 통화 기준 반올림) | | fee_formatted | string | `3,000원` | `fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | region | string | `경기 안산 풍도동` | 지역명 (도서산간 등 관리자 참고용 표시 라벨) | @@ -60,8 +60,8 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | is_active | boolean | `true` | active 여부 | | created_by | null | `null` | 등록자 UUID (creator 관계 파생, 없으면 null) | | updated_by | null | `null` | 최종 수정자 UUID (updater 관계 파생, 없으면 null) | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| created_at | string | `2026-07-30 23:35:47` | 생성 일시 | +| updated_at | string | `2026-07-30 23:35:47` | 최종 수정 일시 | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -77,24 +77,22 @@ HTTP/1.1 200 "data": { "data": [ { - "number": 1, - "id": 1, - "zipcode": "00000", + "number": 36, + "id": 399, + "zipcode": "15654", "fee": 3000, "fee_formatted": "3,000원", - "region": null, - "description": null, - "is_active": true, - "created_by": null, - "updated_by": null, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } + "...": "(8개 키 생략, 총 13개)" + }, + { + "number": 35, + "id": 400, + "zipcode": "23008-23010", + "fee": 3000, + "fee_formatted": "3,000원", + "...": "(8개 키 생략, 총 13개)" + }, + "... (총 25건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -102,19 +100,55 @@ HTTP/1.1 200 "can_delete": true }, "statistics": { - "total": 1, - "active": 1, + "total": 36, + "active": 36, "inactive": 0, - "by_region": [] + "by_region": [ + { + "region": "경기 안산 풍도동", + "count": 1, + "avg_fee": 3000, + "min_fee": "3000.00", + "max_fee": "3000.00" + }, + { + "region": "경남 사천 섬지역", + "count": 1, + "avg_fee": 3000, + "min_fee": "3000.00", + "max_fee": "3000.00" + }, + { + "region": "경남 통영 산양/한산/욕지/사량면", + "count": 1, + "avg_fee": 3000, + "min_fee": "3000.00", + "max_fee": "3000.00" + }, + { + "region": "경남 통영 용남면", + "count": 1, + "avg_fee": 3000, + "min_fee": "3000.00", + "max_fee": "3000.00" + }, + { + "region": "경북 울릉도", + "count": 1, + "avg_fee": 3000, + "min_fee": "3000.00", + "max_fee": "3000.00" + }, + "... (총 34건 중 5건 표시)" + ] }, "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 2, "per_page": 25, - "total": 1, + "total": 36, "from": 1, - "to": 1, - "has_more_pages": false + "...": "(2개 키 생략, 총 7개)" } } } @@ -169,19 +203,62 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ExtraFeeTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| zipcode | string | `06234` | 추가배송비를 적용할 우편번호 (단일 또는 `-` 로 이은 범위) | +| fee | integer | `3000` | 해당 우편번호에 부과할 추가 배송비 (상점 기본 통화 기준 반올림) | +| fee_formatted | string | `3,000원` | `fee` 값의 표시용 포맷 문자열 (기본 통화 포맷) | +| region | string \| null | `경기 안산 풍도동` | 지역명 (도서산간 등 관리자 참고용 표시 라벨, 미입력 시 null) | +| description | string \| null | `도서산간 지역` | 설명 (미입력 시 null) | +| is_active | boolean | `true` | 활성 여부 (true 이면 배송비 계산에 할증 반영) | +| created_by | string \| null | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 등록자 UUID (creator 관계 파생, 없으면 null) | +| updated_by | string \| null | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정자 UUID (updater 관계 파생, 없으면 null) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 포맷) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 포맷) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `sirsoft-ecommerce.settings.update` 권한 기반) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "추가배송비 템플릿이 등록되었습니다.", + "data": { + "id": 1, + "zipcode": "06234", + "fee": 3000, + "fee_formatted": "3,000원", + "region": "경기 안산 풍도동", + "description": "도서산간 지역", + "is_active": true, + "created_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 저장 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `zipcode` 는 숫자 또는 `숫자-숫자` 범위 형식이어야 하며 이미 등록된 우편번호면 중복 오류 | @@ -211,9 +288,9 @@ Authorization: Bearer {YOUR_TOKEN} | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| zipcode | string | `00000` | 추가배송비 적용 우편번호 (배송정책 설정용 축약 필드) | +| zipcode | string | `15654` | 추가배송비 적용 우편번호 (배송정책 설정용 축약 필드) | | fee | integer | `3000` | 해당 우편번호의 추가 배송비 (float 변환값) | -| region | string | `` | 지역명 (없으면 빈 문자열) | +| region | string | `경기 안산 풍도동` | 지역명 (없으면 빈 문자열) | **응답 예시** @@ -227,9 +304,184 @@ HTTP/1.1 200 "message": "활성 추가배송비 템플릿 설정을 조회했습니다.", "data": [ { - "zipcode": "00000", + "zipcode": "15654", "fee": 3000, - "region": "" + "region": "경기 안산 풍도동" + }, + { + "zipcode": "23008-23010", + "fee": 3000, + "region": "인천 강화 섬지역" + }, + { + "zipcode": "23100-23116", + "fee": 3000, + "region": "인천 옹진 백령/대청/연평/북도면" + }, + { + "zipcode": "23124-23136", + "fee": 3000, + "region": "인천 옹진 자월/덕적면" + }, + { + "zipcode": "32133", + "fee": 3000, + "region": "충남 태안 섬지역" + }, + { + "zipcode": "33411", + "fee": 3000, + "region": "충남 보령 섬지역" + }, + { + "zipcode": "40200-40240", + "fee": 3000, + "region": "경북 울릉도" + }, + { + "zipcode": "52570-52571", + "fee": 3000, + "region": "경남 사천 섬지역" + }, + { + "zipcode": "53031-53033", + "fee": 3000, + "region": "경남 통영 용남면" + }, + { + "zipcode": "53088-53104", + "fee": 3000, + "region": "경남 통영 산양/한산/욕지/사량면" + }, + { + "zipcode": "54000", + "fee": 3000, + "region": "전북 군산 옥도면" + }, + { + "zipcode": "56347-56349", + "fee": 3000, + "region": "전북 부안 위도면" + }, + { + "zipcode": "57068-57069", + "fee": 3000, + "region": "전남 영광 낙월면" + }, + { + "zipcode": "58760-58761", + "fee": 3000, + "region": "전남 목포 섬지역" + }, + { + "zipcode": "58800-58804", + "fee": 3000, + "region": "전남 신안 임자면" + }, + { + "zipcode": "58809-58810", + "fee": 3000, + "region": "전남 신안 증도/지도" + }, + { + "zipcode": "58816-58818", + "fee": 3000, + "region": "전남 신안 지도/압해" + }, + { + "zipcode": "58826", + "fee": 3000, + "region": "전남 신안 압해" + }, + { + "zipcode": "58832", + "fee": 3000, + "region": "전남 신안 암태면" + }, + { + "zipcode": "58839-58841", + "fee": 3000, + "region": "전남 신안 안좌면" + }, + { + "zipcode": "58843-58866", + "fee": 3000, + "region": "전남 신안 비금/도초/하의/신의/장산/흑산면" + }, + { + "zipcode": "58953-58958", + "fee": 3000, + "region": "전남 진도 조도면" + }, + { + "zipcode": "59102-59103", + "fee": 3000, + "region": "전남 완도 군외면" + }, + { + "zipcode": "59127", + "fee": 3000, + "region": "전남 완도 군외면" + }, + { + "zipcode": "59137-59145", + "fee": 3000, + "region": "전남 완도 금당/금일/생일면" + }, + { + "zipcode": "59149-59170", + "fee": 3000, + "region": "전남 완도 청산/소안/노화/보길/군외/금일면" + }, + { + "zipcode": "59421", + "fee": 3000, + "region": "전남 보성 벌교" + }, + { + "zipcode": "59531", + "fee": 3000, + "region": "전남 고흥 도화면" + }, + { + "zipcode": "59551", + "fee": 3000, + "region": "전남 고흥 도양읍" + }, + { + "zipcode": "59563", + "fee": 3000, + "region": "전남 고흥 도양읍" + }, + { + "zipcode": "59568", + "fee": 3000, + "region": "전남 고흥 봉래면" + }, + { + "zipcode": "59650", + "fee": 3000, + "region": "전남 여수 화정면" + }, + { + "zipcode": "59766", + "fee": 3000, + "region": "전남 여수 경호동" + }, + { + "zipcode": "59781-59790", + "fee": 3000, + "region": "전남 여수 화정/남/삼산면" + }, + { + "zipcode": "63000-63365", + "fee": 3000, + "region": "제주 제주시" + }, + { + "zipcode": "63500-63644", + "fee": 3000, + "region": "제주 서귀포시" } ] } @@ -270,19 +522,38 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (일괄 삭제 결과 요약)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `3` | 실제로 삭제된 템플릿 건수 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 추가배송비 템플릿이 삭제되었습니다.", + "data": { + "deleted_count": 3 + } +} +``` + +> 컨트롤러가 메시지 치환 파라미터를 전달하지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려갑니다. 실제 삭제 건수는 `data.deleted_count` 를 사용하세요. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 삭제 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `ids` 는 1건 이상이어야 하며 각 값은 존재하는 템플릿 ID 여야 함 | @@ -319,19 +590,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (일괄 등록 결과 요약)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| created_count | integer | `2` | 실제로 등록(또는 갱신)된 템플릿 건수 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": ":count개 추가배송비 템플릿이 등록되었습니다.", + "data": { + "created_count": 2 + } +} +``` + +> 컨트롤러가 메시지 치환 파라미터를 전달하지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려갑니다. 실제 등록 건수는 `data.created_count` 를 사용하세요. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 일괄 등록 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 항목별로 `items.*.zipcode` (필수, 숫자 또는 `숫자-숫자` 범위, 최대 20자) 와 `items.*.fee` (필수, 0 이상) 가 검증됨 | @@ -370,19 +660,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (일괄 변경 결과 요약)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 활성 여부가 실제로 변경된 템플릿 건수 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 추가배송비 템플릿의 사용여부가 변경되었습니다.", + "data": { + "updated_count": 3 + } +} +``` + +> 컨트롤러가 메시지 치환 파라미터를 전달하지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려갑니다. 실제 변경 건수는 `data.updated_count` 를 사용하세요. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 일괄 변경 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `ids` 는 1건 이상이며 각 값은 존재하는 템플릿 ID, `is_active` 는 필수 boolean | @@ -404,7 +713,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -412,23 +721,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "추가배송비 템플릿이 삭제되었습니다.", - "data": null -} -``` + **에러 응답** @@ -458,7 +755,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -466,53 +763,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| zipcode | string | `00000` | 우편번호 (단일 또는 범위) | -| fee | integer | `3000` | 추가 배송비 | -| fee_formatted | string | `3,000원` | `fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| region | null | `null` | 지역명 (예: 제주도, 울릉도) | -| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | -| is_active | boolean | `true` | active 여부 | -| created_by | null | `null` | 생성자 ID | -| updated_by | null | `null` | 최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "추가배송비 템플릿 정보를 조회했습니다.", - "data": { - "id": 1, - "zipcode": "00000", - "fee": 3000, - "fee_formatted": "3,000원", - "region": null, - "description": null, - "is_active": true, - "created_by": null, - "updated_by": null, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -547,7 +802,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -564,20 +819,63 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ExtraFeeTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| zipcode | string | `06234` | 추가배송비를 적용할 우편번호 (단일 또는 `-` 로 이은 범위) | +| fee | integer | `5000` | 해당 우편번호에 부과할 추가 배송비 (상점 기본 통화 기준 반올림) | +| fee_formatted | string | `5,000원` | `fee` 값의 표시용 포맷 문자열 (기본 통화 포맷) | +| region | string \| null | `경기 안산 풍도동` | 지역명 (도서산간 등 관리자 참고용 표시 라벨, 미입력 시 null) | +| description | string \| null | `도서산간 지역` | 설명 (미입력 시 null) | +| is_active | boolean | `true` | 활성 여부 (true 이면 배송비 계산에 할증 반영) | +| created_by | string \| null | `null` | 등록자 UUID (creator 관계 파생, 없으면 null) | +| updated_by | string \| null | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정자 UUID (수정 시 현재 로그인 사용자로 갱신) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 포맷) | +| updated_at | string | `2026-07-08 15:00:20` | 최종 수정 일시 (사용자 타임존 기준 포맷) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `sirsoft-ecommerce.settings.update` 권한 기반) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "추가배송비 템플릿이 수정되었습니다.", + "data": { + "id": 1, + "zipcode": "06234", + "fee": 5000, + "fee_formatted": "5,000원", + "region": "경기 안산 풍도동", + "description": "도서산간 지역", + "is_active": true, + "created_by": null, + "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:20", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 수정 중 예외 발생 시 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `zipcode` 는 숫자 또는 `숫자-숫자` 범위 형식이어야 하며 자신을 제외한 다른 템플릿과 중복될 수 없음 | @@ -599,7 +897,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/1/toggle-active HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id}/toggle-active HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -607,53 +905,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| zipcode | string | `00000` | 우편번호 (단일 또는 범위) | -| fee | integer | `3000` | 추가 배송비 | -| fee_formatted | string | `3,000원` | `fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| region | null | `null` | 지역명 (예: 제주도, 울릉도) | -| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | -| is_active | boolean | `false` | active 여부 | -| created_by | null | `null` | 생성자 ID | -| updated_by | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:20` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "추가배송비 템플릿 사용여부가 변경되었습니다.", - "data": { - "id": 1, - "zipcode": "00000", - "fee": 3000, - "fee_formatted": "3,000원", - "region": null, - "description": null, - "is_active": false, - "created_by": null, - "updated_by": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:20", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md b/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md index 545eb3d0..7c7b7d0d 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Guest 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(raw HTTP) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -48,16 +48,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| guest_order_token | string | `eyJpdiI6...` | 비회원 주문 조회 토큰 (30분 유효). 이후 비회원 주문 상세/취소/환불예상 호출 시 `X-Guest-Order-Token` 헤더로 전달 | +| expires_at | string | `2026-07-14T15:02:11+09:00` | 토큰 만료 일시 (ISO 8601) | +| order | object | — | 최소 주문 요약 (상세는 토큰 보호 엔드포인트에서 제공) | +| order.order_number | string | `20260714000012` | 인증된 주문의 주문번호 | +| order.order_status | string | `payment_complete` | 주문 상태 (`pending_order`, `pending_payment`, `payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered`, `confirmed`, `cancelled`) | **응답 예시** - +```json +{ + "success": true, + "message": "주문 정보를 조회했습니다.", + "data": { + "guest_order_token": "eyJpdiI6...", + "expires_at": "2026-07-14T15:02:11+09:00", + "order": { + "order_number": "20260714000012", + "order_status": "payment_complete" + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 404 | Not Found | 주문 없음 / 회원 주문 / 전화번호 불일치 / 비밀번호 오류 / 잠금 — 정보 노출 방지를 위해 모두 동일한 "주문을 찾을 수 없습니다" 로 처리 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -101,24 +123,331 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 취소 후 갱신된 주문 상세)._ + + + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260714000012` | 주문번호 | +| order_status | string | `cancelled` | 주문 상태 (`pending_order`, `pending_payment`, `payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered`, `confirmed`, `cancelled`) | +| order_status_label | string\|null | `주문취소` | 주문 상태의 다국어 라벨 | +| order_status_variant | string\|null | `danger` | 주문 상태 뱃지 표시용 variant | +| is_partially_cancelled | boolean | `false` | 일부 옵션만 취소된 주문인지 여부 (별도 상태가 아닌 파생 플래그, 보조 뱃지용) | +| subtotal_amount | number | `12000` | 상품 합계 금액 (주문 시점 통화 기준) | +| subtotal_amount_formatted | string | `12,000원` | 상품 합계 금액 표기 문자열 | +| total_discount_amount | number | `0` | 총 할인 금액 | +| total_discount_amount_formatted | string | `0원` | 총 할인 금액 표기 문자열 | +| total_shipping_amount | number | `3000` | 총 배송비 | +| total_shipping_amount_formatted | string | `3,000원` | 총 배송비 표기 문자열 | +| total_amount | number | `15000` | 주문 총액 | +| total_amount_formatted | string | `15,000원` | 주문 총액 표기 문자열 | +| total_paid_amount | number | `15000` | 실제 결제 금액 | +| total_paid_amount_formatted | string | `15,000원` | 실제 결제 금액 표기 문자열 | +| total_cancelled_amount | number | `15000` | 누적 취소 금액 | +| total_cancelled_amount_formatted | string | `15,000원` | 누적 취소 금액 표기 문자열 | +| total_refunded_amount | number | `15000` | 누적 PG 환불 금액 | +| total_refunded_amount_formatted | string | `15,000원` | 누적 PG 환불 금액 표기 문자열 | +| total_refunded_points_amount | number | `0` | 누적 마일리지 환불 금액 | +| total_refunded_points_amount_formatted | string | `0원` | 누적 마일리지 환불 금액 표기 문자열 | +| total_points_used_amount | number | `0` | 사용한 마일리지 금액 | +| total_points_used_amount_formatted | string | `0원` | 사용 마일리지 표기 문자열 | +| total_deposit_used_amount | number | `0` | 사용한 예치금 금액 | +| total_deposit_used_amount_formatted | string | `0원` | 사용 예치금 표기 문자열 | +| total_earned_points_amount | number | `0` | 적립 예정/확정 마일리지 금액 | +| total_earned_points_amount_formatted | string | `0원` | 적립 마일리지 표기 문자열 | +| mc_subtotal_amount | object\|null | `{"KRW":"12,000원","USD":"$8.60"}` | 상품 합계의 다중 통화 스냅샷 (통화코드 → 표기 문자열) | +| mc_total_discount_amount | object\|null | — | 총 할인의 다중 통화 스냅샷 | +| mc_total_shipping_amount | object\|null | — | 총 배송비의 다중 통화 스냅샷 | +| mc_total_amount | object\|null | — | 주문 총액의 다중 통화 스냅샷 | +| mc_total_points_used_amount | object\|null | — | 사용 마일리지의 다중 통화 스냅샷 | +| mc_total_deposit_used_amount | object\|null | — | 사용 예치금의 다중 통화 스냅샷 | +| item_count | integer | `1` | 주문 품목 종류 수 | +| total_quantity | integer | `2` | 주문 총 수량 (옵션 수량 합) | +| ordered_at | string\|null | `2026-07-14T14:32:11+09:00` | 주문 일시 (ISO 8601) | +| ordered_at_formatted | string\|null | `2026-07-14 14:32` | 주문 일시 (사용자 타임존 표기) | +| paid_at | string\|null | `2026-07-14T14:33:02+09:00` | 결제 완료 일시 (ISO 8601) | +| paid_at_formatted | string\|null | `2026-07-14 14:33` | 결제 완료 일시 (사용자 타임존 표기) | +| confirmed_at | string\|null | `null` | 구매확정 일시 (ISO 8601) | +| confirmed_at_formatted | string\|null | `null` | 구매확정 일시 (사용자 타임존 표기) | +| cancelled_at | string\|null | `2026-07-14T15:01:44+09:00` | 취소 일시 (ISO 8601) | +| cancelled_at_formatted | string\|null | `2026-07-14 15:01` | 취소 일시 (사용자 타임존 표기) | +| orderer_name | string\|null | `홍길동` | 주문자 이름 | +| orderer_phone | string\|null | `010-1234-5678` | 주문자 전화번호 | +| orderer_email | string\|null | `guest@example.com` | 주문자 이메일 | +| recipient_name | string\|null | `홍길동` | 수령인 이름 | +| recipient_phone | string\|null | `010-1234-5678` | 수령인 연락처 | +| recipient_zipcode | string\|null | `06234` | 배송지 우편번호 | +| recipient_address | string\|null | `서울특별시 강남구 테헤란로 1` | 배송지 기본 주소 | +| recipient_detail_address | string\|null | `101호` | 배송지 상세 주소 | +| delivery_memo | string\|null | `부재 시 경비실에 맡겨주세요` | 배송 메모 | +| delivery_memo_label | string\|null | `부재 시 경비실` | 배송 메모 프리셋 라벨 | +| options | array | — | 주문 옵션(품목) 목록 (`OrderOptionResource`, 관계 로드 시에만 포함) | +| shipping_address | object\|null | — | 배송지 상세 (`OrderAddressResource`) | +| payment | object\|null | — | 결제 정보 (`OrderPaymentResource`, 관계 로드 시에만 포함) | +| shippings | array | — | 배송 정보 목록 (`OrderShippingResource`, 관계 로드 시에만 포함) | +| cancels | array | — | 취소 이력 목록 (`OrderCancelResource` — 취소 사유·상세·취소일시) | +| abilities | object | `{"can_cancel": false}` | 주문 상태로 판정한 허용 액션 (회원 권한이 아닌 상태 기반). `can_cancel` 은 환경설정 `order_settings.cancellable_statuses` 기준 | + +회원용 `OrderResource` 와 달리 관리자/내부 메모(`admin_memo`, `customer_memo`), 회원 정보(`user`, `user_id`), 내부 계산 스냅샷(`promotions_applied_snapshot` 등)은 노출하지 않습니다. **응답 예시** - +```json +{ + "success": true, + "message": "주문이 취소되었습니다.", + "data": { + "order_number": "20260714000012", + "order_status": "cancelled", + "order_status_label": "주문취소", + "order_status_variant": "danger", + "is_partially_cancelled": false, + "subtotal_amount": 12000, + "subtotal_amount_formatted": "12,000원", + "total_discount_amount": 0, + "total_discount_amount_formatted": "0원", + "total_shipping_amount": 3000, + "total_shipping_amount_formatted": "3,000원", + "total_amount": 15000, + "total_amount_formatted": "15,000원", + "total_paid_amount": 15000, + "total_paid_amount_formatted": "15,000원", + "total_cancelled_amount": 15000, + "total_cancelled_amount_formatted": "15,000원", + "total_refunded_amount": 15000, + "total_refunded_amount_formatted": "15,000원", + "total_refunded_points_amount": 0, + "total_refunded_points_amount_formatted": "0원", + "total_points_used_amount": 0, + "total_points_used_amount_formatted": "0원", + "total_deposit_used_amount": 0, + "total_deposit_used_amount_formatted": "0원", + "total_earned_points_amount": 0, + "total_earned_points_amount_formatted": "0원", + "item_count": 1, + "total_quantity": 2, + "ordered_at": "2026-07-14T14:32:11+09:00", + "ordered_at_formatted": "2026-07-14 14:32", + "paid_at": "2026-07-14T14:33:02+09:00", + "paid_at_formatted": "2026-07-14 14:33", + "confirmed_at": null, + "confirmed_at_formatted": null, + "cancelled_at": "2026-07-14T15:01:44+09:00", + "cancelled_at_formatted": "2026-07-14 15:01", + "orderer_name": "홍길동", + "orderer_phone": "010-1234-5678", + "orderer_email": "guest@example.com", + "recipient_name": "홍길동", + "recipient_phone": "010-1234-5678", + "recipient_zipcode": "06234", + "recipient_address": "서울특별시 강남구 테헤란로 1", + "recipient_detail_address": "101호", + "delivery_memo": null, + "delivery_memo_label": null, + "options": [], + "shipping_address": {}, + "shippings": [], + "cancels": [], + "abilities": { + "can_cancel": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 (토큰이 지목한 주문 외에는 접근 불가) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 (`error.errors` 에 필드별 메시지), 또는 취소 처리 실패 (`주문 취소에 실패했습니다.`) | **설명** 비회원이 조회 토큰으로 인증된 상태에서 자신의 주문을 취소합니다. 주문 소유권은 `VerifyGuestOrderToken` 미들웨어가 `X-Guest-Order-Token` 헤더로 검증하며, `OrderController@cancel`이 회원 취소와 동일한 `OrderCancellationService`를 재사용하되 취소자(`cancelledBy`)는 null로 둡니다. `items`를 전달하면 부분취소, 없으면 전체취소로 처리하고, `refund_priority`로 PG 환불과 포인트 환불 중 우선순위를 지정할 수 있습니다. 취소 후 갱신된 주문 상세를 `GuestOrderResource`로 반환합니다. +### GET /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt + +- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CashReceiptController@show` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 설명 | +| --- | --- | --- | +| issuable | boolean | 지금 발급이 가능한지 여부 (무통장 + 입금완료 + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨) | +| cash_receipt | object\|null | 현재 활성 영수증 1건 (`CashReceiptResource`). 발급 전이거나 전액 취소된 경우 `null` | + +`cash_receipt` 의 하위 필드 구성은 [발급 API 의 응답 필드 표](orders.md)와 동일합니다. 금액(`amount`)은 **결제 통화 기준 실청구액**입니다 — base 통화와 결제 통화가 다른 상점에서도 구매자가 실제로 낸 금액으로 발행됩니다. + +비회원 **주문 상세** 응답(`GET user/orders/{orderNumber}` + `X-Guest-Order-Token`)도 회원과 동일하게 `cash_receipt`(활성 영수증 1건 또는 `null`)와 `cash_receipts`(발급·취소 이력 배열)를 포함합니다. 주문상세 화면의 현금영수증 카드가 이 두 필드로 발급완료/미발급을 가르므로, 누락되면 발급에 성공해도 화면이 계속 미발급으로 표시됩니다. + +**응답 예시** + +발급 전 (발급 가능): + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "현금영수증 정보를 조회했습니다.", + "data": { + "issuable": true, + "cash_receipt": null + } +} +``` + +발급 완료: + +```json +{ + "success": true, + "message": "현금영수증 정보를 조회했습니다.", + "data": { + "issuable": false, + "cash_receipt": { + "id": 12, + "provider": "sirsoft-pay_tosspayments", + "transaction_type": "issue", + "receipt_type": "income", + "receipt_type_label": "소득공제용", + "amount": 12000, + "amount_formatted": "12,000원", + "tax_free_amount": 0, + "tax_free_amount_formatted": "0원", + "identifier_masked": "010****5678", + "receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...", + "issue_number": "CR20260710000012", + "issue_status": "COMPLETED", + "error_code": null, + "error_message": null, + "issued_at": "2026-07-10T14:32:11+09:00", + "issued_at_formatted": "2026-07-10 14:32" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +> 요청 예시에는 표시되지 않지만 **`X-Guest-Order-Token` 헤더가 필수**입니다 — 비회원 주문 조회 인증 시 발급받은 토큰을 그대로 전달합니다. 누락하거나 주문번호와 맞지 않으면 404 를 반환합니다. + +**설명** 비회원이 주문 조회 후 해당 주문(`orderNumber`)의 현금영수증 발급 상태를 조회합니다. `VerifyGuestOrderToken` 미들웨어가 `X-Guest-Order-Token` 헤더(비회원 주문 조회 인증 시 발급)를 검증하며, **토큰이 지목한 주문 외에는 접근할 수 없습니다** — 토큰이 없거나 주문번호와 맞지 않으면 404 를 반환합니다. + +응답 형식은 회원용 조회(`GET user/orders/{id}/cash-receipt`)와 동일합니다 — `data.issuable`(발급 가능 여부)과 `data.cash_receipt`(활성 영수증 또는 `null`). 이 엔드포인트는 요청 본문을 받지 않으므로 발급 폼 검증에 걸리지 않습니다. + + +### POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt + +- **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.issue` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\CashReceiptController@issue` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderNumber | path | string | 예 | — | 대상 order number의 식별자 | +| receipt_type | body | string | 예 | `income`, `expense` | 발급 용도 (income 소득공제용 — 개인 연말정산 / expense 지출증빙용 — 사업자 매입세액공제) | +| identifier_type | body | string | 예 | `phone`, `card`, `business` | 발급 수단 (phone 휴대폰번호 / card 현금영수증카드번호 / business 사업자등록번호 — 사업자등록번호는 지출증빙 전용) | +| identifier | body | string | 예 | max 30 | 식별번호 (하이픈·공백 제거 후 검증 — 휴대폰 10~11자리 / 현금영수증카드 13~19자리 / 사업자등록번호 10자리 체크섬) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/cash-receipt HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "receipt_type": "income", + "identifier_type": "phone", + "identifier": "example-key" +} +``` + +**응답 필드** (`data` 내부) + +`data` 는 발급 이력 1건(`CashReceiptResource`)이며, 필드 구성은 [발급 API 의 응답 필드 표](orders.md)와 동일합니다. + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "현금영수증이 발급되었습니다.", + "data": { + "id": 12, + "provider": "sirsoft-pay_tosspayments", + "transaction_type": "issue", + "receipt_type": "income", + "receipt_type_label": "소득공제용", + "amount": 12000, + "amount_formatted": "12,000원", + "tax_free_amount": 0, + "tax_free_amount_formatted": "0원", + "identifier_masked": "010****5678", + "receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...", + "issue_number": "CR20260710000012", + "issue_status": "COMPLETED", + "error_code": null, + "error_message": null, + "issued_at": "2026-07-10T14:32:11+09:00", + "issued_at_formatted": "2026-07-10 14:32" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +> 요청 예시에는 표시되지 않지만 **`X-Guest-Order-Token` 헤더가 필수**입니다 — 누락하거나 주문번호와 맞지 않으면 404 를 반환합니다. + +**설명** 비회원이 주문 당시 신청하지 않은 현금영수증을 주문상세에서 **직접 사후 발급**합니다. `VerifyGuestOrderToken` 미들웨어가 `X-Guest-Order-Token` 헤더를 검증해 소유권을 보장하며, 토큰이 검증한 주문에 대해서만 발급합니다. + +요청 본문(`receipt_type` / `identifier_type` / `identifier`), 검증 규칙, 발급 가능 조건, 오류 코드 체계는 관리자·회원 발급 API 와 모두 동일합니다 — 이미 발급된 주문은 409(`ALREADY_ISSUED`), 그 외 발급 불가 사유는 422 에 `errors.error_code` 로 구분해 담깁니다. + +**발급 취소는 제공하지 않습니다**(관리자 전용). 회원 경로와 마찬가지로 비회원용 `DELETE` 라우트 자체를 노출하지 않습니다. + + ### POST /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/estimate-refund - **라우트명**: `api.modules.sirsoft-ecommerce.guest.orders.estimate-refund` @@ -151,18 +480,87 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AdjustmentResult::toPreviewArray()` — 실제 취소는 수행하지 않는 환불 예상 계산 결과)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| refund_amount | number | `12000` | PG 환불 예상 금액 (음수면 추가결제 필요) | +| refund_points_amount | number | `0` | 마일리지 환불 예상 금액 | +| original_paid_amount | number | `15000` | 원 결제 금액 | +| recalculated_paid_amount | number | `3000` | 취소 후 재계산된 결제 금액 | +| shipping_difference | number | `0` | 배송비 차이 (양수: 환불 / 음수: 추가결제) | +| discount_difference | number | `0` | 할인 차이 (양수: 할인 감소분) | +| additional_payment_amount | number | `0` | 추가결제 필요 금액 (환불액이 음수일 때만 > 0) | +| cancelled_items | array | — | 취소 대상 항목 정보 (`order_option_id`, `cancel_quantity`, `cancel_amount`) | +| refund_priority | string | `pg_first` | 적용된 환불 배분 우선순위 (`pg_first`, `points_first`) | +| remaining_pg_balance | number | `0` | 환불 후 잔여 PG 결제 잔액 | +| remaining_points_balance | number | `0` | 환불 후 잔여 마일리지 잔액 | +| refund_total | number | `12000` | 총 환불 예상액 (PG 환불액 + 마일리지 환불액) | +| refund_formatted | object | — | 환불 총액·잔액의 표기 문자열 (base 통화 + 결제 통화 병기 — 취소 모달 표기 SSoT) | +| restored_coupons | array | — | 취소로 복원되는 쿠폰 정보 (`coupon_name`, `discount_amount`) | +| shipping_details | array | — | 배송비 정책별 상세 (`policy_name`, `base_difference`, `extra_difference`, `total_difference`) | +| mc_refund_amount | object\|null | — | PG 환불 금액의 다중 통화 표기 (통화코드 → 금액) | +| mc_refund_points_amount | object\|null | — | 마일리지 환불 금액의 다중 통화 표기 | +| mc_refund_shipping_amount | object\|null | — | 배송비 환불 금액의 다중 통화 표기 | +| original_snapshot | object | — | 재계산 전 주문 금액 스냅샷 | +| recalculated_snapshot | object | — | 재계산 후 주문 금액 스냅샷 | +| mc_original_snapshot | object\|null | — | 재계산 전 다중 통화 스냅샷 | +| mc_recalculated_snapshot | object\|null | — | 재계산 후 다중 통화 스냅샷 | +| original_coupons | array | — | 원 주문에 적용된 쿠폰 상세 (`name`, `target_type`, `discount_amount`) | +| recalculated_coupons | array | — | 재계산 후 유지되는 쿠폰 상세 | +| cancel_blocked | boolean | `false` | 취소 차단 여부 (부분취소로 쿠폰 조건이 깨져 추가결제가 필요해지는 경우 true) | +| cancel_blocked_reason | string\|null | `null` | 차단 사유 메시지 (차단이 아니면 `null`) | **응답 예시** - +```json +{ + "success": true, + "message": "환불 예상금액을 조회했습니다.", + "data": { + "refund_amount": 12000, + "refund_points_amount": 0, + "original_paid_amount": 15000, + "recalculated_paid_amount": 3000, + "shipping_difference": 0, + "discount_difference": 0, + "additional_payment_amount": 0, + "cancelled_items": [ + { + "order_option_id": 34, + "cancel_quantity": 2, + "cancel_amount": 12000 + } + ], + "refund_priority": "pg_first", + "remaining_pg_balance": 3000, + "remaining_points_balance": 0, + "refund_total": 12000, + "refund_formatted": {}, + "restored_coupons": [], + "shipping_details": [], + "mc_refund_amount": null, + "mc_refund_points_amount": null, + "mc_refund_shipping_amount": null, + "original_snapshot": {}, + "recalculated_snapshot": {}, + "mc_original_snapshot": null, + "mc_recalculated_snapshot": null, + "original_coupons": [], + "recalculated_coupons": [], + "cancel_blocked": false, + "cancel_blocked_reason": null + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 500 | Internal Server Error | 환불 예상금액 계산 실패 (`환불 예상금액 조회에 실패했습니다.`) | @@ -192,17 +590,51 @@ Accept: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 구매확정 후 갱신된 주문 상세). 필드 구성은 [비회원 주문 취소 응답 필드 표](#guest-order-resource)와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260714000012` | 주문번호 | +| order_status | string | `confirmed` | 구매확정 반영된 주문 상태 | +| order_status_label | string\|null | `구매확정` | 주문 상태의 다국어 라벨 | +| confirmed_at | string\|null | `2026-07-14T16:10:03+09:00` | 구매확정 일시 (ISO 8601) | +| options | array | — | 주문 옵션 목록 — 확정된 옵션의 상태가 갱신되어 포함 | +| abilities | object | `{"can_cancel": false}` | 주문 상태 기반 허용 액션 | +| _(그 외)_ | — | — | 금액·일시·주문자/수취인·배송지·결제·배송·취소이력 필드는 [비회원 주문 취소 응답 필드 표](#guest-order-resource)와 동일 | **응답 예시** - +```json +{ + "success": true, + "message": "구매확정이 완료되었습니다.", + "data": { + "order_number": "20260714000012", + "order_status": "confirmed", + "order_status_label": "구매확정", + "order_status_variant": "success", + "is_partially_cancelled": false, + "total_amount": 15000, + "total_amount_formatted": "15,000원", + "confirmed_at": "2026-07-14T16:10:03+09:00", + "confirmed_at_formatted": "2026-07-14 16:10", + "options": [], + "shipping_address": {}, + "shippings": [], + "cancels": [], + "abilities": { + "can_cancel": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치, 또는 `optionId` 가 해당 주문에 속하지 않는 경우 | +| 422 | Unprocessable Entity | 구매확정 불가 상태 (배송 미완료 등 — `현재 상태에서는 구매확정할 수 없습니다.`) | @@ -261,18 +693,54 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 배송지 수정 후 갱신된 주문 상세). 필드 구성은 [비회원 주문 취소 응답 필드 표](#guest-order-resource)와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260714000012` | 주문번호 | +| recipient_name | string\|null | `홍길동` | 수정 반영된 수령인 이름 | +| recipient_phone | string\|null | `010-1234-5678` | 수정 반영된 수령인 연락처 | +| recipient_zipcode | string\|null | `06234` | 수정 반영된 우편번호 | +| recipient_address | string\|null | `서울특별시 강남구 테헤란로 1` | 수정 반영된 기본 주소 | +| recipient_detail_address | string\|null | `101호` | 수정 반영된 상세 주소 | +| delivery_memo | string\|null | `부재 시 경비실에 맡겨주세요` | 수정 반영된 배송 메모 | +| shipping_address | object\|null | — | 배송지 상세 (`OrderAddressResource` — 국제 주소 필드 포함) | +| _(그 외)_ | — | — | 상태·금액·일시·옵션·결제·배송·취소이력·abilities 필드는 [비회원 주문 취소 응답 필드 표](#guest-order-resource)와 동일 | **응답 예시** - +```json +{ + "success": true, + "message": "배송지가 변경되었습니다.", + "data": { + "order_number": "20260714000012", + "order_status": "payment_complete", + "order_status_label": "결제완료", + "recipient_name": "홍길동", + "recipient_phone": "010-1234-5678", + "recipient_zipcode": "06234", + "recipient_address": "서울특별시 강남구 테헤란로 1", + "recipient_detail_address": "101호", + "delivery_memo": "부재 시 경비실에 맡겨주세요", + "delivery_memo_label": null, + "shipping_address": {}, + "options": [], + "shippings": [], + "cancels": [], + "abilities": { + "can_cancel": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 (`error.errors` 에 필드별 메시지), 또는 배송이 이미 시작되어 수정 불가 (`배송 전 상태에서만 배송지를 변경할 수 있습니다.`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md index 0e5d4a7f..4e7810e8 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Inquiries 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -40,11 +40,23 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 성공 여부 (컨트롤러가 상수 `true` 로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "문의가 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** @@ -53,6 +65,8 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.delete`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteInquiry()` 의 `RuntimeException` | +| 500 | Internal Server Error | 삭제 처리 중 예기치 못한 예외 (`문의 삭제에 실패했습니다.`) | @@ -82,11 +96,23 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 답변 삭제 성공 여부 (컨트롤러가 상수 `true` 로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** @@ -95,6 +121,8 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 삭제 처리 중 예기치 못한 예외 (`답변 삭제에 실패했습니다.`) | @@ -130,11 +158,25 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 답변이 등록된 문의(피벗 `product_inquiries`)의 기본 키 | +| is_answered | boolean | `true` | 답변 등록 후의 문의 답변 완료 여부 | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 등록되었습니다.", + "data": { + "id": 1, + "is_answered": true + } +} +``` **에러 응답** @@ -142,8 +184,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) | @@ -179,11 +222,23 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 답변을 수정한 문의(피벗 `product_inquiries`)의 기본 키 (요청 path 의 `inquiryId` 를 그대로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 수정되었습니다.", + "data": { + "id": 1 + } +} +``` **에러 응답** @@ -191,8 +246,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 수정 처리 중 예기치 못한 예외 (`답변 수정에 실패했습니다.`) | @@ -207,12 +263,17 @@ Content-Type: application/json **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| search | query | string | 아니오 | max 100 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| is_answered | query | boolean | 아니오 | — | answered 여부 | **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/user/inquiries HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/user/inquiries?page=1&per_page=1&search=%EC%98%88%EC%8B%9C%EA%B0%92&is_answered=1 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -224,8 +285,8 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| items | array | `[]` | 내 문의 항목 배열 (각 항목: id, product 요약, product_name, is_answered, 게시판 연동 시 title/category/content/is_secret/reply/attachments) | -| meta | object | `{"current_page":1,"per_page":25,"total":0,"last_page":1,"…` | 페이지네이션 메타 (current_page/per_page/total/last_page/from/to, 문의 게시판 연동 여부 inquiry_available, abilities 답변·삭제 권한, board_settings) | +| items | array | `[{"id":19,"product_id":1732,"product":{"id":1732,"product…` | 내 문의 항목 배열 (각 항목: id, product 요약, product_name, is_answered, 게시판 연동 시 title/category/content/is_secret/reply/attachments) | +| meta | object | `{"current_page":1,"per_page":25,"total":39,"last_page":2,…` | 페이지네이션 메타 (current_page/per_page/total/last_page/from/to, 문의 게시판 연동 여부 inquiry_available, abilities 답변·삭제 권한, board_settings) | **응답 예시** @@ -240,51 +301,84 @@ HTTP/1.1 200 "data": { "items": [ { - "id": 1, - "product_id": 1, + "id": 19, + "product_id": 1732, "product": { - "id": 1, - "name": "API 문서 샘플 상품", - "thumbnail_url": null, - "url": "/shop/products/1" + "id": 1732, + "product_code": "CJTFHBL8SLRQ8ILM", + "name": "면 손수건 3매입 #1", + "thumbnail_url": "/api/modules/sirsoft-ecommerce/product-image/7df7761cdf16", + "url": "/shop/products/CJTFHBL8SLRQ8ILM" }, - "product_name": "iste et inventore", + "product_name": "면 손수건 3매입 #1", + "is_answered": true, + "...": "(9개 키 생략, 총 14개)" + }, + { + "id": 20, + "product_id": 1732, + "product": { + "id": 1732, + "product_code": "CJTFHBL8SLRQ8ILM", + "name": "면 손수건 3매입 #1", + "thumbnail_url": "/api/modules/sirsoft-ecommerce/product-image/7df7761cdf16", + "url": "/shop/products/CJTFHBL8SLRQ8ILM" + }, + "product_name": "면 손수건 3매입 #1", + "is_answered": true, + "...": "(9개 키 생략, 총 14개)" + }, + { + "id": 21, + "product_id": 1732, + "product": { + "id": 1732, + "product_code": "CJTFHBL8SLRQ8ILM", + "name": "면 손수건 3매입 #1", + "thumbnail_url": "/api/modules/sirsoft-ecommerce/product-image/7df7761cdf16", + "url": "/shop/products/CJTFHBL8SLRQ8ILM" + }, + "product_name": "면 손수건 3매입 #1", "is_answered": false, - "answered_at": null, - "created_at": "2026-07-08T01:44:49+00:00", - "updated_at": "2026-07-08T01:44:49+00:00", - "title": null, - "category": null, - "content": null, - "is_secret": "{MASKED}", - "reply": null, - "attachments": [] - } + "...": "(9개 키 생략, 총 14개)" + }, + { + "id": 22, + "product_id": 1732, + "product": { + "id": 1732, + "product_code": "CJTFHBL8SLRQ8ILM", + "name": "면 손수건 3매입 #1", + "thumbnail_url": "/api/modules/sirsoft-ecommerce/product-image/7df7761cdf16", + "url": "/shop/products/CJTFHBL8SLRQ8ILM" + }, + "product_name": "면 손수건 3매입 #1", + "is_answered": true, + "...": "(9개 키 생략, 총 14개)" + }, + { + "id": 23, + "product_id": 1732, + "product": { + "id": 1732, + "product_code": "CJTFHBL8SLRQ8ILM", + "name": "면 손수건 3매입 #1", + "thumbnail_url": "/api/modules/sirsoft-ecommerce/product-image/7df7761cdf16", + "url": "/shop/products/CJTFHBL8SLRQ8ILM" + }, + "product_name": "면 손수건 3매입 #1", + "is_answered": false, + "...": "(9개 키 생략, 총 14개)" + }, + "... (총 25건 중 5건 표시)" ], "meta": { "current_page": 1, "per_page": 25, - "total": 1, - "last_page": 1, + "total": 39, + "last_page": 2, "from": 1, - "to": 1, - "inquiry_available": false, - "abilities": { - "can_update": true, - "can_delete": true - }, - "board_settings": { - "secret_mode": "{MASKED}", - "categories": [], - "use_file_upload": false, - "max_file_count": 5, - "max_file_size": 10485760, - "allowed_extensions": [], - "min_title_length": 2, - "max_title_length": 200, - "min_content_length": 10, - "max_content_length": 10000 - } + "...": "(4개 키 생략, 총 9개)" } } } @@ -295,10 +389,20 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -**설명** 로그인 회원이 마이페이지에서 자신이 작성한 상품 문의 목록을 조회합니다. `auth:sanctum` 인증만 요구하며, `ProductInquiryService::getUserInquiries()` 가 로그인 사용자(`Auth::id()`)의 문의를 `search`(검색어)·`is_answered`(답변 여부) 필터와 `per_page`(기본 10)로 페이지네이션해 `items`(문의 배열)와 `meta`(페이지 정보)로 반환합니다. 마이페이지 문의 내역 화면을 채우는 데 사용합니다. +**요청 파라미터** + +| 이름 | 타입 | 필수 | 기본값 | 용도 | +| --- | --- | :---: | --- | --- | +| `page` | integer | - | 1 | 페이지 번호 (최소 1) | +| `per_page` | integer | - | 10 | 페이지당 건수 (1~100). 범위를 벗어나면 422 | +| `search` | string | - | - | 검색어 — **상품명**(`product_name_snapshot` 의 로케일별 값) 대상. 문의 제목·본문은 검색하지 않음 | +| `is_answered` | boolean | - | - | 답변 여부 (`0` 답변대기 / `1` 답변완료) | + +**설명** 로그인 회원이 마이페이지에서 자신이 작성한 상품 문의 목록을 조회합니다. `auth:sanctum` 인증만 요구하며, `ProductInquiryService::getUserInquiries()` 가 로그인 사용자(`Auth::id()`)의 문의를 `search`(검색어)·`is_answered`(답변 여부) 필터와 `per_page`(기본 10, 허용 1~100)로 페이지네이션해 `items`(문의 배열)와 `meta`(페이지 정보)로 반환합니다. 마이페이지 문의 내역 화면을 채우는 데 사용합니다. ### DELETE /api/modules/sirsoft-ecommerce/user/inquiries/{inquiryId} @@ -324,18 +428,32 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 성공 여부 (컨트롤러가 상수 `true` 로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "문의가 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 문의 작성자(`user_id`)가 로그인 사용자와 다른 경우 (`해당 문의에 대한 권한이 없습니다.`) | +| 404 | Not Found | 해당 ID 의 문의가 존재하지 않는 경우 (`문의를 찾을 수 없습니다.`) | +| 500 | Internal Server Error | 삭제 처리 중 예기치 못한 예외 (`문의 삭제에 실패했습니다.`) — 문의 게시판 미설정 등 서비스 `RuntimeException` 포함 | @@ -353,10 +471,10 @@ Authorization: Bearer {YOUR_TOKEN} | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | inquiryId | path | string | 예 | — | 대상 inquiry의 식별자 | -| title | body | string | 아니오 | — | 제목 | +| title | body | string | 아니오 | — | 문의 제목 (연동 게시판 Post 의 제목으로 저장. 게시판 설정에 따라 min/max 길이 규칙이 Filter 훅으로 추가됨) | | category | body | string | 아니오 | — | 문의 분류 (게시판 설정 기반 유형 슬러그, 연동 게시판 Post 로 저장) | -| content | body | string | 예 | — | 본문 내용 | -| is_secret | body | boolean | 아니오 | — | secret 여부 | +| content | body | string | 예 | — | 문의 본문 (필수. 게시판 설정에 따라 min/max 길이 규칙이 Filter 훅으로 추가됨) | +| is_secret | body | boolean | 아니오 | — | 비밀글 여부 (게시판 비밀글 모드 설정에 따라 적용) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.inquiry.update_validation_rules`). @@ -379,19 +497,33 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정한 문의(피벗 `product_inquiries`)의 기본 키 (요청 path 의 `inquiryId` 를 그대로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "문의가 수정되었습니다.", + "data": { + "id": 1 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 문의 작성자(`user_id`)가 로그인 사용자와 다른 경우 (`해당 문의에 대한 권한이 없습니다.`) | +| 404 | Not Found | 해당 ID 의 문의가 존재하지 않는 경우 (`문의를 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateInquiry()` 의 `RuntimeException` | +| 500 | Internal Server Error | 수정 처리 중 예기치 못한 예외 (`문의 수정에 실패했습니다.`) | @@ -421,18 +553,33 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 답변 삭제 성공 여부 (컨트롤러가 상수 `true` 로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 삭제 처리 중 예기치 못한 예외 (`답변 삭제에 실패했습니다.`) | @@ -468,19 +615,35 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 답변이 등록된 문의(피벗 `product_inquiries`)의 기본 키 | +| is_answered | boolean | `true` | 답변 등록 후의 문의 답변 완료 여부 | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 등록되었습니다.", + "data": { + "id": 1, + "is_answered": true + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) | @@ -516,19 +679,33 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 답변을 수정한 문의(피벗 `product_inquiries`)의 기본 키 (요청 path 의 `inquiryId` 를 그대로 반환) | **응답 예시** - +```json +{ + "success": true, + "message": "답변이 수정되었습니다.", + "data": { + "id": 1 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateReply()` 의 `RuntimeException` | +| 500 | Internal Server Error | 답변 수정 처리 중 예기치 못한 예외 (`답변 수정에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md index c177968a..f8236706 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage-transactions.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Mileage Transactions 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -52,45 +52,45 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `68` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | integer | `528` | 기본 키 (내부 식별자) | -| user_id | integer | `166` | user 식별자 (연관 리소스 참조) | +| number | integer | `21` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `830` | 기본 키 (내부 식별자) | +| user_id | integer | `1209` | user 식별자 (연관 리소스 참조) | | currency | string | `KRW` | 거래 기록 통화 코드 (주문 기준통화 스냅샷, 금액 표기·잔액 집계 단위) | -| type | string | `admin_earn` | 거래 유형 (MileageTransactionTypeEnum 8종: purchase_earn·admin_earn·order_use·admin_deduct·expired·refund_restore·order_cancel_restore·earn_cancel) | -| type_label | string | `관리자 지급` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| type | string | `admin_deduct` | 거래 유형 (MileageTransactionTypeEnum 8종: purchase_earn·admin_earn·order_use·admin_deduct·expired·refund_restore·order_cancel_restore·earn_cancel) | +| type_label | string | `관리자 차감` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | admin_badge_group | string | `amber` | 관리자 내역 화면 배지 색상 그룹 (적립계=green·사용계=blue·소멸=gray·복원계=teal·수동/회수계=amber) | | user_display_category | string | `adjust` | 회원 마이페이지 표시용 4분류 (earn·use·expire·adjust — 복원·수동·회수는 adjust 로 통합) | -| amount | integer | `1000` | 거래 금액 (양수=적립, 음수=차감) | -| amount_formatted | string | `1,000원` | `amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| amount | integer | `-300` | 거래 금액 (양수=적립, 음수=차감) | +| amount_formatted | string | `-300원` | `amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | remaining_amount | integer | `0` | 잔여 금액 (적립건만 양수, FIFO 차감으로 소진 — 미만료 잔여 합이 잔액 SSoT) | | remaining_amount_formatted | string | `0원` | `remaining_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| balance_after | integer | `1000` | 거래 직후 잔액 (감사용 스냅샷, 베스트에포트) | -| order_id | integer | `436` | order 식별자 (연관 리소스 참조) | -| order_option_id | integer | `824` | order option 식별자 (연관 리소스 참조) | -| order_cancel_id | integer | `18` | order cancel 식별자 (연관 리소스 참조) | -| source_transaction_id | integer | `523` | source transaction 식별자 (연관 리소스 참조) | -| granted_by | integer | `1` | 부여 주체 식별자 (NULL=시스템 자동, user ID=관리자 수동 부여) | -| granted_by_name | string | `관리자` | 부여 관리자 이름 (grantedByUser 관계 eager load 시에만 노출) | -| granted_by_uuid | string | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 부여 관리자 UUID (grantedByUser 관계 eager load 시에만 노출) | -| user_name | string | `API 문서 샘플 사용자` | 거래 대상 회원 이름 (user 관계 eager load 시에만 노출) | -| user_uuid | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 거래 대상 회원 UUID (user 관계 eager load 시에만 노출) | -| order_number | string | `20260619-1425382147` | 연관 주문번호 (order 관계 eager load 시에만 노출) | -| description | string | `마일리지 적립 (660원)` | 설명 (다국어 필드는 로케일별 값 객체) | -| memo | string | `111` | 관리자 메모 (수동 지급/차감·적립건 편집 시 입력) | -| expires_at | string | `2027-07-02T15:29:30+00:00` | expires 일시 | -| expires_at_formatted | string | `2027-07-03 00:29:30` | `expires_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| expires_at_date | string | `2027-07-03` | `expires_at` 의 사이트 타임존 기준 날짜 부분 (시각 제외) | +| balance_after | integer | `102200` | 거래 직후 잔액 (감사용 스냅샷, 베스트에포트) | +| order_id | null | `null` | order 식별자 (연관 리소스 참조) | +| order_option_id | null | `null` | order option 식별자 (연관 리소스 참조) | +| order_cancel_id | null | `null` | order cancel 식별자 (연관 리소스 참조) | +| source_transaction_id | integer | `802` | source transaction 식별자 (연관 리소스 참조) | +| granted_by | integer | `1209` | 부여 주체 식별자 (NULL=시스템 자동, user ID=관리자 수동 부여) | +| granted_by_name | string | `최고관리자` | 부여 관리자 이름 (grantedByUser 관계 eager load 시에만 노출) | +| granted_by_uuid | string | `a26219fc-94a0-4f63-9404-04c2a6ac99e4` | 부여 관리자 UUID (grantedByUser 관계 eager load 시에만 노출) | +| user_name | string | `최고관리자` | 거래 대상 회원 이름 (user 관계 eager load 시에만 노출) | +| user_uuid | string | `a26219fc-94a0-4f63-9404-04c2a6ac99e4` | 거래 대상 회원 UUID (user 관계 eager load 시에만 노출) | +| order_number | null | `null` | 연관 주문번호 (order 관계 eager load 시에만 노출) | +| description | string | `관리자 마일리지 차감 (300원)` | 설명 (다국어 필드는 로케일별 값 객체) | +| memo | string | `D-30 재실측` | 관리자 메모 (수동 지급/차감·적립건 편집 시 입력) | +| expires_at | string | `2027-07-31T12:56:55+00:00` | expires 일시 | +| expires_at_formatted | string | `2027-07-31 21:56:55` | `expires_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| expires_at_date | string | `2027-07-31` | `expires_at` 의 사이트 타임존 기준 날짜 부분 (시각 제외) | | expired_at | null | `null` | expired 일시 | | expired_at_formatted | null | `null` | `expired_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| created_at | string | `2026-07-07T05:47:31+00:00` | 생성 일시 | -| created_at_formatted | string | `2026-07-07 14:47:31` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| created_at_date | string | `2026-07-07` | `created_at` 의 사이트 타임존 기준 날짜 부분 (시각 제외) | -| is_earning | boolean | `true` | earning 여부 | +| created_at | string | `2026-07-31T13:36:05+00:00` | 생성 일시 | +| created_at_formatted | string | `2026-07-31 22:36:05` | `created_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| created_at_date | string | `2026-07-31` | `created_at` 의 사이트 타임존 기준 날짜 부분 (시각 제외) | +| is_earning | boolean | `false` | earning 여부 | | can_edit_expiry | boolean | `false` | edit expiry 수행 가능 여부 (권한 기반) | | expired_amount | integer | `0` | 이 적립 lot 을 source 로 소멸(expired)된 금액 합계 (목록 조회 시 eager 집계, 단건 조회 시 0 폴백) | | expired_amount_formatted | string | `0원` | `expired_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | expiry_state | string | `active` | 적립건 소멸 상태 (active=미소멸, partial_expired=일부 소멸, fully_expired=전액 소멸 — 적립계만 의미) | -| abilities | object | `{"can_manage":true,"can_edit":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| abilities | object | `{"can_manage":true,"can_edit":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -105,49 +105,94 @@ HTTP/1.1 200 "data": { "data": [ { - "number": 1, - "id": 1, - "user_id": 6, + "number": 21, + "id": 830, + "user_id": 1209, "currency": "KRW", - "type": "admin_earn", - "type_label": "관리자 지급", + "type": "admin_deduct", + "type_label": "관리자 차감", "admin_badge_group": "amber", "user_display_category": "adjust", - "amount": 1000, - "amount_formatted": "1,000원", + "amount": -300, + "amount_formatted": "-300원", "remaining_amount": 0, "remaining_amount_formatted": "0원", - "balance_after": 1000, + "balance_after": 102200, "order_id": null, "order_option_id": null, "order_cancel_id": null, - "source_transaction_id": null, - "granted_by": null, - "granted_by_name": null, - "granted_by_uuid": null, - "user_name": "API 문서 샘플 사용자", - "user_uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "source_transaction_id": 802, + "granted_by": 1209, + "granted_by_name": "최고관리자", + "granted_by_uuid": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", + "user_name": "최고관리자", + "user_uuid": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", "order_number": null, - "description": null, - "memo": null, + "description": "관리자 마일리지 차감 (300원)", + "memo": "D-30 재실측", "expires_at": null, "expires_at_formatted": null, "expires_at_date": null, "expired_at": null, "expired_at_formatted": null, - "created_at": "2026-07-08T01:44:49+00:00", - "created_at_formatted": "2026-07-08 10:44:49", - "created_at_date": "2026-07-08", - "is_earning": true, + "created_at": "2026-07-31T13:36:05+00:00", + "created_at_formatted": "2026-07-31 22:36:05", + "created_at_date": "2026-07-31", + "is_earning": false, "can_edit_expiry": false, "expired_amount": 0, "expired_amount_formatted": "0원", "expiry_state": "active", "abilities": { "can_manage": true, - "can_edit": true + "can_edit": false } - } + }, + { + "number": 20, + "id": 829, + "user_id": 1209, + "currency": "KRW", + "type": "admin_deduct", + "type_label": "관리자 차감", + "admin_badge_group": "amber", + "user_display_category": "adjust", + "amount": -2500, + "amount_formatted": "-2,500원", + "remaining_amount": 0, + "remaining_amount_formatted": "0원", + "balance_after": 102500, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": 801, + "granted_by": 1209, + "granted_by_name": "최고관리자", + "granted_by_uuid": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", + "user_name": "최고관리자", + "user_uuid": "a26219fc-94a0-4f63-9404-04c2a6ac99e4", + "order_number": null, + "description": "관리자 마일리지 차감 (2500원)", + "memo": "검수 차감 #5", + "expires_at": null, + "expires_at_formatted": null, + "expires_at_date": null, + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-07-31T12:50:13+00:00", + "created_at_formatted": "2026-07-31 21:50:13", + "created_at_date": "2026-07-31", + "is_earning": false, + "can_edit_expiry": false, + "expired_amount": 0, + "expired_amount_formatted": "0원", + "expiry_state": "active", + "abilities": { + "can_manage": true, + "can_edit": false + } + }, + "... (총 21건 중 2건 표시)" ], "abilities": { "can_manage": true @@ -159,9 +204,9 @@ HTTP/1.1 200 "current_page": 1, "last_page": 1, "per_page": 25, - "total": 1, + "total": 21, "from": 1, - "to": 1, + "to": 21, "has_more_pages": false } } @@ -223,11 +268,94 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (성공 시 HTTP 201). 생성 직후 모델을 그대로 직렬화하므로 관계 기반 필드(`user_name`·`user_uuid`·`granted_by_name`·`granted_by_uuid`·`order_number`)는 eager load 되지 않아 응답에서 생략됩니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `529` | 생성된 거래의 기본 키 | +| user_id | integer | `166` | 거래 대상 회원 ID (요청의 `user_id` uuid 를 내부 ID 로 해석한 값) | +| currency | string | `KRW` | 거래 기록 통화 코드 (요청 `currency`) | +| type | string | `admin_earn` | 거래 유형 (`action=earn` → `admin_earn`, `action=deduct` → `admin_deduct`) | +| type_label | string | `관리자 지급` | `type` 값의 사람이 읽는 라벨 | +| admin_badge_group | string | `amber` | 관리자 내역 화면 배지 색상 그룹 (수동/회수계=amber) | +| user_display_category | string | `adjust` | 회원 마이페이지 표시용 4분류 (수동 지급/차감은 adjust) | +| amount | integer | `1000` | 거래 금액 (지급=양수, 차감=음수) | +| amount_formatted | string | `1,000원` | `amount` 의 표시용 포맷 문자열 | +| remaining_amount | integer | `1000` | 잔여 금액 (지급 시 amount 와 동일, 차감 거래는 0) | +| remaining_amount_formatted | string | `1,000원` | `remaining_amount` 의 표시용 포맷 문자열 | +| balance_after | integer | `1000` | 거래 직후 해당 통화 잔액 스냅샷 | +| order_id | null | `null` | 수동 거래이므로 연결 주문 없음 | +| order_option_id | null | `null` | 수동 거래이므로 연결 주문 옵션 없음 | +| order_cancel_id | null | `null` | 수동 거래이므로 연결 주문취소 없음 | +| source_transaction_id | null | `null` | 수동 거래이므로 원본 거래 없음 | +| granted_by | integer | `1` | 처리한 관리자 user ID (인증 사용자) | +| description | string | `관리자 마일리지 지급 (1,000)` | 설명 (요청 `description` 미지정 시 활동로그 기본 문구) | +| memo | string \| null | `수기 보상` | 관리자 메모 (요청 `memo`) | +| expires_at | string \| null | `2027-07-02T15:29:30+00:00` | 만료 일시 (지급 시 정책 기본 또는 `expires_at`, 차감 거래는 null) | +| expires_at_formatted | string \| null | `2027-07-03 00:29:30` | `expires_at` 의 표시용 포맷 문자열 | +| expires_at_date | string \| null | `2027-07-03` | `expires_at` 의 사이트 타임존 기준 날짜 | +| expired_at | null | `null` | 소멸 일시 (신규 거래는 항상 null) | +| expired_at_formatted | null | `null` | `expired_at` 의 표시용 포맷 문자열 | +| created_at | string | `2026-07-07T05:47:31+00:00` | 생성 일시 | +| created_at_formatted | string | `2026-07-07 14:47:31` | `created_at` 의 표시용 포맷 문자열 | +| created_at_date | string | `2026-07-07` | `created_at` 의 사이트 타임존 기준 날짜 | +| is_earning | boolean | `true` | 적립계 여부 (`admin_earn`=true, `admin_deduct`=false) | +| can_edit_expiry | boolean | `true` | 만료일 편집 가능 여부 (적립계 + 미소멸 + 잔여>0) | +| expired_amount | integer | `0` | 이 적립 lot 을 source 로 소멸된 금액 합계 (단건 응답은 집계 미주입 → 0 폴백) | +| expired_amount_formatted | string | `0원` | `expired_amount` 의 표시용 포맷 문자열 | +| expiry_state | string | `active` | 적립건 소멸 상태 (신규 거래는 active) | +| abilities | object | `{"can_manage":true,"can_edit":true}` | 현재 사용자가 이 거래에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "마일리지 거래가 처리되었습니다.", + "data": { + "id": 529, + "user_id": 166, + "currency": "KRW", + "type": "admin_earn", + "type_label": "관리자 지급", + "admin_badge_group": "amber", + "user_display_category": "adjust", + "amount": 1000, + "amount_formatted": "1,000원", + "remaining_amount": 1000, + "remaining_amount_formatted": "1,000원", + "balance_after": 1000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": 1, + "description": "관리자 마일리지 지급 (1,000)", + "memo": "수기 보상", + "expires_at": "2027-07-02T15:29:30+00:00", + "expires_at_formatted": "2027-07-03 00:29:30", + "expires_at_date": "2027-07-03", + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-07-07T05:47:31+00:00", + "created_at_formatted": "2026-07-07 14:47:31", + "created_at_date": "2026-07-07", + "is_earning": true, + "can_edit_expiry": true, + "expired_amount": 0, + "expired_amount_formatted": "0원", + "expiry_state": "active", + "abilities": { + "can_manage": true, + "can_edit": true + } + } +} +``` **에러 응답** @@ -235,7 +363,7 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반했거나, 도메인 규칙 위반(`MileageValidationException` — 예: 차감액이 잔액 초과) 시. 후자는 `message`=`마일리지 처리에 실패했습니다.` + `error.errors.general[]` 에 사유 문장 | @@ -276,11 +404,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| extended_count | integer | `2` | 실제로 만료일이 연장된 적립 lot 수 (잔여 없는/대상 외 lot 은 제외되므로 요청한 `lot_ids` 수보다 작을 수 있음) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "마일리지 유효기간이 연장되었습니다.", + "data": { + "extended_count": 2 + } +} +``` **에러 응답** @@ -312,7 +456,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/mileage-transactions/1 HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/mileage-transactions/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -326,11 +470,94 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 거래 전체). 관계 기반 필드(`user_name`·`user_uuid`·`granted_by_name`·`granted_by_uuid`·`order_number`)는 eager load 되지 않아 응답에서 생략됩니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `528` | 수정한 거래의 기본 키 (path `{id}`) | +| user_id | integer | `166` | 거래 대상 회원 ID | +| currency | string | `KRW` | 거래 기록 통화 코드 | +| type | string | `admin_earn` | 거래 유형 (적립계만 수정 가능 — purchase_earn·admin_earn) | +| type_label | string | `관리자 지급` | `type` 값의 사람이 읽는 라벨 | +| admin_badge_group | string | `amber` | 관리자 내역 화면 배지 색상 그룹 | +| user_display_category | string | `adjust` | 회원 마이페이지 표시용 4분류 | +| amount | integer | `1000` | 거래 금액 (원장 불변 — 수정 불가) | +| amount_formatted | string | `1,000원` | `amount` 의 표시용 포맷 문자열 | +| remaining_amount | integer | `1000` | 잔여 금액 (FIFO 차감으로 소진) | +| remaining_amount_formatted | string | `1,000원` | `remaining_amount` 의 표시용 포맷 문자열 | +| balance_after | integer | `1000` | 거래 직후 잔액 스냅샷 | +| order_id | integer \| null | `null` | 연결 주문 ID | +| order_option_id | integer \| null | `null` | 연결 주문 옵션 ID | +| order_cancel_id | integer \| null | `null` | 연결 주문취소 ID | +| source_transaction_id | integer \| null | `null` | 원본 거래 ID | +| granted_by | integer \| null | `1` | 부여 주체 user ID (NULL=시스템 자동) | +| description | string \| null | `관리자 마일리지 지급 (1,000)` | 설명 | +| memo | string \| null | `사유 보정` | 갱신된 관리자 메모 (요청에 `memo` 를 포함한 경우 반영, 빈 값이면 null) | +| expires_at | string \| null | `2027-08-01T15:00:00+00:00` | 갱신된 만료 일시 (요청에 `expires_at` 을 포함한 경우 반영) | +| expires_at_formatted | string \| null | `2027-08-02 00:00:00` | `expires_at` 의 표시용 포맷 문자열 | +| expires_at_date | string \| null | `2027-08-02` | `expires_at` 의 사이트 타임존 기준 날짜 | +| expired_at | null | `null` | 소멸 일시 (소멸된 lot 은 만료일 수정 불가) | +| expired_at_formatted | null | `null` | `expired_at` 의 표시용 포맷 문자열 | +| created_at | string | `2026-07-07T05:47:31+00:00` | 생성 일시 | +| created_at_formatted | string | `2026-07-07 14:47:31` | `created_at` 의 표시용 포맷 문자열 | +| created_at_date | string | `2026-07-07` | `created_at` 의 사이트 타임존 기준 날짜 | +| is_earning | boolean | `true` | 적립계 여부 (수정 대상은 항상 true) | +| can_edit_expiry | boolean | `true` | 만료일 편집 가능 여부 (적립계 + 미소멸 + 잔여>0) | +| expired_amount | integer | `0` | 소멸 금액 합계 (단건 응답은 집계 미주입 → 0 폴백) | +| expired_amount_formatted | string | `0원` | `expired_amount` 의 표시용 포맷 문자열 | +| expiry_state | string | `active` | 적립건 소멸 상태 (active·partial_expired·fully_expired) | +| abilities | object | `{"can_manage":true,"can_edit":true}` | 현재 사용자가 이 거래에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "마일리지 거래가 수정되었습니다.", + "data": { + "id": 528, + "user_id": 166, + "currency": "KRW", + "type": "admin_earn", + "type_label": "관리자 지급", + "admin_badge_group": "amber", + "user_display_category": "adjust", + "amount": 1000, + "amount_formatted": "1,000원", + "remaining_amount": 1000, + "remaining_amount_formatted": "1,000원", + "balance_after": 1000, + "order_id": null, + "order_option_id": null, + "order_cancel_id": null, + "source_transaction_id": null, + "granted_by": 1, + "description": "관리자 마일리지 지급 (1,000)", + "memo": "사유 보정", + "expires_at": "2027-08-01T15:00:00+00:00", + "expires_at_formatted": "2027-08-02 00:00:00", + "expires_at_date": "2027-08-02", + "expired_at": null, + "expired_at_formatted": null, + "created_at": "2026-07-07T05:47:31+00:00", + "created_at_formatted": "2026-07-07 14:47:31", + "created_at_date": "2026-07-07", + "is_earning": true, + "can_edit_expiry": true, + "expired_amount": 0, + "expired_amount_formatted": "0원", + "expiry_state": "active", + "abilities": { + "can_manage": true, + "can_edit": true + } + } +} +``` **에러 응답** @@ -338,8 +565,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.mileage.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반했거나, 도메인 규칙 위반(`MileageValidationException`) 시. `message`=`마일리지 처리에 실패했습니다.` + `error.errors.general[]` 에 사유 문장 — 거래 없음(`마일리지 거래를 찾을 수 없습니다.`), 적립계 아님(`적립 거래만 수정할 수 있습니다.`), 소멸/전액 사용된 적립건(`이미 소멸되었거나 모두 사용된 적립건은 유효기간을 변경할 수 없습니다.`), 만료일이 적립일시 이전(`유효기간은 적립일시보다 이전일 수 없습니다.`) | @@ -361,7 +588,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/mileage-transactions/1/linked HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/mileage-transactions/{id}/linked HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -369,29 +596,48 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_목록 응답: `data.data[]` 배열 항목의 필드._ +_목록 응답: `data.data[]` 배열 항목의 필드. 페이지네이션 없는 컬렉션이라 `data.pagination` 은 없고, 컬렉션 레벨 `data.abilities`(`can_manage`)와 통화 필터 후보 `data.currencies`(이 엔드포인트는 주입하지 않아 항상 `[]`)가 함께 내려옵니다._ - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| number | integer | `1` | 목록에서의 순번 (HasRowNumber 파생) | +| id | integer | `531` | 연결 거래의 기본 키 | +| user_id | integer | `166` | 거래 대상 회원 ID | +| currency | string | `KRW` | 거래 기록 통화 코드 | +| type | string | `order_use` | 거래 유형 (적립건이면 이를 FIFO 로 소비한 차감 거래, 차감/복원건이면 원본 적립·취소 연결 거래) | +| type_label | string | `주문 사용` | `type` 값의 사람이 읽는 라벨 | +| admin_badge_group | string | `blue` | 관리자 내역 화면 배지 색상 그룹 | +| user_display_category | string | `use` | 회원 마이페이지 표시용 4분류 | +| amount | integer | `-500` | 거래 금액 (양수=적립, 음수=차감) | +| amount_formatted | string | `-500원` | `amount` 의 표시용 포맷 문자열 | +| remaining_amount | integer | `0` | 잔여 금액 (적립건만 양수) | +| remaining_amount_formatted | string | `0원` | `remaining_amount` 의 표시용 포맷 문자열 | +| balance_after | integer | `500` | 거래 직후 잔액 스냅샷 | +| order_id | integer \| null | `436` | 연결 주문 ID | +| order_option_id | integer \| null | `824` | 연결 주문 옵션 ID | +| order_cancel_id | integer \| null | `null` | 연결 주문취소 ID | +| source_transaction_id | integer \| null | `528` | 원본(source) 거래 ID — FIFO 소비된 적립 lot 또는 복원 원본 | +| granted_by | integer \| null | `null` | 부여 주체 user ID (NULL=시스템 자동) | +| description | string \| null | `주문 마일리지 사용` | 설명 | +| memo | string \| null | `null` | 관리자 메모 | +| expires_at | string \| null | `null` | 만료 일시 | +| expires_at_formatted | string \| null | `null` | `expires_at` 의 표시용 포맷 문자열 | +| expires_at_date | string \| null | `null` | `expires_at` 의 사이트 타임존 기준 날짜 | +| expired_at | string \| null | `null` | 소멸 일시 | +| expired_at_formatted | string \| null | `null` | `expired_at` 의 표시용 포맷 문자열 | +| created_at | string | `2026-07-07T05:47:31+00:00` | 생성 일시 | +| created_at_formatted | string | `2026-07-07 14:47:31` | `created_at` 의 표시용 포맷 문자열 | +| created_at_date | string | `2026-07-07` | `created_at` 의 사이트 타임존 기준 날짜 | +| is_earning | boolean | `false` | 적립계 여부 | +| can_edit_expiry | boolean | `false` | 만료일 편집 가능 여부 | +| expired_amount | integer | `0` | 소멸 금액 합계 (연결 거래 조회는 집계 미주입 → 0 폴백) | +| expired_amount_formatted | string | `0원` | `expired_amount` 의 표시용 포맷 문자열 | +| expiry_state | string | `active` | 적립건 소멸 상태 (적립계만 의미) | +| abilities | object | `{"can_manage":true,"can_edit":false}` | 현재 사용자가 이 거래에 수행 가능한 작업 불리언 맵 | **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "연결 거래를 조회했습니다.", - "data": { - "data": [], - "abilities": { - "can_manage": true - }, - "currencies": [] - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md index ec3f163a..1ccf83de 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/mileage.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Mileage 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -42,7 +42,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| mileage | object | `{"enabled":false,"available":12910,"pending":14000,"expir…` | 마일리지 잔액 요약 객체 (enabled 기능 활성화 여부, available 사용 가능, pending 적립 대기, expiring_soon/expiring_date 소멸 예정, total_earned/total_used 누적 적립·사용, by_currency 통화별 잔액) | +| mileage | object | `{"enabled":false,"available":102200,"pending":0,"expiring…` | 마일리지 잔액 요약 객체 (enabled 기능 활성화 여부, available 사용 가능, pending 적립 대기, expiring_soon/expiring_date 소멸 예정, total_earned/total_used 누적 적립·사용, by_currency 통화별 잔액) | **응답 예시** @@ -57,13 +57,22 @@ HTTP/1.1 200 "data": { "mileage": { "enabled": false, - "available": 0, + "available": 102200, "pending": 0, "expiring_soon": 0, "expiring_date": null, - "total_earned": 0, - "total_used": 0, - "by_currency": [] + "total_earned": 110000, + "total_used": 7800, + "by_currency": { + "KRW": { + "available": 102200, + "pending": 0, + "total_earned": 110000, + "total_used": 7800, + "expiring_soon": 0, + "expiring_date": null + } + } } } } @@ -109,7 +118,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| transactions | object | `{"data":[{"number":6,"id":527,"user_id":1,"currency":"KRW…` | 마일리지 거래 내역 페이지네이션 객체 (`data` 거래 항목 배열 + 페이지 메타, `MileageTransactionCollection` 으로 직렬화) | +| transactions | object | `{"data":[{"number":21,"id":830,"user_id":1209,"currency":…` | 마일리지 거래 내역 페이지네이션 객체 (`data` 거래 항목 배열 + 페이지 메타, `MileageTransactionCollection` 으로 직렬화) | **응답 예시** @@ -125,49 +134,46 @@ HTTP/1.1 200 "transactions": { "data": [ { - "number": 1, - "id": 1, - "user_id": 6, + "number": 21, + "id": 830, + "user_id": 1209, "currency": "KRW", - "type": "admin_earn", - "type_label": "관리자 지급", - "admin_badge_group": "amber", - "user_display_category": "adjust", - "amount": 1000, - "amount_formatted": "1,000원", - "remaining_amount": 0, - "remaining_amount_formatted": "0원", - "balance_after": 1000, - "order_id": null, - "order_option_id": null, - "order_cancel_id": null, - "source_transaction_id": null, - "granted_by": null, - "granted_by_name": [], - "granted_by_uuid": [], - "user_name": [], - "user_uuid": [], - "order_number": [], - "description": null, - "memo": null, - "expires_at": null, - "expires_at_formatted": null, - "expires_at_date": null, - "expired_at": null, - "expired_at_formatted": null, - "created_at": "2026-07-08T01:44:49+00:00", - "created_at_formatted": "2026-07-08 10:44:49", - "created_at_date": "2026-07-08", - "is_earning": true, - "can_edit_expiry": false, - "expired_amount": 0, - "expired_amount_formatted": "0원", - "expiry_state": "active", - "abilities": { - "can_manage": true, - "can_edit": true - } - } + "type": "admin_deduct", + "...": "(34개 키 생략, 총 39개)" + }, + { + "number": 20, + "id": 829, + "user_id": 1209, + "currency": "KRW", + "type": "admin_deduct", + "...": "(34개 키 생략, 총 39개)" + }, + { + "number": 19, + "id": 828, + "user_id": 1209, + "currency": "KRW", + "type": "admin_deduct", + "...": "(34개 키 생략, 총 39개)" + }, + { + "number": 18, + "id": 827, + "user_id": 1209, + "currency": "KRW", + "type": "admin_deduct", + "...": "(34개 키 생략, 총 39개)" + }, + { + "number": 17, + "id": 826, + "user_id": 1209, + "currency": "KRW", + "type": "admin_deduct", + "...": "(34개 키 생략, 총 39개)" + }, + "... (총 21건 중 5건 표시)" ], "abilities": { "can_manage": true @@ -177,10 +183,9 @@ HTTP/1.1 200 "current_page": 1, "last_page": 1, "per_page": 25, - "total": 1, + "total": 21, "from": 1, - "to": 1, - "has_more_pages": false + "...": "(2개 키 생략, 총 7개)" } } } @@ -224,11 +229,29 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| max_usable | integer | `1000` | 해당 주문금액(`order_amount`)에 사용 가능한 최대 마일리지. 보유 잔액·최대 사용 한도(percent/fixed)·주문금액 상한 중 최솟값을 취한 뒤 사용 단위(`use_unit`)로 내림 보정한 값 (기본 통화 사용 규칙 미설정 시 `0`) | +| available | integer | `12910` | 현재 사용 가능한 마일리지 잔액 (`getBalance()` 의 `available` 값과 동일) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "사용 가능한 최대 마일리지를 조회했습니다.", + "data": { + "max_usable": 1000, + "available": 12910 + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/options.md b/modules/_bundled/sirsoft-ecommerce/docs/api/options.md index a9748ed7..46f4b59a 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/options.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/options.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Options 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -59,11 +59,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductOptionService::bulkUpdatePriceByMixedIds()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 판매가가 변경된 옵션 건수 (`product_ids` 로 확장된 옵션 + `option_ids` 로 개별 선택된 옵션을 중복 제거한 대상 수) | +| requested_product_count | integer | `1` | 요청에 담긴 `product_ids` 배열의 원소 수 (옵션만 선택한 경우 `0`) | **응답 예시** - +```json +{ + "success": true, + "message": ":count개 옵션의 가격이 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_product_count": 1 + } +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.options.bulk_price_updated` 를 그대로 사용합니다. 컨트롤러가 `messageParams` 를 전달하지 않으므로 `:count` 플레이스홀더는 치환되지 않은 채 응답됩니다 (변경 건수는 `data.updated_count` 로 확인). **에러 응답** @@ -71,7 +87,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / Service 단계에서 `ValidationException` 발생 시 `옵션 가격 일괄 변경에 실패했습니다.` | +| 500 | Internal Server Error | 그 외 예외 발생 시 (`옵션 가격 일괄 변경에 실패했습니다.`) | @@ -118,11 +135,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductOptionService::bulkUpdateStockByMixedIds()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 재고가 변경된 옵션 건수 (`product_ids` 로 확장된 옵션 + `option_ids` 로 개별 선택된 옵션을 중복 제거한 대상 수) | +| requested_product_count | integer | `1` | 요청에 담긴 `product_ids` 배열의 원소 수 (옵션만 선택한 경우 `0`) | **응답 예시** - +```json +{ + "success": true, + "message": ":count개 옵션의 재고가 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_product_count": 1 + } +} +``` + +> `message` 는 `sirsoft-ecommerce::messages.options.bulk_stock_updated` 를 그대로 사용합니다. 컨트롤러가 `messageParams` 를 전달하지 않으므로 `:count` 플레이스홀더는 치환되지 않은 채 응답됩니다 (변경 건수는 `data.updated_count` 로 확인). **에러 응답** @@ -130,7 +163,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / Service 단계에서 `ValidationException` 발생 시 `옵션 재고 일괄 변경에 실패했습니다.` | +| 500 | Internal Server Error | 그 외 예외 발생 시 (`옵션 재고 일괄 변경에 실패했습니다.`) | @@ -147,9 +181,15 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | +| ids | body | array | 예 | min 1 | 대상 옵션 식별자 배열. 각 원소는 `"productId-optionId"` 형식 문자열 (`regex:/^\d+-\d+$/`) | | bulk_changes | body | array | 아니오 | — | 옵션 일괄 변경 조건 (`price_adjustment`/`stock_quantity` 각각 method+value, 설정된 필드가 개별 수정보다 우선 적용) | -| items | body | array | 아니오 | — | 처리 대상 항목 배열 | +| bulk_changes.price_adjustment | body | array | 아니오 | — | 옵션 추가금액(`price_adjustment`) 일괄 변경 조건 객체. 존재 시 하위 `method`/`value` 필수 | +| bulk_changes.price_adjustment.method | body | string | 아니오 | `set`, `add`, `percent` | 추가금액 변경 방식 (set 지정값으로 설정 / add 기존값에 가산 / percent 기존값에 비율 적용) | +| bulk_changes.price_adjustment.value | body | number | 아니오 | — | 추가금액 변경 값 (`method` 에 따라 설정값·가감액·비율로 해석. 소수 통화 대응) | +| bulk_changes.stock_quantity | body | array | 아니오 | — | 옵션 재고수량(`stock_quantity`) 일괄 변경 조건 객체. 존재 시 하위 `method`/`value` 필수 | +| bulk_changes.stock_quantity.method | body | string | 아니오 | `set`, `add`, `subtract` | 재고 변경 방식 (set 특정 수량으로 설정 / add 증가 / subtract 감소) | +| bulk_changes.stock_quantity.value | body | integer | 아니오 | min 0 | 재고 변경 값 (`method` 에 따라 설정 수량·증감 수량으로 해석) | +| items | body | array | 아니오 | — | 개별 인라인 수정 항목 배열. 항목별 키: `product_id`(필수, 존재하는 상품), `option_id`(필수, 존재하는 옵션), `option_name`(로케일별 문자열 배열, 각 max 255), `sku`(max 100), `list_price`(min 0), `price_adjustment`, `stock_quantity`(min 0), `safe_stock_quantity`(min 0), `is_default`, `is_active`. `bulk_changes` 에 설정된 필드는 개별값보다 우선 적용되어 무시됨 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.option.bulk_update_validation_rules`). @@ -169,6 +209,16 @@ Content-Type: application/json "bulk_changes": [ "예시값" ], + "bulk_changes.price_adjustment": [ + "예시값" + ], + "bulk_changes.price_adjustment.method": "set", + "bulk_changes.price_adjustment.value": 1, + "bulk_changes.stock_quantity": [ + "예시값" + ], + "bulk_changes.stock_quantity.method": "set", + "bulk_changes.stock_quantity.value": 1, "items": [ "예시값" ] @@ -177,11 +227,25 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductOptionService::bulkUpdate()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| options_updated | integer | `5` | 적용된 변경 횟수. 일괄 변경(`bulk_changes.price_adjustment`, `bulk_changes.stock_quantity`)은 각 조건마다 대상 옵션 수만큼 누적되고, 개별 인라인 수정(`items`)은 실제로 변경 필드가 남은 항목마다 1씩 누적됩니다 (대상 옵션이 없으면 `0`) | **응답 예시** - +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.options.bulk_updated", + "data": { + "options_updated": 5 + } +} +``` + +> 컨트롤러가 사용하는 메시지 키 `messages.options.bulk_updated` 는 모듈 lang(ko/en)에 정의되어 있지 않습니다(정의된 키는 `messages.options.bulk_update_success`). 따라서 `message` 에는 번역문 대신 키 문자열이 그대로 반환됩니다. **에러 응답** @@ -189,7 +253,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / Service 단계에서 `ValidationException` 발생 시 `옵션 일괄 업데이트에 실패했습니다.` | +| 500 | Internal Server Error | 그 외 예외 발생 시 (`옵션 일괄 업데이트에 실패했습니다.`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 1109b561..0ca1fa31 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Orders 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(raw HTTP) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -35,7 +35,7 @@ | order_status | query | array | 아니오 | — | 주문상태 다중 선택 필터 (OrderStatusEnum 값 배열, 해당 상태의 주문만 조회) | | option_status | query | array | 아니오 | — | 주문옵션 상태 다중 선택 필터 (OrderStatusEnum 값 배열, 해당 옵션 상태를 가진 주문만 조회) | | shipping_type | query | array | 아니오 | — | 배송유형 다중 선택 필터 (ShippingType 코드 배열) | -| payment_method | query | array | 아니오 | 결제수단 카탈로그에 등록된 ID | 결제수단 다중 선택 필터. 코어 8종과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`)를 모두 허용 | +| payment_method | query | array | 아니오 | — | 결제수단 다중 선택 필터. 코어 8종과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`)를 모두 허용 | | category_id | query | integer | 아니오 | — | category 식별자 | | min_amount | query | integer | 아니오 | min 0 | 주문금액 범위 필터 하한 (이 금액 이상 주문만 조회) | | max_amount | query | integer | 아니오 | min 0 | 주문금액 범위 필터 상한 (이 금액 이하 주문만 조회) | @@ -47,7 +47,7 @@ | user_id | query | integer | 아니오 | — | user 식별자 | | orderer_uuid | query | uuid | 아니오 | — | 특정 회원의 주문만 조회하는 주문자 UUID 필터 (회원 검색 연동용) | | member_type | query | string | 아니오 | `member`, `guest` | 회원 구분 필터 (member 회원 주문 / guest 비회원 주문) | -| sort_by | query | string | 아니오 | `ordered_at`, `paid_at`, `total_amount` | 정렬 기준 필드명 | +| sort_by | query | string | 아니오 | `ordered_at`, `paid_at`, `total_amount`, `shipped_at` | 정렬 기준 필드명 (그 외 값은 422). `shipped_at` 은 배송 테이블 기준 — `desc` 는 주문별 **가장 늦은** 발송일, `asc` 는 **가장 이른** 발송일로 정렬하며 발송 이력이 없는 주문도 목록에 남는다 | | sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | | per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | @@ -69,39 +69,39 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `128` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | integer | `455` | 기본 키 (내부 식별자) | -| order_number | string | `ORD-20260707-000002` | 주문번호 (사용자 노출용 고유 식별 코드) | +| number | integer | `100` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1408` | 기본 키 (내부 식별자) | +| order_number | string | `20260730-1436301187` | 주문번호 (사용자 노출용 고유 식별 코드) | | order_status | string | `pending_payment` | 주문상태 (OrderStatusEnum 값 — 결제대기/결제완료/배송중 등) | | order_status_label | string | `결제대기` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | order_status_variant | string | `warning` | `order_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | -| base_currency | string | `KRW` | 금액 표기 기준 통화 (모든 *_formatted 필드의 통화, 주문 시점 base_currency 고정) | -| payment_currency | string | `KRW` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) | +| base_currency | string | `JPY` | 금액 표기 기준 통화 (모든 *_formatted 필드의 통화, 주문 시점 base_currency 고정) | +| payment_currency | string | `JPY` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) | | is_cross_currency | boolean | `false` | cross currency 여부 | | is_partially_cancelled | boolean | `false` | partially cancelled 여부 | -| total_amount | integer | `193397` | 최종 주문금액 (상품합계 − 할인 + 배송비) | -| total_amount_formatted | string | `193,397원` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_amount | integer | `125000` | 최종 주문금액 (상품합계 − 할인 + 배송비) | +| total_amount_formatted | string | `¥125,000` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_shipping_amount | integer | `0` | 총 배송비 | -| total_shipping_amount_formatted | string | `0원` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_shipping_amount_formatted | string | `¥0` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_paid_amount | integer | `0` | 총 실제 결제금액 (PG 결제된 금액) | -| total_paid_amount_formatted | string | `0원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_unpaid_amount | integer | `193397` | 미결제 잔액 (최종 주문금액 − 실제 결제금액) | -| total_unpaid_amount_formatted | string | `193,397원` | `total_unpaid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_paid_amount_formatted | string | `¥0` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_unpaid_amount | integer | `125000` | 미결제 잔액 (최종 주문금액 − 실제 결제금액) | +| total_unpaid_amount_formatted | string | `¥125,000` | `total_unpaid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_cancelled_amount | integer | `0` | 총 취소금액 | | total_refunded_amount | integer | `0` | 총 환불금액 | | total_points_used_amount | integer | `0` | 총 포인트(마일리지) 사용액 | -| total_points_used_amount_formatted | string | `0원` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_earned_points_amount | integer | `1934` | 총 적립 예정 포인트 | -| total_earned_points_amount_formatted | string | `1,934원` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| ordered_at | string | `2026-07-07T05:47:30+00:00` | ordered 일시 | -| ordered_at_formatted | string | `2026-07-07 14:47:30` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_points_used_amount_formatted | string | `¥0` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_earned_points_amount | integer | `1250` | 총 적립 예정 포인트 | +| total_earned_points_amount_formatted | string | `¥1,250` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| ordered_at | string | `2026-07-30T10:36:30+00:00` | ordered 일시 | +| ordered_at_formatted | string | `2026-07-30 19:36:30` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | order_device | string | `pc` | 주문 디바이스 (DeviceTypeEnum 값 — pc/mobile/app) | | order_device_label | string | `PC` | `order_device` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| is_first_order | boolean | `true` | first order 여부 | -| user | object | `{"uuid":"a23317ba-05bc-4de3-8272-2b73c091a266","name":"남상준"}` | 회원 주문의 주문자 요약 (uuid·name, 비회원 주문이면 미포함) | -| first_option | object | `{"product_name":"quisquam et quia","product_option_name":…` | 대표 표시용 첫 번째 주문 옵션 요약 (상품명·옵션명·수량·썸네일·추가옵션 요약) | +| is_first_order | boolean | `false` | first order 여부 | +| user | object | `{"uuid":"a26219fc-94a0-4f63-9404-04c2a6ac99e4","name":"최고…` | 회원 주문의 주문자 요약 (uuid·name, 비회원 주문이면 미포함) | +| first_option | object | `{"product_name":"겨울 패딩 점퍼 #16","product_option_name":"카키\…` | 대표 표시용 첫 번째 주문 옵션 요약 (상품명·옵션명·수량·썸네일·추가옵션 요약) | | options_count | integer | `1` | options 개수 (집계) | -| address | object | `{"orderer_name":"관리자","recipient_name":"구태호","recipient_c…` | 배송지 요약 (주문자명·수령인명·배송국가 코드/현지화명) | +| address | object | `{"orderer_name":"연정훈","recipient_name":"소정훈","recipient_c…` | 배송지 요약 (주문자명·수령인명·배송국가 코드/현지화명) | | payment | object | `{"payment_method":"dbank","payment_method_label":"무통장입금"}` | 결제 요약 (결제수단 값·현지화 라벨) | | shipping | object | `{"shipping_type":null,"shipping_type_label":null,"shippin…` | 배송 요약 (배송유형·배송방법 라벨·택배사명·송장번호, 첫 번째 배송 기준) | | is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | @@ -120,49 +120,54 @@ HTTP/1.1 200 "data": { "data": [ { - "number": 2, - "id": 5, - "order_number": "ORD-20260708-000002", + "number": 100, + "id": 1408, + "order_number": "20260730-1436301187", "order_status": "pending_payment", "order_status_label": "결제대기", "order_status_variant": "warning", - "base_currency": "KRW", - "payment_currency": "KRW", + "base_currency": "JPY", + "payment_currency": "JPY", "is_cross_currency": false, "is_partially_cancelled": false, - "total_amount": 163969, - "total_amount_formatted": "163,969원", - "total_shipping_amount": 2500, - "total_shipping_amount_formatted": "2,500원", + "total_amount": 125000, + "total_amount_formatted": "¥125,000", + "total_shipping_amount": 0, + "total_shipping_amount_formatted": "¥0", "total_paid_amount": 0, - "total_paid_amount_formatted": "0원", - "total_unpaid_amount": 163969, - "total_unpaid_amount_formatted": "163,969원", + "total_paid_amount_formatted": "¥0", + "total_unpaid_amount": 125000, + "total_unpaid_amount_formatted": "¥125,000", "total_cancelled_amount": 0, "total_refunded_amount": 0, "total_points_used_amount": 0, - "total_points_used_amount_formatted": "0원", - "total_earned_points_amount": 1640, - "total_earned_points_amount_formatted": "1,640원", - "ordered_at": "2026-07-08T01:44:49+00:00", - "ordered_at_formatted": "2026-07-08 10:44:49", + "total_points_used_amount_formatted": "¥0", + "total_earned_points_amount": 1250, + "total_earned_points_amount_formatted": "¥1,250", + "ordered_at": "2026-07-30T10:36:30+00:00", + "ordered_at_formatted": "2026-07-30 19:36:30", "order_device": "pc", "order_device_label": "PC", - "is_first_order": true, - "user": { - "uuid": "a234c3ea-a4f9-496d-a5e7-b44f0d53fc2f", - "name": "선지영" - }, + "is_first_order": false, + "user": null, "first_option": { - "product_name": "assumenda doloremque ipsam", - "product_option_name": "ea", - "product_code": null, - "quantity": 2, - "thumbnail_url": null, + "product_name": "겨울 패딩 점퍼 #16", + "product_option_name": "카키/XL", + "product_code": "5E1WSBY0CHFX7UJU", + "quantity": 1, + "thumbnail_url": "/api/modules/sirsoft-ecommerce/product-image/81d10a0b743c", "additional_options_summary": null }, "options_count": 1, - "address": null, + "address": { + "orderer_name": "연정훈", + "recipient_name": "소정훈", + "recipient_country_code": "KR", + "recipient_country_name": { + "ko": "한국", + "en": "South Korea" + } + }, "payment": null, "shipping": { "shipping_type": null, @@ -178,49 +183,54 @@ HTTP/1.1 200 } }, { - "number": 1, - "id": 4, - "order_number": "APIDOC-20260708-000001", + "number": 99, + "id": 1405, + "order_number": "20260730-1436298546", "order_status": "pending_payment", "order_status_label": "결제대기", "order_status_variant": "warning", - "base_currency": "KRW", - "payment_currency": "KRW", + "base_currency": "JPY", + "payment_currency": "JPY", "is_cross_currency": false, - "is_partially_cancelled": false, - "total_amount": 327327, - "total_amount_formatted": "327,327원", - "total_shipping_amount": 3000, - "total_shipping_amount_formatted": "3,000원", - "total_paid_amount": 0, - "total_paid_amount_formatted": "0원", - "total_unpaid_amount": 327327, - "total_unpaid_amount_formatted": "327,327원", + "is_partially_cancelled": true, + "total_amount": 66000, + "total_amount_formatted": "¥66,000", + "total_shipping_amount": 0, + "total_shipping_amount_formatted": "¥0", + "total_paid_amount": 66000, + "total_paid_amount_formatted": "¥66,000", + "total_unpaid_amount": 0, + "total_unpaid_amount_formatted": "¥0", "total_cancelled_amount": 0, "total_refunded_amount": 0, "total_points_used_amount": 0, - "total_points_used_amount_formatted": "0원", - "total_earned_points_amount": 3273, - "total_earned_points_amount_formatted": "3,273원", - "ordered_at": "2026-07-08T01:44:49+00:00", - "ordered_at_formatted": "2026-07-08 10:44:49", + "total_points_used_amount_formatted": "¥0", + "total_earned_points_amount": 698, + "total_earned_points_amount_formatted": "¥698", + "ordered_at": "2026-07-30T08:36:29+00:00", + "ordered_at_formatted": "2026-07-30 17:36:29", "order_device": "pc", "order_device_label": "PC", "is_first_order": false, - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자" - }, + "user": null, "first_option": { - "product_name": "", - "product_option_name": "", - "product_code": null, - "quantity": null, - "thumbnail_url": null, + "product_name": "기본 양말 5족 #2", + "product_option_name": "그레이/M", + "product_code": "S0SO3A6SJFYLAKSF", + "quantity": 3, + "thumbnail_url": "/api/modules/sirsoft-ecommerce/product-image/8fc3ae669be2", "additional_options_summary": null }, - "options_count": 0, - "address": null, + "options_count": 3, + "address": { + "orderer_name": "양호민", + "recipient_name": "전종수", + "recipient_country_code": "KR", + "recipient_country_name": { + "ko": "한국", + "en": "South Korea" + } + }, "payment": null, "shipping": { "shipping_type": null, @@ -229,33 +239,42 @@ HTTP/1.1 200 "carrier_name": null, "tracking_number": null }, - "is_owner": true, + "is_owner": false, "abilities": { "can_read": true, "can_update": true } - } + }, + "... (총 25건 중 2건 표시)" ], "abilities": { "can_update": true }, "statistics": { - "total": 2, + "total": 100, "status_counts": { - "pending_payment": 2 + "confirmed": 10, + "delivered": 20, + "cancelled": 5, + "shipping": 13, + "payment_complete": 20, + "preparing": 15, + "shipping_ready": 5, + "shipping_hold": 5, + "pending_payment": 7 }, - "today_count": 2, - "today_revenue": "0.00", - "monthly_revenue": "0.00" + "today_count": 0, + "today_revenue": 0, + "monthly_revenue": 0 }, "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 4, "per_page": 25, - "total": 2, + "total": 100, "from": 1, - "to": 2, - "has_more_pages": false + "to": 25, + "has_more_pages": true } } } @@ -310,11 +329,29 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`OrderService::bulkUpdate()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 변경된 주문 건수 (상태 변경 건수와 배송정보 변경 건수 중 큰 값) | +| requested_count | integer | `3` | 요청한 `ids` 배열의 건수 (대상 주문 수) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 주문이 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_count": 3 + } +} +``` **에러 응답** @@ -322,7 +359,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 상태 전이 규칙 위반(현재 상태 → 목표 상태 불가), 배송 관련 상태 전환 시 `carrier_id`/`tracking_number` 누락, 취소→판매상태 복원 시 재고 부족 등도 여기에 해당 (`일괄 처리에 실패했습니다.`) | +| 500 | Server Error | 일괄 처리 중 예기치 못한 오류 (`일괄 처리에 실패했습니다.`) | @@ -344,7 +382,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/orders/4 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/orders/{order} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -352,11 +390,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 소프트 삭제 성공 여부 (항상 `true`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** @@ -386,7 +440,7 @@ Authorization: Bearer {YOUR_TOKEN} **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/orders/4 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/orders/{order} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -398,111 +452,118 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `322` | 기본 키 (내부 식별자) | -| order_number | string | `20260617-0207256237` | 주문번호 | -| base_currency | string | `KRW` | 금액 표기 기준 통화 (모든 *_formatted 필드의 통화, 주문 시점 base_currency 고정) | -| payment_currency | string | `KRW` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) | +| id | integer | `1316` | 기본 키 (내부 식별자) | +| order_number | string | `20260730-1436224914` | 주문번호 | +| base_currency | string | `JPY` | 금액 표기 기준 통화 (모든 *_formatted 필드의 통화, 주문 시점 base_currency 고정) | +| payment_currency | string | `JPY` | 결제 통화 (유저가 선택·결제한 통화, base_currency 와 다르면 병기 표시) | | is_cross_currency | boolean | `false` | cross currency 여부 | | order_status | string | `payment_complete` | 주문상태 (OrderStatusEnum) | -| order_status_label | string | `결제완료` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| order_status_label | string | `Payment Complete` | `order_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | order_status_variant | string | `info` | `order_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | | is_partially_cancelled | boolean | `false` | partially cancelled 여부 | | order_device | string | `pc` | 주문 디바이스 (pc/mobile/app) | | order_device_label | string | `PC` | `order_device` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | is_first_order | boolean | `true` | first order 여부 | -| subtotal_amount | integer | `140000` | 상품 합계 (할인 전, 상품가×수량 합계) | -| subtotal_amount_formatted | string | `140,000원` | `subtotal_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_discount_amount | integer | `25000` | 총 할인금액 (모든 할인 합계) | -| total_discount_amount_formatted | string | `25,000원` | `total_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| subtotal_amount | integer | `179000` | 상품 합계 (할인 전, 상품가×수량 합계) | +| subtotal_amount_formatted | string | `¥179,000` | `subtotal_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_discount_amount | integer | `3000` | 총 할인금액 (모든 할인 합계) | +| total_discount_amount_formatted | string | `¥3,000` | `total_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_shipping_amount | integer | `0` | 총 배송비 | -| total_shipping_amount_formatted | string | `0원` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_amount | integer | `115000` | 최종 주문금액 (subtotal - discount + shipping) | -| total_amount_formatted | string | `115,000원` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_paid_amount | integer | `115000` | 총 실제 결제금액 (PG 결제액) | -| total_paid_amount_formatted | string | `115,000원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_shipping_amount_formatted | string | `¥0` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_amount | integer | `176000` | 최종 주문금액 (subtotal - discount + shipping) | +| total_amount_formatted | string | `¥176,000` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_paid_amount | integer | `176000` | 총 실제 결제금액 (PG 결제액) | +| total_paid_amount_formatted | string | `¥176,000` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_due_amount | integer | `0` | 총 결제예정금액 (무통장 등) | -| total_due_amount_formatted | string | `0원` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_due_amount_formatted | string | `¥0` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_due_charge_amount | integer | `0` | **결제 통화(`payment_currency`) 기준 실청구액**, 최소 화폐단위 정수 (KRW ×1 / 소수통화 ×10^n). `total_due_amount` 는 base 통화 금액이므로 base≠결제 통화면 값이 다릅니다. 무통장 입금확인이 검증하는 금액이 이 값이며, 화면은 이 값을 그대로 입금액으로 보내야 합니다 | +| total_due_charge_amount_formatted | string | `0원` | `total_due_charge_amount` 를 결제 통화 기호로 표기한 문자열 | | depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) | | total_cancelled_amount | integer | `0` | 총 취소금액 | -| total_cancelled_amount_formatted | string | `0원` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_cancelled_amount_formatted | string | `¥0` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_refunded_amount | integer | `0` | 총 환불금액 | -| total_refunded_amount_formatted | string | `0원` | `total_refunded_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_refunded_amount_formatted | string | `¥0` | `total_refunded_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_refunded_points_amount | integer | `0` | 총 환불 포인트 | -| total_refunded_points_amount_formatted | string | `0원` | `total_refunded_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_product_coupon_discount_amount | integer | `0` | 상품 쿠폰 할인 합계 | -| total_product_coupon_discount_amount_formatted | string | `0원` | `total_product_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_order_coupon_discount_amount | integer | `25000` | 주문 쿠폰 할인 합계 | -| total_order_coupon_discount_amount_formatted | string | `25,000원` | `total_order_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_coupon_discount_amount | integer | `25000` | 총 쿠폰 할인금액 | -| total_coupon_discount_amount_formatted | string | `25,000원` | `total_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_refunded_points_amount_formatted | string | `¥0` | `total_refunded_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_product_coupon_discount_amount | integer | `3000` | 상품 쿠폰 할인 합계 | +| total_product_coupon_discount_amount_formatted | string | `¥3,000` | `total_product_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_order_coupon_discount_amount | integer | `0` | 주문 쿠폰 할인 합계 | +| total_order_coupon_discount_amount_formatted | string | `¥0` | `total_order_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_coupon_discount_amount | integer | `3000` | 총 쿠폰 할인금액 | +| total_coupon_discount_amount_formatted | string | `¥3,000` | `total_coupon_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_code_discount_amount | integer | `0` | 총 할인코드 할인금액 | -| total_code_discount_amount_formatted | string | `0원` | `total_code_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_code_discount_amount_formatted | string | `¥0` | `total_code_discount_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_points_used_amount | integer | `0` | 총 포인트 사용액 | -| total_points_used_amount_formatted | string | `0원` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_points_used_amount_formatted | string | `¥0` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_deposit_used_amount | integer | `0` | 총 예치금 사용액 | -| total_deposit_used_amount_formatted | string | `0원` | `total_deposit_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_earned_points_amount | integer | `1150` | 총 적립 예정 포인트 | -| total_earned_points_amount_formatted | string | `1,150원` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| mc_subtotal_amount | object | `{"KRW":{"amount":140000,"formatted":"140,000원"},"USD":{"a…` | 상품합계 다중 통화 | -| mc_total_discount_amount | object | `{"KRW":{"amount":25000,"formatted":"25,000원"},"USD":{"amo…` | 총 할인 다중 통화 | +| total_deposit_used_amount_formatted | string | `¥0` | `total_deposit_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_earned_points_amount | integer | `1760` | 총 적립 예정 포인트 | +| total_earned_points_amount_formatted | string | `¥1,760` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| mc_subtotal_amount | object | `{"KRW":{"amount":179000,"formatted":"179,000원"},"USD":{"a…` | 상품합계 다중 통화 | +| mc_total_discount_amount | object | `{"KRW":{"amount":3000,"formatted":"3,000원"},"USD":{"amoun…` | 총 할인 다중 통화 | | mc_total_shipping_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 총 배송비 다중 통화 | -| mc_total_amount | object | `{"KRW":{"amount":115000,"formatted":"115,000원"},"USD":{"a…` | 최종금액 다중 통화 (payment_amount) | -| mc_total_product_coupon_discount_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 상품 쿠폰 할인 다중 통화 | -| mc_total_order_coupon_discount_amount | object | `{"KRW":{"amount":25000,"formatted":"25,000원"},"USD":{"amo…` | 주문 쿠폰 할인 다중 통화 | -| mc_total_coupon_discount_amount | object | `{"KRW":{"amount":25000,"formatted":"25,000원"},"USD":{"amo…` | 쿠폰 할인 합계 다중 통화 | +| mc_total_amount | object | `{"KRW":{"amount":176000,"formatted":"176,000원"},"USD":{"a…` | 최종금액 다중 통화 (payment_amount) | +| mc_total_product_coupon_discount_amount | object | `{"KRW":{"amount":3000,"formatted":"3,000원"},"USD":{"amoun…` | 상품 쿠폰 할인 다중 통화 | +| mc_total_order_coupon_discount_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 주문 쿠폰 할인 다중 통화 | +| mc_total_coupon_discount_amount | object | `{"KRW":{"amount":3000,"formatted":"3,000원"},"USD":{"amoun…` | 쿠폰 할인 합계 다중 통화 | | mc_total_code_discount_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 할인코드 할인 다중 통화 | | mc_total_points_used_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 포인트 사용 다중 통화 | | mc_total_deposit_used_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 예치금 사용 다중 통화 | | item_count | integer | `2` | item 개수 (집계) | | total_quantity | integer | `5` | 주문 옵션 수량 합계 (options 로드 시) | -| total_list_price | integer | `177000` | 정가 합계 (옵션 스냅샷 정가 × 수량 합계) | -| total_list_price_formatted | string | `177,000원` | `total_list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| ordered_at | string | `2026-06-15T02:07:25+00:00` | ordered 일시 | -| ordered_at_formatted | string | `2026-06-15 11:07:25` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| paid_at | string | `2026-06-17T02:07:25+00:00` | paid 일시 | -| paid_at_formatted | string | `2026-06-17 11:07:25` | `paid_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_list_price | integer | `207000` | 정가 합계 (옵션 스냅샷 정가 × 수량 합계) | +| total_list_price_formatted | string | `¥207,000` | `total_list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| ordered_at | string | `2026-07-27T14:36:22+00:00` | ordered 일시 | +| ordered_at_formatted | string | `2026-07-27 23:36:22` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| paid_at | string | `2026-07-30T14:36:22+00:00` | paid 일시 | +| paid_at_formatted | string | `2026-07-30 23:36:22` | `paid_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | confirmed_at | null | `null` | confirmed 일시 | | confirmed_at_formatted | null | `null` | `confirmed_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | cancelled_at | null | `null` | cancelled 일시 | | cancelled_at_formatted | null | `null` | `cancelled_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | delivered_at | null | `null` | delivered 일시 | -| total_tax_amount | integer | `10455` | 총 과세금액 | -| total_tax_amount_formatted | string | `10,455원` | `total_tax_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_vat_amount | integer | `0` | 총 부가세금액 | -| total_vat_amount_formatted | string | `0원` | `total_vat_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_taxable_supply_amount | integer | `10455` | 과세 공급가액 (총 과세금액 − 부가세, 영수증 과세금액 표시 SSoT) | -| total_taxable_supply_amount_formatted | string | `10,455원` | `total_taxable_supply_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_tax_amount | integer | `16000` | 총 과세금액 | +| total_tax_amount_formatted | string | `¥16,000` | `total_tax_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_vat_amount | integer | `1455` | 총 부가세금액 | +| total_vat_amount_formatted | string | `¥1,455` | `total_vat_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_taxable_supply_amount | integer | `14545` | 과세 공급가액 (총 과세금액 − 부가세, 영수증 과세금액 표시 SSoT) | +| total_taxable_supply_amount_formatted | string | `¥14,545` | `total_taxable_supply_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_tax_free_amount | integer | `0` | 총 면세금액 | -| total_tax_free_amount_formatted | string | `0원` | `total_tax_free_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| user | object | `{"uuid":"a20683c6-14f8-4061-baa9-157c45e9de5a","name":"Jo…` | 회원 주문의 주문자 정보 (uuid·name·email, user 관계 로드 시 · 비회원이면 미포함) | -| user_id | string | `a20683c6-14f8-4061-baa9-157c45e9de5a` | user 식별자 (연관 리소스 참조) | +| total_tax_free_amount_formatted | string | `¥0` | `total_tax_free_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| user | object | `{"uuid":"a26219fc-94a0-4f63-9404-04c2a6ac99e4","name":"최고…` | 회원 주문의 주문자 정보 (uuid·name·email, user 관계 로드 시 · 비회원이면 미포함) | +| user_id | string | `a26219fc-94a0-4f63-9404-04c2a6ac99e4` | user 식별자 (연관 리소스 참조) | | user_login_id | null | `null` | 회원 로그인 아이디 (login_id, 비회원 주문이면 null) | -| orderer_name | string | `설창용` | 주문자 이름 (배송지에서 플래튼) | -| orderer_phone | string | `010-0650-9192` | 주문자 휴대전화 (배송지에서 플래튼) | +| orderer_name | string | `박대수` | 주문자 이름 (배송지에서 플래튼) | +| orderer_phone | string | `010-4416-4675` | 주문자 휴대전화 (배송지에서 플래튼) | | orderer_tel | null | `null` | 주문자 일반전화 (배송지에서 플래튼, 미입력 시 null) | -| orderer_email | string | `moonchang.shim@gmail.com` | 주문자 이메일 (배송지에서 플래튼, 비회원 알림 수신 통로) | -| recipient_name | string | `길준` | 수령인 이름 (배송지에서 플래튼) | -| recipient_phone | string | `010-1612-1979` | 수령인 휴대전화 (배송지에서 플래튼) | +| orderer_email | string | `shin.yewon@cheon.biz` | 주문자 이메일 (배송지에서 플래튼, 비회원 알림 수신 통로) | +| recipient_name | string | `권강희` | 수령인 이름 (배송지에서 플래튼) | +| recipient_phone | string | `010-6286-6243` | 수령인 휴대전화 (배송지에서 플래튼) | | recipient_tel | null | `null` | 수령인 일반전화 (배송지에서 플래튼, 미입력 시 null) | -| recipient_zipcode | string | `19882` | 수령인 우편번호 (배송지에서 플래튼) | -| recipient_address | string | `부산광역시 도봉구 역삼로 201` | 수령인 기본 주소 (배송지에서 플래튼) | +| recipient_zipcode | string | `25530` | 수령인 우편번호 (배송지에서 플래튼) | +| recipient_address | string | `경상남도 광주시 선릉로 768` | 수령인 기본 주소 (배송지에서 플래튼) | | recipient_detail_address | null | `null` | 수령인 상세 주소 (배송지에서 플래튼, 미입력 시 null) | -| delivery_memo | null | `null` | 배송 메모 (배송지에서 플래튼, 미입력 시 null) | -| delivery_memo_label | null | `null` | `delivery_memo` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| options | array | `[{"id":612,"option_status":"payment_complete","option_sta…` | 주문 옵션(품목) 목록 (OrderOptionResource — 상품·옵션·수량·옵션상태·금액) | -| shipping_address | object | `{"id":320,"address_type":"shipping","orderer_name":"설창용",…` | 배송지 상세 (OrderAddressResource — 주문자/수령인/국내·해외 주소) | +| delivery_memo | string | `parcel_box` | 배송 메모 (배송지에서 플래튼, 미입력 시 null) | +| delivery_memo_label | string | `택배함에 넣어주세요` | `delivery_memo` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| options | array | `[{"id":1263,"option_status":"payment_complete","option_st…` | 주문 옵션(품목) 목록 (OrderOptionResource — 상품·옵션·수량·옵션상태·금액) | +| shipping_address | object | `{"id":319,"address_type":"shipping","orderer_name":"박대수",…` | 배송지 상세 (OrderAddressResource — 주문자/수령인/국내·해외 주소) | | billing_address | null | `null` | 청구지 상세 (OrderAddressResource, 미분리 시 null) | -| payment | object | `{"id":284,"payment_status":"paid","payment_status_label":…` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) | -| payments | array | `[{"id":284,"payment_status":"paid","payment_status_label"…` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) | +| payment | object | `{"id":1,"payment_status":"paid","payment_status_label":"P…` | 대표 결제 정보 (OrderPaymentResource — 결제수단·결제상태·금액) | +| payments | array | `[{"id":1,"payment_status":"paid","payment_status_label":"…` | 결제 이력 목록 (OrderPaymentResource 배열 — 다회 결제/부분결제 포함) | +| cash_receipt | null | `null` | 현재 유효한 현금영수증 (CashReceiptResource — 취소되지 않은 발급 건, 없으면 null) | +| cash_receipts | array | `[]` | 현금영수증 발급·취소 이력 전체 (CashReceiptResource 배열 — 취소된 건 포함) | + +> `CashReceiptResource` 의 `amount`/`tax_free_amount` 는 **결제 통화 기준 실청구액**입니다 (구매자가 실제로 낸 금액으로 세금 증빙이 발행되어야 하므로). `amount_formatted` 도 결제 통화 기호로 표기합니다. | shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | | cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | | promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) | -| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 | +| shipping_policy_applied_snapshot (비회원 응답) | object | `{"items": [{"product_option_id": 481, "policy": {"policy_name": "국내 무료배송", "standalone_shipping_amount": 0, "standalone_shipping_amount_formatted": "무료배송"}}]}` | 비로그인(비회원 조회 토큰) 응답은 **표시용 필드만** 내보낸다 — `policy_name` · `standalone_shipping_amount(_formatted)`. 정책 id·계산 근거와 배송지 메타(`address`)는 제외된다. 비회원 주문 상세 화면이 회원과 같은 partial 로 상품별 정책명·개별 배송비를 그리므로 필드 자체를 빼면 그 줄만 오류 없이 사라진다 | | admin_memo | null | `null` | 관리자 메모 (내부 관리용) | | customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) | -| created_at | string | `2026-06-17T02:07:25+00:00` | 생성 일시 | -| updated_at | string | `2026-06-17T02:07:25+00:00` | 최종 수정 일시 | -| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| created_at | string | `2026-07-30T14:36:22+00:00` | 생성 일시 | +| updated_at | string | `2026-07-30T14:36:22+00:00` | 최종 수정 일시 | +| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | | abilities | object | `{"can_read":true,"can_update":true,"can_cancel":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -516,120 +577,12 @@ HTTP/1.1 200 "success": true, "message": "주문 정보를 조회했습니다.", "data": { - "id": 4, - "order_number": "APIDOC-20260708-000001", - "base_currency": "KRW", - "payment_currency": "KRW", + "id": 1316, + "order_number": "20260730-1436224914", + "base_currency": "JPY", + "payment_currency": "JPY", "is_cross_currency": false, - "order_status": "pending_payment", - "order_status_label": "결제대기", - "order_status_variant": "warning", - "is_partially_cancelled": false, - "order_device": "pc", - "order_device_label": "PC", - "is_first_order": false, - "subtotal_amount": 324327, - "subtotal_amount_formatted": "324,327원", - "total_discount_amount": 0, - "total_discount_amount_formatted": "0원", - "total_shipping_amount": 3000, - "total_shipping_amount_formatted": "3,000원", - "total_amount": 327327, - "total_amount_formatted": "327,327원", - "total_paid_amount": 0, - "total_paid_amount_formatted": "0원", - "total_due_amount": 327327, - "total_due_amount_formatted": "327,327원", - "depositor_name": null, - "total_cancelled_amount": 0, - "total_cancelled_amount_formatted": "0원", - "total_refunded_amount": 0, - "total_refunded_amount_formatted": "0원", - "total_refunded_points_amount": 0, - "total_refunded_points_amount_formatted": "0원", - "total_product_coupon_discount_amount": 0, - "total_product_coupon_discount_amount_formatted": "0원", - "total_order_coupon_discount_amount": 0, - "total_order_coupon_discount_amount_formatted": "0원", - "total_coupon_discount_amount": 0, - "total_coupon_discount_amount_formatted": "0원", - "total_code_discount_amount": 0, - "total_code_discount_amount_formatted": "0원", - "total_points_used_amount": 0, - "total_points_used_amount_formatted": "0원", - "total_deposit_used_amount": 0, - "total_deposit_used_amount_formatted": "0원", - "total_earned_points_amount": 3273, - "total_earned_points_amount_formatted": "3,273원", - "mc_subtotal_amount": [], - "mc_total_discount_amount": [], - "mc_total_shipping_amount": [], - "mc_total_amount": [], - "mc_total_product_coupon_discount_amount": [], - "mc_total_order_coupon_discount_amount": [], - "mc_total_coupon_discount_amount": [], - "mc_total_code_discount_amount": [], - "mc_total_points_used_amount": [], - "mc_total_deposit_used_amount": [], - "item_count": 4, - "total_quantity": 0, - "total_list_price": 0, - "total_list_price_formatted": "0원", - "ordered_at": "2026-07-08T01:44:49+00:00", - "ordered_at_formatted": "2026-07-08 10:44:49", - "paid_at": null, - "paid_at_formatted": null, - "confirmed_at": null, - "confirmed_at_formatted": null, - "cancelled_at": null, - "cancelled_at_formatted": null, - "delivered_at": null, - "total_tax_amount": 29757, - "total_tax_amount_formatted": "29,757원", - "total_vat_amount": 0, - "total_vat_amount_formatted": "0원", - "total_taxable_supply_amount": 29757, - "total_taxable_supply_amount_formatted": "29,757원", - "total_tax_free_amount": 0, - "total_tax_free_amount_formatted": "0원", - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자", - "email": "apidoc-sample-user@example.com" - }, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user_login_id": null, - "orderer_name": null, - "orderer_phone": null, - "orderer_tel": null, - "orderer_email": null, - "recipient_name": null, - "recipient_phone": null, - "recipient_tel": null, - "recipient_zipcode": null, - "recipient_address": null, - "recipient_detail_address": null, - "delivery_memo": null, - "delivery_memo_label": null, - "options": [], - "shipping_address": null, - "billing_address": null, - "payment": null, - "payments": [], - "shippings": [], - "cancels": [], - "promotions_applied_snapshot": null, - "shipping_policy_applied_snapshot": null, - "admin_memo": null, - "customer_memo": null, - "created_at": "2026-07-08T01:44:49+00:00", - "updated_at": "2026-07-08T01:44:49+00:00", - "is_owner": true, - "abilities": { - "can_read": true, - "can_update": true, - "can_cancel": true - } + "...": "(101개 키 생략, 총 106개)" } } ``` @@ -677,7 +630,7 @@ HTTP/1.1 200 **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/orders/4 HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -704,11 +657,66 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 수정된 주문을 `OrderResource` 로 반환하므로 필드 구성은 `GET /admin/orders/{order}` (주문 상세) 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| order_number | string | `20260706-1405449337` | 주문번호 | +| order_status | string | `preparing` | 수정 반영된 주문상태 (OrderStatusEnum) | +| order_status_label | string | `상품준비중` | `order_status` 값의 사람이 읽는 라벨 | +| admin_memo | string | `고객 요청으로 주소 정정` | 수정 반영된 관리자 메모 | +| recipient_name | string | `심채원` | 수정 반영된 수령인 이름 | +| recipient_phone | string | `010-3955-6018` | 수정 반영된 수령인 휴대전화 | +| recipient_zipcode | string | `38022` | 수정 반영된 수령인 우편번호 | +| recipient_address | string | `부산광역시 양천구 공항대로 9` | 수정 반영된 수령인 기본 주소 | +| recipient_detail_address | string | `101동 202호` | 수정 반영된 수령인 상세 주소 | +| delivery_memo | string | `parcel_box` | 수정 반영된 배송 메모 | +| shipping_address | object | `{"id":1,"address_type":"shipping",…}` | 배송지 상세 (OrderAddressResource) | +| options / payments / shippings / cancels | array | `[…]` | 주문 상세와 동일한 하위 리소스 목록 | +| updated_at | string | `2026-07-06T14:05:45+00:00` | 최종 수정 일시 | +| abilities | object | `{"can_read":true,"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 나머지 금액/일시/다중통화 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문이 수정되었습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "preparing", + "order_status_label": "상품준비중", + "admin_memo": "고객 요청으로 주소 정정", + "recipient_name": "심채원", + "recipient_phone": "010-3955-6018", + "recipient_zipcode": "38022", + "recipient_address": "부산광역시 양천구 공항대로 9", + "recipient_detail_address": "101동 202호", + "delivery_memo": "parcel_box", + "delivery_memo_label": "택배함에 넣어주세요", + "options": [], + "shipping_address": {}, + "payments": [], + "shippings": [], + "cancels": [], + "updated_at": "2026-07-06T14:05:45+00:00", + "abilities": { + "can_read": true, + "can_update": true + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 수정 관련 필드만 발췌). **에러 응답** @@ -716,8 +724,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 주문상태 전이 규칙 위반 등 Service 단계 검증 실패도 포함 (`messages.orders.update_failed`) | +| 500 | Server Error | 수정 처리 중 예기치 못한 오류 (`messages.orders.update_failed`) | @@ -735,17 +744,20 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | order | path | string | 예 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) | -| type | body | string | 예 | `full`, `partial` | 취소 유형 (full 전체취소 / partial 부분취소 — partial 이면 items 필수) | +| type | body | string | 예 | — | 취소 유형 (full 전체취소 / partial 부분취소 — partial 이면 items 필수) | | reason | body | string | 예 | — | 취소 사유 코드 (ClaimReason 의 refund·활성 코드) | | reason_detail | body | string | 아니오 | max 500 | 취소 사유 상세 (관리자 입력 자유 텍스트) | | items | body | array | 아니오 | min 1 | 처리 대상 항목 배열 | | cancel_pg | body | boolean | 아니오 | — | PG 결제 취소 동반 여부 (미지정 시 기본 true — 실제 PG 취소 수행) | | refund_priority | body | string | 아니오 | `pg_first`, `points_first` | 환불 배분 우선순위 (pg_first PG 우선 / points_first 포인트 우선, 기본 pg_first) | +| refund_bank.bank_code | body | string | 아니오 | max 10 | 환불 계좌 은행코드. 가상계좌 + 입금완료 건은 필수(주문 시 입력된 계좌가 있으면 생략 가능). 세 필드는 전부 입력하거나 전부 비워야 함 | +| refund_bank.account_number | body | string | 아니오 | max 50 | 환불 계좌번호 | +| refund_bank.holder | body | string | 아니오 | max 50 | 환불 계좌 예금주 | **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/admin/orders/4/cancel HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cancel HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -759,17 +771,68 @@ Content-Type: application/json "예시값" ], "cancel_pg": true, - "refund_priority": "pg_first" + "refund_priority": "pg_first", + "refund_bank.bank_code": "예시값", + "refund_bank.account_number": "예시값", + "refund_bank.holder": "예시값" } ``` **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 취소 처리 후 재조회한 주문을 `OrderResource` 로 반환하므로 필드 구성은 `GET /admin/orders/{order}` (주문 상세) 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| order_number | string | `20260706-1405449337` | 주문번호 | +| order_status | string | `cancelled` | 취소 반영된 주문상태 (전체취소 시 `cancelled`, 부분취소 시 기존 상태 유지) | +| is_partially_cancelled | boolean | `true` | 부분취소 여부 (일부 옵션만 취소된 경우 `true`) | +| total_cancelled_amount | integer | `31000` | 취소 반영 후 총 취소금액 | +| total_refunded_amount | integer | `31000` | 취소 반영 후 총 환불금액 (PG 환불액) | +| total_refunded_points_amount | integer | `0` | 취소 반영 후 환불된 포인트 | +| cancelled_at | string | `2026-07-11T02:10:00+00:00` | 취소 일시 (전체취소 시 기록) | +| cancels | array | `[{"reason":"change_of_mind","reason_detail":null,…}]` | 취소 이력 목록 (OrderCancelResource — 취소 사유·상세·취소일시, 최근순) | +| options | array | `[{"id":1,"option_status":"cancelled",…}]` | 주문 옵션 목록 (취소된 옵션은 `option_status: cancelled`) | +| payments | array | `[{"payment_status":"cancelled",…}]` | 결제 이력 (PG 취소 동반 시 결제상태 반영) | +| abilities | object | `{"can_read":true,"can_update":true,"can_cancel":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 나머지 금액/일시/다중통화 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문이 취소되었습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "cancelled", + "order_status_label": "취소완료", + "is_partially_cancelled": false, + "total_amount": 184000, + "total_cancelled_amount": 184000, + "total_refunded_amount": 184000, + "total_refunded_points_amount": 0, + "cancelled_at": "2026-07-11T02:10:00+00:00", + "cancels": [], + "options": [], + "payments": [], + "abilities": { + "can_read": true, + "can_update": true, + "can_cancel": false + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 취소 관련 필드만 발췌). **에러 응답** @@ -777,14 +840,296 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 취소 처리 실패 — 취소 불가 상태, PG 취소 실패, 가상계좌 환불계좌 정보 누락 등 (`주문 취소에 실패했습니다.` + `errors.detail` 에 예외 메시지) | **설명** 관리자가 특정 주문(`order`)을 전체취소 또는 부분취소합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@cancelOrder`가 `items` 유무에 따라 `OrderCancellationService`의 `cancelOrder()`(전체) 또는 `cancelOrderOptions()`(부분)를 호출합니다. 취소자(`cancelledBy`)로 관리자 ID가 기록되고, `cancel_pg`로 PG 결제 취소 동반 여부를, `refund_priority`로 PG/포인트 환불 우선순위를 지정합니다. 취소 후 갱신된 주문을 `OrderResource`로 반환하며, 취소 불가 상태 등 실패 시 422를 반환합니다. +### DELETE /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.cancel` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CashReceiptController@cancel` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 대상 order의 식별자 | + +**요청 예시** + +```http +DELETE /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +`data` 는 항상 `null` 입니다 — 취소 성공 여부는 `success` 로 판정합니다. 취소 후의 원장 상태가 필요하면 주문 상세(`GET admin/orders/{order}`)의 `cash_receipts` 를 다시 조회합니다. + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "현금영수증이 취소되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 특정 주문(`order`)에 발급된 현금영수증을 **전액 취소**합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\CashReceiptController@cancel`이 `CashReceiptService::cancelAll()`로 활성 영수증을 발급 프로바이더(토스페이먼츠 등)에 취소 요청합니다. 부분취소 API 는 사용하지 않습니다 — 금액이 바뀌는 경우는 재발급(`/cash-receipt/reissue`)이 담당합니다. 취소 이력은 원장에 남아 국세청 신고 근거로 유지되며, 재발급용 식별번호 암호문은 폐기하지 않습니다(관리자가 같은 번호로 다시 발급할 수 있어야 하므로). 발급 취소는 관리자 전용이며 회원·비회원에게는 노출하지 않습니다. + +실패 시 사유는 `errors.error_code` 로 구분합니다 — 활성 영수증이 없으면 `NO_ACTIVE_RECEIPT`, 프로바이더 취소가 실패하면 `CANCEL_FAILED` 이며 둘 다 422 입니다. + +```json +{ + "success": false, + "message": "취소할 현금영수증이 없습니다.", + "errors": { + "error_code": "NO_ACTIVE_RECEIPT" + } +} +``` + + +### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.issue` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CashReceiptController@issue` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 대상 order의 식별자 | +| receipt_type | body | string | 예 | `income`, `expense` | 발급 용도 (income 소득공제용 — 개인 연말정산 / expense 지출증빙용 — 사업자 매입세액공제) | +| identifier_type | body | string | 예 | `phone`, `card`, `business` | 발급 수단 (phone 휴대폰번호 / card 현금영수증카드번호 / business 사업자등록번호 — 사업자등록번호는 지출증빙 전용) | +| identifier | body | string | 예 | max 30 | 식별번호 (하이픈·공백 제거 후 검증 — 휴대폰 10~11자리 / 현금영수증카드 13~19자리 / 사업자등록번호 10자리 체크섬) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "receipt_type": "income", + "identifier_type": "phone", + "identifier": "example-key" +} +``` + +**응답 필드** (`data` 내부) + +`data` 는 발급 이력 1건(`CashReceiptResource`)입니다. + +| 필드 | 타입 | 설명 | +| --- | --- | --- | +| id | integer | 발급 이력 ID | +| provider | string | 발급을 수행한 프로바이더 식별자 (예: `sirsoft-pay_tosspayments`) | +| transaction_type | string | 거래 유형 — `issue`(발급) / `cancel`(취소). 발급 응답은 항상 `issue` | +| receipt_type | string | 발급 용도 — `income`(소득공제용) / `expense`(지출증빙용) | +| receipt_type_label | string | 발급 용도의 다국어 표시명 (예: `소득공제용`) | +| amount | integer | 발급 금액 (주문 통화 기준 반올림, 과세분 포함 총액) | +| amount_formatted | string | 발급 금액의 통화 표기 문자열 (예: `12,000원`) | +| tax_free_amount | integer | 발급 금액 중 면세 금액 | +| tax_free_amount_formatted | string | 면세 금액의 통화 표기 문자열 | +| identifier_masked | string | 마스킹된 식별번호 (원본은 어떤 응답에도 노출되지 않음) | +| receipt_url | string\|null | 프로바이더가 발급한 영수증 조회 링크 | +| issue_number | string\|null | 프로바이더가 부여한 승인번호 | +| issue_status | string | 발급 상태 — `IN_PROGRESS` / `COMPLETED` / `FAILED`. 성공 응답은 항상 `COMPLETED` | +| error_code | string\|null | 실패 사유 코드 (성공 시 `null`) | +| error_message | string\|null | 실패 사유 상세 (성공 시 `null`) | +| issued_at | string\|null | 발급 일시 (ISO8601, 머신 판독용) | +| issued_at_formatted | string\|null | 발급 일시 (사용자 타임존 기준 표시용) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "현금영수증이 발급되었습니다.", + "data": { + "id": 12, + "provider": "sirsoft-pay_tosspayments", + "transaction_type": "issue", + "receipt_type": "income", + "receipt_type_label": "소득공제용", + "amount": 12000, + "amount_formatted": "12,000원", + "tax_free_amount": 0, + "tax_free_amount_formatted": "0원", + "identifier_masked": "010****5678", + "receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...", + "issue_number": "CR20260710000012", + "issue_status": "COMPLETED", + "error_code": null, + "error_message": null, + "issued_at": "2026-07-10T14:32:11+09:00", + "issued_at_formatted": "2026-07-10 14:32" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 관리자가 특정 주문(`order`)에 현금영수증을 발급합니다. 주문 당시 구매자가 신청하지 않은 건의 **사후 발급**과, 발급 실패 후 식별번호를 다시 입력해 발급하는 경우에 사용합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\CashReceiptController@issue`가 `CashReceiptService::issue()`로 발급 프로바이더에 위임합니다. + +발급 가능 조건은 **무통장입금(dbank) + 입금완료(PAID) + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨** 이며, 하나라도 어긋나면 `errors.error_code` 로 사유를 구분해 반환합니다 — 이미 발급된 주문은 **409**(`ALREADY_ISSUED`), 그 외는 422(`NOT_CASH_PAYMENT` / `PAYMENT_NOT_PAID` / `NO_ISSUABLE_AMOUNT` / `PROVIDER_NOT_CONFIGURED`). 프로바이더가 발급을 거부하면 422(`PROVIDER_ERROR`)와 함께 `errors.error_message` 에 상세 사유가 담깁니다. + +`receipt_type`(발급 용도)과 `identifier_type`(발급 수단)은 독립 필드입니다. 지출증빙용도 휴대폰번호로 발급할 수 있으며, 사업자등록번호는 체크섬을 검증합니다. 소득공제 + 사업자등록번호 조합과, 지출증빙 + 국세청 자진발급 지정번호(`0100001234`) 조합은 거부됩니다. + +응답의 `data` 는 발급 이력 1건이며 `receipt_url`(영수증 조회 링크)·`identifier_masked`(뒤 4자리 외 마스킹)·`issue_number` 를 포함합니다. **식별번호 원본은 응답·로그 어디에도 노출되지 않으며**, 재발급용으로만 암호화 보관하다 구매확정 시 폐기합니다. + +발급 불가 응답의 `errors.error_code` 는 두 계열로 나뉩니다. 프론트는 이 값으로 안내 문구와 버튼 노출을 분기합니다. + +**① 사전 가드**(`resolveIssueBlocker`) — 프로바이더 호출 전에 판정하며 코드 6종이 고정입니다. + +| error_code | 상태코드 | 의미 | +| --- | --- | --- | +| `ALREADY_ISSUED` | 409 | 이미 활성 현금영수증이 발급된 주문 | +| `PROVIDER_NOT_CONFIGURED` | 422 | 현금영수증 발급 프로바이더가 설정되지 않음 | +| `PAYMENT_NOT_FOUND` | 422 | 주문에 결제 정보가 없음 | +| `NOT_CASH_PAYMENT` | 422 | 무통장입금(dbank) 주문이 아님 | +| `PAYMENT_NOT_PAID` | 422 | 입금이 확인되지 않음 | +| `NO_ISSUABLE_AMOUNT` | 422 | 발급 가능한 현금성 금액이 0 (전액 마일리지 결제·전액 환불 등) | + +**② 프로바이더 실패** — 422 이며 `error_code` 는 **프로바이더가 반환한 값을 그대로 통과**시킵니다(고정 목록 없음). 어떤 리스너도 발급 요청을 처리하지 않은 경우에만 코어가 `NO_PROVIDER_HANDLED` 를 채웁니다. 상세 사유는 `errors.error_message` 에 담기므로, 프론트는 알 수 없는 코드를 만나면 `error_message` 를 그대로 노출하는 것을 기본 동작으로 삼습니다. + +```json +{ + "success": false, + "message": "이미 현금영수증이 발급된 주문입니다.", + "errors": { + "error_code": "ALREADY_ISSUED" + } +} +``` + + +### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt/reissue + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.cash-receipt.reissue` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\CashReceiptController@reissue` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.orders.update` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| order | path | string | 예 | — | 대상 order의 식별자 | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/cash-receipt/reissue HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +`data` 는 재발급된 이력 1건(`CashReceiptResource`, 필드 구성은 발급 API 와 동일)이거나, 잔여 발급액이 0 이어서 재발급할 것이 없으면 `null` 입니다. 두 경우 모두 200 이므로 **`success` 만으로 판정하지 말고 `data` 의 존재 여부까지 확인**해야 합니다. + +**응답 예시** + +재발급 성공 (잔여 금액 존재): + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "현금영수증이 재발급되었습니다.", + "data": { + "id": 14, + "provider": "sirsoft-pay_tosspayments", + "transaction_type": "issue", + "receipt_type": "income", + "receipt_type_label": "소득공제용", + "amount": 8000, + "amount_formatted": "8,000원", + "tax_free_amount": 0, + "tax_free_amount_formatted": "0원", + "identifier_masked": "010****5678", + "receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...", + "issue_number": "CR20260710000014", + "issue_status": "COMPLETED", + "error_code": null, + "error_message": null, + "issued_at": "2026-07-10T15:02:44+09:00", + "issued_at_formatted": "2026-07-10 15:02" + } +} +``` + +전액 환불되어 재발급 대상 금액이 0 인 경우 (정상 결과): + +```json +{ + "success": true, + "message": "현금영수증이 재발급되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 관리자가 **"취소 성공 + 재발급 실패"** 중간 상태를 복구합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\CashReceiptController@reissue`가 `CashReceiptService::recoverFailedIssue()`를 호출합니다. + +부분환불이 일어나면 기존 영수증을 전액취소하고 잔여 금액으로 재발급하는데(부분취소 API 미사용), 취소는 성공했는데 재발급만 실패하면 활성 영수증이 없는 상태로 남습니다. 이때 원장에는 취소(COMPLETED)와 발급(FAILED) 2행이 남으며, 관리자 주문 상세가 경고 배지를 띄웁니다. 이 엔드포인트가 그 상태를 되돌립니다 — 마지막 발급 이력의 발급 용도와 저장된 식별번호 암호문을 재사용하므로 **요청 본문이 필요 없습니다**. + +활성 영수증이 이미 있으면 금액 동기화로 위임하고, 잔여 발급액이 0(전액 환불)이면 활성 영수증 없음이 정상 결과이므로 `data: null` 과 함께 200 을 반환합니다. 구매확정 후에는 식별번호 암호문이 폐기되므로 복구할 수 없고 422(`REISSUE_FAILED`)를 반환합니다 — 이 경우 관리자가 발급 API(`POST .../cash-receipt`)로 식별번호를 다시 입력해야 합니다. + + ### PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order}/confirm-deposit - **라우트명**: `api.modules.sirsoft-ecommerce.admin.orders.confirm-deposit` @@ -803,7 +1148,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/orders/4/confirm-deposit HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order}/confirm-deposit HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -818,11 +1163,53 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 입금확인 후 재조회한 주문을 `OrderResource` 로 반환하므로 필드 구성은 `GET /admin/orders/{order}` (주문 상세) 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| order_number | string | `20260706-1405449337` | 주문번호 | +| order_status | string | `payment_complete` | 입금확인 반영된 주문상태 (`mark_order_complete` 지정 시 주문완료 상태) | +| order_status_label | string | `결제완료` | `order_status` 값의 사람이 읽는 라벨 | +| total_paid_amount | integer | `184000` | 입금 반영된 총 실제 결제금액 | +| total_due_amount | integer | `0` | 입금 반영 후 남은 결제예정금액 | +| depositor_name | string | `홍길동` | 확인된 무통장 입금자명 | +| paid_at | string | `2026-07-11T02:10:00+00:00` | 결제완료 일시 (입금확인 시점 기록) | +| payments | array | `[{"payment_status":"paid",…}]` | 결제 이력 (OrderPaymentResource — 무통장 결제건이 `paid` 로 전이) | +| abilities | object | `{"can_read":true,"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 나머지 금액/일시/다중통화 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "입금이 확인되어 결제완료 처리되었습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "payment_complete", + "order_status_label": "결제완료", + "total_amount": 184000, + "total_paid_amount": 184000, + "total_due_amount": 0, + "depositor_name": "홍길동", + "paid_at": "2026-07-11T02:10:00+00:00", + "payments": [], + "abilities": { + "can_read": true, + "can_update": true + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 입금확인 관련 필드만 발췌). **에러 응답** @@ -830,8 +1217,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 입금액 불일치(`입금액이 결제예정금액과 일치하지 않습니다.` + `errors.detail`), 또는 입금확인 처리 실패 — 무통장 결제가 아닌 주문·이미 결제완료된 주문 등 (`입금확인 처리에 실패했습니다.` + `errors.detail`) | @@ -855,7 +1242,7 @@ Content-Type: application/json **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/admin/orders/4/estimate-refund HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/estimate-refund HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -871,11 +1258,88 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AdjustmentResult::toPreviewArray()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| refund_amount | number | `31000` | PG 환불 예상금액 (음수면 추가결제 필요) | +| refund_points_amount | number | `0` | 마일리지(포인트) 환불 예상금액 | +| original_paid_amount | number | `184000` | 재계산 전 원 결제금액 | +| recalculated_paid_amount | number | `153000` | 취소 반영 후 재계산된 결제금액 | +| shipping_difference | number | `0` | 배송비 차이 (양수 환불 / 음수 추가결제) | +| discount_difference | number | `0` | 할인 차이 (양수: 할인 감소분) | +| additional_payment_amount | number | `0` | 추가결제 필요 금액 (환불액이 음수일 때의 절댓값, 없으면 0) | +| cancelled_items | array | `[{"order_option_id":1,"cancel_quantity":1,"cancel_amount":31000}]` | 취소 대상 아이템별 취소 수량·취소 금액 | +| refund_priority | string | `pg_first` | 적용된 환불 배분 우선순위 (RefundPriorityEnum — `pg_first` / `points_first`) | +| remaining_pg_balance | number | `153000` | 환불 후 잔여 PG 결제 잔액 | +| remaining_points_balance | number | `0` | 환불 후 잔여 포인트 잔액 | +| refund_total | number | `31000` | 총 환불 예상금액 (PG 환불액 + 포인트 환불액) | +| refund_formatted | object | `{"refund_total":"31,000원", …}` | 환불 총액·잔액의 base 통화 포맷 문자열 + 결제 통화 병기 (취소 모달 표기 SSoT) | +| restored_coupons | array | `[{"coupon_name":"첫구매 무료배송","discount_amount":0}]` | 취소로 복원되는 쿠폰 정보 | +| shipping_details | array | `[{"policy_name":"기본 배송정책","base_difference":0,"extra_difference":0,"total_difference":0}]` | 배송정책별 배송비 차액 상세 | +| mc_refund_amount | object \| null | `{"KRW":{"amount":31000,"formatted":"31,000원"}, …}` | PG 환불금액 다중 통화 | +| mc_refund_points_amount | object \| null | `{"KRW":{"amount":0,"formatted":"0원"}, …}` | 포인트 환불금액 다중 통화 | +| mc_refund_shipping_amount | object \| null | `{"KRW":{"amount":0,"formatted":"0원"}, …}` | 배송비 환불금액 다중 통화 | +| original_snapshot | object | `{"total_paid_amount":184000,"total_points_used_amount":0, …}` | 재계산 전 주문 금액 스냅샷 | +| recalculated_snapshot | object | `{"total_paid_amount":153000, …}` | 재계산 후 주문 금액 스냅샷 | +| mc_original_snapshot | object \| null | `{"mc_subtotal_amount":{…},"mc_total_paid_amount":{…}}` | 원 주문 다중 통화 스냅샷 | +| mc_recalculated_snapshot | object \| null | `{"mc_subtotal_amount":{…},"mc_total_paid_amount":{…}}` | 재계산 다중 통화 스냅샷 | +| original_coupons | array | `[{"name":"첫구매 무료배송","target_type":"shipping_fee","discount_amount":0}]` | 원 주문에 적용된 쿠폰 상세 | +| recalculated_coupons | array | `[]` | 재계산 후 유지되는 쿠폰 상세 (조건 미달 시 소멸) | +| cancel_blocked | boolean | `false` | 취소 차단 여부 (부분취소로 추가결제가 필요해지는 실결제 주문이면 `true`) | +| cancel_blocked_reason | string \| null | `null` | 차단 사유 문구 (차단이 아니면 `null`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "환불 예상금액을 조회했습니다.", + "data": { + "refund_amount": 31000, + "refund_points_amount": 0, + "original_paid_amount": 184000, + "recalculated_paid_amount": 153000, + "shipping_difference": 0, + "discount_difference": 0, + "additional_payment_amount": 0, + "cancelled_items": [ + { + "order_option_id": 1, + "cancel_quantity": 1, + "cancel_amount": 31000 + } + ], + "refund_priority": "pg_first", + "remaining_pg_balance": 153000, + "remaining_points_balance": 0, + "refund_total": 31000, + "refund_formatted": {}, + "restored_coupons": [], + "shipping_details": [], + "mc_refund_amount": { + "KRW": { + "amount": 31000, + "formatted": "31,000원" + } + }, + "mc_refund_points_amount": null, + "mc_refund_shipping_amount": null, + "original_snapshot": {}, + "recalculated_snapshot": {}, + "mc_original_snapshot": null, + "mc_recalculated_snapshot": null, + "original_coupons": [], + "recalculated_coupons": [], + "cancel_blocked": false, + "cancel_blocked_reason": null + } +} +``` **에러 응답** @@ -883,8 +1347,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Server Error | 환불 예상금액 계산 중 오류 (`환불 예상금액 조회에 실패했습니다.`) | @@ -908,7 +1373,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/orders/4/logs?per_page=1&sort_order=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/orders/{order}/logs?per_page=1&sort_order=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -918,7 +1383,28 @@ Authorization: Bearer {YOUR_TOKEN} _목록 응답: `data.data[]` 배열 항목의 필드._ - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `27789` | 기본 키 (내부 식별자) | +| log_type | string | `admin` | 로그 분류 (`ActivityLogType` Enum 값 — 관리자/사용자/시스템 등 활동 주체 구분) | +| log_type_label | string | `관리자` | `log_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| loggable_type | string | `Modules\Sirsoft\Ecommerce\Models\Orde…` | 로그 대상 모델의 FQCN (다형 관계 타입 — 표시용 짧은 이름은 `loggable_type_display`) | +| loggable_type_display | string | `OrderOption` | `loggable_type` 의 표시용 짧은 이름 (네임스페이스를 제외한 클래스명) | +| loggable_id | integer | `1264` | loggable 식별자 (연관 리소스 참조) | +| action | string | `order_option.partial_cancel` | 수행된 활동의 식별 키 (`{대상}.{행위}` 형식 — 라벨은 `action_label`) | +| action_label | string | `부분 취소` | `action` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| localized_description | string | `주문 옵션 부분 취소 (옵션 ID: 1264)` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description_key | string | `sirsoft-ecommerce::activity_log.descr…` | 설명 문구의 다국어 키 (`localized_description` 은 이 키를 현재 로케일로 해석한 값) | +| properties | object | `{"order_id":1316,"product_name":{"ko":"신제품 출시 예정 #21","en…` | 활동 시점의 부가 정보 (설명 문구의 치환 파라미터 및 참조 식별자 — 활동 종류마다 키가 다름) | +| changes | null | `null` | 단건 수정의 변경 내역 `[{field, old, new}, …]`. 일괄 수정이거나 변경 추적 대상이 아니면 `null` | +| bulk_changes | null | `null` | 일괄 수정의 변경 내역 `[{model_id, changes[]}, …]`. 단건 수정이면 `null` (`changes` 와 동시에 채워지지 않음) | +| has_changes | boolean | `false` | changes 여부 | +| actor_name | string | `시스템` | 행위를 수행한 주체(사용자/시스템)의 이름 | +| user | object | `{"name":"시스템"}` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) | +| ip_address | string | `10.10.10.1` | 요청/행위가 발생한 IP 주소 | +| created_at | string | `2026-07-29 19:56:44` | 생성 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| abilities | object | `{"can_read":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -931,17 +1417,82 @@ HTTP/1.1 200 "success": true, "message": "주문 처리 이력을 조회했습니다.", "data": { - "data": [], + "data": [ + { + "id": 27789, + "log_type": "admin", + "log_type_label": "관리자", + "loggable_type": "Modules\\Sirsoft\\Ecommerce\\Models\\OrderOption", + "loggable_type_display": "OrderOption", + "loggable_id": 1264, + "action": "order_option.partial_cancel", + "action_label": "부분 취소", + "localized_description": "주문 옵션 부분 취소 (옵션 ID: 1264)", + "description_key": "sirsoft-ecommerce::activity_log.description.order_option_partial_cancel", + "properties": { + "order_id": 1316, + "product_name": { + "ko": "신제품 출시 예정 #21", + "en": "Coming Soon Product #21" + }, + "quantity": 3 + }, + "changes": null, + "bulk_changes": null, + "has_changes": false, + "actor_name": "시스템", + "user": { + "name": "시스템" + }, + "ip_address": "10.10.10.1", + "created_at": "2026-07-29 19:56:44", + "is_owner": false, + "abilities": { + "can_read": true, + "can_delete": true + } + }, + { + "id": 27766, + "log_type": "admin", + "log_type_label": "관리자", + "loggable_type": "Modules\\Sirsoft\\Ecommerce\\Models\\OrderOption", + "loggable_type_display": "OrderOption", + "loggable_id": 1263, + "action": "order_option.confirm", + "action_label": "구매 확정", + "localized_description": "구매확인 (옵션 ID: 1263)", + "description_key": "sirsoft-ecommerce::activity_log.description.order_option_confirm", + "properties": { + "order_id": 1316 + }, + "changes": null, + "bulk_changes": null, + "has_changes": false, + "actor_name": "시스템", + "user": { + "name": "시스템" + }, + "ip_address": "10.0.0.5", + "created_at": "2026-07-29 09:06:44", + "is_owner": false, + "abilities": { + "can_read": true, + "can_delete": true + } + }, + "... (총 25건 중 2건 표시)" + ], "links": { - "first": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/4/logs?page=1", - "last": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/4/logs?page=1", + "first": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=1", + "last": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=6", "prev": null, - "next": null + "next": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=2" }, "meta": { "current_page": 1, - "from": null, - "last_page": 1, + "from": 1, + "last_page": 6, "links": [ { "url": null, @@ -950,22 +1501,52 @@ HTTP/1.1 200 "active": false }, { - "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/4/logs?page=1", + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=1", "label": "1", "page": 1, "active": true }, { - "url": null, + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=2", + "label": "2", + "page": 2, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=3", + "label": "3", + "page": 3, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=4", + "label": "4", + "page": 4, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=5", + "label": "5", + "page": 5, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=6", + "label": "6", + "page": 6, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs?page=2", "label": "pagination.next", - "page": null, + "page": 2, "active": false } ], - "path": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/4/logs", + "path": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/orders/1316/logs", "per_page": 25, - "to": null, - "total": 0 + "to": 25, + "total": 126 } } } @@ -977,8 +1558,8 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1004,7 +1585,7 @@ HTTP/1.1 200 **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/orders/4/options/bulk-status HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/orders/{order}/options/bulk-status HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1022,11 +1603,51 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`OrderOptionService::bulkChangeStatusWithQuantity()` 반환 배열)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| changed_count | integer | `2` | 상태가 변경된 주문 옵션 건수 | +| split_count | integer | `1` | 부분 수량 전환으로 옵션이 분할된 건수 | +| results | array | `[{"order_option_id":1, …}]` | 옵션별 처리 결과 배열 (아래 하위 필드) | +| results[].order_option_id | integer | `1` | 상태를 변경한 원본 주문 옵션 ID | +| results[].split_order_option_id | integer \| null | `57` | 부분 수량 전환으로 새로 분할 생성된 옵션 ID (분할 없으면 `null`) | +| results[].merged_into_order_option_id | integer \| null | `null` | 동일 상태 기존 옵션으로 병합된 경우 그 대상 옵션 ID (병합 없으면 `null`) | +| results[].quantity_changed | integer | `1` | 이번에 상태 전환한 수량 | +| results[].is_full_quantity | boolean | `false` | 옵션의 전체 수량을 전환했는지 여부 (부분 전환이면 `false`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 옵션의 상태가 변경되었습니다.", + "data": { + "changed_count": 2, + "split_count": 1, + "results": [ + { + "order_option_id": 1, + "split_order_option_id": 57, + "merged_into_order_option_id": null, + "quantity_changed": 1, + "is_full_quantity": false + }, + { + "order_option_id": 2, + "split_order_option_id": null, + "merged_into_order_option_id": null, + "quantity_changed": 3, + "is_full_quantity": true + } + ] + } +} +``` **에러 응답** @@ -1034,12 +1655,14 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 옵션 상태 변경 실패 — 옵션 전이 규칙 위반, 취소 후 복원 시 재고 부족 등 (`옵션 상태 변경에 실패했습니다.`) | +| 428 | Identity Verification Required | 결제완료(`payment_complete`) 전이 시 본인인증(IDV) 정책이 enforce 이고 미인증인 경우 | +| 500 | Server Error | 옵션 상태 변경 중 예기치 못한 오류 (`옵션 상태 변경에 실패했습니다.`) | -**설명** 관리자가 특정 주문(`order`)의 여러 주문 옵션 상태를 수량 분할까지 지원해 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@bulkChangeOptionStatus`가 `status`를 `OrderStatusEnum`으로 변환한 뒤 `OrderOptionService::bulkChangeStatusWithQuantity()`로 처리합니다. 배송중으로 전환 시 `carrier_id`·`tracking_number`(택배사·송장번호)를 함께 넘길 수 있습니다. 한 옵션의 일부 수량만 상태 전환(부분 배송 등)하는 시나리오를 지원합니다. +**설명** 관리자가 특정 주문(`order`)의 여러 주문 옵션 상태를 수량 분할까지 지원해 일괄 변경합니다. `auth:sanctum` + `sirsoft-ecommerce.orders.update` 권한이 필요하며, `Admin\OrderController@bulkChangeOptionStatus`가 `status`를 `OrderStatusEnum`으로 변환한 뒤 `OrderOptionService::bulkChangeStatusWithQuantity()`로 처리합니다. 배송중으로 전환 시 `carrier_id`·`tracking_number`(택배사·송장번호)를 함께 넘길 수 있습니다. 한 옵션의 일부 수량만 상태 전환(부분 배송 등)하는 시나리오를 지원합니다. `items[].option_id`는 경로의 `{order}`에 속한 옵션이어야 합니다. 다른 주문의 옵션 ID를 포함하면 422(`items.N.option_id`)로 거절되며, 요청 전체가 반영되지 않습니다. 검증을 거치지 않는 내부·훅 호출 경로에서 같은 불일치가 감지되면 400으로 응답합니다. ### POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/reset-guest-lookup-password @@ -1059,7 +1682,7 @@ Content-Type: application/json **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/admin/orders/4/reset-guest-lookup-password HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/reset-guest-lookup-password HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1073,11 +1696,25 @@ Content-Type: application/json **응답 필드** (`data` 내부) - + + +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`). 재설정한 평문 비밀번호는 응답/로그에 노출하지 않습니다._ **응답 예시** - + + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "비회원 조회 비밀번호가 재설정되었습니다.", + "data": null +} +``` **에러 응답** @@ -1085,8 +1722,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지), 또는 회원 주문(`user_id` 가 있는 주문)에 호출한 경우 (`비회원 주문만 조회 비밀번호를 재설정할 수 있습니다.`) | +| 500 | Server Error | 재설정 처리 중 예기치 못한 오류 (`비회원 조회 비밀번호 재설정에 실패했습니다.`) | @@ -1110,7 +1748,7 @@ Content-Type: application/json **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/admin/orders/4/send-email HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/admin/orders/{order}/send-email HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1124,11 +1762,21 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "이메일이 발송되었습니다.", + "data": null +} +``` **에러 응답** @@ -1136,8 +1784,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1161,7 +1809,7 @@ Content-Type: application/json **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/orders/APIDOC-20260708-000001/cancel-payment HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/orders/{orderNumber}/cancel-payment HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) @@ -1175,18 +1823,28 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`). 주문 상태는 변경되지 않고 `order_payments` 에 취소 이력만 기록됩니다._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "결제가 취소되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `orderNumber` 에 해당하는 주문이 없거나 접근 권한이 없는 경우도 포함 (CancelPaymentRequest 의 주문 해석 실패) | @@ -1206,11 +1864,18 @@ Content-Type: application/json | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | | per_page | query | integer | 아니오 | min 1, max 50 | 페이지당 항목 수 | | status | query | string | 아니오 | — | 상태 필터 (해당 상태의 항목만 조회) | +| with_items | query | boolean | 아니오 | 기본 `false` | 주문 아이템 전량을 포함할지. 기본값에서는 대표 아이템 1건과 전체 개수(`item_count`)만 내려갑니다. 주문마다 상품을 전부 나열하는 화면만 켜세요 | + +**목록은 경량 표현입니다.** + +기본 응답의 `items[]` 에는 대표 아이템 1건만 담기고, 전체 개수는 `item_count` 로 제공됩니다. 아이템이 없는 주문은 빈 배열입니다. 주문마다 상품을 전부 나열해야 하는 화면은 `with_items=1` 로 전량을 요청하세요 — 그렇지 않은 호출자까지 주문 수 × 아이템 수를 받지 않게 하려는 기본값입니다. + +부분취소 뱃지(`is_partially_cancelled`)는 아이템 전량 없이도 정확합니다. 서버가 집계로 판정하므로 두 경로의 값이 같습니다. **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/user/orders?page=1&per_page=1&status=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/user/orders?page=1&per_page=1&status=%EC%98%88%EC%8B%9C%EA%B0%92&with_items=1 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1222,27 +1887,27 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `452` | 기본 키 (내부 식별자) | -| order_number | string | `20260625-1144420949` | 주문번호 (사용자 노출용 고유 식별 코드) | -| status | string | `shipping` | 주문상태 값 (OrderStatusEnum value — 마이페이지용 status 별칭) | -| status_label | string | `배송중` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | -| status_variant | string | `primary` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | +| id | integer | `1345` | 기본 키 (내부 식별자) | +| order_number | string | `20260730-1436245243` | 주문번호 (사용자 노출용 고유 식별 코드) | +| status | string | `preparing` | 주문상태 값 (OrderStatusEnum value — 마이페이지용 status 별칭) | +| status_label | string | `상품준비중` | 상태의 사람이 읽는 라벨 (상태 Enum label() 산물) | +| status_variant | string | `info` | 상태 표시 색상/스타일 변형 키 (상태 Enum variant() 산물 — UI 배지용) | | is_partially_cancelled | boolean | `false` | partially cancelled 여부 | | recipient_country_code | string | `KR` | 배송국가 코드 (ISO 3166-1 alpha-2, shippingAddress 로드 시) | | recipient_country_name | object | `{"ko":"한국","en":"South Korea"}` | 배송국가 현지화명 (로케일별 국가명 맵) | -| ordered_at | string | `2026-06-25T11:44:42+00:00` | ordered 일시 | -| ordered_at_formatted | string | `2026-06-25 20:44:42` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_amount | integer | `31000` | 최종 주문금액 (상품합계 − 할인 + 배송비) | -| total_amount_formatted | string | `31,000원` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| mc_total_amount | object | `{"KRW":{"amount":31000,"formatted":"31,000원"},"USD":{"amo…` | 최종 주문금액 다중 통화 (주문 시점 스냅샷, 통화별 amount·formatted) | +| ordered_at | string | `2026-07-29T14:36:24+00:00` | ordered 일시 | +| ordered_at_formatted | string | `2026-07-29 23:36:24` | `ordered_at` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_amount | integer | `30000` | 최종 주문금액 (상품합계 − 할인 + 배송비) | +| total_amount_formatted | string | `¥30,000` | `total_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| mc_total_amount | object | `{"KRW":{"amount":30000,"formatted":"30,000원"},"USD":{"amo…` | 최종 주문금액 다중 통화 (주문 시점 스냅샷, 통화별 amount·formatted) | | total_shipping_amount | integer | `0` | 총 배송비 | -| total_shipping_amount_formatted | string | `0원` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_shipping_amount_formatted | string | `¥0` | `total_shipping_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | mc_total_shipping_amount | object | `{"KRW":{"amount":0,"formatted":"0원"},"USD":{"amount":0,"f…` | 총 배송비 다중 통화 (주문 시점 스냅샷, 통화별 amount·formatted) | | total_points_used_amount | integer | `0` | 총 포인트(마일리지) 사용액 | -| total_points_used_amount_formatted | string | `0원` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| total_earned_points_amount | integer | `310` | 총 적립 예정 포인트 | -| total_earned_points_amount_formatted | string | `310원` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| items | array | `[{"product_name":"프리미엄 샤인머스캣 2kg #94","product_option_nam…` | 주문 품목 목록 (상품명·옵션명·썸네일·수량·단가/소계·추가옵션 요약) | +| total_points_used_amount_formatted | string | `¥0` | `total_points_used_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_earned_points_amount | integer | `300` | 총 적립 예정 포인트 | +| total_earned_points_amount_formatted | string | `¥300` | `total_earned_points_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| items | array | `[{"product_name":"USB 충전 케이블 #7","product_option_name":"2…` | 주문 품목 목록 (상품명·옵션명·썸네일·수량·단가/소계·추가옵션 요약) | | item_count | integer | `1` | item 개수 (집계) | | abilities | object | `{"can_view":true,"can_cancel":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -1259,50 +1924,39 @@ HTTP/1.1 200 "data": { "data": [ { - "id": 4, - "order_number": "APIDOC-20260708-000001", - "status": "pending_payment", - "status_label": "결제대기", - "status_variant": "warning", - "is_partially_cancelled": false, - "recipient_country_code": null, - "recipient_country_name": null, - "ordered_at": "2026-07-08T01:44:49+00:00", - "ordered_at_formatted": "2026-07-08 10:44:49", - "total_amount": 327327, - "total_amount_formatted": "327,327원", - "mc_total_amount": [], - "total_shipping_amount": 3000, - "total_shipping_amount_formatted": "3,000원", - "mc_total_shipping_amount": [], - "total_points_used_amount": 0, - "total_points_used_amount_formatted": "0원", - "total_earned_points_amount": 3273, - "total_earned_points_amount_formatted": "3,273원", - "items": [], - "item_count": 0, - "abilities": { - "can_view": true, - "can_cancel": true - } - } + "id": 1345, + "order_number": "20260730-1436245243", + "status": "preparing", + "status_label": "상품준비중", + "status_variant": "info", + "...": "(18개 키 생략, 총 23개)" + }, + { + "id": 1344, + "order_number": "20260730-1436244391", + "status": "preparing", + "status_label": "상품준비중", + "status_variant": "info", + "...": "(18개 키 생략, 총 23개)" + }, + "... (총 25건 중 2건 표시)" ], "statistics": { - "pending_payment": 1, - "payment_complete": 0, - "preparing": 0, + "pending_payment": 0, + "payment_complete": 20, + "preparing": 10, "shipping": 0, "delivered": 0, - "confirmed": 0 + "...": "(1개 키 생략, 총 6개)" }, "abilities": { "can_create": true }, "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 2, "per_page": 25, - "total": 1 + "total": 30 } } } @@ -1330,11 +1984,39 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| payment_method | body | string | 예 | 결제수단 카탈로그에 등록된 ID | 결제수단. 코어 8종(`card`/`vbank`/`dbank`/`bank`/`phone`/`point`/`deposit`/`free`) 과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`, `kginicis_lpay`)를 모두 허용한다. 확장 결제수단도 1급 시민으로 그대로 저장된다. 카탈로그에 없는 값은 422 | +| orderer.name | body | string | 예 | max 50 | 대상의 이름/명칭 | +| orderer.phone | body | string | 예 | max 20 | 전화번호 | +| shipping.recipient_name | body | string | 예 | max 50 | shipping.recipient 이름 (식별자) | +| shipping.recipient_phone | body | string | 아니오 | max 20 | 수령인 연락처 | +| shipping.recipient_tel | body | string | 아니오 | max 20 | 수령인 일반전화 (`shipping.recipient_phone` 미입력 시 필수 — 둘 중 하나는 반드시 입력) | +| shipping.country_code | body | string | 아니오 | — | 국가 코드 (ISO 3166-1 alpha-2) | +| shipping.zipcode | body | string | 아니오 | max 10 | 우편번호 | +| shipping.address | body | string | 아니오 | max 255 | 기본 주소 | +| shipping.address_detail | body | string | 예 | max 255 | 상세 주소 | +| shipping.address_type_code | body | string | 아니오 | `R`, `J` | 국내 주소 표기 방식 (`R` 도로명 / `J` 지번) | +| shipping.address_line_1 | body | string | 아니오 | max 255 | 주소 1행 (기본 주소) | +| shipping.address_line_2 | body | string | 아니오 | max 255 | 주소 2행 (상세 주소) | +| shipping.intl_city | body | string | 아니오 | max 100 | 도시 (국제 주소) | +| shipping.intl_state | body | string | 아니오 | max 100 | 주/도 (국제 주소) | +| shipping.intl_postal_code | body | string | 아니오 | max 20 | 우편번호 (국제 주소) | +| payment_method | body | string | 예 | — | 결제수단. 코어 8종(`card`/`vbank`/`dbank`/`bank`/`phone`/`point`/`deposit`/`free`) 과 PG 플러그인이 등록한 확장 결제수단 ID(예: `nhnkcp_naverpay`, `kginicis_lpay`)를 모두 허용한다. 확장 결제수단도 1급 시민으로 그대로 저장된다. 카탈로그에 없는 값은 422 | | expected_total_amount | body | number | 예 | min 0 | 프론트가 계산한 예상 결제금액 (서버 재계산값과 대조해 금액 위변조 검증) | | shipping_memo | body | string | 아니오 | max 500 | 배송 요청사항 메모 | | depositor_name | body | string | 아니오 | max 50 | depositor 이름 (식별자) | +| dbank.bank_code | body | string | 아니오 | max 10 | 수동 무통장입금 계좌의 은행코드 (`payment_method=dbank` 이면 필수) | +| dbank.bank_name | body | string | 아니오 | max 50 | 수동 무통장입금 계좌의 은행명 (표시용) | +| dbank.account_number | body | string | 아니오 | max 50 | 수동 무통장입금 입금 계좌번호 (`payment_method=dbank` 이면 필수) | +| dbank.account_holder | body | string | 아니오 | max 50 | 수동 무통장입금 계좌 예금주 (`payment_method=dbank` 이면 필수) | +| dbank.due_days | body | integer | 아니오 | min 1, max 30 | 입금 기한 일수 (주문일로부터 며칠 이내 입금, 1~30일) | | save_shipping_address | body | boolean | 아니오 | — | 회원 주소록에 이번 배송지 저장 여부 (회원 주문 한정) | +| cash_receipt_requested | body | boolean | 아니오 | — | 현금영수증 신청 여부 (true 면 아래 3개 필드가 필수) | +| cash_receipt_type | body | string | 아니오 | — | 발급 용도 (`income` 소득공제 / `expense` 지출증빙) | +| cash_receipt_identifier_type | body | string | 아니오 | — | 발급 수단 (`phone` 휴대폰번호 / `card` 현금영수증카드 / `business` 사업자등록번호 — business 는 지출증빙 전용) | +| cash_receipt_identifier | body | string | 아니오 | max 30 | 발급 식별번호 (하이픈 없는 숫자. 사업자등록번호는 체크섬 검증. 마스킹 저장 + 원본은 암호화 보관) | +| refund_bank.bank_code | body | string | 아니오 | max 10 | 환불 계좌 은행코드 (세 필드는 전부 입력하거나 전부 비워야 함) | +| refund_bank.account_number | body | string | 아니오 | max 50 | 환불 계좌번호 | +| refund_bank.holder | body | string | 아니오 | max 50 | 환불 계좌 예금주 | +| orderer.email | body | email | 예 | max 255 | 이메일 주소 | | guest_lookup_password | body | string | 예 | min 8, max 255 | 비회원 주문 조회 비밀번호 (비회원만 필수, 8자 이상 · 해시로 저장) | | guest_lookup_password_confirmation | body | string | 예 | — | 조회 비밀번호 확인 (guest_lookup_password 와 일치해야 함) | @@ -1350,11 +2032,39 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 Content-Type: application/json { + "orderer.name": "예시 이름", + "orderer.phone": "010-1234-5678", + "shipping.recipient_name": "예시 이름", + "shipping.recipient_phone": "010-1234-5678", + "shipping.recipient_tel": "예시값", + "shipping.country_code": "KR", + "shipping.zipcode": "06234", + "shipping.address": "서울특별시 강남구 테헤란로 1", + "shipping.address_detail": "서울특별시 강남구 테헤란로 1", + "shipping.address_type_code": "R", + "shipping.address_line_1": "서울특별시 강남구 테헤란로 1", + "shipping.address_line_2": "서울특별시 강남구 테헤란로 1", + "shipping.intl_city": "예시값", + "shipping.intl_state": "예시값", + "shipping.intl_postal_code": "06234", "payment_method": "예시값", "expected_total_amount": 1, "shipping_memo": "예시값", "depositor_name": "예시 이름", + "dbank.bank_code": "예시값", + "dbank.bank_name": "예시 이름", + "dbank.account_number": "예시값", + "dbank.account_holder": "예시값", + "dbank.due_days": 1, "save_shipping_address": true, + "cash_receipt_requested": true, + "cash_receipt_type": "예시값", + "cash_receipt_identifier_type": "example-key", + "cash_receipt_identifier": "example-key", + "refund_bank.bank_code": "예시값", + "refund_bank.account_number": "예시값", + "refund_bank.holder": "예시값", + "orderer.email": "user@example.com", "guest_lookup_password": "Password123!", "guest_lookup_password_confirmation": "Password123!" } @@ -1362,38 +2072,100 @@ Content-Type: application/json **응답 필드** (`data` 내부) -| 이름 | 타입 | 조건 | 용도 | +_단건 응답: `data` 객체의 필드 (HTTP 201). 회원은 `order` 에 `OrderResource`, 비회원은 민감 필드를 가린 `GuestOrderResource` 가 담깁니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| order | object | 항상 | 생성된 주문 리소스 | -| redirect_url | string | 항상 | 결제가 필요 없는 주문의 이동 대상 (주문완료 페이지) | -| requires_pg_payment | boolean | 항상 | PG 결제창을 거쳐야 하는 주문인지. 결제수단이 PG 를 요구하고(`needs_pg`), 해석된 PG 가 `manual`/`internal`/`none` 이 아니며, 잔여 결제액이 0보다 클 때 `true` | -| pg_provider | string | `requires_pg_payment=true` | 결제를 처리할 PG (`sirsoft-` 접두사 부착 — 예: `sirsoft-nhnkcp`) | -| pg_payment_data | object | `requires_pg_payment=true` | PG 결제창에 전달할 결제 정보 (아래 표) | -| pg_payment_handler | string | `requires_pg_payment=true` **이고** PG 가 결제 진입 핸들러를 선언한 경우 | 프론트가 그대로 dispatch 할 결제 진입 핸들러 풀네임 (예: `sirsoft-pay_nhnkcp.requestPayment`). PG 가 미선언이면 이 키가 없으며, 템플릿은 PG 분기를 발화하지 않고 비-PG 흐름으로 안전 강하한다 | +| order | object | `{"id":181,"order_number":"20260711-0210001234", …}` | 생성된 주문 (회원: `OrderResource` / 비회원: `GuestOrderResource`) | +| redirect_url | string | `/shop/orders/20260711-0210001234/complete` | 주문완료 페이지 경로 (프론트가 이동할 URL). 앞의 상점 경로는 상점 주소 설정을 반영한다 — `basic_info.route_path` 를 바꾸면 그 값이(`/store/orders/…`), `basic_info.no_route` 를 켜면 세그먼트 없이(`/orders/…`) 내려간다 | +| requires_pg_payment | boolean | `true` | PG 결제창 호출이 필요한지 여부 (무통장·전액 마일리지 등 non-PG 는 `false`) | +| pg_provider | string | `sirsoft-tosspayments` | PG 플러그인 식별자 (`requires_pg_payment=true` 일 때만 포함) | +| pg_payment_handler | string | `sirsoft-tosspayments.requestPayment` | 프론트가 dispatch 할 결제 진입 핸들러 풀네임 (provider 가 선언한 경우에만 포함) | +| pg_payment_data | object | `{"order_number":"…","amount":184000,"currency":"KRW", …}` | PG SDK 결제창 호출 파라미터 (`requires_pg_payment=true` 일 때만 포함 — 아래 표 참조) | -`pg_payment_data` 필드: +`pg_payment_data` 하위 필드 (프로바이더 비의존 공통): -| 이름 | 타입 | 용도 | -| --- | --- | --- | -| order_number | string | 주문번호 | -| order_name | string | 주문명 (첫 상품명 + 외 N건) | -| amount | number | PG 청구액 (결제 통화의 최소 화폐단위 정수) | -| currency | string | 결제 통화 코드 | -| customer_name / customer_email / customer_phone | string\|null | 주문자 정보 | -| customer_key | string\|null | 회원 주문의 고객 식별자 (`user_{id}`), 비회원은 null | -| payment_method | string | 선택된 결제수단 ID. 확장 결제수단이면 확장 ID 그대로(예: `nhnkcp_naverpay`) — PG 플러그인이 이 값으로 결제창의 결제수단을 결정한다 | +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260711-0210001234` | 주문번호 (PG 주문 식별자) | +| order_name | string | `코튼 후드티 #12 외 2건` | 결제창에 표시할 주문명 (첫 상품명 + 외 N건) | +| amount | integer | `184000` | PG 청구금액 — 결제 통화의 최소 화폐단위 정수 (KRW 원 / USD 센트) | +| currency | string | `KRW` | PG 청구 통화 (주문 스냅샷 환율로 환산된 결제 통화) | +| customer_name | string | `유정우` | 주문자명 (배송지에서 조회) | +| customer_email | string | `ji792@mail.test` | 주문자 이메일 | +| customer_phone | string | `01055144949` | 주문자 휴대전화 (숫자만) | +| customer_key | string \| null | `user_12` | 회원 식별 키 (비회원은 `null`) | +| escrow_products | array | `[{"id":12,"name":"코튼 후드티 #12","code":"YD1JTVLJEMKAUTKS","unitPrice":31000,"quantity":1}]` | 에스크로 결제(가상계좌·계좌이체)용 상품 상세 (`unitPrice` 는 개당가, 비에스크로는 무시) | + +**응답 예시** + +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "주문이 완료되었습니다.", + "data": { + "order": { + "id": 181, + "order_number": "20260711-0210001234", + "order_status": "pending_payment", + "total_amount": 184000, + "total_due_amount": 184000 + }, + "redirect_url": "/shop/orders/20260711-0210001234/complete", + "requires_pg_payment": true, + "pg_provider": "sirsoft-tosspayments", + "pg_payment_handler": "sirsoft-tosspayments.requestPayment", + "pg_payment_data": { + "order_number": "20260711-0210001234", + "order_name": "코튼 후드티 #12 외 2건", + "amount": 184000, + "currency": "KRW", + "customer_name": "유정우", + "customer_email": "ji792@mail.test", + "customer_phone": "01055144949", + "customer_key": "user_12", + "escrow_products": [ + { + "id": 12, + "name": "코튼 후드티 #12", + "code": "YD1JTVLJEMKAUTKS", + "unitPrice": 31000, + "quantity": 1 + } + ] + } + } +} +``` + +> `order` 는 주문 상세와 동일한 `OrderResource`(회원) / `GuestOrderResource`(비회원) 전체 구조입니다 (위 예시는 주요 필드만 발췌). **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.create`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 | +| 404 | Not Found | 임시 주문(주문서)이 없거나 만료된 경우 (`주문서를 찾을 수 없습니다.` 계열 — `exceptions.temp_order_not_found`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 예상 결제금액 불일치(`expected_total_amount` ≠ 서버 재계산값), 결제 통화 미지원(`errors.code = unsupported_payment_currency`), 재고 부족(`errors.insufficient_items`), 구매 불가 상품(`errors.code = cart_unavailable`), 주문 확정 재계산 검증 실패(쿠폰 만료·최소주문금액 미달 등 — `errors.code = order_calculation_validation_failed`). `payment_method` 가 결제수단 카탈로그에 없는 값이면 여기서 차단된다 | +| 428 | Identity Verification Required | 결제 진입 본인인증(IDV) 정책이 활성이고 미인증(grace 만료)인 경우 | +| 500 | Server Error | 주문 생성 중 예기치 못한 오류 (`주문 생성에 실패했습니다.`) | **설명** 주문서 작성을 마치고 실제 주문을 생성(결제하기)하는 회원/비회원 공용 엔드포인트입니다. PG 플러그인의 fetch 인터셉터가 이 한 경로만 매칭하므로 회원/비회원이 동일 URL로 진입하고, `Public\OrderController@store`가 `Auth::id()`로 분기합니다(회원은 `OrderResource`, 비회원은 민감 필드를 가린 `GuestOrderResource`). `optional.sanctum` + `sirsoft-ecommerce.user-orders.create` 권한이 필요하며, `expected_total_amount`로 금액 위변조를 검증하고 비회원은 `guest_lookup_password`로 이후 조회 비밀번호를 설정합니다. 회원이 `save_shipping_address`를 켜면 배송지가 자동 저장(PG 결제는 결제완료 시점) 됩니다. +주문 확정 시점에는 재고·구매대상제한·배송국가·쿠폰 유효성과 함께 **마일리지 사용 정책**도 현재 설정 기준으로 재검증합니다. 임시 주문을 만든 뒤 관리자가 한도를 강화했거나 임시 주문이 조작된 경우 `422`(`errors.code = mileage_usage_not_allowed`)로 차단되며 주문은 생성되지 않습니다. 반대로 정상 생성된 주문에는 그 시점의 사용 정책이 `mileage_policy_snapshot` 으로 고정되어, 이후 설정이 바뀌어도 해당 주문의 판정 근거를 재현할 수 있습니다(통화·프로모션·배송정책 스냅샷과 동일 취지). + +**`pg_payment_data` 응답 필드 (PG 결제 주문 한정)** — 결제수단이 PG(카드·가상계좌·계좌이체 등)인 주문은 응답에 `pg_payment_handler`(프론트가 호출할 결제 핸들러 식별자)와 `pg_payment_data` 객체가 함께 내려갑니다. `pg_payment_data`는 PG SDK 결제창 호출에 필요한 값(`order_number`, `order_name`, `amount`, `currency`, `success_url`, `fail_url`, `customer_email`, `customer_phone`, `customer_key` 등)을 담습니다. 이 객체는 결제수단·PG에 따라 동적으로 조립되므로 자동 실측 문서화 대상이 아닙니다. 다음 필드는 프로바이더 비의존적으로 항상 포함됩니다: + +| 필드 | 타입 | 용도 | +| --- | --- | --- | +| `escrow_products` | array | 에스크로 결제(가상계좌·계좌이체)에서 필수인 상품 상세 배열. 각 원소는 `{id, name, code, unitPrice, quantity}` 형식이며 `unitPrice`는 개당가(합계 아님), `name`은 현재 로케일로 로컬라이즈됩니다. 에스크로 사용 여부는 PG 프론트에서 결정하므로 항상 조립되어 내려가고, 비에스크로 결제는 이 필드를 무시합니다. | + ### GET /api/modules/sirsoft-ecommerce/user/orders/{id} @@ -1410,7 +2182,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/user/orders/4 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/user/orders/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1446,6 +2218,8 @@ _단건 응답: `data` 객체의 필드._ | total_paid_amount_formatted | string | `0원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_due_amount | integer | `327327` | 총 결제예정금액 (무통장 등) | | total_due_amount_formatted | string | `327,327원` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_due_charge_amount | integer | `327327` | **결제 통화(`payment_currency`) 기준 실청구액**, 최소 화폐단위 정수. base≠결제 통화면 `total_due_amount` 와 다릅니다 | +| total_due_charge_amount_formatted | string | `327,327원` | `total_due_charge_amount` 를 결제 통화 기호로 표기한 문자열 | | depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) | | total_cancelled_amount | integer | `0` | 총 취소금액 | | total_cancelled_amount_formatted | string | `0원` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | @@ -1521,7 +2295,7 @@ _단건 응답: `data` 객체의 필드._ | shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | | cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | | promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) | -| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 | | admin_memo | null | `null` | 관리자 메모 (내부 관리용) | | customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) | | created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 | @@ -1540,130 +2314,33 @@ HTTP/1.1 200 "success": true, "message": "주문 정보를 조회했습니다.", "data": { - "id": 4, - "order_number": "APIDOC-20260708-000001", - "base_currency": "KRW", - "payment_currency": "KRW", - "is_cross_currency": false, - "order_status": "pending_payment", - "order_status_label": "결제대기", - "order_status_variant": "warning", - "is_partially_cancelled": false, - "order_device": "pc", - "order_device_label": "PC", - "is_first_order": false, - "subtotal_amount": 324327, - "subtotal_amount_formatted": "324,327원", - "total_discount_amount": 0, - "total_discount_amount_formatted": "0원", - "total_shipping_amount": 3000, - "total_shipping_amount_formatted": "3,000원", - "total_amount": 327327, - "total_amount_formatted": "327,327원", - "total_paid_amount": 0, - "total_paid_amount_formatted": "0원", - "total_due_amount": 327327, - "total_due_amount_formatted": "327,327원", - "depositor_name": null, - "total_cancelled_amount": 0, - "total_cancelled_amount_formatted": "0원", - "total_refunded_amount": 0, - "total_refunded_amount_formatted": "0원", - "total_refunded_points_amount": 0, - "total_refunded_points_amount_formatted": "0원", - "total_product_coupon_discount_amount": 0, - "total_product_coupon_discount_amount_formatted": "0원", - "total_order_coupon_discount_amount": 0, - "total_order_coupon_discount_amount_formatted": "0원", - "total_coupon_discount_amount": 0, - "total_coupon_discount_amount_formatted": "0원", - "total_code_discount_amount": 0, - "total_code_discount_amount_formatted": "0원", - "total_points_used_amount": 0, - "total_points_used_amount_formatted": "0원", - "total_deposit_used_amount": 0, - "total_deposit_used_amount_formatted": "0원", - "total_earned_points_amount": 3273, - "total_earned_points_amount_formatted": "3,273원", - "mc_subtotal_amount": [], - "mc_total_discount_amount": [], - "mc_total_shipping_amount": [], - "mc_total_amount": [], - "mc_total_product_coupon_discount_amount": [], - "mc_total_order_coupon_discount_amount": [], - "mc_total_coupon_discount_amount": [], - "mc_total_code_discount_amount": [], - "mc_total_points_used_amount": [], - "mc_total_deposit_used_amount": [], - "item_count": 4, - "total_quantity": 0, - "total_list_price": 0, - "total_list_price_formatted": "0원", - "ordered_at": "2026-07-08T01:44:49+00:00", - "ordered_at_formatted": "2026-07-08 10:44:49", - "paid_at": null, - "paid_at_formatted": null, - "confirmed_at": null, - "confirmed_at_formatted": null, - "cancelled_at": null, - "cancelled_at_formatted": null, - "delivered_at": null, - "total_tax_amount": 29757, - "total_tax_amount_formatted": "29,757원", - "total_vat_amount": 0, - "total_vat_amount_formatted": "0원", - "total_taxable_supply_amount": 29757, - "total_taxable_supply_amount_formatted": "29,757원", - "total_tax_free_amount": 0, - "total_tax_free_amount_formatted": "0원", - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자", - "email": "apidoc-sample-user@example.com" - }, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user_login_id": null, - "orderer_name": null, - "orderer_phone": null, - "orderer_tel": null, - "orderer_email": null, - "recipient_name": null, - "recipient_phone": null, - "recipient_tel": null, - "recipient_zipcode": null, - "recipient_address": null, - "recipient_detail_address": null, - "delivery_memo": null, - "delivery_memo_label": null, + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "payment_complete", + "order_status_label": "결제완료", + "total_amount": 184000, + "total_amount_formatted": "184,000원", "options": [], - "shipping_address": null, - "billing_address": null, - "payment": null, + "shipping_address": {}, "payments": [], "shippings": [], "cancels": [], - "promotions_applied_snapshot": null, - "shipping_policy_applied_snapshot": null, - "admin_memo": null, - "customer_memo": null, - "created_at": "2026-07-08T01:44:49+00:00", - "updated_at": "2026-07-08T01:44:49+00:00", - "is_owner": true, "abilities": { "can_read": true, - "can_update": true, "can_cancel": true } } } ``` +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 주요 필드만 발췌). + **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 주문이 없거나 본인 주문이 아닌 경우 (정보 노출 방지를 위해 권한 없음도 404 — `주문을 찾을 수 없습니다.`) | @@ -1689,7 +2366,7 @@ HTTP/1.1 200 **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/user/orders/4/cancel HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/user/orders/{id}/cancel HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1707,11 +2384,53 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 취소 처리 후 재조회한 주문을 `OrderResource` 로 반환하므로 필드 구성은 주문 상세와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| order_number | string | `20260706-1405449337` | 주문번호 | +| order_status | string | `cancelled` | 취소 반영된 주문상태 (전체취소 시 `cancelled`) | +| is_partially_cancelled | boolean | `false` | 부분취소 여부 (`items` 지정 시 `true`) | +| total_cancelled_amount | integer | `184000` | 총 취소금액 | +| total_refunded_amount | integer | `184000` | 총 환불금액 | +| cancelled_at | string | `2026-07-11T02:10:00+00:00` | 취소 일시 | +| cancels | array | `[{"reason":"change_of_mind", …}]` | 취소 이력 (OrderCancelResource) | +| options | array | `[{"option_status":"cancelled", …}]` | 주문 옵션 목록 (취소된 옵션 반영) | +| abilities | object | `{"can_read":true,"can_cancel":false}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | + +> 나머지 금액/일시/다중통화 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "주문이 취소되었습니다.", + "data": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "cancelled", + "order_status_label": "취소완료", + "is_partially_cancelled": false, + "total_cancelled_amount": 184000, + "total_refunded_amount": 184000, + "cancelled_at": "2026-07-11T02:10:00+00:00", + "cancels": [], + "options": [], + "abilities": { + "can_read": true, + "can_cancel": false + } + } +} +``` + +> `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 취소 관련 필드만 발췌). **에러 응답** @@ -1719,14 +2438,192 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패(본인 주문 아님·취소 불가 상태 포함), 또는 취소 처리 실패 (`exceptions.order_cancel_failed`) | **설명** 회원이 마이페이지에서 본인 주문(`id`)을 취소합니다. `auth:sanctum` + `sirsoft-ecommerce.user-orders.cancel` 권한이 필요하며, `User\OrderController@cancel`이 `items` 유무에 따라 `OrderCancellationService`의 `cancelOrderOptions()`(부분) 또는 `cancelOrder()`(전체)를 호출합니다. 취소자(`cancelledBy`)로 회원 본인 ID가 기록되고, `refund_priority`로 PG/포인트 환불 우선순위를 지정합니다. 취소 가능 상태의 주문만 취소되며, 취소 후 갱신된 주문을 `OrderResource`로 반환합니다. +### GET /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.cash-receipt.show` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\CashReceiptController@show` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 설명 | +| --- | --- | --- | +| issuable | boolean | 지금 발급이 가능한지 여부 (무통장 + 입금완료 + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨) | +| cash_receipt | object\|null | 현재 활성 영수증 1건 (`CashReceiptResource`). 발급 전이거나 전액 취소된 경우 `null` | + +`cash_receipt` 의 하위 필드 구성은 발급 API(`POST admin/orders/{order}/cash-receipt`)의 응답 필드 표와 동일합니다. + +**응답 예시** + +발급 전 (발급 가능): + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "현금영수증 정보를 조회했습니다.", + "data": { + "issuable": true, + "cash_receipt": null + } +} +``` + +발급 완료: + +```json +{ + "success": true, + "message": "현금영수증 정보를 조회했습니다.", + "data": { + "issuable": false, + "cash_receipt": { + "id": 12, + "provider": "sirsoft-pay_tosspayments", + "transaction_type": "issue", + "receipt_type": "income", + "receipt_type_label": "소득공제용", + "amount": 12000, + "amount_formatted": "12,000원", + "tax_free_amount": 0, + "tax_free_amount_formatted": "0원", + "identifier_masked": "010****5678", + "receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...", + "issue_number": "CR20260710000012", + "issue_status": "COMPLETED", + "error_code": null, + "error_message": null, + "issued_at": "2026-07-10T14:32:11+09:00", + "issued_at_formatted": "2026-07-10 14:32" + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** 회원이 마이페이지 주문상세에서 본인 주문(`id`)의 현금영수증 발급 상태를 조회합니다. `auth:sanctum` 인증이 필요하며, `User\CashReceiptController@show`가 클라이언트가 넘긴 사용자 식별자를 신뢰하지 않고 `Auth::id()` 로만 소유권을 판정합니다 — 타인 주문이면 404 를 반환합니다(존재 여부를 노출하지 않기 위해 403 이 아닌 404). + +응답의 `data.issuable` 은 지금 발급이 가능한지 여부(무통장 + 입금완료 + 미발급 + 현금성 금액 > 0 + 프로바이더 설정됨)이고, `data.cash_receipt` 는 현재 활성 영수증 또는 발급 전이면 `null` 입니다. 주문상세 화면이 이 두 값으로 **[현금영수증 발급] 버튼**과 **[영수증 보기] 링크** 중 무엇을 보일지 결정합니다. + + +### POST /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt + +- **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.cash-receipt.issue` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\User\CashReceiptController@issue` +- **인증/권한**: `auth:sanctum` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| id | path | string | 예 | — | 대상 리소스의 식별자 | +| receipt_type | body | string | 예 | `income`, `expense` | 발급 용도 (income 소득공제용 — 개인 연말정산 / expense 지출증빙용 — 사업자 매입세액공제) | +| identifier_type | body | string | 예 | `phone`, `card`, `business` | 발급 수단 (phone 휴대폰번호 / card 현금영수증카드번호 / business 사업자등록번호 — 사업자등록번호는 지출증빙 전용) | +| identifier | body | string | 예 | max 30 | 식별번호 (하이픈·공백 제거 후 검증 — 휴대폰 10~11자리 / 현금영수증카드 13~19자리 / 사업자등록번호 10자리 체크섬) | + +**요청 예시** + +```http +POST /api/modules/sirsoft-ecommerce/user/orders/{id}/cash-receipt HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "receipt_type": "income", + "identifier_type": "phone", + "identifier": "example-key" +} +``` + +**응답 필드** (`data` 내부) + +`data` 는 발급 이력 1건(`CashReceiptResource`)이며, 필드 구성은 관리자 발급 API(`POST admin/orders/{order}/cash-receipt`)의 응답 필드 표와 동일합니다. + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "현금영수증이 발급되었습니다.", + "data": { + "id": 12, + "provider": "sirsoft-pay_tosspayments", + "transaction_type": "issue", + "receipt_type": "income", + "receipt_type_label": "소득공제용", + "amount": 12000, + "amount_formatted": "12,000원", + "tax_free_amount": 0, + "tax_free_amount_formatted": "0원", + "identifier_masked": "010****5678", + "receipt_url": "https://dashboard.tosspayments.com/receipt/cash/...", + "issue_number": "CR20260710000012", + "issue_status": "COMPLETED", + "error_code": null, + "error_message": null, + "issued_at": "2026-07-10T14:32:11+09:00", + "issued_at_formatted": "2026-07-10 14:32" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** 회원이 주문 당시 신청하지 않은 현금영수증을 마이페이지 주문상세에서 **직접 사후 발급**합니다. `auth:sanctum` 인증이 필요하며, `User\CashReceiptController@issue`가 `Auth::id()` 로 소유권을 확인한 뒤(타인 주문이면 404) 관리자 발급과 **동일한 검증·가드·프로바이더 위임**을 거칩니다. + +요청 본문(`receipt_type` / `identifier_type` / `identifier`)과 오류 코드 체계는 관리자 발급 API 와 같습니다 — 이미 발급된 주문은 409(`ALREADY_ISSUED`), 그 외 발급 불가 사유는 422 에 `errors.error_code` 로 구분해 담깁니다. + +**발급 취소는 제공하지 않습니다** — 국세청 신고 정정을 동반하므로 관리자 전용이며, 유저용 `DELETE` 라우트 자체를 노출하지 않습니다(403 이 아니라 404/405). + + ### POST /api/modules/sirsoft-ecommerce/user/orders/{id}/estimate-refund - **라우트명**: `api.modules.sirsoft-ecommerce.user.orders.estimate-refund` @@ -1744,7 +2641,7 @@ Content-Type: application/json **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/user/orders/4/estimate-refund HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/user/orders/{id}/estimate-refund HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1760,11 +2657,78 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`AdjustmentResult::toPreviewArray()` 반환 배열 — 관리자 `POST /admin/orders/{order}/estimate-refund` 와 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| refund_amount | number | `31000` | PG 환불 예상금액 (음수면 추가결제 필요) | +| refund_points_amount | number | `0` | 마일리지(포인트) 환불 예상금액 | +| original_paid_amount | number | `184000` | 재계산 전 원 결제금액 | +| recalculated_paid_amount | number | `153000` | 취소 반영 후 재계산된 결제금액 | +| shipping_difference | number | `0` | 배송비 차이 (양수 환불 / 음수 추가결제) | +| discount_difference | number | `0` | 할인 차이 (양수: 할인 감소분) | +| additional_payment_amount | number | `0` | 추가결제 필요 금액 (없으면 0) | +| cancelled_items | array | `[{"order_option_id":1,"cancel_quantity":1,"cancel_amount":31000}]` | 취소 대상 아이템별 취소 수량·금액 | +| refund_priority | string | `pg_first` | 적용된 환불 배분 우선순위 | +| remaining_pg_balance | number | `153000` | 환불 후 잔여 PG 결제 잔액 | +| remaining_points_balance | number | `0` | 환불 후 잔여 포인트 잔액 | +| refund_total | number | `31000` | 총 환불 예상금액 (PG + 포인트) | +| refund_formatted | object | `{"refund_total":"31,000원", …}` | 환불 금액의 통화 포맷 문자열 (취소 모달 표기) | +| restored_coupons | array | `[]` | 취소로 복원되는 쿠폰 정보 | +| shipping_details | array | `[]` | 배송정책별 배송비 차액 상세 | +| mc_refund_amount / mc_refund_points_amount / mc_refund_shipping_amount | object \| null | `{"KRW":{"amount":31000,"formatted":"31,000원"}}` | 환불 금액 다중 통화 | +| original_snapshot / recalculated_snapshot | object | `{"total_paid_amount":184000, …}` | 재계산 전/후 금액 스냅샷 | +| mc_original_snapshot / mc_recalculated_snapshot | object \| null | `{"mc_total_paid_amount":{…}}` | 다중 통화 스냅샷 | +| original_coupons / recalculated_coupons | array | `[]` | 재계산 전/후 쿠폰 적용 상세 | +| cancel_blocked | boolean | `false` | 취소 차단 여부 (부분취소로 추가결제가 필요해지는 실결제 주문이면 `true`) | +| cancel_blocked_reason | string \| null | `null` | 차단 사유 문구 (차단이 아니면 `null`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "환불 예상금액을 조회했습니다.", + "data": { + "refund_amount": 31000, + "refund_points_amount": 0, + "original_paid_amount": 184000, + "recalculated_paid_amount": 153000, + "shipping_difference": 0, + "discount_difference": 0, + "additional_payment_amount": 0, + "cancelled_items": [ + { + "order_option_id": 1, + "cancel_quantity": 1, + "cancel_amount": 31000 + } + ], + "refund_priority": "pg_first", + "remaining_pg_balance": 153000, + "remaining_points_balance": 0, + "refund_total": 31000, + "refund_formatted": {}, + "restored_coupons": [], + "shipping_details": [], + "mc_refund_amount": null, + "mc_refund_points_amount": null, + "mc_refund_shipping_amount": null, + "original_snapshot": {}, + "recalculated_snapshot": {}, + "mc_original_snapshot": null, + "mc_recalculated_snapshot": null, + "original_coupons": [], + "recalculated_coupons": [], + "cancel_blocked": false, + "cancel_blocked_reason": null + } +} +``` **에러 응답** @@ -1772,8 +2736,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (본인 주문 아님·취소 불가 옵션 포함) | +| 500 | Server Error | 환불 예상금액 계산 중 오류 (`exceptions.order_estimate_refund_failed`) | @@ -1796,7 +2761,7 @@ Content-Type: application/json **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/user/orders/4/options/{optionId}/confirm HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/user/orders/{id}/options/{optionId}/confirm HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1804,11 +2769,44 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order | object | `{"id":1,"order_status":"confirmed","options":[…], …}` | 구매확정 반영 후 재조회한 주문 (`OrderResource` — 필드 구성은 주문 상세와 동일). 확정된 옵션의 `option_status` 가 `confirmed` 로 전이하며, 전 옵션 확정 시 주문상태도 `confirmed` 가 됩니다 | + +> `order` 의 세부 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "구매확정이 완료되었습니다.", + "data": { + "order": { + "id": 1, + "order_number": "20260706-1405449337", + "order_status": "confirmed", + "order_status_label": "구매확정", + "confirmed_at": "2026-07-11T02:10:00+00:00", + "options": [ + { + "id": 1, + "option_status": "confirmed", + "option_status_label": "구매확정" + } + ] + } + } +} +``` + +> `order` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 구매확정 관련 필드만 발췌). **에러 응답** @@ -1817,6 +2815,7 @@ Authorization: Bearer {YOUR_TOKEN} | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.confirm`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 구매확정 불가 — 본인 주문이 아니거나 배송완료 전 등 확정 불가 상태 (`exceptions.order_option_cannot_confirm`) | @@ -1838,7 +2837,7 @@ Authorization: Bearer {YOUR_TOKEN} **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/user/orders/4/reorder HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/user/orders/{id}/reorder HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1846,13 +2845,15 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ +_단건 응답: `data` 객체의 필드 (`CartService::reorderFromOrder()` 반환 배열)._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| added_count | integer | `0` | added 개수 (집계) | -| skipped | array | `[]` | 장바구니에 담지 못한 항목 목록 (품절·단종 등으로 재주문에서 건너뛴 옵션) | -| cart_count | integer | `0` | cart 개수 (집계) | +| added_count | integer | `2` | 장바구니에 실제로 담긴 옵션 건수 | +| skipped | array | `[{"product_name":"코튼 후드티 #12","reason":"재고가 부족합니다."}]` | 품절·단종 등으로 담지 못한 항목 목록 (`product_name` + `reason`) | +| skipped[].product_name | string | `코튼 후드티 #12` | 담지 못한 상품명 (현재 로케일로 로컬라이즈) | +| skipped[].reason | string | `재고가 부족합니다.` | 담지 못한 사유 (재고 부족·옵션 미존재 등) | +| cart_count | integer | `5` | 재주문 반영 후 현재 장바구니의 총 아이템 수 | **응답 예시** @@ -1865,9 +2866,14 @@ HTTP/1.1 200 "success": true, "message": "과거 주문의 상품을 장바구니에 추가했습니다.", "data": { - "added_count": 0, - "skipped": [], - "cart_count": 0 + "added_count": 2, + "skipped": [ + { + "product_name": "코튼 후드티 #12", + "reason": "재고가 부족합니다." + } + ], + "cart_count": 5 } } ``` @@ -1878,10 +2884,11 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 재주문 실패 — 주문이 없거나 본인 주문이 아닌 경우 (`재주문에 실패했습니다.`) | -**설명** 회원이 과거 주문(`id`)의 옵션들을 현재 장바구니에 다시 담는 재주문 기능입니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@reorder`가 `CartService::reorderFromOrder()`로 처리해 담긴 수량(`added_count`), 담지 못한 항목(`skipped[]`), 현재 장바구니 총 개수(`cart_count`)를 반환합니다. 취소된 주문도 재주문 대상이 되며, 품절·단종 등으로 추가 불가한 항목은 건너뛰어 `skipped` 배열로 안내합니다. 마이페이지 주문내역의 "재주문" 버튼에 사용합니다. +**설명** 회원이 과거 주문(`id`)의 옵션들을 현재 장바구니에 다시 담는 재주문 기능입니다. `auth:sanctum` 인증이 필요하며, `User\OrderController@reorder`가 `CartService::reorderFromOrder()`로 처리해 담긴 수량(`added_count`), 담지 못한 항목(`skipped[]`), 현재 장바구니 총 개수(`cart_count`)를 반환합니다. 취소된 주문도 재주문 대상이 되며, 품절·단종·상품별 구매수량 한도 초과 등으로 추가 불가한 항목은 건너뛰어 `skipped` 배열로 안내합니다. 한 항목이 담기지 못해도 나머지 항목은 그대로 담기며, 응답은 200 입니다(항목 하나 때문에 재주문 전체를 실패시키지 않습니다). 마이페이지 주문내역의 "재주문" 버튼에 사용합니다. ### PUT /api/modules/sirsoft-ecommerce/user/orders/{id}/shipping-address @@ -1914,7 +2921,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/user/orders/4/shipping-address HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/user/orders/{id}/shipping-address HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1943,7 +2950,9 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| order | object | `{"id":4,"order_number":"APIDOC-20260708-000001","base_cur…` | 배송지 변경이 반영된 주문 상세 (OrderResource — 변경 후 최신 주문 전체) | +| order | object | `{"id":1,"shipping_address":{…},"recipient_name":"심채원", …}` | 배송지 변경 반영 후 주문 (`OrderResource` — 필드 구성은 주문 상세와 동일). `shipping_address` 및 플래튼된 수령인/주소 필드에 변경 내용이 반영됩니다 | + +> `order` 의 세부 필드는 `GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표를 참조하세요 (동일 `OrderResource`). **응답 예시** @@ -1957,121 +2966,29 @@ HTTP/1.1 200 "message": "배송지가 변경되었습니다.", "data": { "order": { - "id": 4, - "order_number": "APIDOC-20260708-000001", - "base_currency": "KRW", - "payment_currency": "KRW", - "is_cross_currency": false, - "order_status": "pending_payment", - "order_status_label": "결제대기", - "order_status_variant": "warning", - "is_partially_cancelled": false, - "order_device": "pc", - "order_device_label": "PC", - "is_first_order": false, - "subtotal_amount": 324327, - "subtotal_amount_formatted": "324,327원", - "total_discount_amount": 0, - "total_discount_amount_formatted": "0원", - "total_shipping_amount": 3000, - "total_shipping_amount_formatted": "3,000원", - "total_amount": 327327, - "total_amount_formatted": "327,327원", - "total_paid_amount": 0, - "total_paid_amount_formatted": "0원", - "total_due_amount": 327327, - "total_due_amount_formatted": "327,327원", - "total_cancelled_amount": 0, - "total_cancelled_amount_formatted": "0원", - "total_refunded_amount": 0, - "total_refunded_amount_formatted": "0원", - "total_refunded_points_amount": 0, - "total_refunded_points_amount_formatted": "0원", - "total_product_coupon_discount_amount": 0, - "total_product_coupon_discount_amount_formatted": "0원", - "total_order_coupon_discount_amount": 0, - "total_order_coupon_discount_amount_formatted": "0원", - "total_coupon_discount_amount": 0, - "total_coupon_discount_amount_formatted": "0원", - "total_code_discount_amount": 0, - "total_code_discount_amount_formatted": "0원", - "total_points_used_amount": 0, - "total_points_used_amount_formatted": "0원", - "total_deposit_used_amount": 0, - "total_deposit_used_amount_formatted": "0원", - "total_earned_points_amount": 3273, - "total_earned_points_amount_formatted": "3,273원", - "mc_subtotal_amount": [], - "mc_total_discount_amount": [], - "mc_total_shipping_amount": [], - "mc_total_amount": [], - "mc_total_product_coupon_discount_amount": [], - "mc_total_order_coupon_discount_amount": [], - "mc_total_coupon_discount_amount": [], - "mc_total_code_discount_amount": [], - "mc_total_points_used_amount": [], - "mc_total_deposit_used_amount": [], - "item_count": 4, - "total_quantity": 0, - "total_list_price": 0, - "total_list_price_formatted": "0원", - "ordered_at": "2026-07-08T01:44:49+00:00", - "ordered_at_formatted": "2026-07-08 10:44:49", - "paid_at": null, - "paid_at_formatted": null, - "confirmed_at": null, - "confirmed_at_formatted": null, - "cancelled_at": null, - "cancelled_at_formatted": null, - "delivered_at": null, - "total_tax_amount": 29757, - "total_tax_amount_formatted": "29,757원", - "total_vat_amount": 0, - "total_vat_amount_formatted": "0원", - "total_taxable_supply_amount": 29757, - "total_taxable_supply_amount_formatted": "29,757원", - "total_tax_free_amount": 0, - "total_tax_free_amount_formatted": "0원", - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user_login_id": null, - "orderer_name": null, - "orderer_phone": null, - "orderer_tel": null, - "orderer_email": null, - "recipient_name": null, - "recipient_phone": null, - "recipient_tel": null, - "recipient_zipcode": null, - "recipient_address": null, - "recipient_detail_address": null, - "delivery_memo": null, - "delivery_memo_label": null, - "options": [], - "shipping_address": null, - "promotions_applied_snapshot": null, - "shipping_policy_applied_snapshot": null, - "admin_memo": null, - "customer_memo": null, - "created_at": "2026-07-08T01:44:49+00:00", - "updated_at": "2026-07-08T01:44:49+00:00", - "is_owner": true, - "abilities": { - "can_read": true, - "can_update": true, - "can_cancel": true - } + "id": 1, + "order_number": "20260706-1405449337", + "recipient_name": "심채원", + "recipient_phone": "010-3955-6018", + "recipient_zipcode": "38022", + "recipient_address": "부산광역시 양천구 공항대로 9", + "recipient_detail_address": "101동 202호", + "delivery_memo": "parcel_box", + "shipping_address": {} } } } ``` +> `order` 는 주문 상세와 동일한 `OrderResource` 전체 구조입니다 (위 예시는 배송지 관련 필드만 발췌). + **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 주문이 없거나 본인 주문이 아닌 경우 (`주문을 찾을 수 없습니다.`) | +| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 배송지 변경 불가 상태 — 이미 배송이 시작된 주문 등 (`배송 전 상태에서만 배송지를 변경할 수 있습니다.`) | @@ -2093,7 +3010,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/user/orders/APIDOC-20260708-000001 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/user/orders/{orderNumber} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) @@ -2129,6 +3046,8 @@ _단건 응답: `data` 객체의 필드._ | total_paid_amount_formatted | string | `0원` | `total_paid_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | total_due_amount | integer | `327327` | 총 결제예정금액 (무통장 등) | | total_due_amount_formatted | string | `327,327원` | `total_due_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| total_due_charge_amount | integer | `327327` | **결제 통화(`payment_currency`) 기준 실청구액**, 최소 화폐단위 정수. base≠결제 통화면 `total_due_amount` 와 다릅니다 | +| total_due_charge_amount_formatted | string | `327,327원` | `total_due_charge_amount` 를 결제 통화 기호로 표기한 문자열 | | depositor_name | null | `null` | 무통장 입금자명 (입금확인 모달 기본값, payment 관계 로드 시에만 노출) | | total_cancelled_amount | integer | `0` | 총 취소금액 | | total_cancelled_amount_formatted | string | `0원` | `total_cancelled_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | @@ -2204,7 +3123,7 @@ _단건 응답: `data` 객체의 필드._ | shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) | | cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) | | promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) | -| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) | +| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 | | admin_memo | null | `null` | 관리자 메모 (내부 관리용) | | customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) | | created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 | @@ -2212,7 +3131,45 @@ _단건 응답: `data` 객체의 필드._ | is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | | abilities | object | `{"can_read":true,"can_update":true,"can_cancel":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | -**응답 예시** +비회원 응답(`GuestOrderResource`) 필드 — 회원 응답에 있는 `id`·`user`·`admin_memo`·`promotions_applied_snapshot` 등 민감/내부 필드는 포함되지 않습니다: + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| order_number | string | `20260706-1405449337` | 주문번호 (비회원 식별 기준 — 내부 `id` 는 미노출) | +| order_status | string | `payment_complete` | 주문상태 (OrderStatusEnum) | +| order_status_label | string | `결제완료` | `order_status` 의 사람이 읽는 라벨 | +| order_status_variant | string | `info` | `order_status` 의 표시 변형 키 (UI 배지 색상) | +| is_partially_cancelled | boolean | `false` | 부분취소 여부 (options 로드 시) | +| subtotal_amount / subtotal_amount_formatted | integer / string | `184000` / `184,000원` | 상품 합계 (할인 전) | +| total_discount_amount / total_discount_amount_formatted | integer / string | `0` / `0원` | 총 할인금액 | +| total_shipping_amount / total_shipping_amount_formatted | integer / string | `0` / `0원` | 총 배송비 | +| total_amount / total_amount_formatted | integer / string | `184000` / `184,000원` | 최종 주문금액 | +| total_paid_amount / total_paid_amount_formatted | integer / string | `184000` / `184,000원` | 총 실제 결제금액 | +| total_cancelled_amount / total_cancelled_amount_formatted | integer / string | `0` / `0원` | 총 취소금액 | +| total_refunded_amount / total_refunded_amount_formatted | integer / string | `0` / `0원` | 총 환불금액 | +| total_refunded_points_amount / _formatted | integer / string | `0` / `0원` | 총 환불 포인트 | +| total_points_used_amount / _formatted | integer / string | `0` / `0원` | 총 포인트 사용액 | +| total_deposit_used_amount / _formatted | integer / string | `0` / `0원` | 총 예치금 사용액 | +| total_earned_points_amount / _formatted | integer / string | `1840` / `1,840원` | 총 적립 예정 포인트 | +| mc_subtotal_amount / mc_total_discount_amount / mc_total_shipping_amount / mc_total_amount / mc_total_points_used_amount / mc_total_deposit_used_amount | object | `{"KRW":{"amount":184000,"formatted":"184,000원"}, …}` | 각 금액의 다중 통화 표기 | +| item_count | integer | `3` | 주문 품목 수 | +| total_quantity | integer | `4` | 주문 옵션 수량 합계 (options 로드 시) | +| ordered_at / ordered_at_formatted | string | `2026-07-05T14:05:44+00:00` / `2026-07-05 23:05:44` | 주문 일시 | +| paid_at / paid_at_formatted | string \| null | `2026-07-06T14:05:44+00:00` / `2026-07-06 23:05:44` | 결제 일시 | +| confirmed_at / confirmed_at_formatted | string \| null | `null` | 구매확정 일시 | +| cancelled_at / cancelled_at_formatted | string \| null | `null` | 취소 일시 | +| orderer_name / orderer_phone / orderer_email | string | `유정우` / `010-5514-4949` / `ji792@mail.test` | 주문자 정보 (배송지에서 플래튼) | +| recipient_name / recipient_phone | string | `심채원` / `010-3955-6018` | 수령인 정보 | +| recipient_zipcode / recipient_address / recipient_detail_address | string | `38022` / `부산광역시 양천구 공항대로 9` / `101동 202호` | 수령인 주소 | +| delivery_memo / delivery_memo_label | string \| null | `parcel_box` / `택배함에 넣어주세요` | 배송 메모 및 라벨 | +| options | array | `[{"id":1,"option_status":"payment_complete", …}]` | 주문 옵션 목록 (OrderOptionResource) | +| shipping_address | object | `{"recipient_name":"심채원", …}` | 배송지 상세 (OrderAddressResource) | +| payment | object \| null | `{"payment_method":"dbank", …}` | 대표 결제 정보 (OrderPaymentResource) | +| shippings | array | `[]` | 배송 이력 (OrderShippingResource) | +| cancels | array | `[]` | 취소 이력 (OrderCancelResource) | +| abilities | object | `{"can_cancel":true}` | 비회원이 이 주문에 수행 가능한 작업 (취소 가능 상태 여부) | + +**응답 예시** (비회원 — `GuestOrderResource`) ```http HTTP/1.1 200 @@ -2223,129 +3180,56 @@ HTTP/1.1 200 "success": true, "message": "주문 정보를 조회했습니다.", "data": { - "id": 4, - "order_number": "APIDOC-20260708-000001", - "base_currency": "KRW", - "payment_currency": "KRW", - "is_cross_currency": false, - "order_status": "pending_payment", - "order_status_label": "결제대기", - "order_status_variant": "warning", + "order_number": "20260706-1405449337", + "order_status": "payment_complete", + "order_status_label": "결제완료", + "order_status_variant": "info", "is_partially_cancelled": false, - "order_device": "pc", - "order_device_label": "PC", - "is_first_order": false, - "subtotal_amount": 324327, - "subtotal_amount_formatted": "324,327원", + "subtotal_amount": 184000, + "subtotal_amount_formatted": "184,000원", "total_discount_amount": 0, "total_discount_amount_formatted": "0원", - "total_shipping_amount": 3000, - "total_shipping_amount_formatted": "3,000원", - "total_amount": 327327, - "total_amount_formatted": "327,327원", - "total_paid_amount": 0, - "total_paid_amount_formatted": "0원", - "total_due_amount": 327327, - "total_due_amount_formatted": "327,327원", - "depositor_name": null, - "total_cancelled_amount": 0, - "total_cancelled_amount_formatted": "0원", - "total_refunded_amount": 0, - "total_refunded_amount_formatted": "0원", - "total_refunded_points_amount": 0, - "total_refunded_points_amount_formatted": "0원", - "total_product_coupon_discount_amount": 0, - "total_product_coupon_discount_amount_formatted": "0원", - "total_order_coupon_discount_amount": 0, - "total_order_coupon_discount_amount_formatted": "0원", - "total_coupon_discount_amount": 0, - "total_coupon_discount_amount_formatted": "0원", - "total_code_discount_amount": 0, - "total_code_discount_amount_formatted": "0원", - "total_points_used_amount": 0, - "total_points_used_amount_formatted": "0원", - "total_deposit_used_amount": 0, - "total_deposit_used_amount_formatted": "0원", - "total_earned_points_amount": 3273, - "total_earned_points_amount_formatted": "3,273원", - "mc_subtotal_amount": [], - "mc_total_discount_amount": [], - "mc_total_shipping_amount": [], - "mc_total_amount": [], - "mc_total_product_coupon_discount_amount": [], - "mc_total_order_coupon_discount_amount": [], - "mc_total_coupon_discount_amount": [], - "mc_total_code_discount_amount": [], - "mc_total_points_used_amount": [], - "mc_total_deposit_used_amount": [], - "item_count": 4, - "total_quantity": 0, - "total_list_price": 0, - "total_list_price_formatted": "0원", - "ordered_at": "2026-07-08T01:44:49+00:00", - "ordered_at_formatted": "2026-07-08 10:44:49", - "paid_at": null, - "paid_at_formatted": null, - "confirmed_at": null, - "confirmed_at_formatted": null, - "cancelled_at": null, - "cancelled_at_formatted": null, - "delivered_at": null, - "total_tax_amount": 29757, - "total_tax_amount_formatted": "29,757원", - "total_vat_amount": 0, - "total_vat_amount_formatted": "0원", - "total_taxable_supply_amount": 29757, - "total_taxable_supply_amount_formatted": "29,757원", - "total_tax_free_amount": 0, - "total_tax_free_amount_formatted": "0원", - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자", - "email": "apidoc-sample-user@example.com" - }, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user_login_id": null, - "orderer_name": null, - "orderer_phone": null, - "orderer_tel": null, - "orderer_email": null, - "recipient_name": null, - "recipient_phone": null, - "recipient_tel": null, - "recipient_zipcode": null, - "recipient_address": null, - "recipient_detail_address": null, - "delivery_memo": null, - "delivery_memo_label": null, + "total_shipping_amount": 0, + "total_shipping_amount_formatted": "0원", + "total_amount": 184000, + "total_amount_formatted": "184,000원", + "total_paid_amount": 184000, + "total_paid_amount_formatted": "184,000원", + "item_count": 3, + "total_quantity": 4, + "ordered_at": "2026-07-05T14:05:44+00:00", + "ordered_at_formatted": "2026-07-05 23:05:44", + "paid_at": "2026-07-06T14:05:44+00:00", + "paid_at_formatted": "2026-07-06 23:05:44", + "orderer_name": "유정우", + "orderer_phone": "010-5514-4949", + "orderer_email": "ji792@mail.test", + "recipient_name": "심채원", + "recipient_phone": "010-3955-6018", + "recipient_zipcode": "38022", + "recipient_address": "부산광역시 양천구 공항대로 9", + "recipient_detail_address": "101동 202호", + "delivery_memo": "parcel_box", + "delivery_memo_label": "택배함에 넣어주세요", "options": [], - "shipping_address": null, - "billing_address": null, + "shipping_address": {}, "payment": null, - "payments": [], "shippings": [], "cancels": [], - "promotions_applied_snapshot": null, - "shipping_policy_applied_snapshot": null, - "admin_memo": null, - "customer_memo": null, - "created_at": "2026-07-08T01:44:49+00:00", - "updated_at": "2026-07-08T01:44:49+00:00", - "is_owner": true, "abilities": { - "can_read": true, - "can_update": true, "can_cancel": true } } } ``` +> 로그인 상태의 본인 주문이면 `data` 는 주문 상세와 동일한 `OrderResource` 전체 구조로 내려갑니다 (`GET /api/modules/sirsoft-ecommerce/admin/orders/{order}` 의 응답 필드 표 참조). + **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 주문이 없거나 접근 권한이 없는 경우 — 회원: 본인 주문 아님(`errors.redirect_to = /mypage/orders`), 비회원: `X-Guest-Order-Token` 부재·만료·위조(`errors.redirect_to = /shop/guest/orders` — 상점 주소 설정 `basic_info.route_path`/`no_route` 반영). 정보 노출 방지를 위해 모든 실패를 동일 404 로 처리 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md b/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md index 97a7316d..3ea7821a 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/payments.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Payments 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -39,17 +39,96 @@ Accept: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ `data` 는 Resource 가 아니라 `sirsoft-ecommerce.payment.get_client_config` **필터 훅의 반환 배열을 그대로** 담습니다. 따라서 키 구성은 설치된 PG 플러그인이 결정하며, 코어(모듈)는 어떤 키도 강제하지 않습니다. 아래는 번들 PG 플러그인 두 종의 실제 반환 키입니다. + +공통 (모든 PG 플러그인이 공통으로 내려주는 키) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| sdk_url | string | `https://js.tosspayments.com/v2/standard` | 프론트가 로드해야 할 PG 결제 SDK 스크립트 URL (테스트/운영 모드에 따라 달라질 수 있음) | +| callback_urls | object | `{"success":"/plugins/sirsoft-tosspayments/payment/success","fail":"/plugins/sirsoft-tosspayments/payment/fail"}` | 결제창 성공/실패/콜백 등에서 사용할 플러그인 콜백 경로 모음 (키 구성은 PG마다 다름) | + +`tosspayments` (플러그인 `sirsoft-tosspayments`) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| client_key | string | `test_ck_...` | 토스페이먼츠 SDK 초기화용 클라이언트 키 (테스트 모드면 test 키, 운영 모드면 live 키) | +| sdk_url | string | `https://js.tosspayments.com/v2/standard` | 토스페이먼츠 v2 표준 SDK URL | +| order_sheet_mode | boolean | `false` | 결제수단을 주문서(오더시트)에서 직접 선택하는 모드 여부. `false` 면 통합결제창 방식 | +| enabled_methods | array | `[]` | 오더시트 모드에서 활성화된 결제수단 목록. 결제창형(`order_sheet_mode: false`)이면 빈 배열 | +| enabled_methods[].id | string | `toss_card` | 플러그인 설정상의 결제수단 식별자 | +| enabled_methods[].method | string | `CARD` | 토스 SDK 에 전달할 결제수단 코드 | +| enabled_methods[].easy_pay_provider | string\|null | `null` | 간편결제일 때 토스 SDK 의 easyPay provider 값 (일반 결제수단이면 null) | +| enabled_methods[].core_payment_method | string | `card` | 이 결제수단에 대응하는 코어 결제수단 값 (프론트 하드코딩 제거용) | +| vbank | object | `{"valid_hours":24,"cash_receipt_type":""}` | 가상계좌 관련 설정 묶음 | +| vbank.valid_hours | integer | `24` | 가상계좌 입금 기한(시간) | +| vbank.cash_receipt_type | string | `""` | 가상계좌 현금영수증 발급 유형 (미설정이면 빈 문자열) | +| use_escrow | string | `off` | 에스크로 사용 설정값 (플러그인 설정 문자열 그대로) | +| callback_urls.success | string | `/plugins/sirsoft-tosspayments/payment/success` | 결제 성공 리다이렉트 경로 | +| callback_urls.fail | string | `/plugins/sirsoft-tosspayments/payment/fail` | 결제 실패 리다이렉트 경로 | + +`kginicis` (플러그인 `sirsoft-pay_kginicis`) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| mid | string | `INIpayTest` | 이니시스 상점 아이디(MID). 테스트/운영 및 에스크로 사용 여부에 따라 선택됨 | +| sdk_url | string | `https://stgstdpay.inicis.com/stdjs/INIStdPay.js` | 이니시스 표준결제 SDK URL (테스트: stgstdpay, 운영: stdpay) | +| callback_urls | object | — | 서명/종료보고/콜백/CBT/모바일 등 이니시스 연동 경로 모음 | +| callback_urls.signature | string | `/plugins/sirsoft-pay_kginicis/payment/signature` | PC 표준결제 서명 발급 경로 | +| callback_urls.close_report | string | `/plugins/sirsoft-pay_kginicis/payment/close-report` | 결제창 종료 보고 경로 | +| callback_urls.callback | string | `/plugins/sirsoft-pay_kginicis/payment/callback` | PC 표준결제 인증 결과 콜백 경로 | +| callback_urls.close | string | `/plugins/sirsoft-pay_kginicis/payment/close` | 결제창 닫기 처리 경로 | +| callback_urls.cbt_checkout_token | string | `/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token` | 해외결제(CBT) 체크아웃 토큰 발급 경로 | +| callback_urls.cbt_hash_data | string | `/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data` | CBT 해시 데이터 생성 경로 | +| callback_urls.cbt_callback | string | `/plugins/sirsoft-pay_kginicis/payment/cbt/callback` | CBT 결제 결과 콜백 경로 | +| callback_urls.cbt_cvs_notify | string | `/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify` | CBT 편의점 결제 입금 통보 경로 | +| callback_urls.cbt_auth_url | string | — | CBT 인증 요청 URL (테스트/운영 상수값) | +| callback_urls.mobile_signature | string | `/plugins/sirsoft-pay_kginicis/payment/mobile/signature` | 모바일 결제 서명 발급 경로 | +| callback_urls.mobile_callback | string | `/plugins/sirsoft-pay_kginicis/payment/mobile/callback` | 모바일 결제 결과 콜백 경로 | +| callback_urls.mobile_vbank_notify | string | `/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify` | 모바일 가상계좌 입금 통보 경로 | +| japan_enabled | boolean | `false` | 일본 결제(해외결제) 사용 여부 설정값 | +| japan_restrict_jpy_payment_methods | boolean | `false` | JPY 결제 시 결제수단을 일본 전용으로 제한할지 여부 | +| japan_configured | boolean | `false` | 일본 결제에 필요한 설정(MID/키 등)이 모두 채워졌는지 여부 | +| standard_configured | boolean | `true` | PC 표준결제 설정이 모두 채워졌는지 여부 | +| mobile_configured | boolean | `true` | 모바일 결제 설정이 모두 채워졌는지 여부 | +| use_escrow | boolean | `false` | 에스크로 사용 여부 설정값 | +| japan_mid | string | — | 일본 결제용 상점 아이디 (테스트면 고정 테스트 MID, 운영이면 설정된 live MID) | +| cbt_extra_data | object | — | CBT 결제 요청에 부가로 실어보낼 데이터 (플러그인 설정 기반으로 구성) | +| use_credit_point | boolean | `false` | 신용카드 포인트 결제 사용 여부 | +| easy_pay_show_brand_button | boolean | `false` | 간편결제 브랜드 버튼 노출 여부 | +| easy_pay_enabled_methods | array | `[]` | 활성화된 국내 간편결제 수단 목록 | **응답 예시** - +`tosspayments` (결제창형, 테스트 모드) 기준: + +```json +{ + "success": true, + "message": "결제 클라이언트 설정을 조회했습니다.", + "data": { + "client_key": "test_ck_xxxxxxxxxxxxxxxxxxxxxxxx", + "sdk_url": "https://js.tosspayments.com/v2/standard", + "order_sheet_mode": false, + "enabled_methods": [], + "vbank": { + "valid_hours": 24, + "cash_receipt_type": "" + }, + "use_escrow": "off", + "callback_urls": { + "success": "/plugins/sirsoft-tosspayments/payment/success", + "fail": "/plugins/sirsoft-tosspayments/payment/fail" + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해당 `provider` 에 대해 `sirsoft-ecommerce.payment.get_client_config` 필터가 빈 설정을 반환한 경우 (PG 플러그인 미설치·미활성·미지원 provider). 메시지: `결제 제공자를 찾을 수 없습니다.` | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md b/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md index 5cb25e37..862c31d8 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/presets.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Presets 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -42,9 +42,18 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) +_목록 응답: `data[]` 배열 항목의 필드 (페이지네이션 없음 — 현재 사용자·해당 화면의 프리셋 전체를 배열로 반환)._ - - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 프리셋 ID (기본 키) | +| target_screen | string | `products` | 대상 화면 (`products` / `orders` / `customers`) | +| name | string | `배송 대기 상품` | 프리셋 이름 (DB 컬럼 `preset_name`, 최대 100자) | +| conditions | object | `{"status": "active"}` | 저장된 검색 조건 JSON (필터 필드 → 값) | +| filters | object | `{"preset_id": 1, "filters[status]": "active"}` | 프론트엔드가 그대로 붙여 쓰는 쿼리 파라미터 형태 (`SearchPreset::toQueryParams()` 생성, 배열 값은 콤마 결합) | +| sort_order | integer | `0` | 정렬 순서 (작을수록 우선) | +| is_default | boolean | `false` | 기본 프리셋 여부 | +| created_at | string | `2026-07-14 10:20:30` | 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | **응답 예시** @@ -55,11 +64,29 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.presets.fetch_success", - "data": [] + "message": "검색 프리셋 목록을 조회했습니다.", + "data": [ + { + "id": 1, + "target_screen": "products", + "name": "배송 대기 상품", + "conditions": { + "status": "active" + }, + "filters": { + "preset_id": 1, + "filters[status]": "active" + }, + "sort_order": 0, + "is_default": false, + "created_at": "2026-07-14 10:20:30" + } + ] } ``` +> `message` 는 컨트롤러가 `sirsoft-ecommerce::messages.presets.fetch_success` 키를 넘기지만 모듈 lang 파일(`src/lang/ko/messages.php` 의 `presets` 배열)에 해당 키가 정의되어 있지 않아, 실측 응답에서 키 문자열이 그대로 노출됩니다. + **에러 응답** | 상태코드 | 의미 | 발생 조건 | @@ -108,18 +135,53 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 프리셋 1건)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 프리셋 ID | +| target_screen | string | `products` | 대상 화면 (`products` / `orders` / `customers`, 미지정 시 `products`) | +| name | string | `배송 대기 상품` | 프리셋 이름 (DB 컬럼 `preset_name`) | +| conditions | object | `{"status": "active"}` | 저장된 검색 조건 | +| filters | object | `{"preset_id": 1, "filters[status]": "active"}` | 프론트엔드용 쿼리 파라미터 형태 | +| sort_order | integer | `0` | 정렬 순서 (생성 시 기본값 `0`) | +| is_default | boolean | `false` | 기본 프리셋 여부 (생성 시 기본값 `false`) | +| created_at | string | `2026-07-14 10:20:30` | 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "검색 프리셋이 저장되었습니다.", + "data": { + "id": 1, + "target_screen": "products", + "name": "배송 대기 상품", + "conditions": { + "status": "active" + }, + "filters": { + "preset_id": 1, + "filters[status]": "active" + }, + "sort_order": 0, + "is_default": false, + "created_at": "2026-07-14 10:20:30" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 동일 사용자·동일 화면에 같은 이름의 프리셋이 이미 있으면 `name` 에 "동일한 이름의 프리셋이 이미 존재합니다." | @@ -149,11 +211,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (삭제 결과 플래그만 반환하며, 삭제된 프리셋 본문은 반환하지 않습니다)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 완료 여부 (컨트롤러가 `['deleted' => true]` 를 고정 반환) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "검색 프리셋이 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** @@ -161,6 +239,7 @@ Authorization: Bearer {YOUR_TOKEN} | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 500 | UnauthorizedPresetAccessException | 다른 사용자 소유의 프리셋을 삭제하려는 경우 (`SearchPresetService::delete()` 의 소유권 검사 — "프리셋(ID: :preset_id)에 대한 접근 권한이 없습니다."). 이 예외는 전용 status 나 `render()` 를 갖지 않아 기본 예외 처리로 500 이 됩니다 (테스트 하네스 `ModuleTestCase` 에서만 403 으로 매핑) | @@ -204,19 +283,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 프리셋 1건)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 프리셋 ID | +| target_screen | string | `products` | 대상 화면 (수정 대상이 아님 — 생성 시 값 유지) | +| name | string | `배송 대기 상품 (수정)` | 수정된 프리셋 이름 (DB 컬럼 `preset_name`) | +| conditions | object | `{"status": "inactive"}` | 수정된 검색 조건 | +| filters | object | `{"preset_id": 1, "filters[status]": "inactive"}` | 프론트엔드용 쿼리 파라미터 형태 | +| sort_order | integer | `1` | 정렬 순서 (작을수록 우선) | +| is_default | boolean | `false` | 기본 프리셋 여부 | +| created_at | string | `2026-07-14 10:20:30` | 생성 일시 (사용자 타임존 기준 `Y-m-d H:i:s`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "검색 프리셋이 수정되었습니다.", + "data": { + "id": 1, + "target_screen": "products", + "name": "배송 대기 상품 (수정)", + "conditions": { + "status": "inactive" + }, + "filters": { + "preset_id": 1, + "filters[status]": "inactive" + }, + "sort_order": 1, + "is_default": false, + "created_at": "2026-07-14 10:20:30" + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 동일 사용자·동일 화면에 같은 이름의 다른 프리셋이 있으면 `name` 에 "동일한 이름의 프리셋이 이미 존재합니다." | +| 500 | UnauthorizedPresetAccessException | 다른 사용자 소유의 프리셋을 수정하려는 경우 (`SearchPresetService::update()` 의 소유권 검사). 전용 status/`render()` 가 없어 기본 예외 처리로 500 (테스트 하네스에서만 403 매핑) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md index 09bb1d2e..620d67d5 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Product Common Infos 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -25,12 +25,17 @@ **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | +| active_only | query | boolean | 아니오 | — | | +| default_only | query | boolean | 아니오 | — | | +| per_page | query | string | 아니오 | — | 페이지당 항목 수 | **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/product-common-infos HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/product-common-infos?search=%EC%98%88%EC%8B%9C%EA%B0%92&active_only=1&default_only=1&per_page=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -42,7 +47,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `207` | 기본 키 (내부 식별자) | +| id | integer | `1` | 기본 키 (내부 식별자) | | name | object | `{"ko":"일반 배송 안내","en":"Standard Shipping"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | | localized_name | string | `일반 배송 안내` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | content | object | `{"ko":"• 배송 기간: 결제 완료 후 1~3일 이내 출고 (영업일 기준)\n• 배송 업체: CJ대…` | 본문 내용 | @@ -52,9 +57,9 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | is_active | boolean | `true` | active 여부 | | sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | | icon | string | `info-circle` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-06-15 11:24:00` | 생성 일시 | -| updated_at | string | `2026-06-15 11:24:00` | 최종 수정 일시 | -| products_count | integer | `81` | products 개수 (집계) | +| created_at | string | `2026-07-30 23:35:46` | 생성 일시 | +| updated_at | string | `2026-07-30 23:35:46` | 최종 수정 일시 | +| products_count | integer | `85` | products 개수 (집계) | | language_count | integer | `2` | language 개수 (집계) | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -73,27 +78,58 @@ HTTP/1.1 200 { "id": 1, "name": { - "ko": "API 문서 샘플 공통정보", - "en": "API Doc Sample Common Info" + "ko": "일반 배송 안내", + "en": "Standard Shipping" }, - "localized_name": "API 문서 샘플 공통정보", - "content": [], - "localized_content": "", + "localized_name": "일반 배송 안내", + "content": { + "ko": "• 배송 기간: 결제 완료 후 1~3일 이내 출고 (영업일 기준)\n• 배송 업체: CJ대한통운, 한진택배, 롯데택배\n• 배송 지역: 전국 배송 가능 (일부 도서산간 지역 제외)\n• 배송 조회: 마이페이지 > 주문내역에서 실시간 조회 가능\n• 배송비: 3만원 이상 구매 시 무료, 미만 시 3,000원", + "en": "• Delivery Time: Ships within 1-3 business days after payment\n• Carriers: CJ Logistics, Hanjin, Lotte\n• Coverage: Nationwide (some remote areas excluded)\n• Tracking: Available in My Page > Order History\n• Shipping Fee: Free for orders over 30,000 KRW, otherwise 3,000 KRW" + }, + "localized_content": "• 배송 기간: 결제 완료 후 1~3일 이내 출고 (영업일 기준)\n• 배송 업체: CJ대한통운, 한진택배, 롯데택배\n• 배송 지역: 전국 배송 가능 (일부 도서산간 지역 제외)\n• 배송 조회: 마이페이지 > 주문내역에서 실시간 조회 가능\n• 배송비: 3만원 이상 구매 시 무료, 미만 시 3,000원", "content_mode": "text", - "is_default": false, + "is_default": true, "is_active": true, "sort_order": 0, "icon": "info-circle", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "products_count": 0, - "language_count": 0, + "created_at": "2026-07-30 23:35:46", + "updated_at": "2026-07-30 23:35:46", + "products_count": 85, + "language_count": 2, "abilities": { "can_create": true, "can_update": true, "can_delete": true } - } + }, + { + "id": 2, + "name": { + "ko": "당일 배송 안내", + "en": "Same-Day Delivery" + }, + "localized_name": "당일 배송 안내", + "content": { + "ko": "• 주문 마감: 오전 11시까지 결제 완료 시 당일 출고\n• 배송 지역: 서울, 경기, 인천 일부 지역\n• 배송비: 7만원 이상 무료, 미만 시 4,000원\n• 수령 시간: 오후 6시~9시 사이 도착\n• 주의: 재고 상황에 따라 익일 배송으로 변경될 수 있습니다", + "en": "• Order Deadline: Complete payment by 11 AM for same-day shipping\n• Coverage: Parts of Seoul, Gyeonggi, Incheon\n• Fee: Free for orders over 70,000 KRW, otherwise 4,000 KRW\n• Arrival: Between 6 PM - 9 PM\n• Note: May change to next-day delivery based on stock availability" + }, + "localized_content": "• 주문 마감: 오전 11시까지 결제 완료 시 당일 출고\n• 배송 지역: 서울, 경기, 인천 일부 지역\n• 배송비: 7만원 이상 무료, 미만 시 4,000원\n• 수령 시간: 오후 6시~9시 사이 도착\n• 주의: 재고 상황에 따라 익일 배송으로 변경될 수 있습니다", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 1, + "icon": "info-circle", + "created_at": "2026-07-30 23:35:46", + "updated_at": "2026-07-30 23:35:46", + "products_count": 0, + "language_count": 2, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 25건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -102,12 +138,12 @@ HTTP/1.1 200 }, "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 5, "per_page": 25, - "total": 1, + "total": 103, "from": 1, - "to": 1, - "has_more_pages": false + "to": 25, + "has_more_pages": true } } } @@ -119,6 +155,7 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -169,11 +206,62 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductCommonInfoResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 공통정보의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 공통정보","en":"API Doc Sample Common Info"}` | 공통정보 이름 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 공통정보` | `name` 의 현재 로케일 해석 값 | +| content | object\|array | `[]` | 안내 본문 내용 (로케일별 값 객체, 미입력 시 빈 배열) | +| localized_content | string | `` | `content` 의 현재 로케일 해석 값 | +| content_mode | string | `text` | 내용 표시 모드 (`text` 일반 텍스트 / `html` HTML, 기본값 `text`) | +| is_default | boolean | `false` | 신규 상품에 기본으로 사용할 공통정보인지 여부 | +| is_active | boolean | `true` | 활성 여부 (false 이면 상품에 노출되지 않음) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `info-circle` | 아이콘 식별자 (목록 UI 표시용 고정값) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| products_count | integer | `0` | 이 공통정보를 사용 중인 상품 수 (생성 직후 0) | +| language_count | integer | `0` | 값이 입력된 로케일 개수 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (권한 맵 기반) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "공통정보가 생성되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 공통정보", + "en": "API Doc Sample Common Info" + }, + "localized_name": "API 문서 샘플 공통정보", + "content": [], + "localized_content": "", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 0, + "icon": "info-circle", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "products_count": 0, + "language_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -203,7 +291,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/product-common-infos/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -211,29 +299,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| common_info_id | integer | `1` | common info 식별자 (연관 리소스 참조) | -| products_count | integer | `0` | products 개수 (집계) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "공통정보가 삭제되었습니다.", - "data": { - "common_info_id": 1, - "products_count": 0 - } -} -``` + **에러 응답** @@ -263,7 +333,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/product-common-infos/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -271,62 +341,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 공통정보","en":"API Doc Sample Common Info"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 공통정보` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| content | array | `[]` | 본문 내용 | -| localized_content | string | `` | `content` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| content_mode | string | `text` | 내용 모드 (text: 텍스트, html: HTML) | -| is_default | boolean | `false` | default 여부 | -| is_active | boolean | `true` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| icon | string | `info-circle` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| products_count | integer | `0` | products 개수 (집계) | -| language_count | integer | `0` | language 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "공통정보 목록을 불러왔습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 공통정보", - "en": "API Doc Sample Common Info" - }, - "localized_name": "API 문서 샘플 공통정보", - "content": [], - "localized_content": "", - "content_mode": "text", - "is_default": false, - "is_active": true, - "sort_order": 0, - "icon": "info-circle", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "products_count": 0, - "language_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -364,7 +383,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/product-common-infos/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -386,11 +405,64 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductCommonInfoResource` — 수정 반영 후 값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 공통정보의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 공통정보(수정)","en":"API Doc Sample Common Info (updated)"}` | 공통정보 이름 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 공통정보(수정)` | `name` 의 현재 로케일 해석 값 | +| content | object\|array | `{"ko":"배송은 결제 후 1~3일 이내 출고됩니다."}` | 안내 본문 내용 (로케일별 값 객체) | +| localized_content | string | `배송은 결제 후 1~3일 이내 출고됩니다.` | `content` 의 현재 로케일 해석 값 | +| content_mode | string | `text` | 내용 표시 모드 (`text` 일반 텍스트 / `html` HTML, 기본값 `text`) | +| is_default | boolean | `false` | 신규 상품에 기본으로 사용할 공통정보인지 여부 | +| is_active | boolean | `true` | 활성 여부 (false 이면 상품에 노출되지 않음) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `info-circle` | 아이콘 식별자 (목록 UI 표시용 고정값) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:26` | 최종 수정 일시 (이번 수정 시각) | +| products_count | integer | `0` | 이 공통정보를 사용 중인 상품 수 | +| language_count | integer | `1` | 값이 입력된 로케일 개수 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (권한 맵 기반) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "공통정보가 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 공통정보(수정)", + "en": "API Doc Sample Common Info (updated)" + }, + "localized_name": "API 문서 샘플 공통정보(수정)", + "content": { + "ko": "배송은 결제 후 1~3일 이내 출고됩니다." + }, + "localized_content": "배송은 결제 후 1~3일 이내 출고됩니다.", + "content_mode": "text", + "is_default": false, + "is_active": true, + "sort_order": 0, + "icon": "info-circle", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:26", + "products_count": 0, + "language_count": 1, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -398,8 +470,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -421,7 +493,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/product-common-infos/1/toggle-active HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id}/toggle-active HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -429,62 +501,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 공통정보","en":"API Doc Sample Common Info"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 공통정보` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| content | array | `[]` | 본문 내용 | -| localized_content | string | `` | `content` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| content_mode | string | `text` | 내용 모드 (text: 텍스트, html: HTML) | -| is_default | boolean | `false` | default 여부 | -| is_active | boolean | `false` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| icon | string | `info-circle` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:26` | 최종 수정 일시 | -| products_count | integer | `0` | products 개수 (집계) | -| language_count | integer | `0` | language 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "공통정보가 비활성화되었습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 공통정보", - "en": "API Doc Sample Common Info" - }, - "localized_name": "API 문서 샘플 공통정보", - "content": [], - "localized_content": "", - "content_mode": "text", - "is_default": false, - "is_active": false, - "sort_order": 0, - "icon": "info-circle", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:26", - "products_count": 0, - "language_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md index 8c499c56..ae536e4c 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-image.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Product Image 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -27,7 +27,7 @@ | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| hash | path | string | 예 | — | 대상 리소스의 해시 식별자 | +| hash | path | string | 예 | — | 서빙할 상품 이미지의 URL용 고유 해시 (`ecommerce_product_images.hash`) | **요청 예시** @@ -39,17 +39,41 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투(`data`)를 반환하지 않습니다. 성공 시 이미지 원본 파일의 바이너리 스트림(`StreamedResponse`)을 직접 반환합니다._ + +| 응답 헤더 | 값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `image/jpeg` (레코드의 `mime_type` 값 그대로 — `image/png` \| `image/webp` \| `image/gif` 등) | 저장된 이미지의 MIME 타입 | +| Content-Disposition | `attachment; filename="product-main.jpg"` (레코드의 `original_filename`) | 업로드 당시의 원본 파일명 | +| Cache-Control | `public, max-age=31536000` | 브라우저/CDN 1년 캐싱 (해시 기반 URL이므로 내용 변경 시 URL 자체가 바뀜) | **응답 예시** - +성공 시 JSON 이 아닌 이미지 바이너리가 스트리밍됩니다. + +```http +HTTP/1.1 200 OK +Content-Type: image/jpeg +Content-Disposition: attachment; filename="product-main.jpg" +Cache-Control: public, max-age=31536000 + + +``` + +실패(404) 시에만 JSON 봉투가 반환됩니다. + +```json +{ + "success": false, + "message": "상품 이미지를 찾을 수 없습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 레코드는 있으나 스토리지에 실제 파일이 없어 스트림 생성에 실패한 경우 (`ProductImageService::download()` null — 서버 로그에 `상품 이미지 스토리지에 없음` 기록) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md index ac029b50..efb8732b 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Product Labels 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -31,7 +31,7 @@ | active_only | query | boolean | 아니오 | — | 활성 라벨만 필터 (true 시 내부적으로 `is_active=true` 로 변환 — 기존 호환용) | | search | query | string | 아니오 | max 100 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | | sort | query | string | 아니오 | `name_asc`, `name_desc`, `created_asc`, `created_desc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | -| locale | query | string | 아니오 | `ko`, `en`, `fr`, `ja` | 로케일 코드 (표시 언어/지역) | +| locale | query | string | 아니오 | `ko`, `en` | 로케일 코드 (표시 언어/지역) | **요청 예시** @@ -48,14 +48,14 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `37` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 라벨","en":"API Doc Sample Label"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| color | string | `#6B7280` | 라벨 색상 코드 (`#RRGGBB` 6자리 HEX, 뱃지 배경/글자색 등 표시에 사용) | +| id | integer | `29` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"신상품","en":"New"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| color | string | `#22C55E` | 라벨 색상 코드 (`#RRGGBB` 6자리 HEX, 뱃지 배경/글자색 등 표시에 사용) | | is_active | boolean | `true` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | -| assignments_count | integer | `0` | assignments 개수 (집계) | +| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-30 23:35:47` | 생성 일시 | +| updated_at | string | `2026-07-30 23:35:47` | 최종 수정 일시 | +| assignments_count | integer | `12` | assignments 개수 (집계) | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -71,23 +71,42 @@ HTTP/1.1 200 "data": { "data": [ { - "id": 1, + "id": 29, "name": { - "ko": "API 문서 샘플 라벨", - "en": "API Doc Sample Label" + "ko": "신상품", + "en": "New" }, - "color": null, + "color": "#22C55E", "is_active": true, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "assignments_count": 0, + "sort_order": 1, + "created_at": "2026-07-30 23:35:47", + "updated_at": "2026-07-30 23:35:47", + "assignments_count": 12, "abilities": { "can_create": true, "can_update": true, "can_delete": true } - } + }, + { + "id": 30, + "name": { + "ko": "베스트", + "en": "Best" + }, + "color": "#F59E0B", + "is_active": true, + "sort_order": 2, + "created_at": "2026-07-30 23:35:47", + "updated_at": "2026-07-30 23:35:47", + "assignments_count": 18, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 10건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -147,11 +166,50 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductLabelResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"신상품","en":"New"}` | 라벨명 (다국어 필드 — 로케일별 값 객체) | +| color | string | `#4F46E5` | 라벨 색상 코드 (`#RRGGBB` 6자리 HEX) | +| is_active | boolean | `true` | 활성 여부 (미지정 시 모델 기본값) | +| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| assignments_count | integer | `0` | 이 라벨이 부여된 상품 수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (권한 맵 기반) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "라벨이 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "신상품", + "en": "New" + }, + "color": "#4F46E5", + "is_active": true, + "sort_order": 1, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "assignments_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -181,7 +239,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/product-labels/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/product-labels/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -189,29 +247,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| label_id | integer | `1` | label 식별자 (연관 리소스 참조) | -| products_count | integer | `0` | products 개수 (집계) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "라벨이 삭제되었습니다.", - "data": { - "label_id": 1, - "products_count": 0 - } -} -``` + **에러 응답** @@ -241,7 +281,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/product-labels/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/product-labels/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -249,50 +289,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 라벨","en":"API Doc Sample Label"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| color | null | `null` | 색상 코드 (예: #FF5733) | -| is_active | boolean | `true` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| assignments_count | integer | `0` | assignments 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "라벨 정보를 조회했습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 라벨", - "en": "API Doc Sample Label" - }, - "color": null, - "is_active": true, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "assignments_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -326,7 +327,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/product-labels/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/product-labels/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -344,11 +345,50 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductLabelResource` — 수정 후 라벨)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"베스트","en":"Best"}` | 라벨명 (다국어 필드 — 로케일별 값 객체) | +| color | string | `#4F46E5` | 라벨 색상 코드 (`#RRGGBB` 6자리 HEX) | +| is_active | boolean | `true` | 활성 여부 | +| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 (수정 반영 후 값) | +| assignments_count | integer | `0` | 이 라벨이 부여된 상품 수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (권한 맵 기반) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "라벨이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "베스트", + "en": "Best" + }, + "color": "#4F46E5", + "is_active": true, + "sort_order": 1, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:27", + "assignments_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -356,8 +396,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -379,7 +419,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/product-labels/1/toggle-status HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/product-labels/{id}/toggle-status HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -387,50 +427,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 라벨","en":"API Doc Sample Label"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| color | null | `null` | 색상 코드 (예: #FF5733) | -| is_active | boolean | `false` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 | -| assignments_count | integer | `0` | assignments 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "라벨 상태가 변경되었습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 라벨", - "en": "API Doc Sample Label" - }, - "color": null, - "is_active": false, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:27", - "assignments_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md index 3f578d88..9b81ea14 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Product Notice Templates 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -29,7 +29,7 @@ | --- | --- | --- | --- | --- | --- | | search | query | string | 아니오 | max 200 | 검색어 (지정한 검색 대상 필드에서 부분 일치) | | active_only | query | boolean | 아니오 | — | true 시 활성(is_active) 템플릿만 조회 | -| per_page | query | string | 아니오 | — | 페이지당 항목 수 | +| per_page | query | string | 아니오 | — | 페이지당 항목 수 (`all` 은 전체 조회, 숫자는 최대 100) | | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | **요청 예시** @@ -47,17 +47,17 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `172` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 고시템플릿","en":"API Doc Sample Notice Templ…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 고시템플릿` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류","en":"Clothing"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `의류` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | category | string | `clothing` | 이 템플릿이 적용되는 품목 카테고리 식별자 | -| fields | array | `[{"label":"품명","value":"샘플"}]` | 고시 항목 정의 배열 (항목별 name/content 다국어 — 상품 등록 시 고시 항목 자동 채움에 사용) | -| fields_count | integer | `1` | fields 개수 (집계) | +| fields | array | `[{"name":{"ko":"제품 소재 (섬유의 조성 또는 혼용률)","en":"Material Com…` | 고시 항목 정의 배열 (항목별 name/content 다국어 — 상품 등록 시 고시 항목 자동 채움에 사용) | +| fields_count | integer | `9` | fields 개수 (집계) | | is_active | boolean | `true` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | | icon | string | `file-alt` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| created_at | string | `2026-07-30 23:35:46` | 생성 일시 | +| updated_at | string | `2026-07-30 23:35:46` | 최종 수정 일시 | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -75,29 +75,130 @@ HTTP/1.1 200 { "id": 1, "name": { - "ko": "API 문서 샘플 고시템플릿", - "en": "API Doc Sample Notice Template" + "ko": "의류", + "en": "Clothing" }, - "localized_name": "API 문서 샘플 고시템플릿", - "category": null, + "localized_name": "의류", + "category": "clothing", "fields": [ { - "label": "품명", - "value": "샘플" - } + "name": { + "ko": "제품 소재 (섬유의 조성 또는 혼용률)", + "en": "Material Composition" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + { + "name": { + "ko": "색상", + "en": "Color" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + { + "name": { + "ko": "치수", + "en": "Size" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + { + "name": { + "ko": "제조자/수입자", + "en": "Manufacturer/Importer" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + { + "name": { + "ko": "제조국", + "en": "Country of Origin" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + "... (총 9건 중 5건 표시)" ], - "fields_count": 1, - "is_active": true, - "sort_order": 0, - "icon": "file-alt", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } + "...": "(7개 키 생략, 총 12개)" + }, + { + "id": 2, + "name": { + "ko": "구두/신발", + "en": "Shoes" + }, + "localized_name": "구두/신발", + "category": "shoes", + "fields": [ + { + "name": { + "ko": "제품 주소재 (겉감/안감)", + "en": "Main Material (Outer/Inner)" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + { + "name": { + "ko": "색상", + "en": "Color" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + { + "name": { + "ko": "치수 (발 길이)", + "en": "Size (Foot Length)" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + { + "name": { + "ko": "굽 높이", + "en": "Heel Height" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + { + "name": { + "ko": "제조자/수입자", + "en": "Manufacturer/Importer" + }, + "content": { + "ko": "상세페이지 참조", + "en": "See product page" + } + }, + "... (총 9건 중 5건 표시)" + ], + "...": "(7개 키 생략, 총 12개)" + }, + "... (총 25건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -106,12 +207,11 @@ HTTP/1.1 200 }, "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 3, "per_page": 25, - "total": 1, + "total": 57, "from": 1, - "to": 1, - "has_more_pages": false + "...": "(2개 키 생략, 총 7개)" } } } @@ -172,16 +272,67 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `3` | 생성된 템플릿의 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류 고시","en":"Clothing Notice"}` | 템플릿명 (로케일별 값 객체) | +| localized_name | string | `의류 고시` | `name` 의 현재 로케일 해석 값 | +| category | string\|null | `clothing` | 이 템플릿이 적용되는 품목 카테고리 식별자 (미지정 시 `null`) | +| fields | array | `[{"label":"품명","value":"샘플"}]` | 고시 항목 정의 배열 (요청의 `fields` 가 그대로 저장됨) | +| fields_count | integer | `1` | `fields` 배열의 개수 (집계값) | +| is_active | boolean | `true` | 활성 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `file-alt` | 목록 UI(SortableMenuList)용 고정 아이콘 식별자 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시가 생성되었습니다.", + "data": { + "id": 3, + "name": { + "ko": "의류 고시", + "en": "Clothing Notice" + }, + "localized_name": "의류 고시", + "category": "clothing", + "fields": [ + { + "label": "품명", + "value": "샘플" + } + ], + "fields_count": 1, + "is_active": true, + "sort_order": 0, + "icon": "file-alt", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 생성 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.create`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -206,7 +357,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -214,27 +365,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| template_id | integer | `1` | template 식별자 (연관 리소스 참조) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "상품정보제공고시가 삭제되었습니다.", - "data": { - "template_id": 1 - } -} -``` + **에러 응답** @@ -264,7 +399,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -272,61 +407,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 고시템플릿","en":"API Doc Sample Notice Templ…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 고시템플릿` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| category | null | `null` | 품목 카테고리 | -| fields | array | `[{"label":"품명","value":"샘플"}]` | 필드 정의 JSON | -| fields_count | integer | `1` | fields 개수 (집계) | -| is_active | boolean | `true` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| icon | string | `file-alt` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "상품정보제공고시 목록을 불러왔습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 고시템플릿", - "en": "API Doc Sample Notice Template" - }, - "localized_name": "API 문서 샘플 고시템플릿", - "category": null, - "fields": [ - { - "label": "품명", - "value": "샘플" - } - ], - "fields_count": 1, - "is_active": true, - "sort_order": 0, - "icon": "file-alt", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -363,7 +448,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -384,20 +469,71 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 템플릿의 기본 키 (내부 식별자) | +| name | object | `{"ko":"의류 고시(수정)","en":"Clothing Notice (Updated)"}` | 템플릿명 (로케일별 값 객체) | +| localized_name | string | `의류 고시(수정)` | `name` 의 현재 로케일 해석 값 | +| category | string\|null | `clothing` | 이 템플릿이 적용되는 품목 카테고리 식별자 (미지정 시 `null`) | +| fields | array | `[{"label":"품명","value":"샘플"}]` | 고시 항목 정의 배열 (요청의 `fields` 로 대체됨) | +| fields_count | integer | `1` | `fields` 배열의 개수 (집계값) | +| is_active | boolean | `true` | 활성 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| icon | string | `file-alt` | 목록 UI(SortableMenuList)용 고정 아이콘 식별자 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품정보제공고시가 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "의류 고시(수정)", + "en": "Clothing Notice (Updated)" + }, + "localized_name": "의류 고시(수정)", + "category": "clothing", + "fields": [ + { + "label": "품명", + "value": "샘플" + } + ], + "fields_count": 1, + "is_active": true, + "sort_order": 0, + "icon": "file-alt", + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:27", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 수정 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -419,7 +555,7 @@ Content-Type: application/json **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1/copy HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id}/copy HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -427,61 +563,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `2` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 고시템플릿 (복사)","en":"API Doc Sample Notice …` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 고시템플릿 (복사)` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| category | null | `null` | 품목 카테고리 | -| fields | array | `[{"label":"품명","value":"샘플"}]` | 필드 정의 JSON | -| fields_count | integer | `1` | fields 개수 (집계) | -| is_active | boolean | `true` | active 여부 | -| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | -| icon | string | `file-alt` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-08 15:00:27` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 201 -``` - -```json -{ - "success": true, - "message": "상품정보제공고시가 복사되었습니다.", - "data": { - "id": 2, - "name": { - "ko": "API 문서 샘플 고시템플릿 (복사)", - "en": "API Doc Sample Notice Template (Copy)" - }, - "localized_name": "API 문서 샘플 고시템플릿 (복사)", - "category": null, - "fields": [ - { - "label": "품명", - "value": "샘플" - } - ], - "fields_count": 1, - "is_active": true, - "sort_order": 1, - "icon": "file-alt", - "created_at": "2026-07-08 15:00:27", - "updated_at": "2026-07-08 15:00:27", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -511,7 +597,7 @@ HTTP/1.1 201 **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/product-notice-templates/1/toggle-active HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id}/toggle-active HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -519,61 +605,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 고시템플릿","en":"API Doc Sample Notice Templ…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 고시템플릿` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| category | null | `null` | 품목 카테고리 | -| fields | array | `[{"label":"품명","value":"샘플"}]` | 필드 정의 JSON | -| fields_count | integer | `1` | fields 개수 (집계) | -| is_active | boolean | `false` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| icon | string | `file-alt` | 아이콘 식별자 (아이콘 클래스/이름) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:27` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "상품정보제공고시가 비활성화되었습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 고시템플릿", - "en": "API Doc Sample Notice Template" - }, - "localized_name": "API 문서 샘플 고시템플릿", - "category": null, - "fields": [ - { - "label": "품명", - "value": "샘플" - } - ], - "fields_count": 1, - "is_active": false, - "sort_order": 0, - "icon": "file-alt", - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:27", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md index 8464fbfa..a8f591cb 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md @@ -8,14 +8,42 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Products 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` --- +## 목록 응답의 총 건수와 페이지 이동 + +상품 목록(관리자·공개 양쪽)은 총 건수를 상한까지만 셉니다. 상한을 넘으면 응답의 +`pagination` 에 정확도가 함께 실립니다 — `total_relation` 이 `at_least`, `total_is_exact` +가 `false`, `result_cap` 이 적용된 상한값입니다. 이때 `last_page` 는 `null` 이고, +`has_more_pages` 는 그대로 정확하므로 마지막 페이지 점프만 감춰지고 다음 페이지 이동은 +끝까지 열려 있습니다. 상한 이하이면 종전과 동일하게 `total` 이 정확한 값이고 +`total_relation` 은 `exact` 입니다. + +같은 규약이 이 모듈의 다른 목록 응답(주문·쿠폰·마일리지·문의·리뷰 등)에도 적용됩니다. +상세 규약은 [pagination.md](../../../../../docs/backend/pagination.md) 를 참고하세요. + +## 목록 응답의 리뷰 통계 + +`review_count` 와 `rating_avg` 는 상품 표에 저장된 값이 아니라 조회 시 함께 계산되는 집계입니다. +따라서 **그 계산을 수행하는 조회에서만 응답에 실립니다.** + +| 조회 | 리뷰 통계 | +| --- | --- | +| 공개 상품 목록 · 인기 · 신상품 · 최근 본 상품 · 상품 검색 | 실림 | +| 관리자 상품 목록 | 실리지 않음 (화면이 사용하지 않습니다) | + +리뷰가 한 건도 없는 상품은 계산이 수행된 조회에서 `review_count: 0` · `rating_avg: 0.0` 으로 +실립니다. 계산하지 않은 조회에서 두 항목이 **아예 빠지는 것**과는 다릅니다 — 값이 0 인 것과 +값이 없는 것을 구분해야 하므로, 항목이 없을 때를 0 으로 간주하지 마세요. + +--- + ### GET /api/modules/sirsoft-ecommerce/admin/products @@ -45,6 +73,7 @@ | min_stock | query | integer | 아니오 | — | 재고 범위 필터 하한 (재고 수량이 이 값 이상) | | max_stock | query | integer | 아니오 | — | 재고 범위 필터 상한 (재고 수량이 이 값 이하) | | shipping_policy_id | query | integer | 아니오 | — | shipping policy 식별자 | +| with_options | query | boolean | 아니오 | 기본 `false` | 옵션 배열(`options`)을 응답에 포함할지. 기본값에서는 옵션 상세 대신 집계(`options_count`/`options_total_count`/`option_stock_sum`)만 내려갑니다. 화면은 행을 펼칠 때 별도 배치 조회를 씁니다 | | sort_by | query | string | 아니오 | `created_at`, `updated_at`, `selling_price`, `stock_quantity`, `name` | 정렬 기준 필드명 | | sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | | per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | @@ -55,7 +84,7 @@ **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/products?search_field=all&search_keyword=%EC%98%88%EC%8B%9C%EA%B0%92&category_id=1&no_category=1&date_type=%EC%98%88%EC%8B%9C%EA%B0%92&start_date=2026-01-01&end_date=2026-01-01&sales_status=%EC%98%88%EC%8B%9C%EA%B0%92&display_status=%EC%98%88%EC%8B%9C%EA%B0%92&brand_id=1&no_brand=1&tax_status=%EC%98%88%EC%8B%9C%EA%B0%92&price_type=%EC%98%88%EC%8B%9C%EA%B0%92&min_price=1&max_price=1&min_stock=1&max_stock=1&shipping_policy_id=1&sort_by=created_at&sort_order=asc&per_page=1&page=1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/products?search_field=all&search_keyword=%EC%98%88%EC%8B%9C%EA%B0%92&category_id=1&no_category=1&date_type=%EC%98%88%EC%8B%9C%EA%B0%92&start_date=2026-01-01&end_date=2026-01-01&sales_status=%EC%98%88%EC%8B%9C%EA%B0%92&display_status=%EC%98%88%EC%8B%9C%EA%B0%92&brand_id=1&no_brand=1&tax_status=%EC%98%88%EC%8B%9C%EA%B0%92&price_type=%EC%98%88%EC%8B%9C%EA%B0%92&min_price=1&max_price=1&min_stock=1&max_stock=1&shipping_policy_id=1&with_options=1&sort_by=created_at&sort_order=asc&per_page=1&page=1 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -67,45 +96,45 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `115` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | integer | `322` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"eum et quia","en":"tenetur id quae"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | -| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| number | integer | `100` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1831` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"겨울 패딩 점퍼 #100","en":"Winter Padded Jacket #100"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `JK-0100` | 재고관리코드(SKU) | | thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | -| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화된 값) | -| list_price_formatted | string | `112,594원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화된 값) | -| selling_price_formatted | string | `88,949원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| discount_rate | integer | `21` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | -| multi_currency_list_price | object | `{"KRW":{"price":112594,"formatted":"112,594원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | -| multi_currency_selling_price | object | `{"KRW":{"price":88949,"formatted":"88,949원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | -| stock_quantity | integer | `22` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | -| safe_stock_quantity | integer | `12` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | +| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) | +| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) | +| selling_price_formatted | string | `169,000원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | number | `15.5` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":200000,"formatted":"200,000원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":169000,"formatted":"169,000원","is_default…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `213` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `18` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | | is_below_safe_stock | boolean | `false` | below safe stock 여부 | -| option_stock_sum | integer | `51` | 활성 옵션의 재고 합계 (is_active 옵션들의 stock_quantity 총합) | +| option_stock_sum | integer | `213` | 활성 옵션의 재고 합계 (is_active 옵션들의 stock_quantity 총합) | | sales_status | string | `on_sale` | 판매상태 값 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | | sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | sales_status_variant | string | `success` | `sales_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | | display_status | string | `visible` | 전시상태 값 (visible 전시 / hidden 숨김) | | display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | display_status_variant | string | `success` | `display_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | -| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | -| primary_category | string | `바지` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | -| categories_with_path | array | `[]` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | -| brand_name | string | `ASUS` | 브랜드명 (연관 브랜드의 현지화 이름) | -| shipping_policy_id | integer | `31` | shipping policy 식별자 (연관 리소스 참조) | +| categories | array | `[{"id":56,"name":"스포츠","is_primary":0},{"id":57,"name":"축…` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| primary_category | string | `축구` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| categories_with_path | array | `[{"id":56,"path":[{"id":56,"name":"스포츠","slug":"sports"}]…` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | +| brand_name | string | `유니클로` | 브랜드명 (연관 브랜드의 현지화 이름) | +| shipping_policy_id | integer | `444` | shipping policy 식별자 (연관 리소스 참조) | | shipping_policy_name | string | `국내 무료배송` | 배송정책명 (연관 배송정책의 현지화 이름) | | min_purchase_qty | integer | `1` | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | | max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | -| has_options | boolean | `false` | options 여부 | -| options_count | integer | `1` | options 개수 (집계) | -| options | array | `[{"id":1597,"option_code":"OPT-JLNF-2511","option_values"…` | 활성 옵션(SKU) 목록 (각 옵션의 코드·옵션값·가격·재고 등, ProductOptionResource) | +| has_options | boolean | `true` | options 여부 | +| options_count | integer | `9` | options 개수 (집계) | +| options_total_count | integer | `9` | options total 개수 (집계) | | review_count | integer | `0` | review 개수 (집계) | | rating_avg | integer | `0` | 평균 별점 (공개 리뷰 별점 평균, 소수 1자리 반올림) | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| created_at | string | `2026-07-30 23:36:17` | 생성 일시 | +| updated_at | string | `2026-08-05 07:27:04` | 최종 수정 일시 | | is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | | abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -122,369 +151,28 @@ HTTP/1.1 200 "data": { "data": [ { - "number": 3, - "id": 3, + "number": 100, + "id": 1831, "name": { - "ko": "nisi et corrupti", - "en": "suscipit esse id" + "ko": "겨울 패딩 점퍼 #100", + "en": "Winter Padded Jacket #100" }, - "name_localized": "nisi et corrupti", - "product_code": "PROD-YNAZ-3669", - "sku": "SKU-EOQY-1167", - "thumbnail_url": null, - "list_price": 22605, - "list_price_formatted": "22,605원", - "selling_price": 20118, - "selling_price_formatted": "20,118원", - "discount_rate": 11, - "multi_currency_list_price": { - "KRW": { - "price": 22605, - "formatted": "22,605원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 19.21, - "formatted": "$19.21", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2599, - "formatted": "¥2,599", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 131.11, - "formatted": "元131.11", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 17.63, - "formatted": "€17.63", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 20118, - "formatted": "20,118원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 17.1, - "formatted": "$17.10", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2313, - "formatted": "¥2,313", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 116.68, - "formatted": "元116.68", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 15.69, - "formatted": "€15.69", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 164, - "safe_stock_quantity": 8, - "is_below_safe_stock": false, - "option_stock_sum": 24, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "brand_name": null, - "shipping_policy_id": null, - "shipping_policy_name": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "options_count": 1, - "options": [ - { - "id": 1, - "option_code": "OPT-IXWA-1855", - "option_values": [ - { - "key": { - "ko": "색상", - "en": "Color" - }, - "value": { - "ko": "흰색", - "en": "Blue" - } - }, - { - "key": { - "ko": "사이즈", - "en": "Size" - }, - "value": { - "ko": "M", - "en": "M" - } - } - ], - "option_values_localized": { - "색상": "흰색", - "사이즈": "M" - }, - "option_name": { - "ko": "흰색/M", - "en": "Blue/M" - }, - "option_name_localized": "흰색/M", - "price_adjustment": 1000, - "price_adjustment_formatted": "+1,000원", - "price_adjustment_type": "increase", - "list_price": 23605, - "list_price_formatted": "23,605원", - "selling_price": 21118, - "selling_price_formatted": "21,118원", - "final_price": 21118, - "final_price_formatted": "21,118원", - "multi_currency_list_price": { - "KRW": { - "price": 23605, - "formatted": "23,605원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 20.06, - "formatted": "$20.06", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2714, - "formatted": "¥2,714", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 136.91, - "formatted": "元136.91", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 18.41, - "formatted": "€18.41", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 21118, - "formatted": "21,118원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 17.95, - "formatted": "$17.95", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2428, - "formatted": "¥2,428", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 122.48, - "formatted": "元122.48", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 16.47, - "formatted": "€16.47", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 24, - "safe_stock_quantity": 11, - "is_below_safe_stock": false, - "is_sold_out": false, - "is_default": false, - "is_active": true, - "sku": "SKU-OPT-MMYU-1269", - "sort_order": 75 - } - ], - "review_count": 0, - "rating_avg": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } + "name_localized": "겨울 패딩 점퍼 #100", + "product_code": "GGK6A9N8PXNR35OQ", + "...": "(36개 키 생략, 총 41개)" }, { - "number": 2, - "id": 2, + "number": 99, + "id": 1830, "name": { - "ko": "repudiandae et et", - "en": "velit alias distinctio" + "ko": "무선 마우스 세트 #99", + "en": "Wireless Mouse Set #99" }, - "name_localized": "repudiandae et et", - "product_code": "PROD-XSMH-5438", - "sku": "SKU-XXSC-9588", - "thumbnail_url": null, - "list_price": 654947, - "list_price_formatted": "654,947원", - "selling_price": 465012, - "selling_price_formatted": "465,012원", - "discount_rate": 29, - "multi_currency_list_price": { - "KRW": { - "price": 654947, - "formatted": "654,947원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 556.7, - "formatted": "$556.70", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 75318, - "formatted": "¥75,318", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 3798.69, - "formatted": "元3,798.69", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 510.86, - "formatted": "€510.86", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 465012, - "formatted": "465,012원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 395.26, - "formatted": "$395.26", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 53476, - "formatted": "¥53,476", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 2697.07, - "formatted": "元2,697.07", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 362.71, - "formatted": "€362.71", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 161, - "safe_stock_quantity": 49, - "is_below_safe_stock": false, - "option_stock_sum": 0, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "brand_name": null, - "shipping_policy_id": null, - "shipping_policy_name": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "options_count": 0, - "options": [], - "review_count": 0, - "rating_avg": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } + "name_localized": "무선 마우스 세트 #99", + "product_code": "J9HTLXFNIJZHI4B1", + "...": "(36개 키 생략, 총 41개)" }, - "... (총 3건 중 2건 표시)" + "... (총 25건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -492,24 +180,26 @@ HTTP/1.1 200 "can_delete": true }, "statistics": { - "total": 3, + "total": 100, "sales_status": { - "on_sale": 3 + "coming_soon": 2, + "on_sale": 97, + "sold_out": 1 }, "display_status": { - "visible": 3 + "hidden": 2, + "visible": 98 }, - "low_stock_count": 0, + "low_stock_count": 4, "out_of_stock_count": 0 }, "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 4, "per_page": 25, - "total": 3, + "total": 100, "from": 1, - "to": 3, - "has_more_pages": false + "...": "(2개 키 생략, 총 7개)" } } } @@ -662,11 +352,129 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductResource`). 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 상품의 기본 키 (내부 식별자) | +| name | object | `{"ko":"샘플 상품","en":"Sample Product"}` | 상품명 (로케일별 값 객체) | +| name_localized | string | `샘플 상품` | `name` 의 현재 로케일 해석 값 | +| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| categories | array | `[]` | 소속 카테고리 목록 (categories 관계 로드 시) | +| category_ids | array | `[1]` | 소속 카테고리 ID 배열 (categories 관계 로드 시) | +| primary_category_id | integer | `1` | 대표 카테고리 ID (is_primary 카테고리) | +| brand_id | integer | `null` | 브랜드 식별자 | +| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화) | +| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화) | +| discount_rate | integer | `21` | 할인율(%) ((1 - 판매가/정가) × 100) | +| stock_quantity | integer | `22` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `12` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전재고 미만인지 여부 | +| is_stock_consistent | boolean | `true` | 상품 재고와 옵션 재고 합계의 일치 여부 | +| sales_status | string | `on_sale` | 판매상태 (on_sale / suspended / sold_out / coming_soon) | +| sales_status_label | string | `판매중` | `sales_status` 의 현지화 라벨 | +| display_status | string | `visible` | 전시상태 (visible / hidden) | +| display_status_label | string | `전시` | `display_status` 의 현지화 라벨 | +| tax_status | string | `taxable` | 과세여부 (taxable / tax_free) | +| tax_status_label | string | `과세` | `tax_status` 의 현지화 라벨 | +| tax_rate | string | `10.00` | 세율(%) | +| shipping_policy_id | integer | `null` | 배송정책 식별자 | +| common_info_id | integer | `null` | 공통정보 식별자 | +| description | object | `{"ko":"상품 설명","en":"Description"}` | 상세 설명 (로케일별 값 객체) | +| description_localized | string | `상품 설명` | `description` 의 현재 로케일 해석 값 | +| description_mode | string | `text` | 상세 설명 편집 모드 (text / html) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 대상 제한 (none / restricted) | +| allowed_roles | array | `null` | 구매 허용 역할 ID 배열 | +| barcode | string | `null` | 바코드 | +| hs_code | string | `null` | HS 코드 (수출입 관세 분류 코드) | +| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | +| thumbnail_hash | string | `null` | 대표 이미지 해시 | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | +| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | +| meta_keywords | array | `null` | SEO 메타 키워드 배열 | +| seo_sync_title | boolean | `true` | SEO 제목 자동 동기화 여부 | +| seo_sync_description | boolean | `true` | SEO 설명 자동 동기화 여부 | +| has_options | boolean | `false` | 옵션 사용 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의 (예: `[{"name":"색상","values":["빨강","파랑"]}]`) | +| options | array | `[]` | 생성된 옵션(SKU) 목록 (`ProductOptionResource`) | +| additional_options | array | `[]` | 추가옵션 그룹 목록. 각 그룹의 `values[]` 는 `id`, `name`, `price_adjustment`(기본 통화 기준 추가금), `price_adjustment_formatted`, `multi_currency_price_adjustment`(통화별 추가금 — 기본 통화가 아닌 통화로 표시할 때 사용), `is_default`, `allow_custom_text` 를 가집니다 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 | + +> `categories` / `shipping_policy` / `label_assignments` / `notice_items` 는 해당 관계가 eager load 된 경우에만 포함됩니다 (`whenLoaded`). **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "상품이 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "샘플 상품", + "en": "Sample Product" + }, + "name_localized": "샘플 상품", + "product_code": "PROD-GJUX-1484", + "sku": "SKU-MRAD-9306", + "list_price": 112594, + "selling_price": 88949, + "discount_rate": 21, + "stock_quantity": 22, + "safe_stock_quantity": 12, + "is_below_safe_stock": false, + "is_stock_consistent": true, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "display_status": "visible", + "display_status_label": "전시", + "tax_status": "taxable", + "tax_status_label": "과세", + "tax_rate": "10.00", + "shipping_policy_id": null, + "common_info_id": null, + "description": { + "ko": "상품 설명", + "en": "Description" + }, + "description_localized": "상품 설명", + "description_mode": "text", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": null, + "barcode": null, + "hs_code": null, + "thumbnail_hash": null, + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "seo_sync_title": true, + "seo_sync_description": true, + "has_options": false, + "option_groups": [], + "options": [], + "additional_options": [], + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -719,11 +527,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductService::bulkUpdatePrice()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 가격이 변경된 상품 건수 | +| requested_count | integer | `3` | 요청한 `ids` 의 개수 (대상으로 지정한 건수) | + +> 응답 `message` 의 `:count` 는 `updated_count` 로 치환됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 상품의 가격이 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_count": 3 + } +} +``` **에러 응답** @@ -774,11 +602,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductService::bulkUpdateStatus()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 상태가 변경된 상품 건수 | +| requested_count | integer | `3` | 요청한 `ids` 의 개수 (대상으로 지정한 건수) | + +> 응답 `message` 의 `:count` 는 `updated_count` 로 치환됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 상품이 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_count": 3 + } +} +``` **에러 응답** @@ -829,11 +677,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductService::bulkUpdateStock()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 실제로 재고가 변경된 상품 건수 | +| requested_count | integer | `3` | 요청한 `ids` 의 개수 (대상으로 지정한 건수) | + +> 응답 `message` 의 `:count` 는 `updated_count` 로 치환됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 상품의 재고가 수정되었습니다.", + "data": { + "updated_count": 3, + "requested_count": 3 + } +} +``` **에러 응답** @@ -860,8 +728,16 @@ Content-Type: application/json | --- | --- | --- | --- | --- | --- | | ids | body | array | 예 | min 1 | 대상 리소스 식별자 배열 (대량 작업 대상) | | bulk_changes | body | array | 아니오 | — | 상품 조건 기반 일괄 변경값 (지정 시 ids 전체에 sales_status/display_status 일괄 적용) | -| items | body | array | 아니오 | — | 처리 대상 항목 배열 | +| bulk_changes.sales_status | body | string | 아니오 | — | `ids` 전체에 일괄 적용할 판매상태 (지정 시 items 의 개별 sales_status 보다 우선) | +| bulk_changes.display_status | body | string | 아니오 | — | `ids` 전체에 일괄 적용할 전시상태 (지정 시 items 의 개별 display_status 보다 우선) | +| items | body | array | 아니오 | — | 처리 대상 항목 배열 (각 항목: id + 개별 수정할 상품 필드 — name/list_price/selling_price/sales_status/display_status. bulk_changes 로 지정된 필드는 제외됨) | | option_bulk_changes | body | array | 아니오 | — | 옵션 조건 기반 일괄 변경값 (price_adjustment/stock_quantity 를 method+value 로 일괄 조정) | +| option_bulk_changes.price_adjustment | body | array | 아니오 | — | 대상 상품들의 모든 옵션에 적용할 옵션 추가금 일괄 변경 블록 (method + value 쌍) | +| option_bulk_changes.price_adjustment.method | body | string | 아니오 | `set`, `add`, `percent` | 옵션 추가금 변경 방식 (set 지정값으로 설정 / add 기존값에 가산 / percent 비율 적용) | +| option_bulk_changes.price_adjustment.value | body | number | 아니오 | — | 옵션 추가금 변경 값 (method 에 따라 설정값·가산값·비율로 해석) | +| option_bulk_changes.stock_quantity | body | array | 아니오 | — | 대상 상품들의 모든 옵션에 적용할 옵션 재고 일괄 변경 블록 (method + value 쌍) | +| option_bulk_changes.stock_quantity.method | body | string | 아니오 | `set`, `add`, `subtract` | 옵션 재고 변경 방식 (set 지정값으로 설정 / add 증가 / subtract 감소) | +| option_bulk_changes.stock_quantity.value | body | integer | 아니오 | min 0 | 옵션 재고 변경 수량 (method 에 따라 설정 수량·증감 수량으로 해석) | | option_items | body | array | 아니오 | — | 옵션 개별 인라인 수정 배열 (각 항목: product_id·option_id + 수정할 옵션 필드) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.bulk_update_validation_rules`). @@ -882,12 +758,24 @@ Content-Type: application/json "bulk_changes": [ "예시값" ], + "bulk_changes.sales_status": "예시값", + "bulk_changes.display_status": "예시값", "items": [ "예시값" ], "option_bulk_changes": [ "예시값" ], + "option_bulk_changes.price_adjustment": [ + "예시값" + ], + "option_bulk_changes.price_adjustment.method": "set", + "option_bulk_changes.price_adjustment.value": 1, + "option_bulk_changes.stock_quantity": [ + "예시값" + ], + "option_bulk_changes.stock_quantity.method": "set", + "option_bulk_changes.stock_quantity.value": 1, "option_items": [ "예시값" ] @@ -896,11 +784,31 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductService::bulkUpdate()` 반환값)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| products_updated | integer | `3` | 반영된 상품 건수 (bulk_changes 적용 건수 + items 개별 수정 건수) | +| options_updated | integer | `5` | 반영된 옵션 건수 (`ProductOptionService::bulkUpdate()` 결과) | + +> 응답 `message` 의 `:count` 는 `products_updated + options_updated` 합산값으로 치환됩니다. **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "8개 상품이 수정되었습니다.", + "data": { + "products_updated": 3, + "options_updated": 5 + } +} +``` **에러 응답** @@ -930,7 +838,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/products/by-code/APIDOCSAMPLE01 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/products/by-code/{code} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -938,59 +846,9 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| product_code | string | `APIDOCSAMPLE01` | 상품코드 | -| sku | string | `SKU-DKOK-1319` | SKU | -| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | -| category_ids | array | `[]` | category 식별자 배열 (연관 리소스 참조) | -| primary_category_id | null | `null` | primary category 식별자 (연관 리소스 참조) | -| brand_id | null | `null` | brand 식별자 (연관 리소스 참조) | -| list_price | integer | `703155` | 정가 (기본통화 기준) | -| selling_price | integer | `597682` | 판매가 (기본통화 기준) | -| discount_rate | integer | `15` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | -| stock_quantity | integer | `509` | 재고 수량 (옵션 있으면 옵션 합계) | -| safe_stock_quantity | integer | `38` | 안전재고 수량 | -| is_below_safe_stock | boolean | `false` | below safe stock 여부 | -| is_stock_consistent | boolean | `true` | stock consistent 여부 | -| sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | -| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| display_status | string | `visible` | 전시상태: visible(전시), hidden(숨김) | -| display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| tax_status | string | `taxable` | 과세여부: taxable(과세), tax_free(면세) | -| tax_status_label | string | `과세` | `tax_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| tax_rate | string | `10.00` | 세율 (%) | -| shipping_policy_id | null | `null` | shipping policy 식별자 (연관 리소스 참조) | -| common_info_id | null | `null` | common info 식별자 (연관 리소스 참조) | -| description | object | `{"ko":"Tempore et non fugit eos et aperiam dolore. Aut es…` | 설명 (다국어 필드는 로케일별 값 객체) | -| description_localized | string | `Tempore et non fugit eos et aperiam d…` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | -| min_purchase_qty | integer | `1` | 최소 구매 수량 | -| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | -| purchase_restriction | string | `none` | 구매 제한: none(없음), restricted(제한) | -| allowed_roles | null | `null` | 구매 허용 역할 ID 배열 | -| barcode | null | `null` | 바코드 | -| hs_code | null | `null` | HS 코드 (관세 분류) | -| images | array | `[]` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) | -| thumbnail_hash | null | `null` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | -| thumbnail_url | null | `null` | thumbnail URL | -| meta_title | null | `null` | SEO 제목 (다국어 JSON) | -| meta_description | null | `null` | SEO 설명 (다국어 JSON) | -| meta_keywords | null | `null` | SEO 키워드 (배열) | -| seo_sync_title | boolean | `true` | SEO 제목 동기화 여부 (1: 상품명으로 자동 채움, 0: 직접 입력 보존) | -| seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) | -| has_options | boolean | `false` | options 여부 | -| option_groups | array | `[]` | 옵션 그룹 정의: [{name: "색상", values: ["빨강", "파랑"]}] | -| options | array | `[]` | 옵션(SKU) 목록 (수정 폼 바인딩용, 각 옵션의 id·코드·옵션값·가격·재고 등) | -| additional_options | array | `[]` | 추가옵션 그룹 목록 (수정 폼 바인딩용, 그룹명·선택지·추가금 등) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| (ProductResource) | object | `{"id":201,"product_code":"P-201", …}` | 상품 상세 1건. 필드 구성은 `GET /admin/products/{identifier}` 와 동일하며, 목록·옵션·이미지·상품고시·공통정보·추가옵션을 포함합니다 | **응답 예시** @@ -1001,66 +859,18 @@ HTTP/1.1 200 ```json { "success": true, - "message": "상품 정보를 조회했습니다.", + "message": "상품을 조회했습니다.", "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 상품", - "en": "API Doc Sample Product" - }, - "name_localized": "API 문서 샘플 상품", - "product_code": "APIDOCSAMPLE01", - "sku": "SKU-DKOK-1319", - "categories": [], - "category_ids": [], - "primary_category_id": null, - "brand_id": null, - "list_price": 703155, - "selling_price": 597682, - "discount_rate": 15, - "stock_quantity": 509, - "safe_stock_quantity": 38, - "is_below_safe_stock": false, - "is_stock_consistent": true, + "id": 201, + "product_code": "P-201", + "name": { "ko": "티셔츠", "en": "T-Shirt" }, + "selling_price": 19000, + "selling_price_formatted": "19,000원", "sales_status": "on_sale", - "sales_status_label": "판매중", "display_status": "visible", - "display_status_label": "전시", - "tax_status": "taxable", - "tax_status_label": "과세", - "tax_rate": "10.00", - "shipping_policy_id": null, - "common_info_id": null, - "description": { - "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", - "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." - }, - "description_localized": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", - "description_mode": "text", - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "purchase_restriction": "none", - "allowed_roles": null, - "barcode": null, - "hs_code": null, - "images": [], - "thumbnail_hash": null, - "thumbnail_url": null, - "meta_title": null, - "meta_description": null, - "meta_keywords": null, - "seo_sync_title": true, - "seo_sync_description": true, - "has_options": false, - "option_groups": [], "options": [], - "additional_options": [], - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_update": true, - "can_delete": true - } + "images": [], + "additional_options": [] } } ``` @@ -1135,7 +945,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/products/by-code/APIDOCSAMPLE01 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/products/by-code/{code} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1213,11 +1023,125 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품 전체). 필드 구성은 `GET /admin/products/by-code/{code}` 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 상품의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 상품명 (로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 | +| product_code | string | `APIDOCSAMPLE01` | 상품코드 | +| sku | string | `SKU-DKOK-1319` | 재고관리코드(SKU) | +| categories | array | `[]` | 소속 카테고리 목록 (categories 관계 로드 시) | +| category_ids | array | `[]` | 소속 카테고리 ID 배열 | +| primary_category_id | integer | `null` | 대표 카테고리 ID | +| brand_id | integer | `null` | 브랜드 식별자 | +| list_price | integer | `703155` | 정가 (기본통화 기준) | +| selling_price | integer | `597682` | 판매가 (기본통화 기준) | +| discount_rate | integer | `15` | 할인율(%) | +| stock_quantity | integer | `509` | 재고 수량 | +| safe_stock_quantity | integer | `38` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전재고 미만인지 여부 | +| is_stock_consistent | boolean | `true` | 상품 재고와 옵션 재고 합계 일치 여부 | +| sales_status | string | `on_sale` | 판매상태 (on_sale / suspended / sold_out / coming_soon) | +| sales_status_label | string | `판매중` | `sales_status` 의 현지화 라벨 | +| display_status | string | `visible` | 전시상태 (visible / hidden) | +| display_status_label | string | `전시` | `display_status` 의 현지화 라벨 | +| tax_status | string | `taxable` | 과세여부 (taxable / tax_free) | +| tax_status_label | string | `과세` | `tax_status` 의 현지화 라벨 | +| tax_rate | string | `10.00` | 세율(%) | +| shipping_policy_id | integer | `null` | 배송정책 식별자 | +| common_info_id | integer | `null` | 공통정보 식별자 | +| description | object | `{"ko":"상품 설명","en":"Description"}` | 상세 설명 (로케일별 값 객체) | +| description_localized | string | `상품 설명` | `description` 의 현재 로케일 해석 값 | +| description_mode | string | `text` | 상세 설명 편집 모드 (text / html) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 대상 제한 (none / restricted) | +| allowed_roles | array | `null` | 구매 허용 역할 ID 배열 | +| barcode | string | `null` | 바코드 | +| hs_code | string | `null` | HS 코드 | +| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | +| thumbnail_hash | string | `null` | 대표 이미지 해시 | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | +| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | +| meta_keywords | array | `null` | SEO 메타 키워드 배열 | +| seo_sync_title | boolean | `true` | SEO 제목 자동 동기화 여부 | +| seo_sync_description | boolean | `true` | SEO 설명 자동 동기화 여부 | +| has_options | boolean | `false` | 옵션 사용 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의 | +| options | array | `[]` | 옵션(SKU) 목록 (`ProductOptionResource`) | +| additional_options | array | `[]` | 추가옵션 그룹 목록. 각 그룹의 `values[]` 는 `id`, `name`, `price_adjustment`(기본 통화 기준 추가금), `price_adjustment_formatted`, `multi_currency_price_adjustment`(통화별 추가금 — 기본 통화가 아닌 통화로 표시할 때 사용), `is_default`, `allow_custom_text` 를 가집니다 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 11:02:13` | 최종 수정 일시 (이번 수정 시각으로 갱신) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "category_ids": [], + "primary_category_id": null, + "brand_id": null, + "list_price": 703155, + "selling_price": 597682, + "discount_rate": 15, + "stock_quantity": 509, + "safe_stock_quantity": 38, + "is_below_safe_stock": false, + "is_stock_consistent": true, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "display_status": "visible", + "display_status_label": "전시", + "tax_status": "taxable", + "tax_status_label": "과세", + "tax_rate": "10.00", + "shipping_policy_id": null, + "common_info_id": null, + "description_mode": "text", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": null, + "barcode": null, + "hs_code": null, + "thumbnail_hash": null, + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "seo_sync_title": true, + "seo_sync_description": true, + "has_options": false, + "option_groups": [], + "options": [], + "additional_options": [], + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 11:02:13", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -1225,8 +1149,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1254,11 +1178,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| product_code | string | `PROD-GJUX-1484` | `ProductService::generateUniqueCode()` 가 발급한, 기존 상품과 중복되지 않는 신규 상품코드 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품코드가 생성되었습니다.", + "data": { + "product_code": "PROD-GJUX-1484" + } +} +``` **에러 응답** @@ -1286,6 +1226,8 @@ Authorization: Bearer {YOUR_TOKEN} | temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (같은 상품의 여러 이미지를 한 세션으로 묶음, 생략 시 서버가 UUID 자동 발급) | | collection | body | string | 아니오 | — | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | +| alt_text.ko | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `ko` 로케일 값 | +| alt_text.en | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `en` 로케일 값 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-image.filter_upload_validation_rules`). @@ -1314,25 +1256,71 @@ Content-Disposition: form-data; name="collection" ------G7ExampleBoundary Content-Disposition: form-data; name="alt_text" +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text.ko" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text.en" + 예시값 ------G7ExampleBoundary-- ``` **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. FileUploader 컴포넌트 규약에 맞춰 실제 이미지 정보는 `data.data` 로 한 단계 더 감싸져 있습니다. 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data | object | `{ ... }` | 업로드된 이미지 정보 객체 (아래 하위 필드) | +| data.id | integer | `1` | 업로드된 이미지 레코드의 기본 키 | +| data.hash | string | `3f7a1c9e…` | 이미지 해시 (상품 생성/수정 시 `thumbnail_hash` 지정에 사용) | +| data.original_filename | string | `product-main.jpg` | 업로드한 원본 파일명 | +| data.mime_type | string | `image/jpeg` | 파일 MIME 타입 | +| data.size | integer | `204800` | 파일 크기 (바이트) | +| data.size_formatted | string | `200 KB` | 사람이 읽는 파일 크기 문자열 (B/KB/MB/GB) | +| data.download_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 이미지 다운로드/표시 URL | +| data.order | integer | `1` | 이미지 노출 순서 (`sort_order`, 미지정 시 1) | +| data.is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | +| data.is_thumbnail | boolean | `true` | 대표 이미지 여부 (컬렉션의 첫 이미지는 자동 지정) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "이미지가 업로드되었습니다.", + "data": { + "data": { + "id": 1, + "hash": "3f7a1c9e", + "original_filename": "product-main.jpg", + "mime_type": "image/jpeg", + "size": 204800, + "size_formatted": "200 KB", + "download_url": "/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download", + "order": 1, + "is_image": true, + "is_thumbnail": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 파일 저장 등 업로드 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 상품 이미지 개수 상한 초과 (`ProductImageUploadLimitException`) | @@ -1371,16 +1359,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 데이터를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "이미지 순서가 변경되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 순서 갱신 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1405,7 +1404,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/products/images/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/products/images/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1413,23 +1412,110 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 데이터를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "이미지가 삭제되었습니다.", + "data": null +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 400 | Bad Request | 삭제 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | +| 404 | Not Found | `id` 에 해당하는 이미지가 없는 경우 (`messages.product_images.not_found`) | + + + +**설명** 상품 이미지 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::delete()`가 `id`에 해당하는 이미지 레코드와 저장 파일을 제거합니다. 임시 업로드 이미지와 상품에 귀속된 이미지 모두 삭제할 수 있으며, 해당 이미지가 없으면 404를 반환합니다. + + +### GET /api/modules/sirsoft-ecommerce/admin/products/options + +- **라우트명**: `api.modules.sirsoft-ecommerce.admin.products.options` +- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\ProductController@options` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-ecommerce.products.read` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| product_ids | query | array | 예 | min 1, max 100 | product 식별자 배열 | + +**요청 예시** + +```http +GET /api/modules/sirsoft-ecommerce/admin/products/options?product_ids=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| options | object | `{"201":[{"id":1086, …}]}` | 상품 ID(문자열 키) ⇒ 해당 상품의 옵션 목록(ProductOptionResource 배열). 조회 결과가 없어도 JSON 객체(`{}`)로 나갑니다 | +| product_ids | array | `[201, 202]` | 요청한 상품 ID 목록 (응답의 `options` 키 순서 기준) | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품 옵션을 조회했습니다.", + "data": { + "options": { + "201": [ + { + "id": 1086, + "option_code": "OPT-1086", + "option_name": "색상/사이즈", + "option_name_localized": "색상/사이즈", + "selling_price": 19000, + "selling_price_formatted": "19,000원", + "stock_quantity": 30, + "is_active": true + } + ], + "202": [] + }, + "product_ids": [201, 202] + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -**설명** 상품 이미지 1건을 삭제합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::delete()`가 `id`에 해당하는 이미지 레코드와 저장 파일을 제거합니다. 임시 업로드 이미지와 상품에 귀속된 이미지 모두 삭제할 수 있으며, 해당 이미지가 없으면 404를 반환합니다. +**설명** 여러 상품의 옵션을 한 번에 조회합니다. 관리자 상품 목록이 옵션을 기본 적재하지 않게 되면서(`GET /admin/products` 는 개수·재고합계 집계만 반환), 행을 펼칠 때 이 엔드포인트가 옵션 상세를 공급합니다. 단건 라우트만 두면 한 페이지를 전부 펼쳤을 때 100요청이 되므로 배치를 기본형으로 합니다. 상품 수와 무관하게 쿼리 3개(허용 ID 확정 → 옵션 조회 → 옵션 가격 계산용 상품 로드)로 처리됩니다. + +이 엔드포인트는 **부분 성공**을 계약으로 합니다. 존재하지 않거나 권한 스코프 밖인 상품 ID 는 404 를 만들지 않고 응답의 `product_ids` 에서 빠집니다 — 하나가 없다고 나머지의 정상 조회를 버릴 이유가 없고, 배치에서 "무엇이 없었는지" 는 상태코드로 표현할 수 없기 때문입니다. 단건 404 semantics 가 필요하면 `GET /admin/products/{identifier}` 를 사용합니다. 권한 스코프는 옵션 테이블이 아니라 상품 쿼리에서 먼저 적용되므로, `scope=self` 사용자가 타인 상품 ID 를 실어 보내도 옵션이 노출되지 않습니다. + +라우트는 `/{identifier}` 보다 위에 등록해야 합니다 — identifier 제약이 `[0-9a-zA-Z]+` 라 리터럴 `options` 를 삼켜 상품 상세로 잘못 라우팅됩니다. ### GET /api/modules/sirsoft-ecommerce/admin/products/{identifier} @@ -1447,7 +1533,7 @@ Authorization: Bearer {YOUR_TOKEN} **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/products/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/products/{identifier} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1455,61 +1541,9 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| product_code | string | `APIDOCSAMPLE01` | 상품코드 | -| sku | string | `SKU-DKOK-1319` | SKU | -| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | -| category_ids | array | `[]` | category 식별자 배열 (연관 리소스 참조) | -| primary_category_id | null | `null` | primary category 식별자 (연관 리소스 참조) | -| brand_id | null | `null` | brand 식별자 (연관 리소스 참조) | -| list_price | integer | `703155` | 정가 (기본통화 기준) | -| selling_price | integer | `597682` | 판매가 (기본통화 기준) | -| discount_rate | integer | `15` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | -| stock_quantity | integer | `509` | 재고 수량 (옵션 있으면 옵션 합계) | -| safe_stock_quantity | integer | `38` | 안전재고 수량 | -| is_below_safe_stock | boolean | `false` | below safe stock 여부 | -| is_stock_consistent | boolean | `true` | stock consistent 여부 | -| sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | -| sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| display_status | string | `visible` | 전시상태: visible(전시), hidden(숨김) | -| display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| tax_status | string | `taxable` | 과세여부: taxable(과세), tax_free(면세) | -| tax_status_label | string | `과세` | `tax_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| tax_rate | string | `10.00` | 세율 (%) | -| shipping_policy_id | null | `null` | shipping policy 식별자 (연관 리소스 참조) | -| common_info_id | null | `null` | common info 식별자 (연관 리소스 참조) | -| description | object | `{"ko":"Tempore et non fugit eos et aperiam dolore. Aut es…` | 설명 (다국어 필드는 로케일별 값 객체) | -| description_localized | string | `Tempore et non fugit eos et aperiam d…` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | -| min_purchase_qty | integer | `1` | 최소 구매 수량 | -| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | -| purchase_restriction | string | `none` | 구매 제한: none(없음), restricted(제한) | -| allowed_roles | null | `null` | 구매 허용 역할 ID 배열 | -| barcode | null | `null` | 바코드 | -| hs_code | null | `null` | HS 코드 (관세 분류) | -| label_assignments | array | `[]` | 라벨 할당 목록 (각 항목: label_id + 노출 시작/종료일) | -| notice_items | null | `null` | 상품정보제공고시 항목 목록 (각 항목: 항목명·내용 다국어) | -| images | array | `[]` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) | -| thumbnail_hash | null | `null` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | -| thumbnail_url | null | `null` | thumbnail URL | -| meta_title | null | `null` | SEO 제목 (다국어 JSON) | -| meta_description | null | `null` | SEO 설명 (다국어 JSON) | -| meta_keywords | null | `null` | SEO 키워드 (배열) | -| seo_sync_title | boolean | `true` | SEO 제목 동기화 여부 (1: 상품명으로 자동 채움, 0: 직접 입력 보존) | -| seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) | -| has_options | boolean | `false` | options 여부 | -| option_groups | array | `[]` | 옵션 그룹 정의: [{name: "색상", values: ["빨강", "파랑"]}] | -| options | array | `[]` | 옵션(SKU) 목록 (수정 폼 바인딩용, 각 옵션의 id·코드·옵션값·가격·재고 등) | -| additional_options | array | `[]` | 추가옵션 그룹 목록 (수정 폼 바인딩용, 그룹명·선택지·추가금 등) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| (ProductResource) | object | `{"id":201,"product_code":"P-201", …}` | 상품 상세 1건. 필드 구성은 `GET /admin/products/{identifier}` 와 동일하며, 목록·옵션·이미지·상품고시·공통정보·추가옵션을 포함합니다 | **응답 예시** @@ -1520,68 +1554,18 @@ HTTP/1.1 200 ```json { "success": true, - "message": "상품 정보를 조회했습니다.", + "message": "상품을 조회했습니다.", "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 상품", - "en": "API Doc Sample Product" - }, - "name_localized": "API 문서 샘플 상품", - "product_code": "APIDOCSAMPLE01", - "sku": "SKU-DKOK-1319", - "categories": [], - "category_ids": [], - "primary_category_id": null, - "brand_id": null, - "list_price": 703155, - "selling_price": 597682, - "discount_rate": 15, - "stock_quantity": 509, - "safe_stock_quantity": 38, - "is_below_safe_stock": false, - "is_stock_consistent": true, + "id": 201, + "product_code": "P-201", + "name": { "ko": "티셔츠", "en": "T-Shirt" }, + "selling_price": 19000, + "selling_price_formatted": "19,000원", "sales_status": "on_sale", - "sales_status_label": "판매중", "display_status": "visible", - "display_status_label": "전시", - "tax_status": "taxable", - "tax_status_label": "과세", - "tax_rate": "10.00", - "shipping_policy_id": null, - "common_info_id": null, - "description": { - "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", - "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." - }, - "description_localized": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", - "description_mode": "text", - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "purchase_restriction": "none", - "allowed_roles": null, - "barcode": null, - "hs_code": null, - "label_assignments": [], - "notice_items": null, - "images": [], - "thumbnail_hash": null, - "thumbnail_url": null, - "meta_title": null, - "meta_description": null, - "meta_keywords": null, - "seo_sync_title": true, - "seo_sync_description": true, - "has_options": false, - "option_groups": [], "options": [], - "additional_options": [], - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_update": true, - "can_delete": true - } + "images": [], + "additional_options": [] } } ``` @@ -1614,13 +1598,15 @@ HTTP/1.1 200 | temp_key | body | string | 아니오 | max 64 | 임시 업로드 세션 키 (같은 상품의 여러 이미지를 한 세션으로 묶음, 생략 시 서버가 UUID 자동 발급) | | collection | body | string | 아니오 | — | 첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default) | | alt_text | body | array | 아니오 | — | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) | +| alt_text.ko | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `ko` 로케일 값 | +| alt_text.en | body | string | 아니오 | max 255 | 이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구) — `en` 로케일 값 | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product-image.filter_upload_validation_rules`). **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/admin/products/1/images HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/admin/products/{productId}/images HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1642,26 +1628,72 @@ Content-Disposition: form-data; name="collection" ------G7ExampleBoundary Content-Disposition: form-data; name="alt_text" +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text.ko" + +예시값 +------G7ExampleBoundary +Content-Disposition: form-data; name="alt_text.en" + 예시값 ------G7ExampleBoundary-- ``` **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. FileUploader 컴포넌트 규약에 맞춰 실제 이미지 정보는 `data.data` 로 한 단계 더 감싸져 있습니다. 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| data | object | `{ ... }` | 업로드된 이미지 정보 객체 (아래 하위 필드) | +| data.id | integer | `1` | 업로드된 이미지 레코드의 기본 키 | +| data.hash | string | `3f7a1c9e…` | 이미지 해시 (상품 수정 시 `thumbnail_hash` 지정에 사용) | +| data.original_filename | string | `product-main.jpg` | 업로드한 원본 파일명 | +| data.mime_type | string | `image/jpeg` | 파일 MIME 타입 | +| data.size | integer | `204800` | 파일 크기 (바이트) | +| data.size_formatted | string | `200 KB` | 사람이 읽는 파일 크기 문자열 (B/KB/MB/GB) | +| data.download_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 이미지 다운로드/표시 URL | +| data.order | integer | `1` | 이미지 노출 순서 (`sort_order`) | +| data.is_image | boolean | `true` | MIME 타입이 `image/` 로 시작하는지 여부 | +| data.is_thumbnail | boolean | `true` | 대표 이미지 여부 (컬렉션의 첫 이미지는 자동 지정) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "이미지가 업로드되었습니다.", + "data": { + "data": { + "id": 1, + "hash": "3f7a1c9e", + "original_filename": "product-main.jpg", + "mime_type": "image/jpeg", + "size": 204800, + "size_formatted": "200 KB", + "download_url": "/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download", + "order": 1, + "is_image": true, + "is_thumbnail": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 파일 저장 등 업로드 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 상품 이미지 개수 상한 초과 (`ProductImageUploadLimitException`) | @@ -1684,7 +1716,7 @@ Content-Disposition: form-data; name="alt_text" **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/products/1/images/{imageId}/thumbnail HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/products/{productId}/images/{imageId}/thumbnail HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1692,23 +1724,34 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 데이터를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "대표 이미지가 설정되었습니다.", + "data": null +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 대표 이미지 설정 처리 중 오류가 발생한 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | -**설명** 상품의 대표(썸네일) 이미지를 지정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::setThumbnail()`가 같은 상품의 기존 대표 이미지의 `is_thumbnail`을 해제하고 `imageId` 이미지에 대표 플래그를 부여합니다. 목록·상세에서 노출될 기본 이미지를 교체할 때 사용하며, 지정 대상 상품/이미지가 없으면 404를 반환합니다. +**설명** 상품의 대표(썸네일) 이미지를 지정합니다. `auth:sanctum` + `sirsoft-ecommerce.products.update` 권한이 필요하며, `ProductImageService::setThumbnail()`가 같은 상품의 기존 대표 이미지의 `is_thumbnail`을 해제하고 `imageId` 이미지에 대표 플래그를 부여합니다. 목록·상세에서 노출될 기본 이미지를 교체할 때 사용하며, 지정 대상 상품/이미지가 없으면 404를 반환합니다. `{imageId}`는 경로의 `{productId}`에 속한 이미지여야 합니다. 다른 상품의 이미지 ID를 지정하면 400을 반환하며, 두 상품의 대표 이미지 상태는 모두 그대로 유지됩니다. ### DELETE /api/modules/sirsoft-ecommerce/admin/products/{product} @@ -1726,7 +1769,7 @@ Authorization: Bearer {YOUR_TOKEN} **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/products/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/products/{product} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1734,11 +1777,9 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| deleted | boolean | `true` | 삭제 성공 여부 (상품이 정상 삭제되면 true) | +| deleted | boolean | `true` | 삭제 성공 여부. 주문 이력이 있는 상품은 삭제되지 않고 409 로 차단됩니다 | **응답 예시** @@ -1826,7 +1867,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/products/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/products/{product} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1904,11 +1945,125 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품 전체). 필드 구성은 `GET /admin/products/by-code/{code}` 의 응답 필드 표와 동일합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 상품의 기본 키 (내부 식별자) | +| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 상품명 (로케일별 값 객체) | +| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 | +| product_code | string | `APIDOCSAMPLE01` | 상품코드 | +| sku | string | `SKU-DKOK-1319` | 재고관리코드(SKU) | +| categories | array | `[]` | 소속 카테고리 목록 (categories 관계 로드 시) | +| category_ids | array | `[]` | 소속 카테고리 ID 배열 | +| primary_category_id | integer | `null` | 대표 카테고리 ID | +| brand_id | integer | `null` | 브랜드 식별자 | +| list_price | integer | `703155` | 정가 (기본통화 기준) | +| selling_price | integer | `597682` | 판매가 (기본통화 기준) | +| discount_rate | integer | `15` | 할인율(%) | +| stock_quantity | integer | `509` | 재고 수량 | +| safe_stock_quantity | integer | `38` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전재고 미만인지 여부 | +| is_stock_consistent | boolean | `true` | 상품 재고와 옵션 재고 합계 일치 여부 | +| sales_status | string | `on_sale` | 판매상태 (on_sale / suspended / sold_out / coming_soon) | +| sales_status_label | string | `판매중` | `sales_status` 의 현지화 라벨 | +| display_status | string | `visible` | 전시상태 (visible / hidden) | +| display_status_label | string | `전시` | `display_status` 의 현지화 라벨 | +| tax_status | string | `taxable` | 과세여부 (taxable / tax_free) | +| tax_status_label | string | `과세` | `tax_status` 의 현지화 라벨 | +| tax_rate | string | `10.00` | 세율(%) | +| shipping_policy_id | integer | `null` | 배송정책 식별자 | +| common_info_id | integer | `null` | 공통정보 식별자 | +| description | object | `{"ko":"상품 설명","en":"Description"}` | 상세 설명 (로케일별 값 객체) | +| description_localized | string | `상품 설명` | `description` 의 현재 로케일 해석 값 | +| description_mode | string | `text` | 상세 설명 편집 모드 (text / html) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| purchase_restriction | string | `none` | 구매 대상 제한 (none / restricted) | +| allowed_roles | array | `null` | 구매 허용 역할 ID 배열 | +| barcode | string | `null` | 바코드 | +| hs_code | string | `null` | HS 코드 | +| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) | +| thumbnail_hash | string | `null` | 대표 이미지 해시 | +| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL | +| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) | +| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) | +| meta_keywords | array | `null` | SEO 메타 키워드 배열 | +| seo_sync_title | boolean | `true` | SEO 제목 자동 동기화 여부 | +| seo_sync_description | boolean | `true` | SEO 설명 자동 동기화 여부 | +| has_options | boolean | `false` | 옵션 사용 여부 | +| option_groups | array | `[]` | 옵션 그룹 정의 | +| options | array | `[]` | 옵션(SKU) 목록 (`ProductOptionResource`) | +| additional_options | array | `[]` | 추가옵션 그룹 목록. 각 그룹의 `values[]` 는 `id`, `name`, `price_adjustment`(기본 통화 기준 추가금), `price_adjustment_formatted`, `multi_currency_price_adjustment`(통화별 추가금 — 기본 통화가 아닌 통화로 표시할 때 사용), `is_default`, `allow_custom_text` 를 가집니다 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 11:02:13` | 최종 수정 일시 (이번 수정 시각으로 갱신) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 상품", + "en": "API Doc Sample Product" + }, + "name_localized": "API 문서 샘플 상품", + "product_code": "APIDOCSAMPLE01", + "sku": "SKU-DKOK-1319", + "category_ids": [], + "primary_category_id": null, + "brand_id": null, + "list_price": 703155, + "selling_price": 597682, + "discount_rate": 15, + "stock_quantity": 509, + "safe_stock_quantity": 38, + "is_below_safe_stock": false, + "is_stock_consistent": true, + "sales_status": "on_sale", + "sales_status_label": "판매중", + "display_status": "visible", + "display_status_label": "전시", + "tax_status": "taxable", + "tax_status_label": "과세", + "tax_rate": "10.00", + "shipping_policy_id": null, + "common_info_id": null, + "description_mode": "text", + "min_purchase_qty": 1, + "max_purchase_qty": 0, + "purchase_restriction": "none", + "allowed_roles": null, + "barcode": null, + "hs_code": null, + "thumbnail_hash": null, + "thumbnail_url": null, + "meta_title": null, + "meta_description": null, + "meta_keywords": null, + "seo_sync_title": true, + "seo_sync_description": true, + "has_options": false, + "option_groups": [], + "options": [], + "additional_options": [], + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 11:02:13", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -1916,8 +2071,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -1939,7 +2094,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/products/1/can-delete HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/products/{product}/can-delete HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -1952,8 +2107,8 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | canDelete | boolean | `false` | 삭제 가능 여부 (true 삭제 가능 / false 주문 이력 등으로 삭제 불가) | -| reason | string | `이 상품은 5건의 주문 이력이 있어 삭제할 수 없습니다.` | 삭제 불가 사유 (canDelete=false 일 때 안내 문구) | -| relatedData | object | `{"orders":5,"images":4,"options":3,"additionalOptions":0,…` | 연관 데이터 건수 (orders/images/options 등 상품에 연결된 하위 데이터 수) | +| reason | string | `이 상품은 8건의 주문 이력이 있어 삭제할 수 없습니다.` | 삭제 불가 사유 (canDelete=false 일 때 안내 문구) | +| relatedData | object | `{"orders":8,"images":4,"options":3,"additionalOptions":0,…` | 연관 데이터 건수 (orders/images/options 등 상품에 연결된 하위 데이터 수) | **응답 예시** @@ -1966,14 +2121,14 @@ HTTP/1.1 200 "success": true, "message": "삭제 가능 여부를 확인했습니다.", "data": { - "canDelete": true, - "reason": null, + "canDelete": false, + "reason": "이 상품은 8건의 주문 이력이 있어 삭제할 수 없습니다.", "relatedData": { - "orders": 0, - "images": 0, - "options": 0, + "orders": 8, + "images": 4, + "options": 3, "additionalOptions": 0, - "labelAssignments": 0 + "labelAssignments": 2 } } } @@ -2003,11 +2158,24 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | product | path | string | 예 | — | 대상 product의 식별자 | +| copy_images | query | boolean | 아니오 | — | 상품 이미지 복사 여부 | +| copy_options | query | boolean | 아니오 | — | 옵션 복사 여부 | +| copy_categories | query | boolean | 아니오 | — | 카테고리 연결 복사 여부 | +| copy_sales_info | query | boolean | 아니오 | — | 판매 정보(가격·재고·상태) 복사 여부 | +| copy_description | query | boolean | 아니오 | — | 상세 설명 복사 여부 | +| copy_notice | query | boolean | 아니오 | — | 상품 공지 복사 여부 | +| copy_common_info | query | boolean | 아니오 | — | 상품 공통정보 복사 여부 | +| copy_other_info | query | boolean | 아니오 | — | 기타 정보 복사 여부 | +| copy_shipping | query | boolean | 아니오 | — | 배송 정책 복사 여부 | +| copy_seo | query | boolean | 아니오 | — | SEO 메타 복사 여부 — 메타 제목·설명은 상품마다 달라야 검색 결과에서 구분되므로 기본 제외 | +| copy_identification | query | boolean | 아니오 | — | 식별 정보(SKU·상품코드 등) 복사 여부 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.show_for_copy_validation_rules`). **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/products/1/copy HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/products/{product}/copy HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -2020,30 +2188,30 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | name | object | `{"ko":"면 손수건 3매입 #1","en":"Cotton Handkerchief 3pcs #1"}` | 상품명 (다국어 JSON: {ko: "...", en: "..."}) | -| product_code | string | `BP747N3QSSSNEPII` | 상품코드 | +| product_code | string | `4D6NNDBJOL08X291` | 상품코드 | | sales_product_code | null | `null` | 판매자 상품코드 (사용자 입력용) | | sku | string | `HK-0001` | SKU | -| brand_id | integer | `91` | 브랜드 ID | -| category_ids | array | `[114,116]` | category 식별자 배열 (연관 리소스 참조) | -| primary_category_id | integer | `116` | primary category 식별자 (연관 리소스 참조) | -| list_price | string | `4000.00` | 정가 (기본통화 기준) | -| selling_price | string | `2000.00` | 판매가 (기본통화 기준) | -| stock_quantity | integer | `59` | 재고 수량 (옵션 있으면 옵션 합계) | -| safe_stock_quantity | integer | `15` | 안전재고 수량 | +| brand_id | integer | `7` | 브랜드 ID | +| category_ids | array | `[29,30,32]` | category 식별자 배열 (연관 리소스 참조) | +| primary_category_id | integer | `32` | primary category 식별자 (연관 리소스 참조) | +| list_price | string | `5000.00` | 정가 (기본통화 기준) | +| selling_price | string | `3000.00` | 판매가 (기본통화 기준) | +| stock_quantity | integer | `26` | 재고 수량 (옵션 있으면 옵션 합계) | +| safe_stock_quantity | integer | `5` | 안전재고 수량 | | tax_status | string | `taxable` | 과세여부: taxable(과세), tax_free(면세) | | sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | | display_status | string | `visible` | 전시상태: visible(전시), hidden(숨김) | -| options | array | `[{"option_code":"AS2HM7CEDFHEGS43-001","option_name":{"ko…` | 복사 대상 옵션(SKU) 목록 (신규 등록 폼에 채울 옵션 정의, 다중통화 가격 포함) | +| options | array | `[{"option_code":"CJTFHBL8SLRQ8ILM-001","option_name":{"ko…` | 복사 대상 옵션(SKU) 목록 (신규 등록 폼에 채울 옵션 정의, 다중통화 가격 포함) | | additional_options | array | `[]` | 복사 대상 추가옵션 그룹 목록 (그룹명·선택지·추가금 등) | -| images | array | `[{"hash":"7858be3cf217","url":null,"original_filename":"p…` | 복사 대상 이미지 목록 (각 항목: hash·원본파일명 등, copy_images 선택 시 포함) | -| thumbnail_hash | string | `7858be3cf217` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | +| images | array | `[{"hash":"7df7761cdf16","url":null,"original_filename":"p…` | 복사 대상 이미지 목록 (각 항목: hash·원본파일명 등, copy_images 선택 시 포함) | +| thumbnail_hash | string | `7df7761cdf16` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | | description | object | `{"ko":"

부드러운 면 100% 손수건 3매 세트입니다.<\/p>","en":"

A set …` | 상세 설명 (다국어 JSON, HTML 포함) | | description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | -| notice_items | array | `[{"name":{"ko":"제품 소재 (충전재 포함)","en":"Material (Including…` | 상품정보제공고시 항목 목록 (각 항목: 항목명·내용 다국어) | -| shipping_policy_id | integer | `31` | 배송정책 ID | -| shipping_policy | object | `{"id":31,"name":{"ko":"국내 무료배송","en":"Domestic Free Shipp…` | 현재 부여된 배송정책 객체 (비활성 포함 — 수정폼 활성 목록에 없을 때 union 표시용) | -| common_info_id | integer | `207` | 공통정보 템플릿 ID | -| label_assignments | array | `[{"label_id":26,"start_date":null,"end_date":null}]` | 라벨 할당 목록 (각 항목: label_id + 노출 시작/종료일) | +| notice_items | array | `[{"name":{"ko":"항목1","en":"Field 1"},"content":{"ko":"상세페…` | 상품정보제공고시 항목 목록 (각 항목: 항목명·내용 다국어) | +| shipping_policy_id | integer | `444` | 배송정책 ID | +| shipping_policy | object | `{"id":444,"name":{"ko":"국내 무료배송","en":"Domestic Free Ship…` | 현재 부여된 배송정책 객체 (비활성 포함 — 수정폼 활성 목록에 없을 때 union 표시용) | +| common_info_id | integer | `11` | 공통정보 템플릿 ID | +| label_assignments | array | `[{"label_id":31,"start_date":null,"end_date":null},{"labe…` | 라벨 할당 목록 (각 항목: label_id + 노출 시작/종료일) | | min_purchase_qty | integer | `1` | 최소 구매 수량 | | max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | | purchase_restriction | string | `none` | 구매 제한: none(없음), restricted(제한) | @@ -2056,7 +2224,7 @@ _단건 응답: `data` 객체의 필드._ | barcode | null | `null` | 바코드 | | hs_code | null | `null` | HS 코드 (관세 분류) | | thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | -| categories | array | `[{"id":114,"name":{"ko":"스포츠","en":"Sports"},"name_locali…` | 소속 카테고리 목록 (breadcrumb 포함 — 복사 폼의 카테고리 표시용) | +| categories | array | `[{"id":29,"name":{"ko":"의류","en":"Clothing"},"name_locali…` | 소속 카테고리 목록 (breadcrumb 포함 — 복사 폼의 카테고리 표시용) | **응답 예시** @@ -2070,48 +2238,14 @@ HTTP/1.1 200 "message": "상품 정보를 조회했습니다.", "data": { "name": { - "ko": "API 문서 샘플 상품", - "en": "API Doc Sample Product" + "ko": "면 손수건 3매입 #1", + "en": "Cotton Handkerchief 3pcs #1" }, - "product_code": "WLA9NKCCGO7026T5", + "product_code": "4D6NNDBJOL08X291", "sales_product_code": null, - "sku": "SKU-DKOK-1319", - "brand_id": null, - "category_ids": [], - "primary_category_id": null, - "list_price": "703155.00", - "selling_price": "597682.00", - "stock_quantity": 509, - "safe_stock_quantity": 38, - "tax_status": "taxable", - "sales_status": "on_sale", - "display_status": "visible", - "options": [], - "additional_options": [], - "images": [], - "thumbnail_hash": null, - "description": { - "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", - "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." - }, - "description_mode": "text", - "notice_items": null, - "shipping_policy_id": null, - "shipping_policy": null, - "common_info_id": null, - "label_assignments": [], - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "purchase_restriction": "none", - "allowed_roles": [], - "meta_title": null, - "meta_description": null, - "seo_tags": [], - "seo_sync_title": true, - "seo_sync_description": true, - "barcode": null, - "hs_code": null, - "thumbnail_url": null + "sku": "HK-0001", + "brand_id": 7, + "...": "(33개 키 생략, 총 38개)" } } ``` @@ -2122,6 +2256,7 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -2144,7 +2279,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/products/1/form HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/products/{product}/form HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -2156,40 +2291,40 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `201` | 기본 키 (내부 식별자) | +| id | integer | `1732` | 기본 키 (내부 식별자) | | name | object | `{"ko":"면 손수건 3매입 #1","en":"Cotton Handkerchief 3pcs #1"}` | 상품명 (다국어 JSON: {ko: "...", en: "..."}) | -| product_code | string | `AS2HM7CEDFHEGS43` | 상품코드 | +| product_code | string | `CJTFHBL8SLRQ8ILM` | 상품코드 | | sales_product_code | null | `null` | 판매자 상품코드 (사용자 입력용) | | sku | string | `HK-0001` | SKU | -| brand_id | integer | `91` | 브랜드 ID | -| category_ids | array | `[114,116]` | category 식별자 배열 (연관 리소스 참조) | -| primary_category_id | integer | `116` | primary category 식별자 (연관 리소스 참조) | -| created_at | string | `2026-06-15 02:24:15` | 생성 일시 | -| updated_at | string | `2026-06-15 02:24:15` | 최종 수정 일시 | -| list_price | string | `4000.00` | 정가 (기본통화 기준) | -| selling_price | string | `2000.00` | 판매가 (기본통화 기준) | -| stock_quantity | integer | `59` | 재고 수량 (옵션 있으면 옵션 합계) | -| safe_stock_quantity | integer | `15` | 안전재고 수량 | +| brand_id | integer | `7` | 브랜드 ID | +| category_ids | array | `[29,30,32]` | category 식별자 배열 (연관 리소스 참조) | +| primary_category_id | integer | `32` | primary category 식별자 (연관 리소스 참조) | +| created_at | string | `2026-07-30 14:36:13` | 생성 일시 | +| updated_at | string | `2026-07-30 14:36:13` | 최종 수정 일시 | +| list_price | string | `5000.00` | 정가 (기본통화 기준) | +| selling_price | string | `3000.00` | 판매가 (기본통화 기준) | +| stock_quantity | integer | `26` | 재고 수량 (옵션 있으면 옵션 합계) | +| safe_stock_quantity | integer | `5` | 안전재고 수량 | | tax_status | string | `taxable` | 과세여부: taxable(과세), tax_free(면세) | | sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | | display_status | string | `visible` | 전시상태: visible(전시), hidden(숨김) | -| options | array | `[{"id":1086,"option_code":"AS2HM7CEDFHEGS43-001","option_…` | 옵션(SKU) 목록 (수정 폼 바인딩용, 각 옵션의 id·코드·옵션값·가격·재고 등) | +| options | array | `[{"id":948,"option_code":"CJTFHBL8SLRQ8ILM-001","option_n…` | 옵션(SKU) 목록 (수정 폼 바인딩용, 각 옵션의 id·코드·옵션값·가격·재고 등) | | additional_options | array | `[]` | 추가옵션 그룹 목록 (수정 폼 바인딩용, 그룹명·선택지·추가금 등) | -| images | array | `[{"id":801,"hash":"7858be3cf217","url":null,"original_fil…` | 이미지 목록 (각 항목: id·hash·원본파일명 등) | -| thumbnail_hash | string | `7858be3cf217` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | +| images | array | `[{"id":7,"hash":"7df7761cdf16","url":null,"original_filen…` | 이미지 목록 (각 항목: id·hash·원본파일명 등) | +| thumbnail_hash | string | `7df7761cdf16` | 대표 이미지 해시 (썸네일로 지정된 이미지의 hash) | | description | object | `{"ko":"

부드러운 면 100% 손수건 3매 세트입니다.<\/p>","en":"

A set …` | 상세 설명 (다국어 JSON, HTML 포함) | | description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | -| notice_items | array | `[{"name":{"ko":"제품 소재 (충전재 포함)","en":"Material (Including…` | 상품정보제공고시 항목 목록 (각 항목: 항목명·내용 다국어) | -| shipping_policy_id | integer | `31` | 배송정책 ID | -| shipping_policy | object | `{"id":31,"name":{"ko":"국내 무료배송","en":"Domestic Free Shipp…` | 현재 부여된 배송정책 객체 (비활성 포함 — 수정폼 활성 목록에 없을 때 union 표시용) | -| common_info_id | integer | `207` | 공통정보 템플릿 ID | -| label_assignments | array | `[{"label_id":26,"start_date":null,"end_date":null}]` | 라벨 할당 목록 (각 항목: label_id + 노출 시작/종료일) | +| notice_items | array | `[{"name":{"ko":"항목1","en":"Field 1"},"content":{"ko":"상세페…` | 상품정보제공고시 항목 목록 (각 항목: 항목명·내용 다국어) | +| shipping_policy_id | integer | `444` | 배송정책 ID | +| shipping_policy | object | `{"id":444,"name":{"ko":"국내 무료배송","en":"Domestic Free Ship…` | 현재 부여된 배송정책 객체 (비활성 포함 — 수정폼 활성 목록에 없을 때 union 표시용) | +| common_info_id | integer | `11` | 공통정보 템플릿 ID | +| label_assignments | array | `[{"label_id":31,"start_date":null,"end_date":null},{"labe…` | 라벨 할당 목록 (각 항목: label_id + 노출 시작/종료일) | | min_purchase_qty | integer | `1` | 최소 구매 수량 | | max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | | purchase_restriction | string | `none` | 구매 제한: none(없음), restricted(제한) | | allowed_roles | array | `[]` | 구매 허용 역할 ID 배열 | | meta_title | null | `null` | SEO 제목 (다국어 JSON) | -| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| meta_description | object | `{"ko":"면 손수건 3매입 #1 의 직접 입력 SEO 설명입니다.","en":"Custom SEO …` | SEO 설명 (다국어 JSON) | | seo_tags | array | `[]` | SEO 태그 목록 (메타 키워드 등 검색엔진 노출용 태그) | | seo_sync_title | boolean | `true` | SEO 제목 동기화 여부 (1: 상품명으로 자동 채움, 0: 직접 입력 보존) | | seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) | @@ -2207,51 +2342,15 @@ HTTP/1.1 200 "success": true, "message": "상품 정보를 조회했습니다.", "data": { - "id": 1, + "id": 1732, "name": { - "ko": "API 문서 샘플 상품", - "en": "API Doc Sample Product" + "ko": "면 손수건 3매입 #1", + "en": "Cotton Handkerchief 3pcs #1" }, - "product_code": "APIDOCSAMPLE01", + "product_code": "CJTFHBL8SLRQ8ILM", "sales_product_code": null, - "sku": "SKU-DKOK-1319", - "brand_id": null, - "category_ids": [], - "primary_category_id": null, - "created_at": "2026-07-08 01:44:49", - "updated_at": "2026-07-08 01:44:49", - "list_price": "703155.00", - "selling_price": "597682.00", - "stock_quantity": 509, - "safe_stock_quantity": 38, - "tax_status": "taxable", - "sales_status": "on_sale", - "display_status": "visible", - "options": [], - "additional_options": [], - "images": [], - "thumbnail_hash": null, - "description": { - "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", - "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." - }, - "description_mode": "text", - "notice_items": null, - "shipping_policy_id": null, - "shipping_policy": null, - "common_info_id": null, - "label_assignments": [], - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "purchase_restriction": "none", - "allowed_roles": [], - "meta_title": null, - "meta_description": null, - "seo_tags": [], - "seo_sync_title": true, - "seo_sync_description": true, - "barcode": null, - "hs_code": null + "sku": "HK-0001", + "...": "(34개 키 생략, 총 39개)" } } ``` @@ -2280,11 +2379,15 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | product | path | string | 예 | — | 대상 product의 식별자 | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 로그 수 | +| sort_order | query | string | 아니오 | — | 작성 시각 정렬 방향 | + +> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.product.logs_validation_rules`). **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/products/1/logs HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/products/{product}/logs HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -2296,25 +2399,25 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `151066` | 기본 키 (내부 식별자) | -| log_type | string | `admin` | 로그 구분 값 (admin 관리자 작업 / user 사용자 작업 등 활동 로그 채널) | -| log_type_label | string | `관리자` | `log_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| id | integer | `45291` | 기본 키 (내부 식별자) | +| log_type | string | `user` | 로그 구분 값 (admin 관리자 작업 / user 사용자 작업 등 활동 로그 채널) | +| log_type_label | string | `사용자` | `log_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | loggable_type | string | `Modules\Sirsoft\Ecommerce\Models\Product` | 로그 대상 모델의 전체 클래스명 (상품 또는 상품옵션 모델) | | loggable_type_display | string | `Product` | 로그 대상 모델의 표시용 짧은 이름 (클래스 basename) | -| loggable_id | integer | `201` | loggable 식별자 (연관 리소스 참조) | -| action | string | `product.create` | 활동 액션 키 (product.create/update 등 수행된 작업 식별자) | -| action_label | string | `생성` | `action` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| localized_description | string | `상품 생성 (면 손수건 3매입 #1)` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| loggable_id | integer | `1732` | loggable 식별자 (연관 리소스 참조) | +| action | string | `wishlist.add` | 활동 액션 키 (product.create/update 등 수행된 작업 식별자) | +| action_label | string | `추가` | `action` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| localized_description | string | `위시리스트 추가 (면 손수건 3매입 #1)` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | description_key | string | `sirsoft-ecommerce::activity_log.descr…` | 설명 번역 키 (localized_description 을 생성하는 다국어 키) | | properties | null | `null` | 로그 부가 속성 (액션에 첨부된 임의 메타데이터, 없으면 null) | -| changes | array | `[{"field":"sku","label_key":"sirsoft-ecommerce::activity_…` | 단일 수정 변경 내역 (각 항목: field·label·old·new, 일괄 수정 로그면 null) | +| changes | array | `[{"field":"selling_price","label_key":"sirsoft-ecommerce:…` | 단일 수정 변경 내역 (각 항목: field·label·old·new, 일괄 수정 로그면 null) | | bulk_changes | null | `null` | 일괄 수정 변경 내역 (각 항목: model_id·changes 배열, 단일 수정 로그면 null) | | has_changes | boolean | `false` | changes 여부 | -| actor_name | string | `관리자` | 행위를 수행한 주체(사용자/시스템)의 이름 | -| user | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리…` | 행위 수행 사용자 정보 (uuid·name·email, 시스템 작업이면 name 만 '시스템') | +| actor_name | string | `시스템` | 행위를 수행한 주체(사용자/시스템)의 이름 | +| user | object | `{"name":"시스템"}` | 행위 수행 사용자 정보 (uuid·name·email, 시스템 작업이면 name 만 '시스템') | | ip_address | string | `192.168.1.10` | 요청/행위가 발생한 IP 주소 | -| created_at | string | `2026-06-14 08:28:44` | 생성 일시 | -| is_owner | boolean | `true` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | +| created_at | string | `2026-07-29 14:31:19` | 생성 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | | abilities | object | `{"can_read":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -2328,17 +2431,82 @@ HTTP/1.1 200 "success": true, "message": "상품 처리 이력을 조회했습니다.", "data": { - "data": [], + "data": [ + { + "id": 45291, + "log_type": "user", + "log_type_label": "사용자", + "loggable_type": "Modules\\Sirsoft\\Ecommerce\\Models\\Product", + "loggable_type_display": "Product", + "loggable_id": 1732, + "action": "wishlist.add", + "action_label": "추가", + "localized_description": "위시리스트 추가 (면 손수건 3매입 #1)", + "description_key": "sirsoft-ecommerce::activity_log.description.wishlist_add", + "properties": null, + "changes": null, + "bulk_changes": null, + "has_changes": false, + "actor_name": "시스템", + "user": { + "name": "시스템" + }, + "ip_address": "192.168.1.10", + "created_at": "2026-07-29 14:31:19", + "is_owner": false, + "abilities": { + "can_read": true, + "can_delete": true + } + }, + { + "id": 170, + "log_type": "admin", + "log_type_label": "관리자", + "loggable_type": "Modules\\Sirsoft\\Ecommerce\\Models\\Product", + "loggable_type_display": "Product", + "loggable_id": 1732, + "action": "product.bulk_price_update", + "action_label": "일괄 가격 수정", + "localized_description": "상품 일괄 가격 수정 (1건)", + "description_key": "sirsoft-ecommerce::activity_log.description.product_bulk_price_update", + "properties": null, + "changes": [ + { + "field": "selling_price", + "label_key": "sirsoft-ecommerce::activity_log.fields.selling_price", + "old": 2553, + "new": "3000.00", + "type": "currency", + "label": "판매가" + } + ], + "bulk_changes": null, + "has_changes": true, + "actor_name": "시스템", + "user": { + "name": "시스템" + }, + "ip_address": "10.0.0.5", + "created_at": "2026-07-29 13:06:43", + "is_owner": false, + "abilities": { + "can_read": true, + "can_delete": true + } + }, + "... (총 25건 중 2건 표시)" + ], "links": { - "first": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1/logs?page=1", - "last": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1/logs?page=1", + "first": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs?page=1", + "last": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs?page=4", "prev": null, - "next": null + "next": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs?page=2" }, "meta": { "current_page": 1, - "from": null, - "last_page": 1, + "from": 1, + "last_page": 4, "links": [ { "url": null, @@ -2347,22 +2515,40 @@ HTTP/1.1 200 "active": false }, { - "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1/logs?page=1", + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs?page=1", "label": "1", "page": 1, "active": true }, { - "url": null, + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs?page=2", + "label": "2", + "page": 2, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs?page=3", + "label": "3", + "page": 3, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs?page=4", + "label": "4", + "page": 4, + "active": false + }, + { + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs?page=2", "label": "pagination.next", - "page": null, + "page": 2, "active": false } ], - "path": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1/logs", + "path": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/products/1732/logs", "per_page": 25, - "to": null, - "total": 0 + "to": 25, + "total": 100 } } } @@ -2374,6 +2560,7 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -2417,22 +2604,22 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `109` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | integer | `322` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"eum et quia","en":"tenetur id quae"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | -| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| number | integer | `98` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `1831` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"겨울 패딩 점퍼 #100","en":"Winter Padded Jacket #100"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `JK-0100` | 재고관리코드(SKU) | | thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | -| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화된 값) | -| list_price_formatted | string | `112,594원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화된 값) | -| selling_price_formatted | string | `88,949원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| discount_rate | integer | `21` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | -| multi_currency_list_price | object | `{"KRW":{"price":112594,"formatted":"112,594원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | -| multi_currency_selling_price | object | `{"KRW":{"price":88949,"formatted":"88,949원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | -| stock_quantity | integer | `22` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | -| safe_stock_quantity | integer | `12` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | +| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) | +| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) | +| selling_price_formatted | string | `169,000원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | number | `15.5` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":200000,"formatted":"200,000원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":169000,"formatted":"169,000원","is_default…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `213` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `18` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | | is_below_safe_stock | boolean | `false` | below safe stock 여부 | | sales_status | string | `on_sale` | 판매상태 값 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | | sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | @@ -2440,19 +2627,19 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | display_status | string | `visible` | 전시상태 값 (visible 전시 / hidden 숨김) | | display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | display_status_variant | string | `success` | `display_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | -| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | -| primary_category | string | `스마트폰` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | -| categories_with_path | array | `[]` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | -| brand_name | string | `CJ제일제당` | 브랜드명 (연관 브랜드의 현지화 이름) | -| shipping_policy_id | integer | `31` | shipping policy 식별자 (연관 리소스 참조) | +| categories | array | `[{"id":56,"name":"스포츠","is_primary":0},{"id":57,"name":"축…` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| primary_category | string | `축구` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| categories_with_path | array | `[{"id":56,"path":[{"id":56,"name":"스포츠","slug":"sports"}]…` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | +| brand_name | string | `유니클로` | 브랜드명 (연관 브랜드의 현지화 이름) | +| shipping_policy_id | integer | `444` | shipping policy 식별자 (연관 리소스 참조) | | min_purchase_qty | integer | `1` | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | | max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | -| has_options | boolean | `false` | options 여부 | -| labels | array | `[]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | +| has_options | boolean | `true` | options 여부 | +| labels | array | `[{"name":"베스트","color":"#F59E0B"}]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | | review_count | integer | `0` | review 개수 (집계) | | rating_avg | integer | `0` | 평균 별점 (공개 리뷰 별점 평균, 소수 1자리 반올림) | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| created_at | string | `2026-07-30 23:36:17` | 생성 일시 | +| updated_at | string | `2026-08-05 07:27:04` | 최종 수정 일시 | | is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | | abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -2469,238 +2656,28 @@ HTTP/1.1 200 "data": { "data": [ { - "number": 3, - "id": 3, + "number": 98, + "id": 1831, "name": { - "ko": "nisi et corrupti", - "en": "suscipit esse id" + "ko": "겨울 패딩 점퍼 #100", + "en": "Winter Padded Jacket #100" }, - "name_localized": "nisi et corrupti", - "product_code": "PROD-YNAZ-3669", - "sku": "SKU-EOQY-1167", - "thumbnail_url": null, - "list_price": 22605, - "list_price_formatted": "22,605원", - "selling_price": 20118, - "selling_price_formatted": "20,118원", - "discount_rate": 11, - "multi_currency_list_price": { - "KRW": { - "price": 22605, - "formatted": "22,605원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 19.21, - "formatted": "$19.21", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2599, - "formatted": "¥2,599", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 131.11, - "formatted": "元131.11", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 17.63, - "formatted": "€17.63", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 20118, - "formatted": "20,118원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 17.1, - "formatted": "$17.10", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2313, - "formatted": "¥2,313", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 116.68, - "formatted": "元116.68", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 15.69, - "formatted": "€15.69", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 164, - "safe_stock_quantity": 8, - "is_below_safe_stock": false, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "brand_name": null, - "shipping_policy_id": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "labels": [], - "review_count": 0, - "rating_avg": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } + "name_localized": "겨울 패딩 점퍼 #100", + "product_code": "GGK6A9N8PXNR35OQ", + "...": "(33개 키 생략, 총 38개)" }, { - "number": 2, - "id": 2, + "number": 97, + "id": 1830, "name": { - "ko": "repudiandae et et", - "en": "velit alias distinctio" + "ko": "무선 마우스 세트 #99", + "en": "Wireless Mouse Set #99" }, - "name_localized": "repudiandae et et", - "product_code": "PROD-XSMH-5438", - "sku": "SKU-XXSC-9588", - "thumbnail_url": null, - "list_price": 654947, - "list_price_formatted": "654,947원", - "selling_price": 465012, - "selling_price_formatted": "465,012원", - "discount_rate": 29, - "multi_currency_list_price": { - "KRW": { - "price": 654947, - "formatted": "654,947원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 556.7, - "formatted": "$556.70", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 75318, - "formatted": "¥75,318", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 3798.69, - "formatted": "元3,798.69", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 510.86, - "formatted": "€510.86", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 465012, - "formatted": "465,012원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 395.26, - "formatted": "$395.26", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 53476, - "formatted": "¥53,476", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 2697.07, - "formatted": "元2,697.07", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 362.71, - "formatted": "€362.71", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 161, - "safe_stock_quantity": 49, - "is_below_safe_stock": false, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "brand_name": null, - "shipping_policy_id": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "labels": [], - "review_count": 0, - "rating_avg": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } + "name_localized": "무선 마우스 세트 #99", + "product_code": "J9HTLXFNIJZHI4B1", + "...": "(33개 키 생략, 총 38개)" }, - "... (총 3건 중 2건 표시)" + "... (총 25건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -2709,12 +2686,11 @@ HTTP/1.1 200 }, "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 4, "per_page": 25, - "total": 3, + "total": 98, "from": 1, - "to": 3, - "has_more_pages": false + "...": "(2개 키 생략, 총 7개)" } } } @@ -2759,21 +2735,21 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `322` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"eum et quia","en":"tenetur id quae"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | -| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| id | integer | `1831` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"겨울 패딩 점퍼 #100","en":"Winter Padded Jacket #100"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `JK-0100` | 재고관리코드(SKU) | | thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | -| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화된 값) | -| list_price_formatted | string | `112,594원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화된 값) | -| selling_price_formatted | string | `88,949원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| discount_rate | integer | `21` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | -| multi_currency_list_price | object | `{"KRW":{"price":112594,"formatted":"112,594원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | -| multi_currency_selling_price | object | `{"KRW":{"price":88949,"formatted":"88,949원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | -| stock_quantity | integer | `22` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | -| safe_stock_quantity | integer | `12` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | +| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) | +| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) | +| selling_price_formatted | string | `169,000원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | number | `15.5` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":200000,"formatted":"200,000원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":169000,"formatted":"169,000원","is_default…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `213` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `18` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | | is_below_safe_stock | boolean | `false` | below safe stock 여부 | | sales_status | string | `on_sale` | 판매상태 값 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | | sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | @@ -2781,18 +2757,18 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | display_status | string | `visible` | 전시상태 값 (visible 전시 / hidden 숨김) | | display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | display_status_variant | string | `success` | `display_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | -| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | -| primary_category | string | `스마트폰` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | -| categories_with_path | array | `[]` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | -| shipping_policy_id | integer | `31` | shipping policy 식별자 (연관 리소스 참조) | +| categories | array | `[{"id":56,"name":"스포츠","is_primary":0},{"id":57,"name":"축…` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| primary_category | string | `축구` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| categories_with_path | array | `[{"id":56,"path":[{"id":56,"name":"스포츠","slug":"sports"}]…` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | +| shipping_policy_id | integer | `444` | shipping policy 식별자 (연관 리소스 참조) | | min_purchase_qty | integer | `1` | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | | max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | -| has_options | boolean | `false` | options 여부 | -| labels | array | `[]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | +| has_options | boolean | `true` | options 여부 | +| labels | array | `[{"name":"베스트","color":"#F59E0B"}]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | | review_count | integer | `0` | review 개수 (집계) | | rating_avg | integer | `0` | 평균 별점 (공개 리뷰 별점 평균, 소수 1자리 반올림) | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| created_at | string | `2026-07-30 23:36:17` | 생성 일시 | +| updated_at | string | `2026-08-05 07:27:04` | 최종 수정 일시 | | is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | | abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -2808,347 +2784,61 @@ HTTP/1.1 200 "message": "상품 정보를 조회했습니다.", "data": [ { - "id": 3, + "id": 1831, "name": { - "ko": "nisi et corrupti", - "en": "suscipit esse id" + "ko": "겨울 패딩 점퍼 #100", + "en": "Winter Padded Jacket #100" }, - "name_localized": "nisi et corrupti", - "product_code": "PROD-YNAZ-3669", - "sku": "SKU-EOQY-1167", - "thumbnail_url": null, - "list_price": 22605, - "list_price_formatted": "22,605원", - "selling_price": 20118, - "selling_price_formatted": "20,118원", - "discount_rate": 11, - "multi_currency_list_price": { - "KRW": { - "price": 22605, - "formatted": "22,605원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 19.21, - "formatted": "$19.21", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2599, - "formatted": "¥2,599", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 131.11, - "formatted": "元131.11", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 17.63, - "formatted": "€17.63", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 20118, - "formatted": "20,118원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 17.1, - "formatted": "$17.10", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2313, - "formatted": "¥2,313", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 116.68, - "formatted": "元116.68", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 15.69, - "formatted": "€15.69", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 164, - "safe_stock_quantity": 8, - "is_below_safe_stock": false, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "shipping_policy_id": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "labels": [], - "review_count": 0, - "rating_avg": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } + "name_localized": "겨울 패딩 점퍼 #100", + "product_code": "GGK6A9N8PXNR35OQ", + "sku": "JK-0100", + "...": "(31개 키 생략, 총 36개)" }, { - "id": 2, + "id": 1830, "name": { - "ko": "repudiandae et et", - "en": "velit alias distinctio" + "ko": "무선 마우스 세트 #99", + "en": "Wireless Mouse Set #99" }, - "name_localized": "repudiandae et et", - "product_code": "PROD-XSMH-5438", - "sku": "SKU-XXSC-9588", - "thumbnail_url": null, - "list_price": 654947, - "list_price_formatted": "654,947원", - "selling_price": 465012, - "selling_price_formatted": "465,012원", - "discount_rate": 29, - "multi_currency_list_price": { - "KRW": { - "price": 654947, - "formatted": "654,947원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 556.7, - "formatted": "$556.70", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 75318, - "formatted": "¥75,318", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 3798.69, - "formatted": "元3,798.69", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 510.86, - "formatted": "€510.86", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 465012, - "formatted": "465,012원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 395.26, - "formatted": "$395.26", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 53476, - "formatted": "¥53,476", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 2697.07, - "formatted": "元2,697.07", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 362.71, - "formatted": "€362.71", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 161, - "safe_stock_quantity": 49, - "is_below_safe_stock": false, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "shipping_policy_id": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "labels": [], - "review_count": 0, - "rating_avg": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } + "name_localized": "무선 마우스 세트 #99", + "product_code": "J9HTLXFNIJZHI4B1", + "sku": "MS-0099", + "...": "(31개 키 생략, 총 36개)" }, { - "id": 1, + "id": 1829, "name": { - "ko": "API 문서 샘플 상품", - "en": "API Doc Sample Product" + "ko": "가죽 크로스백 #98", + "en": "Leather Crossbody Bag #98" }, - "name_localized": "API 문서 샘플 상품", - "product_code": "APIDOCSAMPLE01", - "sku": "SKU-DKOK-1319", - "thumbnail_url": null, - "list_price": 703155, - "list_price_formatted": "703,155원", - "selling_price": 597682, - "selling_price_formatted": "597,682원", - "discount_rate": 15, - "multi_currency_list_price": { - "KRW": { - "price": 703155, - "formatted": "703,155원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 597.68, - "formatted": "$597.68", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 80862, - "formatted": "¥80,862", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 4078.3, - "formatted": "元4,078.30", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 548.46, - "formatted": "€548.46", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } + "name_localized": "가죽 크로스백 #98", + "product_code": "ZVNPXKW88D0ESYDK", + "sku": "BG-0098", + "...": "(31개 키 생략, 총 36개)" + }, + { + "id": 1828, + "name": { + "ko": "스테인리스 텀블러 #97", + "en": "Stainless Tumbler #97" }, - "multi_currency_selling_price": { - "KRW": { - "price": 597682, - "formatted": "597,682원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 508.03, - "formatted": "$508.03", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 68733, - "formatted": "¥68,733", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 3466.56, - "formatted": "元3,466.56", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 466.19, - "formatted": "€466.19", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } + "name_localized": "스테인리스 텀블러 #97", + "product_code": "G6TWL4RFBTWHJD71", + "sku": "TB-0097", + "...": "(31개 키 생략, 총 36개)" + }, + { + "id": 1827, + "name": { + "ko": "코튼 후드티 #96", + "en": "Cotton Hoodie #96" }, - "stock_quantity": 509, - "safe_stock_quantity": 38, - "is_below_safe_stock": false, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "shipping_policy_id": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "labels": [], - "review_count": 1, - "rating_avg": 5, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } - } + "name_localized": "코튼 후드티 #96", + "product_code": "E8CDGTL7BHAPXRHA", + "sku": "HD-0096", + "...": "(31개 키 생략, 총 36개)" + }, + "... (총 10건 중 5건 표시)" ] } ``` @@ -3192,21 +2882,21 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `204` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"베이직 라운드 티셔츠 #4","en":"Basic Round T-Shirt #4"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `베이직 라운드 티셔츠 #4` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| product_code | string | `2R9AKHR0GH2DR3NG` | 상품코드 (상품 고유 관리 식별자) | -| sku | string | `TS-0004` | 재고관리코드(SKU) | +| id | integer | `1736` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"프리미엄 브이넥 티셔츠 #5","en":"Premium V-Neck T-Shirt #5"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `프리미엄 브이넥 티셔츠 #5` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `4R5OG8VYO0JKG1WQ` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `TS-0005` | 재고관리코드(SKU) | | thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | -| list_price | integer | `29000` | 정가 (기본통화 자릿수로 정규화된 값) | -| list_price_formatted | string | `29,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| selling_price | integer | `22000` | 판매가 (기본통화 자릿수로 정규화된 값) | -| selling_price_formatted | string | `22,000원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| discount_rate | number | `24.1` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | -| multi_currency_list_price | object | `{"KRW":{"price":29000,"formatted":"29,000원","is_default":…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | -| multi_currency_selling_price | object | `{"KRW":{"price":22000,"formatted":"22,000원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | -| stock_quantity | integer | `264` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | -| safe_stock_quantity | integer | `15` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | +| list_price | integer | `34000` | 정가 (기본통화 자릿수로 정규화된 값) | +| list_price_formatted | string | `34,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `27000` | 판매가 (기본통화 자릿수로 정규화된 값) | +| selling_price_formatted | string | `27,000원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | number | `20.6` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":34000,"formatted":"34,000원","is_default":…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":27000,"formatted":"27,000원","is_default":…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `137` | 재고 수량 (옵션 사용 시 옵션 재고 합계) | +| safe_stock_quantity | integer | `14` | 안전재고 수량 (이 값 미만이면 재고 부족으로 표시) | | is_below_safe_stock | boolean | `false` | below safe stock 여부 | | sales_status | string | `on_sale` | 판매상태 값 (on_sale 판매중 / suspended 판매중지 / sold_out 품절 / coming_soon 출시예정) | | sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | @@ -3214,18 +2904,18 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | display_status | string | `visible` | 전시상태 값 (visible 전시 / hidden 숨김) | | display_status_label | string | `전시` | `display_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | display_status_variant | string | `success` | `display_status` 값의 표시 변형 키 (UI 배지 색상/스타일) | -| categories | array | `[{"id":109,"name":"식품","is_primary":0},{"id":113,"name":"…` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부) | -| primary_category | string | `해산물` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | -| categories_with_path | array | `[{"id":109,"path":[{"id":109,"name":"식품","slug":"food"}],…` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | -| shipping_policy_id | integer | `31` | shipping policy 식별자 (연관 리소스 참조) | +| categories | array | `[{"id":51,"name":"식품","is_primary":0},{"id":52,"name":"과일…` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부) | +| primary_category | string | `과일` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| categories_with_path | array | `[{"id":51,"path":[{"id":51,"name":"식품","slug":"food"}],"p…` | 소속 카테고리 목록 + 경로 (각 항목: id·breadcrumb path·대표 여부) | +| shipping_policy_id | integer | `444` | shipping policy 식별자 (연관 리소스 참조) | | min_purchase_qty | integer | `1` | 최소 구매 수량 (1회 주문 시 이 수량 이상 구매) | | max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | | has_options | boolean | `true` | options 여부 | -| labels | array | `[]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | +| labels | array | `[{"name":"품절임박","color":"#F97316"}]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | | review_count | integer | `0` | review 개수 (집계) | | rating_avg | integer | `0` | 평균 별점 (공개 리뷰 별점 평균, 소수 1자리 반올림) | -| created_at | string | `2026-06-15 11:24:15` | 생성 일시 | -| updated_at | string | `2026-06-15 11:24:15` | 최종 수정 일시 | +| created_at | string | `2026-07-30 23:36:13` | 생성 일시 | +| updated_at | string | `2026-07-30 23:36:13` | 최종 수정 일시 | | is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 (BaseApiResource 표준 메타) | | abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -3241,347 +2931,61 @@ HTTP/1.1 200 "message": "상품 정보를 조회했습니다.", "data": [ { - "id": 2, + "id": 1736, "name": { - "ko": "repudiandae et et", - "en": "velit alias distinctio" + "ko": "프리미엄 브이넥 티셔츠 #5", + "en": "Premium V-Neck T-Shirt #5" }, - "name_localized": "repudiandae et et", - "product_code": "PROD-XSMH-5438", - "sku": "SKU-XXSC-9588", - "thumbnail_url": null, - "list_price": 654947, - "list_price_formatted": "654,947원", - "selling_price": 465012, - "selling_price_formatted": "465,012원", - "discount_rate": 29, - "multi_currency_list_price": { - "KRW": { - "price": 654947, - "formatted": "654,947원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 556.7, - "formatted": "$556.70", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 75318, - "formatted": "¥75,318", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 3798.69, - "formatted": "元3,798.69", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 510.86, - "formatted": "€510.86", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 465012, - "formatted": "465,012원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 395.26, - "formatted": "$395.26", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 53476, - "formatted": "¥53,476", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 2697.07, - "formatted": "元2,697.07", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 362.71, - "formatted": "€362.71", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 161, - "safe_stock_quantity": 49, - "is_below_safe_stock": false, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "shipping_policy_id": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "labels": [], - "review_count": 0, - "rating_avg": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } + "name_localized": "프리미엄 브이넥 티셔츠 #5", + "product_code": "4R5OG8VYO0JKG1WQ", + "sku": "TS-0005", + "...": "(31개 키 생략, 총 36개)" }, { - "id": 1, + "id": 1743, "name": { - "ko": "API 문서 샘플 상품", - "en": "API Doc Sample Product" + "ko": "코튼 후드티 #12", + "en": "Cotton Hoodie #12" }, - "name_localized": "API 문서 샘플 상품", - "product_code": "APIDOCSAMPLE01", - "sku": "SKU-DKOK-1319", - "thumbnail_url": null, - "list_price": 703155, - "list_price_formatted": "703,155원", - "selling_price": 597682, - "selling_price_formatted": "597,682원", - "discount_rate": 15, - "multi_currency_list_price": { - "KRW": { - "price": 703155, - "formatted": "703,155원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 597.68, - "formatted": "$597.68", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 80862, - "formatted": "¥80,862", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 4078.3, - "formatted": "元4,078.30", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 548.46, - "formatted": "€548.46", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 597682, - "formatted": "597,682원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 508.03, - "formatted": "$508.03", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 68733, - "formatted": "¥68,733", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 3466.56, - "formatted": "元3,466.56", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 466.19, - "formatted": "€466.19", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 509, - "safe_stock_quantity": 38, - "is_below_safe_stock": false, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "shipping_policy_id": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "labels": [], - "review_count": 1, - "rating_avg": 5, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } + "name_localized": "코튼 후드티 #12", + "product_code": "5D0E22LJLQVSKI1G", + "sku": "HD-0012", + "...": "(31개 키 생략, 총 36개)" }, { - "id": 3, + "id": 1747, "name": { - "ko": "nisi et corrupti", - "en": "suscipit esse id" + "ko": "겨울 패딩 점퍼 #16", + "en": "Winter Padded Jacket #16" }, - "name_localized": "nisi et corrupti", - "product_code": "PROD-YNAZ-3669", - "sku": "SKU-EOQY-1167", - "thumbnail_url": null, - "list_price": 22605, - "list_price_formatted": "22,605원", - "selling_price": 20118, - "selling_price_formatted": "20,118원", - "discount_rate": 11, - "multi_currency_list_price": { - "KRW": { - "price": 22605, - "formatted": "22,605원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 19.21, - "formatted": "$19.21", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2599, - "formatted": "¥2,599", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 131.11, - "formatted": "元131.11", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 17.63, - "formatted": "€17.63", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } + "name_localized": "겨울 패딩 점퍼 #16", + "product_code": "5E1WSBY0CHFX7UJU", + "sku": "JK-0016", + "...": "(31개 키 생략, 총 36개)" + }, + { + "id": 1735, + "name": { + "ko": "베이직 라운드 티셔츠 #4", + "en": "Basic Round T-Shirt #4" }, - "multi_currency_selling_price": { - "KRW": { - "price": 20118, - "formatted": "20,118원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 17.1, - "formatted": "$17.10", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 2313, - "formatted": "¥2,313", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 116.68, - "formatted": "元116.68", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 15.69, - "formatted": "€15.69", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } + "name_localized": "베이직 라운드 티셔츠 #4", + "product_code": "Y3Y792BFFW3HRW7A", + "sku": "TS-0004", + "...": "(31개 키 생략, 총 36개)" + }, + { + "id": 1733, + "name": { + "ko": "기본 양말 5족 #2", + "en": "Basic Socks 5 Pairs #2" }, - "stock_quantity": 164, - "safe_stock_quantity": 8, - "is_below_safe_stock": false, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "sales_status_variant": "success", - "display_status": "visible", - "display_status_label": "전시", - "display_status_variant": "success", - "categories": [], - "primary_category": null, - "categories_with_path": [], - "shipping_policy_id": null, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "has_options": false, - "labels": [], - "review_count": 0, - "rating_avg": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "is_owner": false, - "abilities": { - "can_update": true, - "can_delete": true - } - } + "name_localized": "기본 양말 5족 #2", + "product_code": "S0SO3A6SJFYLAKSF", + "sku": "SK-0002", + "...": "(31개 키 생략, 총 36개)" + }, + "... (총 10건 중 5건 표시)" ] } ``` @@ -3623,9 +3027,51 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 **응답 필드** (`data` 내부) +_목록 응답: `data[]` 배열 항목의 필드 (`ProductListResource` — `GET /products/new` 와 동일 구성). `ids` 가 비어 있으면 `data` 는 빈 배열입니다._ - - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `322` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"eum et quia","en":"tenetur id quae"}` | 상품명 (로케일별 값 객체) | +| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 | +| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) | +| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 대표 이미지 URL | +| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화) | +| list_price_formatted | string | `112,594원` | `list_price` 의 표시용 통화 포맷 문자열 | +| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화) | +| selling_price_formatted | string | `88,949원` | `selling_price` 의 표시용 통화 포맷 문자열 | +| discount_rate | integer | `21` | 할인율(%) ((1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":112594,"formatted":"112,594원","is_default":true,"editable":true}}` | 통화별 정가 맵 | +| multi_currency_selling_price | object | `{"KRW":{"price":88949,"formatted":"88,949원","is_default":true,"editable":true}}` | 통화별 판매가 맵 | +| stock_quantity | integer | `22` | 재고 수량 | +| safe_stock_quantity | integer | `12` | 안전재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전재고 미만인지 여부 | +| option_stock_sum | integer | `51` | 활성 옵션의 재고 합계 (options 관계 로드 시) | +| sales_status | string | `on_sale` | 판매상태 (on_sale / suspended / sold_out / coming_soon) | +| sales_status_label | string | `판매중` | `sales_status` 의 현지화 라벨 | +| sales_status_variant | string | `success` | `sales_status` 의 UI 배지 변형 키 | +| display_status | string | `visible` | 전시상태 (visible / hidden) | +| display_status_label | string | `전시` | `display_status` 의 현지화 라벨 | +| display_status_variant | string | `success` | `display_status` 의 UI 배지 변형 키 | +| categories | array | `[]` | 소속 카테고리 목록 (categories 관계 로드 시) | +| primary_category | string | `스마트폰` | 대표 카테고리명 (categories 관계 로드 시) | +| categories_with_path | array | `[]` | 소속 카테고리 + 경로(breadcrumb) 목록 | +| brand_name | string | `ASUS` | 브랜드명 (brand 관계 로드 시) | +| shipping_policy_id | integer | `31` | 배송정책 식별자 | +| shipping_policy_name | string | `국내 무료배송` | 배송정책명 (shippingPolicy 관계 로드 시) | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | +| has_options | boolean | `false` | 옵션 사용 여부 | +| options_count | integer | `1` | 활성 옵션 개수 (options 관계 로드 시) | +| options | array | `[]` | 활성 옵션(SKU) 목록 (`ProductOptionResource`) | +| labels | array | `[]` | 노출 중인 상품 라벨 목록 (activeLabelAssignments 관계 로드 시) | +| review_count | integer | `0` | 리뷰 개수 | +| rating_avg | number | `0` | 평균 별점 (소수 1자리 반올림) | +| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | +| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| is_owner | boolean | `false` | 현재 인증 사용자가 이 리소스의 소유자인지 여부 | +| abilities | object | `{"can_update":false,"can_delete":false}` | 현재 사용자가 이 상품에 수행 가능한 작업 불리언 맵 | **응답 예시** @@ -3653,7 +3099,7 @@ HTTP/1.1 200 **설명** 최근 본 상품 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, 클라이언트가 로컬에 보관한 조회 이력 상품 ID들을 쉼표 구분 문자열(`ids`)로 전달하면 컨트롤러가 정수 배열로 파싱해 `ProductService::getProductsByIds()`로 조회한 뒤 `ProductListResource` 컬렉션을 반환합니다. `ids`가 비어 있으면 빈 배열을 반환하며, 확장은 `sirsoft-ecommerce.product.public_recent_validation_rules` 훅으로 검증 규칙을 확장할 수 있습니다. -### GET /api/modules/sirsoft-ecommerce/products/{id} +### GET /api/modules/sirsoft-ecommerce/products/{product} - **라우트명**: `api.modules.sirsoft-ecommerce.products.show` - **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductController@show` @@ -3663,12 +3109,12 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| id | path | string | 예 | — | 대상 리소스의 식별자 | +| product | path | string | 예 | — | 대상 product의 식별자 | **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/products/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/products/{product} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) @@ -3680,50 +3126,51 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 상품","en":"API Doc Sample Product"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `API 문서 샘플 상품` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| product_code | string | `APIDOCSAMPLE01` | 상품코드 | -| sku | string | `SKU-DKOK-1319` | SKU | -| categories | array | `[]` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | -| category_name | null | `null` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | -| list_price | integer | `703155` | 정가 (기본통화 기준) | -| list_price_formatted | string | `703,155원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| selling_price | integer | `597682` | 판매가 (기본통화 기준) | -| selling_price_formatted | string | `597,682원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| discount_rate | integer | `15` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | -| multi_currency_list_price | object | `{"KRW":{"price":703155,"formatted":"703,155원","is_default…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | -| multi_currency_selling_price | object | `{"KRW":{"price":597682,"formatted":"597,682원","is_default…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | -| stock_quantity | integer | `509` | 재고 수량 (옵션 있으면 옵션 합계) | +| id | integer | `1732` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"면 손수건 3매입 #1","en":"Cotton Handkerchief 3pcs #1"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `면 손수건 3매입 #1` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| product_code | string | `CJTFHBL8SLRQ8ILM` | 상품코드 | +| sku | string | `HK-0001` | SKU | +| categories | array | `[{"id":29,"name":{"ko":"의류","en":"Clothing"},"name_locali…` | 소속 카테고리 목록 (각 항목: id·현지화 이름·대표 여부. categories 관계 eager load 시에만 채워짐) | +| category_name | string | `바지` | 대표 카테고리명 (is_primary 카테고리의 현지화 이름) | +| list_price | integer | `5000` | 정가 (기본통화 기준) | +| list_price_formatted | string | `5,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| selling_price | integer | `3000` | 판매가 (기본통화 기준) | +| selling_price_formatted | string | `3,000원` | `selling_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| discount_rate | integer | `40` | 할인율(%) (정가 대비 판매가 할인 비율, (1 - 판매가/정가) × 100) | +| multi_currency_list_price | object | `{"KRW":{"price":5000,"formatted":"5,000원","is_default":tr…` | 통화별 정가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 정가) | +| multi_currency_selling_price | object | `{"KRW":{"price":3000,"formatted":"3,000원","is_default":tr…` | 통화별 판매가 맵 (통화코드 → {price, formatted, is_default, editable}, 설정된 모든 통화의 환산 판매가) | +| stock_quantity | integer | `26` | 재고 수량 (옵션 있으면 옵션 합계) | | min_purchase_qty | integer | `1` | 최소 구매 수량 | | max_purchase_qty | integer | `0` | 최대 구매 수량 (0=무제한) | | sales_status | string | `on_sale` | 판매상태: on_sale(판매중), suspended(판매중지), sold_out(품절), coming_soon(출시예정) | | sales_status_label | string | `판매중` | `sales_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| brand_name | null | `null` | 브랜드명 (연관 브랜드의 현지화 이름) | -| labels | array | `[]` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | +| brand_name | string | `유니클로` | 브랜드명 (연관 브랜드의 현지화 이름) | +| labels | array | `[{"name":"할인","color":"#EF4444"},{"name":"이벤트","color":"#…` | 노출 중인 상품 라벨 목록 (각 항목: 라벨명·색상, 활성 라벨을 sort_order 순으로 정렬) | | additional_options | array | `[]` | 추가옵션 그룹 목록 (각 그룹: 그룹명·필수 여부·선택지 목록, 활성 옵션을 sort_order 순으로 정렬) | -| shipping_policy_id | null | `null` | shipping policy 식별자 (연관 리소스 참조) | +| shipping_policy_id | integer | `444` | shipping policy 식별자 (연관 리소스 참조) | | is_shippable_to_selected_country | boolean | `true` | shippable to selected country 여부 | | selected_shipping_country | string | `KR` | 배송비 계산에 적용된 배송 국가 코드 (ResolveShippingCountry 해석 결과) | | free_shipping | boolean | `false` | 무료배송 여부 (적용 배송정책의 청구방식이 무료(FREE)인 경우 true) | -| shipping_fee_formatted | string | `` | `shipping_fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| shipping_policy | null | `null` | 적용 배송정책 요약 객체 (정책명·청구방식·기본배송비·무료배송 기준액 등, 상품 정책 없으면 기본 정책으로 폴백) | +| shipping_fee_formatted | string | `KR: 무료배송` | `shipping_fee` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| shipping_policy | object | `{"name":"국내 무료배송","charge_policy":null,"charge_policy_lab…` | 적용 배송정책 요약 객체 (정책명·청구방식·기본배송비·무료배송 기준액 등, 상품 정책 없으면 기본 정책으로 폴백) | | short_description | null | `null` | 짧은 설명 (상품 목록/카드용 요약 설명, 다국어 필드는 로케일별 값 객체) | | short_description_localized | null | `null` | `short description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| description | object | `{"ko":"Tempore et non fugit eos et aperiam dolore. Aut es…` | 설명 (다국어 필드는 로케일별 값 객체) | -| description_localized | string | `Tempore et non fugit eos et aperiam d…` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description | object | `{"ko":"

부드러운 면 100% 손수건 3매 세트입니다.<\/p>","en":"

A set …` | 설명 (다국어 필드는 로케일별 값 객체) | +| description_localized | string | `

부드러운 면 100% 손수건 3매 세트입니다.

` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) | -| images | array | `[]` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) | -| thumbnail_url | null | `null` | thumbnail URL | +| images | array | `[{"id":7,"hash":"7df7761cdf16","original_filename":"produ…` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) | +| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL | | meta_title | null | `null` | SEO 제목 (다국어 JSON) | -| meta_description | null | `null` | SEO 설명 (다국어 JSON) | +| meta_description | object | `{"ko":"면 손수건 3매입 #1 의 직접 입력 SEO 설명입니다.","en":"Custom SEO …` | SEO 설명 (다국어 JSON) | | meta_keywords | null | `null` | SEO 키워드 (배열) | -| has_options | boolean | `false` | options 여부 | -| option_groups | array | `[]` | 옵션 그룹 정의: [{name: "색상", values: ["빨강", "파랑"]}] | -| options | array | `[]` | 활성 옵션(SKU) 목록 (각 옵션의 코드·옵션값·가격·재고 등, ProductOptionResource) | -| notice | null | `null` | 상품정보제공고시 (템플릿명 + 항목별 라벨/내용 목록, notice 관계 로드 시) | -| common_info | null | `null` | 공통정보 (name·content·content_mode, commonInfo 관계 로드 시 — 여러 상품이 공유하는 공통 안내문) | -| is_wishlisted | boolean | `false` | wishlisted 여부 | +| has_options | boolean | `true` | options 여부 | +| option_groups | array | `[{"name":{"ko":"색상","en":"Color"},"name_localized":"색상","…` | 옵션 그룹 정의: [{name: "색상", values: ["빨강", "파랑"]}] | +| options | array | `[{"id":948,"option_code":"CJTFHBL8SLRQ8ILM-001","option_v…` | 활성 옵션(SKU) 목록 (각 옵션의 코드·옵션값·가격·재고 등, ProductOptionResource) | +| notice | object | `{"template_name":null,"values":[{"label":"항목1","value":"상…` | 상품정보제공고시 (템플릿명 + 항목별 라벨/내용 목록, notice 관계 로드 시) | +| common_info | object | `{"name":"부분 배송 안내","content":"• 적용 조건: 주문 상품의 재고 상황이 다른 경…` | 공통정보 (name·content·content_mode, commonInfo 관계 로드 시 — 여러 상품이 공유하는 공통 안내문) | +| is_wishlisted | boolean | `true` | wishlisted 여부 | +| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -3736,126 +3183,15 @@ HTTP/1.1 200 "success": true, "message": "상품 정보를 조회했습니다.", "data": { - "id": 1, + "id": 1732, "name": { - "ko": "API 문서 샘플 상품", - "en": "API Doc Sample Product" + "ko": "면 손수건 3매입 #1", + "en": "Cotton Handkerchief 3pcs #1" }, - "name_localized": "API 문서 샘플 상품", - "product_code": "APIDOCSAMPLE01", - "sku": "SKU-DKOK-1319", - "categories": [], - "category_name": null, - "list_price": 703155, - "list_price_formatted": "703,155원", - "selling_price": 597682, - "selling_price_formatted": "597,682원", - "discount_rate": 15, - "multi_currency_list_price": { - "KRW": { - "price": 703155, - "formatted": "703,155원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 597.68, - "formatted": "$597.68", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 80862, - "formatted": "¥80,862", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 4078.3, - "formatted": "元4,078.30", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 548.46, - "formatted": "€548.46", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_selling_price": { - "KRW": { - "price": 597682, - "formatted": "597,682원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 508.03, - "formatted": "$508.03", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 68733, - "formatted": "¥68,733", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 3466.56, - "formatted": "元3,466.56", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 466.19, - "formatted": "€466.19", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "stock_quantity": 509, - "min_purchase_qty": 1, - "max_purchase_qty": 0, - "sales_status": "on_sale", - "sales_status_label": "판매중", - "brand_name": null, - "labels": [], - "additional_options": [], - "shipping_policy_id": null, - "is_shippable_to_selected_country": true, - "selected_shipping_country": "KR", - "free_shipping": false, - "shipping_fee_formatted": "", - "shipping_policy": null, - "short_description": null, - "short_description_localized": null, - "description": { - "ko": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", - "en": "Soluta et ut repellendus rem. Id ea quibusdam in. Doloribus saepe rem praesentium et quis et molestiae.\n\nDolor voluptas aut qui unde sed quia. Corrupti qui enim laborum maxime possimus quis. Odit ducimus doloremque qui officiis fugit rerum. Magni ea possimus quia commodi optio saepe. Recusandae itaque in voluptatum et.\n\nRerum enim velit est inventore. Ipsa laboriosam provident ullam." - }, - "description_localized": "Tempore et non fugit eos et aperiam dolore. Aut esse fugiat adipisci iure itaque nisi. Earum ab dignissimos ipsa harum dolor sunt voluptatem.\n\nEst voluptatibus in sed distinctio. Delectus occaecati et earum est rerum ex. Beatae ex aut autem impedit facere totam. Et tempore minus veniam doloribus maiores eveniet dolorem ut. Culpa sed inventore ipsam.\n\nFuga praesentium distinctio quia ducimus harum assumenda. Ipsa dolorum odio reiciendis quis id. Tempore facere molestiae et reprehenderit reiciendis dicta quos. Quae labore deserunt fuga quam beatae. Praesentium aut fuga ratione aliquid.", - "description_mode": "text", - "images": [], - "thumbnail_url": null, - "meta_title": null, - "meta_description": null, - "meta_keywords": null, - "has_options": false, - "option_groups": [], - "options": [], - "notice": null, - "common_info": null, - "is_wishlisted": false + "name_localized": "면 손수건 3매입 #1", + "product_code": "CJTFHBL8SLRQ8ILM", + "sku": "HK-0001", + "...": "(40개 키 생략, 총 45개)" } } ``` @@ -3872,7 +3208,7 @@ HTTP/1.1 200 **설명** 쇼핑몰 프런트용 공개 상품 상세를 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductService::getDetail()`로 상품을 로드하되 전시상태가 visible이 아니면 404를 반환합니다. 상세 페이지에 필요한 배송정책·상품고시·공통정보·브랜드·라벨·추가옵션·현재 사용자 위시리스트 관계를 추가 로드하고 `PublicProductResource`로 반환하며, 응답에는 다중통화 가격·배송비 안내(`shipping_fee_formatted`)·`is_wishlisted` 등 프런트 표시용 파생 필드가 포함됩니다. -### GET /api/modules/sirsoft-ecommerce/products/{productId}/downloadable-coupons +### GET /api/modules/sirsoft-ecommerce/products/{product}/downloadable-coupons - **라우트명**: `api.modules.sirsoft-ecommerce.products.downloadable-coupons` - **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\PublicCouponController@downloadableCoupons` @@ -3882,12 +3218,12 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| productId | path | string | 예 | — | 대상 product의 식별자 | +| product | path | string | 예 | — | 대상 product의 식별자 | **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/products/1/downloadable-coupons HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/products/{product}/downloadable-coupons HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) @@ -3899,19 +3235,19 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| coupon_id | integer | `1` | coupon 식별자 (연관 리소스 참조) | -| localized_name | string | `API 문서 샘플 쿠폰` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| benefit_formatted | string | `1,000원 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| multi_currency_benefit_formatted | object | `{"KRW":"1,000원 할인","USD":"$0.85 할인","JPY":"¥115 할인","CNY"…` | `multi_currency_benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| target_type | string | `order_amount` | 적용대상(할인 기준): product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | -| target_type_short_label | string | `주문` | `target_type_short` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| valid_period_formatted | string | `-` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| min_order_amount | string | `0.00` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | -| min_order_amount_formatted | string | `0원` | `min_order_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| multi_currency_min_order_amount | null | `null` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | -| total_quantity | null | `null` | 총 발급 수량 (null=무제한) | -| remaining_quantity | null | `null` | 잔여 발급 가능 수량 (total_quantity − issued_count, 무제한이면 null) | -| is_downloaded | boolean | `false` | downloaded 여부 | +| coupon_id | integer | `107` | coupon 식별자 (연관 리소스 참조) | +| localized_name | string | `카테고리 배송비 3,000원 할인` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| benefit_formatted | string | `¥3,000 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| multi_currency_benefit_formatted | object | `{"KRW":"3,000원 할인","USD":"$25.50 할인","JPY":"¥3,000 할인","C…` | `multi_currency_benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| target_type | string | `shipping_fee` | 적용대상(할인 기준): product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | +| target_type_short_label | string | `배송비` | `target_type_short` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| valid_period_formatted | string | `2026-07-23 ~ 2026-09-21` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| min_order_amount | string | `20000.00` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | +| min_order_amount_formatted | string | `¥20,000` | `min_order_amount` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| multi_currency_min_order_amount | object | `{"KRW":{"price":20000,"formatted":"20,000원","is_default":…` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| total_quantity | integer | `300` | 총 발급 수량 (null=무제한) | +| remaining_quantity | integer | `299` | 잔여 발급 가능 수량 (total_quantity − issued_count, 무제한이면 null) | +| is_downloaded | boolean | `true` | downloaded 여부 | **응답 예시** @@ -3926,26 +3262,106 @@ HTTP/1.1 200 "data": { "data": [ { - "coupon_id": 1, - "localized_name": "API 문서 샘플 쿠폰", - "benefit_formatted": "1,000원 할인", + "coupon_id": 107, + "localized_name": "카테고리 배송비 3,000원 할인", + "benefit_formatted": "¥3,000 할인", "multi_currency_benefit_formatted": { - "KRW": "1,000원 할인", - "USD": "$0.85 할인", - "JPY": "¥115 할인", - "CNY": "元5.80 할인", - "EUR": "€0.78 할인" + "KRW": "3,000원 할인", + "USD": "$25.50 할인", + "JPY": "¥3,000 할인", + "CNY": "元174.00 할인", + "EUR": "€23.40 할인" }, - "target_type": "order_amount", - "target_type_short_label": "주문", - "valid_period_formatted": "-", - "min_order_amount": "0.00", - "min_order_amount_formatted": "0원", - "multi_currency_min_order_amount": null, - "total_quantity": null, - "remaining_quantity": null, - "is_downloaded": false - } + "target_type": "shipping_fee", + "target_type_short_label": "배송비", + "valid_period_formatted": "2026-07-23 ~ 2026-09-21", + "min_order_amount": "20000.00", + "min_order_amount_formatted": "¥20,000", + "multi_currency_min_order_amount": { + "KRW": { + "price": 20000, + "formatted": "20,000원", + "is_default": true + }, + "USD": { + "price": 170, + "formatted": "$170.00", + "is_default": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 20000, + "formatted": "¥20,000", + "is_default": true + }, + "CNY": { + "price": 1160, + "formatted": "元1,160.00", + "is_default": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 156, + "formatted": "€156.00", + "is_default": false, + "exchange_rate": 0.78 + } + }, + "total_quantity": 300, + "remaining_quantity": 299, + "is_downloaded": true + }, + { + "coupon_id": 103, + "localized_name": "배송비 50% 할인", + "benefit_formatted": "50.00% 할인 (최대 ¥3,000)", + "multi_currency_benefit_formatted": { + "KRW": "50.00% 할인 (최대 3,000원)", + "USD": "50.00% 할인 (최대 $25.50)", + "JPY": "50.00% 할인 (최대 ¥3,000)", + "CNY": "50.00% 할인 (최대 元174.00)", + "EUR": "50.00% 할인 (최대 €23.40)" + }, + "target_type": "shipping_fee", + "target_type_short_label": "배송비", + "valid_period_formatted": "발급일로부터 14일", + "min_order_amount": "20000.00", + "min_order_amount_formatted": "¥20,000", + "multi_currency_min_order_amount": { + "KRW": { + "price": 20000, + "formatted": "20,000원", + "is_default": true + }, + "USD": { + "price": 170, + "formatted": "$170.00", + "is_default": false, + "exchange_rate": 0.85 + }, + "JPY": { + "price": 20000, + "formatted": "¥20,000", + "is_default": true + }, + "CNY": { + "price": 1160, + "formatted": "元1,160.00", + "is_default": false, + "exchange_rate": 5.8 + }, + "EUR": { + "price": 156, + "formatted": "€156.00", + "is_default": false, + "exchange_rate": 0.78 + } + }, + "total_quantity": 1000, + "remaining_quantity": 938, + "is_downloaded": true + }, + "... (총 8건 중 2건 표시)" ] } } @@ -3963,7 +3379,7 @@ HTTP/1.1 200 **설명** 특정 상품에서 다운로드 가능한 쿠폰 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `UserCouponService::getProductDownloadableCoupons()`가 해당 상품에 적용 가능한 발급 대기 쿠폰을 반환합니다. 로그인 상태면 사용자 ID를 함께 넘겨 각 쿠폰의 `is_downloaded`(이미 받았는지) 여부를 채워주고, 다중통화 혜택·최소주문금액(`multi_currency_benefit_formatted` 등)이 포함됩니다. 상품 상세의 쿠폰 받기 영역에 사용됩니다. -### GET /api/modules/sirsoft-ecommerce/products/{productId}/inquiries +### GET /api/modules/sirsoft-ecommerce/products/{product}/inquiries - **라우트명**: `api.modules.sirsoft-ecommerce.products.inquiries.index` - **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductInquiryController@index` @@ -3973,12 +3389,15 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| productId | path | string | 예 | — | 대상 product의 식별자 | +| product | path | string | 예 | — | 대상 product의 식별자 | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | +| exclude_secret | query | boolean | 아니오 | — | 비밀글 제외 여부 (기본 `false` — 포함). `true` 면 비밀 문의를 목록에서 제외합니다. 쿼리 문자열 `"true"`/`"false"` 도 해석되며, 해석할 수 없는 값은 그대로 검증되어 422 가 됩니다 | **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/products/1/inquiries HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/products/{product}/inquiries HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) @@ -3990,8 +3409,8 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| items | array | `[]` | 상품문의 항목 배열 (각 항목: id·작성자·작성일·답변 여부·게시판 연동 시 제목/내용/비밀글 여부/답변/첨부) | -| meta | object | `{"board_settings":{"secret_mode":"disabled","categories":…` | 문의 목록 메타 (board_settings 게시판 설정, inquiry_available 문의 게시판 연동 여부, current_page/per_page/total/last_page 페이지네이션, abilities 답변·삭제 권한) | +| items | array | `[{"id":60,"post_id":284,"user_id":1116,"author_name":null…` | 상품문의 항목 배열 (각 항목: id·작성자·작성일·답변 여부·게시판 연동 시 제목/내용/비밀글 여부/답변/첨부) | +| meta | object | `{"board_settings":{"secret_mode":"always","categories":[]…` | 문의 목록 메타 (board_settings 게시판 설정, inquiry_available 문의 게시판 연동 여부, current_page/per_page/total/last_page 페이지네이션, abilities 답변·삭제 권한) | **응답 예시** @@ -4004,25 +3423,63 @@ HTTP/1.1 200 "success": true, "message": "문의 목록을 조회했습니다.", "data": { - "items": [], + "items": [ + { + "id": 60, + "post_id": 284, + "user_id": 1116, + "author_name": null, + "title": "[S34-OTHER-3] 타인 문의", + "...": "(9개 키 생략, 총 14개)" + }, + { + "id": 59, + "post_id": 283, + "user_id": 1116, + "author_name": null, + "title": "[S34-OTHER-2] 타인 문의", + "...": "(9개 키 생략, 총 14개)" + }, + { + "id": 58, + "post_id": 282, + "user_id": 1116, + "author_name": null, + "title": "[S34-OTHER-1] 타인 문의", + "...": "(9개 키 생략, 총 14개)" + }, + { + "id": 19, + "post_id": 223, + "user_id": 1209, + "author_name": "최***자", + "title": "[S33-T4-01] 배송 기간 문의드립니다", + "...": "(9개 키 생략, 총 14개)" + }, + { + "id": 20, + "post_id": 224, + "user_id": 1209, + "author_name": "최***자", + "title": "[S33-2] 재입고 관련 문의", + "...": "(9개 키 생략, 총 14개)" + }, + "... (총 25건 중 5건 표시)" + ], "meta": { "board_settings": { "secret_mode": "{MASKED}", "categories": [], - "use_file_upload": false, - "max_file_count": 5, - "max_file_size": 10485760, - "allowed_extensions": [], - "min_title_length": 2, - "max_title_length": 200, - "min_content_length": 10, - "max_content_length": 10000 + "use_file_upload": true, + "max_file_count": 3, + "max_file_size": 5, + "...": "(7개 키 생략, 총 12개)" }, - "inquiry_available": false, - "total": 0, + "inquiry_available": true, "current_page": 1, "per_page": 25, - "last_page": 1 + "total": 36, + "...": "(2개 키 생략, 총 7개)" } } } @@ -4033,6 +3490,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -4040,7 +3498,7 @@ HTTP/1.1 200 **설명** 상품의 1:1 문의 목록을 조회합니다. `optional.sanctum`(회원/비회원 모두 접근) + `sirsoft-ecommerce.user-products.read` 권한이 적용되며, `ProductInquiryService::getProductInquiries()`가 게시판 모듈과 연동된 문의 글을 페이지네이션해 `items`와 `board_settings`(비밀글 모드·카테고리 등) 메타를 반환합니다. `per_page`/`page`/`exclude_secret` 쿼리로 조회 범위를 조정하며, 비밀 문의는 설정과 열람 권한에 따라 마스킹됩니다. 상품 상세의 문의 탭에 사용됩니다. -### POST /api/modules/sirsoft-ecommerce/products/{productId}/inquiries +### POST /api/modules/sirsoft-ecommerce/products/{product}/inquiries - **라우트명**: `api.modules.sirsoft-ecommerce.products.inquiries.store` - **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductInquiryController@store` @@ -4050,10 +3508,10 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| productId | path | string | 예 | — | 대상 product의 식별자 | -| title | body | string | 아니오 | — | 제목 | +| product | path | string | 예 | — | 대상 product의 식별자 | +| title | body | string | 아니오 | min 2, max 200 | 제목 | | category | body | string | 아니오 | — | 문의 분류 (게시판 설정에 정의된 카테고리, 미지정 시 기본값) | -| content | body | string | 예 | — | 본문 내용 | +| content | body | string | 예 | min 10, max 10000 | 본문 내용 | | is_secret | body | boolean | 아니오 | — | secret 여부 | | temp_key | body | string | 아니오 | — | 첨부파일 임시 업로드 키 (사전 업로드한 첨부를 이 문의에 연결) | @@ -4062,7 +3520,7 @@ HTTP/1.1 200 **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/products/1/inquiries HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/products/{product}/inquiries HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) @@ -4079,26 +3537,42 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 상품 문의 글의 기본 키 (게시판 모듈에 생성된 게시글 ID) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "문의가 등록되었습니다.", + "data": { + "id": 1 + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 도메인 규칙 위반(비밀글 비허용 등, `RuntimeException` → `messages.inquiries.create_failed`) | **설명** 상품에 1:1 문의를 작성합니다. `optional.sanctum` + `sirsoft-ecommerce.user-products.read` 권한이 적용되며(선택적 인증 표면이지만 실제 작성은 인증 사용자를 전제), `ProductInquiryService::createInquiry()`가 게시판 모듈과 연동해 문의 글을 생성하고 성공 시 201과 생성된 `id`를 반환합니다. `content`는 필수, `title`/`category`/`is_secret`은 선택이며, 첨부는 사전 업로드한 `temp_key`로 연결됩니다. 도메인 규칙 위반(비밀글 비허용 등)은 `RuntimeException`으로 422를 반환합니다. -### GET /api/modules/sirsoft-ecommerce/products/{productId}/reviews +### GET /api/modules/sirsoft-ecommerce/products/{product}/reviews - **라우트명**: `api.modules.sirsoft-ecommerce.products.reviews.index` - **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Public\ProductReviewController@index` @@ -4108,7 +3582,7 @@ Content-Type: application/json | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | -| productId | path | string | 예 | — | 대상 product의 식별자 | +| product | path | string | 예 | — | 대상 product의 식별자 | | sort | query | string | 아니오 | `created_at_desc`, `created_at_asc`, `rating_desc`, `rating_asc` | 정렬 기준 (필드명, `-` 접두 시 내림차순) | | photo_only | query | string | 아니오 | `0`, `1`, `true`, `false` | 포토리뷰만 필터 (true 시 사진이 첨부된 리뷰만 조회) | | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | @@ -4121,7 +3595,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/products/1/reviews?sort=created_at_desc&photo_only=0&page=1&per_page=1&rating=1&option_filters=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/products/{product}/reviews?sort=created_at_desc&photo_only=0&page=1&per_page=1&rating=1&option_filters=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생략 가능) @@ -4133,10 +3607,10 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| reviews | object | `{"data":[{"id":1,"product_id":1,"order_option_id":1,"user…` | 리뷰 목록 (data 배열 + 페이지네이션 메타, 공개 상태 리뷰만 정렬/필터 적용해 반환) | -| rating_stats | object | `{"5":{"count":1,"percent":100},"4":{"count":0,"percent":0…` | 별점 분포 통계 (별점(1~5)별 count·percent 맵 + avg 평균 별점, 공개 리뷰 기준) | -| option_filters | array | `[]` | 옵션 조건 필터 후보 (구매 옵션 키·값별 리뷰 건수 집계, 옵션별 리뷰 필터 UI용) | -| total_count | integer | `1` | total 개수 (집계) | +| reviews | object | `{"data":[],"links":{"first":"https:\/\/g7_2.dev\/api\/mod…` | 리뷰 목록 (data 배열 + 페이지네이션 메타, 공개 상태 리뷰만 정렬/필터 적용해 반환) | +| rating_stats | object | `{"5":{"count":0,"percent":0},"4":{"count":0,"percent":0},…` | 별점 분포 통계 (별점(1~5)별 count·percent 맵 + avg 평균 별점, 공개 리뷰 기준) | +| option_filters | array | `[{"key":"색상","values":[{"value":"화이트","count":0},{"value"…` | 옵션 조건 필터 후보 (구매 옵션 키·값별 리뷰 건수 집계, 옵션별 리뷰 필터 UI용) | +| total_count | integer | `0` | total 개수 (집계) | **응답 예시** @@ -4150,51 +3624,16 @@ HTTP/1.1 200 "message": "리뷰 목록을 조회했습니다.", "data": { "reviews": { - "data": [ - { - "id": 1, - "product_id": 1, - "order_option_id": 1, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자", - "email": "apidoc-sample-user@example.com" - }, - "option_snapshot": null, - "option_snapshot_label": "", - "rating": 5, - "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", - "content_mode": "text", - "status": "visible", - "status_label": "전시중", - "status_badge_color": "blue", - "images": [], - "image_count": 0, - "has_reply": false, - "has_reply_label": "미답변", - "has_reply_badge_color": "gray", - "reply_content": null, - "reply_content_mode": "text", - "replied_at": null, - "reply_updated_at": null, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_update": true, - "can_delete": true - } - } - ], + "data": [], "links": { - "first": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1/reviews?page=1", - "last": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1/reviews?page=1", + "first": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1732/reviews?page=1", + "last": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1732/reviews?page=1", "prev": null, "next": null }, "meta": { "current_page": 1, - "from": 1, + "from": null, "last_page": 1, "links": [ { @@ -4204,7 +3643,7 @@ HTTP/1.1 200 "active": false }, { - "url": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1/reviews?page=1", + "url": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1732/reviews?page=1", "label": "1", "page": 1, "active": true @@ -4216,16 +3655,16 @@ HTTP/1.1 200 "active": false } ], - "path": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1/reviews", + "path": "https://api.example.com/api/modules/sirsoft-ecommerce/products/1732/reviews", "per_page": 25, - "to": 1, - "total": 1 + "to": null, + "total": 0 } }, "rating_stats": { "5": { - "count": 1, - "percent": 100 + "count": 0, + "percent": 0 }, "4": { "count": 0, @@ -4243,10 +3682,28 @@ HTTP/1.1 200 "count": 0, "percent": 0 }, - "avg": 5 + "avg": 0 }, - "option_filters": [], - "total_count": 1 + "option_filters": [ + { + "key": "색상", + "values": [ + { + "value": "화이트", + "count": 0 + }, + { + "value": "블루", + "count": 0 + }, + { + "value": "핑크", + "count": 0 + } + ] + } + ], + "total_count": 0 } } ``` @@ -4256,8 +3713,8 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md index fb590202..9ae14fea 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Promotion Coupons 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -29,7 +29,7 @@ | --- | --- | --- | --- | --- | --- | | page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | | per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | -| sort_by | query | string | 아니오 | `created_at`, `name`, `discount_value`, `issued_count` | 정렬 기준 필드명 | +| sort_by | query | string | 아니오 | `created_at`, `name`, `discount_value`, `issued_count`, `valid_to` | 정렬 기준 필드명 (그 외 값은 422). `valid_to` 는 유효기간 종료일 | | sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | | search_field | query | string | 아니오 | `all`, `name`, `description`, `created_by` | 검색 대상 필드명 (검색어를 적용할 컬럼) | | search_keyword | query | string | 아니오 | max 255 | 검색 키워드 (부분 일치) | @@ -64,54 +64,54 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| id | integer | `157` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 쿠폰","en":"API Doc Sample Coupon"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 쿠폰` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| description | object | `{"ko":null,"en":null}` | 설명 (다국어 필드는 로케일별 값 객체) | +| id | integer | `110` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"설날 무료배송 (예정)","en":"Lunar New Year Free Shipping (…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| localized_name | string | `설날 무료배송 (예정)` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | +| description | object | `{"ko":"설날 특별 무료배송 예정","en":"Upcoming Lunar New Year free …` | 설명 (다국어 필드는 로케일별 값 객체) | | localized_description | string | `설날 특별 무료배송 예정` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| target_type | string | `order_amount` | 적용대상(할인 기준): product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | -| target_type_label | string | `주문금액` | `target_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| target_type_badge_color | string | `blue` | `target_type` 배지 색상 (상품금액=teal, 주문금액=blue, 배송비=orange) | +| target_type | string | `shipping_fee` | 적용대상(할인 기준): product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | +| target_type_label | string | `배송비` | `target_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| target_type_badge_color | string | `orange` | `target_type` 배지 색상 (상품금액=teal, 주문금액=blue, 배송비=orange) | | discount_type | string | `fixed` | 혜택유형: fixed(정액 금액 할인), rate(정률 % 할인) | | discount_type_label | string | `정액할인` | `discount_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| discount_value | integer | `1000` | 혜택값 (정액이면 할인 금액, 정률이면 할인율 %). 정액은 기본 통화 자릿수로 정규화 | +| discount_value | integer | `5000` | 혜택값 (정액이면 할인 금액, 정률이면 할인율 %). 정액은 기본 통화 자릿수로 정규화 | | discount_max_amount | integer | `2000` | 최대 할인액 (정률 할인 시 상한 금액, 미설정 시 null) | -| min_order_amount | integer | `0` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | -| benefit_formatted | string | `1,000원 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| multi_currency_discount_value | object | `{"KRW":{"price":1000,"formatted":"1,000원","is_default":tr…` | 정액 할인 금액의 통화별 환산 맵 (정률은 통화 무관이라 null) | -| multi_currency_min_order_amount | object | `{"KRW":{"price":10000,"formatted":"10,000원","is_default":…` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| min_order_amount | integer | `20000` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | +| benefit_formatted | string | `¥5,000 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| multi_currency_discount_value | object | `{"KRW":{"price":5000,"formatted":"5,000원","is_default":tr…` | 정액 할인 금액의 통화별 환산 맵 (정률은 통화 무관이라 null) | +| multi_currency_min_order_amount | object | `{"KRW":{"price":20000,"formatted":"20,000원","is_default":…` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | | multi_currency_discount_max_amount | object | `{"KRW":{"price":2000,"formatted":"2,000원","is_default":tr…` | 최대 할인액의 통화별 환산 맵 (미설정 시 null) | -| issue_method | string | `download` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | -| issue_method_label | string | `다운로드` | `issue_method` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| issue_method_badge_color | string | `teal` | `issue_method` 배지 색상 (직접발급=gray, 다운로드=teal, 자동발급=blue) | +| issue_method | string | `direct` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | +| issue_method_label | string | `직접발급` | `issue_method` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | +| issue_method_badge_color | string | `gray` | `issue_method` 배지 색상 (직접발급=gray, 다운로드=teal, 자동발급=blue) | | issue_condition | string | `manual` | 발급조건: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) | | issue_condition_label | string | `수동발급` | `issue_condition` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | issue_condition_badge_color | string | `orange` | `issue_condition` 배지 색상 (수동=orange, 회원가입=blue, 첫구매=teal, 생일=pink) | | issue_status | string | `issuing` | 발급상태: issuing(발급중), stopped(발급중단) | | issue_status_label | string | `발급중` | `issue_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | issue_status_badge_color | string | `blue` | `issue_status` 배지 색상 (발급중=blue, 발급중단=orange) | -| total_quantity | integer | `1` | 총 발급 수량 (null=무제한) | -| issued_count | integer | `0` | issued 개수 (집계) | +| total_quantity | integer | `3000` | 총 발급 수량 (null=무제한) | +| issued_count | integer | `59` | issued 개수 (집계) | | per_user_limit | integer | `1` | 회원 1인당 발급 제한 수량 (0=무제한) | -| issue_count_formatted | string | `0/무제한` | `issue_count` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| issue_count_formatted | string | `59/3000` | `issue_count` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | | valid_type | string | `period` | 유효기간 유형: period(기간 지정), days_from_issue(발급일로부터 N일) | -| valid_days | integer | `1` | 발급일로부터 유효 일수 (valid_type=days_from_issue 일 때) | -| valid_from | string | `2026-06-30` | 유효기간 시작일 (사이트 타임존 기준 날짜 문자열) | -| valid_to | string | `2026-07-30` | 유효기간 종료일 (사이트 타임존 기준 날짜 문자열) | -| valid_period_formatted | string | `-` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| issue_from | string | `2026-06-30T11:24` | 발급기간 시작 일시 (datetime-local 입력 호환 문자열) | -| issue_to | string | `2026-07-15T11:24` | 발급기간 종료 일시 (datetime-local 입력 호환 문자열) | -| issue_period_formatted | string | `상시발급` | `issue_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| is_combinable | boolean | `false` | combinable 여부 | +| valid_days | integer | `7` | 발급일로부터 유효 일수 (valid_type=days_from_issue 일 때) | +| valid_from | string | `2026-08-14` | 유효기간 시작일 (사이트 타임존 기준 날짜 문자열) | +| valid_to | string | `2026-09-13` | 유효기간 종료일 (사이트 타임존 기준 날짜 문자열) | +| valid_period_formatted | string | `2026-08-14 ~ 2026-09-13` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| issue_from | string | `2026-08-14T23:36` | 발급기간 시작 일시 (datetime-local 입력 호환 문자열) | +| issue_to | string | `2026-08-29T23:36` | 발급기간 종료 일시 (datetime-local 입력 호환 문자열) | +| issue_period_formatted | string | `2026-08-14 ~ 2026-08-29` | `issue_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | +| is_combinable | boolean | `true` | combinable 여부 | | target_scope | string | `all` | 적용 범위: all(전체 상품), products(특정 상품), categories(특정 카테고리) | | target_scope_label | string | `전체상품` | `target_scope` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| is_issuable | boolean | `true` | issuable 여부 | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | -| created_by | string | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 등록자(생성한 관리자) UUID (creator 관계 로드 시) | +| is_issuable | boolean | `false` | issuable 여부 | +| created_at | string | `2026-07-30 23:36:17` | 생성 일시 | +| updated_at | string | `2026-07-31 07:42:40` | 최종 수정 일시 | +| created_by | null | `null` | 등록자(생성한 관리자) UUID (creator 관계 로드 시) | | created_by_name | string | `-` | 등록자 이름 (creator 미로드/미설정 시 `-`) | -| created_by_email | string | `heuristing@gmail.com` | 등록자 이메일 (creator 관계 파생) | -| creator | object | `{"uuid":"a1e0a91a-fba6-491c-a53e-7285a5686857","name":"관리자"}` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | +| created_by_email | null | `null` | 등록자 이메일 (creator 관계 파생) | +| creator | null | `null` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | | issues_count | integer | `0` | issues 개수 (집계) | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -128,99 +128,34 @@ HTTP/1.1 200 "data": { "data": [ { - "id": 1, + "id": 110, "name": { - "ko": "API 문서 샘플 쿠폰", - "en": "API Doc Sample Coupon" + "ko": "설날 무료배송 (예정)", + "en": "Lunar New Year Free Shipping (Upcoming)" }, - "localized_name": "API 문서 샘플 쿠폰", - "description": null, - "localized_description": null, - "target_type": "order_amount", - "target_type_label": "주문금액", - "target_type_badge_color": "blue", - "discount_type": "fixed", - "discount_type_label": "정액할인", - "discount_value": 1000, - "discount_max_amount": null, - "min_order_amount": 0, - "benefit_formatted": "1,000원 할인", - "multi_currency_discount_value": { - "KRW": { - "price": 1000, - "formatted": "1,000원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 0.85, - "formatted": "$0.85", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 115, - "formatted": "¥115", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 5.8, - "formatted": "元5.80", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 0.78, - "formatted": "€0.78", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } + "localized_name": "설날 무료배송 (예정)", + "description": { + "ko": "설날 특별 무료배송 예정", + "en": "Upcoming Lunar New Year free shipping" }, - "multi_currency_min_order_amount": null, - "multi_currency_discount_max_amount": null, - "issue_method": "download", - "issue_method_label": "다운로드", - "issue_method_badge_color": "teal", - "issue_condition": "manual", - "issue_condition_label": "수동발급", - "issue_condition_badge_color": "orange", - "issue_status": "issuing", - "issue_status_label": "발급중", - "issue_status_badge_color": "blue", - "total_quantity": null, - "issued_count": 0, - "per_user_limit": 1, - "issue_count_formatted": "0/무제한", - "valid_type": "period", - "valid_days": null, - "valid_from": null, - "valid_to": null, - "valid_period_formatted": "-", - "issue_from": null, - "issue_to": null, - "issue_period_formatted": "상시발급", - "is_combinable": false, - "target_scope": "all", - "target_scope_label": "전체상품", - "is_issuable": true, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "created_by": null, - "created_by_name": "-", - "created_by_email": null, - "creator": null, - "issues_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } + "localized_description": "설날 특별 무료배송 예정", + "...": "(45개 키 생략, 총 50개)" + }, + { + "id": 109, + "name": { + "ko": "배송비 30% 할인 (종료)", + "en": "Shipping 30% Off (Ended)" + }, + "localized_name": "배송비 30% 할인 (종료)", + "description": { + "ko": "배송비 30% 할인 이벤트 종료", + "en": "Shipping 30% off event ended" + }, + "localized_description": "배송비 30% 할인 이벤트 종료", + "...": "(45개 키 생략, 총 50개)" + }, + "... (총 25건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -229,12 +164,11 @@ HTTP/1.1 200 }, "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 2, "per_page": 25, - "total": 1, + "total": 50, "from": 1, - "to": 1, - "has_more_pages": false + "...": "(2개 키 생략, 총 7개)" } } } @@ -333,11 +267,150 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 쿠폰 = `CouponResource`, 상세 조회(show)와 동일한 필드 집합)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 쿠폰의 기본 키 | +| name | object | `{"ko":"신규가입 쿠폰","en":"Signup Coupon"}` | 쿠폰명 (로케일별 값 객체) | +| localized_name | string | `신규가입 쿠폰` | `name` 의 현재 로케일 해석 값 | +| description | object\|null | `null` | 쿠폰 설명 (로케일별 값 객체) | +| localized_description | string\|null | `null` | `description` 의 현재 로케일 해석 값 | +| target_type | string | `order_amount` | 적용대상: product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | +| target_type_label | string | `주문금액` | `target_type` 의 다국어 라벨 | +| target_type_badge_color | string | `blue` | `target_type` 배지 색상 (상품금액=teal, 주문금액=blue, 배송비=orange) | +| discount_type | string | `fixed` | 혜택유형: fixed(정액), rate(정률 %) | +| discount_type_label | string | `정액할인` | `discount_type` 의 다국어 라벨 | +| discount_value | number | `1000` | 혜택값 (정액=할인 금액, 정률=할인율 %) | +| discount_max_amount | number\|null | `null` | 최대 할인액 (정률 시 상한, 미설정 null) | +| min_order_amount | number | `0` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | +| benefit_formatted | string | `1,000원 할인` | 혜택 표시용 포맷 문자열 | +| multi_currency_discount_value | object\|null | `{"KRW":{"price":1000,"formatted":"1,000원","is_default":true,"editable":true}}` | 정액 할인 금액의 통화별 환산 맵 (정률은 null) | +| multi_currency_min_order_amount | object\|null | `null` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| multi_currency_discount_max_amount | object\|null | `null` | 최대 할인액의 통화별 환산 맵 (미설정 시 null) | +| issue_method | string | `download` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | +| issue_method_label | string | `다운로드` | `issue_method` 의 다국어 라벨 | +| issue_method_badge_color | string | `teal` | `issue_method` 배지 색상 (직접발급=gray, 다운로드=teal, 자동발급=blue) | +| issue_condition | string | `manual` | 발급조건: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) | +| issue_condition_label | string | `수동발급` | `issue_condition` 의 다국어 라벨 | +| issue_condition_badge_color | string | `orange` | `issue_condition` 배지 색상 | +| issue_status | string | `issuing` | 발급상태: issuing(발급중), stopped(발급중단) | +| issue_status_label | string | `발급중` | `issue_status` 의 다국어 라벨 | +| issue_status_badge_color | string | `blue` | `issue_status` 배지 색상 (발급중=blue, 발급중단=orange) | +| total_quantity | integer\|null | `null` | 총 발급 수량 (null=무제한) | +| issued_count | integer | `0` | 현재까지 발급된 수량 (생성 직후 0) | +| per_user_limit | integer | `1` | 회원 1인당 발급 제한 수량 (0=무제한) | +| issue_count_formatted | string | `0/무제한` | 발급 수량 표시용 포맷 문자열 | +| valid_type | string | `period` | 유효기간 유형: period(기간 지정), days_from_issue(발급일로부터 N일) | +| valid_days | integer\|null | `null` | 발급일로부터 유효 일수 (valid_type=days_from_issue) | +| valid_from | string\|null | `2026-07-01` | 유효기간 시작일 | +| valid_to | string\|null | `2026-07-31` | 유효기간 종료일 | +| valid_period_formatted | string | `-` | 유효기간 표시용 포맷 문자열 | +| issue_from | string\|null | `null` | 발급기간 시작 일시 (datetime-local 호환) | +| issue_to | string\|null | `null` | 발급기간 종료 일시 (datetime-local 호환) | +| issue_period_formatted | string | `상시발급` | 발급기간 표시용 포맷 문자열 | +| is_combinable | boolean | `false` | 다른 쿠폰과 중복 사용 가능 여부 | +| target_scope | string | `all` | 적용 범위: all(전체 상품), products(특정 상품), categories(특정 카테고리) | +| target_scope_label | string | `전체상품` | `target_scope` 의 다국어 라벨 | +| is_issuable | boolean | `true` | 현재 발급 가능 상태인지 여부 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| created_by | string\|null | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 등록자(생성한 관리자) 식별자 | +| created_by_name | string | `관리자` | 등록자 이름 (미설정 시 `-`) | +| created_by_email | string\|null | `admin@example.com` | 등록자 이메일 (creator 관계 파생) | +| creator | object\|null | `{"uuid":"a1e0a91a-…","name":"관리자"}` | 생성자 정보 객체 | +| included_products | array | `[]` | 적용 대상 포함 상품 목록 (target_scope=products) | +| excluded_products | array | `[]` | 적용 제외 상품 목록 | +| included_categories | array | `[]` | 적용 대상 포함 카테고리 목록 (target_scope=categories) | +| excluded_categories | array | `[]` | 적용 제외 카테고리 목록 | +| issues_count | integer | `0` | 발급 내역 건수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "쿠폰이 등록되었습니다.", + "data": { + "id": 2, + "name": { + "ko": "신규가입 쿠폰", + "en": "Signup Coupon" + }, + "localized_name": "신규가입 쿠폰", + "description": null, + "localized_description": null, + "target_type": "order_amount", + "target_type_label": "주문금액", + "target_type_badge_color": "blue", + "discount_type": "fixed", + "discount_type_label": "정액할인", + "discount_value": 1000, + "discount_max_amount": null, + "min_order_amount": 0, + "benefit_formatted": "1,000원 할인", + "multi_currency_discount_value": { + "KRW": { + "price": 1000, + "formatted": "1,000원", + "is_default": true, + "editable": true + } + }, + "multi_currency_min_order_amount": null, + "multi_currency_discount_max_amount": null, + "issue_method": "download", + "issue_method_label": "다운로드", + "issue_method_badge_color": "teal", + "issue_condition": "manual", + "issue_condition_label": "수동발급", + "issue_condition_badge_color": "orange", + "issue_status": "issuing", + "issue_status_label": "발급중", + "issue_status_badge_color": "blue", + "total_quantity": null, + "issued_count": 0, + "per_user_limit": 1, + "issue_count_formatted": "0/무제한", + "valid_type": "period", + "valid_days": null, + "valid_from": "2026-07-01", + "valid_to": "2026-07-31", + "valid_period_formatted": "2026-07-01 ~ 2026-07-31", + "issue_from": null, + "issue_to": null, + "issue_period_formatted": "상시발급", + "is_combinable": false, + "target_scope": "all", + "target_scope_label": "전체상품", + "is_issuable": true, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "created_by": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "created_by_name": "관리자", + "created_by_email": "admin@example.com", + "creator": { + "uuid": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "name": "관리자" + }, + "included_products": [], + "excluded_products": [], + "included_categories": [], + "excluded_categories": [], + "issues_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -384,11 +457,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `3` | 발급상태가 실제로 변경된 쿠폰 건수 (`CouponService::bulkUpdateIssueStatus()` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "3개 쿠폰의 발급상태가 변경되었습니다.", + "data": { + "updated_count": 3 + } +} +``` **에러 응답** @@ -418,7 +507,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -426,27 +515,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| coupon_id | integer | `1` | coupon 식별자 (연관 리소스 참조) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "쿠폰이 삭제되었습니다.", - "data": { - "coupon_id": 1 - } -} -``` + **에러 응답** @@ -476,7 +549,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -484,175 +557,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 쿠폰","en":"API Doc Sample Coupon"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 쿠폰` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| description | null | `null` | 설명 (다국어 필드는 로케일별 값 객체) | -| localized_description | null | `null` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| target_type | string | `order_amount` | 적용대상: product_amount(상품금액), order_amount(주문금액), shipping_fee(배송비) | -| target_type_label | string | `주문금액` | `target_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| target_type_badge_color | string | `blue` | `target_type` 배지 색상 (상품금액=teal, 주문금액=blue, 배송비=orange) | -| discount_type | string | `fixed` | 혜택유형: fixed(정액), rate(정률) | -| discount_type_label | string | `정액할인` | `discount_type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| discount_value | integer | `1000` | 혜택값 (정액: 금액, 정률: %) | -| discount_max_amount | null | `null` | 최대 할인액 (정률 시) | -| min_order_amount | integer | `0` | 최소 주문금액 | -| benefit_formatted | string | `1,000원 할인` | `benefit` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| multi_currency_discount_value | object | `{"KRW":{"price":1000,"formatted":"1,000원","is_default":tr…` | 정액 할인 금액의 통화별 환산 맵 (정률은 통화 무관이라 null) | -| multi_currency_min_order_amount | null | `null` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | -| multi_currency_discount_max_amount | null | `null` | 최대 할인액의 통화별 환산 맵 (미설정 시 null) | -| issue_method | string | `download` | 발급방법: direct(직접발급), download(다운로드), auto(자동발급) | -| issue_method_label | string | `다운로드` | `issue_method` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| issue_method_badge_color | string | `teal` | `issue_method` 배지 색상 (직접발급=gray, 다운로드=teal, 자동발급=blue) | -| issue_condition | string | `manual` | 발급조건: manual(수동), signup(회원가입), first_purchase(첫구매), birthday(생일) | -| issue_condition_label | string | `수동발급` | `issue_condition` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| issue_condition_badge_color | string | `orange` | `issue_condition` 배지 색상 (수동=orange, 회원가입=blue, 첫구매=teal, 생일=pink) | -| issue_status | string | `issuing` | 발급상태: issuing(발급중), stopped(발급중단) | -| issue_status_label | string | `발급중` | `issue_status` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| issue_status_badge_color | string | `blue` | `issue_status` 배지 색상 (발급중=blue, 발급중단=orange) | -| total_quantity | null | `null` | 총 발급 수량 (NULL=무제한) | -| issued_count | integer | `0` | issued 개수 (집계) | -| per_user_limit | integer | `1` | 회원당 발급 제한 | -| issue_count_formatted | string | `0/무제한` | `issue_count` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| valid_type | string | `period` | 유효기간 유형: period(기간지정), days_from_issue(발급일로부터) | -| valid_days | null | `null` | 발급일로부터 N일 (valid_type=days_from_issue) | -| valid_from | null | `null` | 유효기간 시작 | -| valid_to | null | `null` | 유효기간 종료 | -| valid_period_formatted | string | `-` | `valid_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| issue_from | null | `null` | 발급기간 시작 | -| issue_to | null | `null` | 발급기간 종료 | -| issue_period_formatted | string | `상시발급` | `issue_period` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) | -| is_combinable | boolean | `false` | combinable 여부 | -| target_scope | string | `all` | 적용 범위: all(전체), products(특정상품), categories(특정카테고리) | -| target_scope_label | string | `전체상품` | `target_scope` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| is_issuable | boolean | `true` | issuable 여부 | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| created_by | null | `null` | 쿠폰 등록자(관리자) 식별자 (users 참조, 삭제 시 null) | -| created_by_name | string | `-` | 등록자 이름 (creator 미로드/미설정 시 `-`) | -| created_by_email | null | `null` | 등록자 이메일 (creator 관계 파생) | -| creator | null | `null` | 생성자 정보 객체 (uuid/name/email — creator 관계 파생) | -| included_products | array | `[]` | 적용 대상 포함 상품 목록 (target_scope=products 시 이 상품에만 적용) | -| excluded_products | array | `[]` | 적용 제외 상품 목록 (해당 상품은 쿠폰 적용에서 제외) | -| included_categories | array | `[]` | 적용 대상 포함 카테고리 목록 (target_scope=categories 시 이 카테고리에만 적용) | -| excluded_categories | array | `[]` | 적용 제외 카테고리 목록 (해당 카테고리는 쿠폰 적용에서 제외) | -| issues_count | integer | `0` | issues 개수 (집계) | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "쿠폰 정보를 조회했습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 쿠폰", - "en": "API Doc Sample Coupon" - }, - "localized_name": "API 문서 샘플 쿠폰", - "description": null, - "localized_description": null, - "target_type": "order_amount", - "target_type_label": "주문금액", - "target_type_badge_color": "blue", - "discount_type": "fixed", - "discount_type_label": "정액할인", - "discount_value": 1000, - "discount_max_amount": null, - "min_order_amount": 0, - "benefit_formatted": "1,000원 할인", - "multi_currency_discount_value": { - "KRW": { - "price": 1000, - "formatted": "1,000원", - "is_default": true, - "editable": true - }, - "USD": { - "price": 0.85, - "formatted": "$0.85", - "is_default": false, - "editable": false, - "exchange_rate": 0.85 - }, - "JPY": { - "price": 115, - "formatted": "¥115", - "is_default": false, - "editable": false, - "exchange_rate": 115 - }, - "CNY": { - "price": 5.8, - "formatted": "元5.80", - "is_default": false, - "editable": false, - "exchange_rate": 5.8 - }, - "EUR": { - "price": 0.78, - "formatted": "€0.78", - "is_default": false, - "editable": false, - "exchange_rate": 0.78 - } - }, - "multi_currency_min_order_amount": null, - "multi_currency_discount_max_amount": null, - "issue_method": "download", - "issue_method_label": "다운로드", - "issue_method_badge_color": "teal", - "issue_condition": "manual", - "issue_condition_label": "수동발급", - "issue_condition_badge_color": "orange", - "issue_status": "issuing", - "issue_status_label": "발급중", - "issue_status_badge_color": "blue", - "total_quantity": null, - "issued_count": 0, - "per_user_limit": 1, - "issue_count_formatted": "0/무제한", - "valid_type": "period", - "valid_days": null, - "valid_from": null, - "valid_to": null, - "valid_period_formatted": "-", - "issue_from": null, - "issue_to": null, - "issue_period_formatted": "상시발급", - "is_combinable": false, - "target_scope": "all", - "target_scope_label": "전체상품", - "is_issuable": true, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "created_by": null, - "created_by_name": "-", - "created_by_email": null, - "creator": null, - "included_products": [], - "excluded_products": [], - "included_categories": [], - "excluded_categories": [], - "issues_count": 0, - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -706,7 +615,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -748,20 +657,160 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (수정된 쿠폰 = `CouponResource`, 상세 조회(show)와 동일한 필드 집합)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 수정된 쿠폰의 기본 키 | +| name | object | `{"ko":"API 문서 샘플 쿠폰(수정)","en":"API Doc Sample Coupon (edited)"}` | 쿠폰명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 쿠폰(수정)` | `name` 의 현재 로케일 해석 값 | +| description | object\|null | `null` | 쿠폰 설명 (로케일별 값 객체) | +| localized_description | string\|null | `null` | `description` 의 현재 로케일 해석 값 | +| target_type | string | `order_amount` | 적용대상: product_amount, order_amount, shipping_fee | +| target_type_label | string | `주문금액` | `target_type` 의 다국어 라벨 | +| target_type_badge_color | string | `blue` | `target_type` 배지 색상 | +| discount_type | string | `fixed` | 혜택유형: fixed(정액), rate(정률 %) | +| discount_type_label | string | `정액할인` | `discount_type` 의 다국어 라벨 | +| discount_value | number | `2000` | 혜택값 (정액=할인 금액, 정률=할인율 %) | +| discount_max_amount | number\|null | `null` | 최대 할인액 (정률 시 상한) | +| min_order_amount | number | `0` | 쿠폰 적용 최소 주문금액 (0=제한 없음) | +| benefit_formatted | string | `2,000원 할인` | 혜택 표시용 포맷 문자열 | +| multi_currency_discount_value | object\|null | `{"KRW":{"price":2000,"formatted":"2,000원","is_default":true,"editable":true}}` | 정액 할인 금액의 통화별 환산 맵 (정률은 null) | +| multi_currency_min_order_amount | object\|null | `null` | 최소 주문금액의 통화별 환산 맵 (0이면 null) | +| multi_currency_discount_max_amount | object\|null | `null` | 최대 할인액의 통화별 환산 맵 (미설정 시 null) | +| issue_method | string | `download` | 발급방법: direct, download, auto | +| issue_method_label | string | `다운로드` | `issue_method` 의 다국어 라벨 | +| issue_method_badge_color | string | `teal` | `issue_method` 배지 색상 | +| issue_condition | string | `manual` | 발급조건: manual, signup, first_purchase, birthday | +| issue_condition_label | string | `수동발급` | `issue_condition` 의 다국어 라벨 | +| issue_condition_badge_color | string | `orange` | `issue_condition` 배지 색상 | +| issue_status | string | `issuing` | 발급상태: issuing(발급중), stopped(발급중단) | +| issue_status_label | string | `발급중` | `issue_status` 의 다국어 라벨 | +| issue_status_badge_color | string | `blue` | `issue_status` 배지 색상 | +| total_quantity | integer\|null | `null` | 총 발급 수량 (null=무제한) | +| issued_count | integer | `0` | 현재까지 발급된 수량 | +| per_user_limit | integer | `1` | 회원 1인당 발급 제한 수량 (0=무제한) | +| issue_count_formatted | string | `0/무제한` | 발급 수량 표시용 포맷 문자열 | +| valid_type | string | `period` | 유효기간 유형: period, days_from_issue | +| valid_days | integer\|null | `null` | 발급일로부터 유효 일수 | +| valid_from | string\|null | `2026-07-01` | 유효기간 시작일 | +| valid_to | string\|null | `2026-07-31` | 유효기간 종료일 | +| valid_period_formatted | string | `2026-07-01 ~ 2026-07-31` | 유효기간 표시용 포맷 문자열 | +| issue_from | string\|null | `null` | 발급기간 시작 일시 | +| issue_to | string\|null | `null` | 발급기간 종료 일시 | +| issue_period_formatted | string | `상시발급` | 발급기간 표시용 포맷 문자열 | +| is_combinable | boolean | `false` | 다른 쿠폰과 중복 사용 가능 여부 | +| target_scope | string | `all` | 적용 범위: all, products, categories | +| target_scope_label | string | `전체상품` | `target_scope` 의 다국어 라벨 | +| is_issuable | boolean | `true` | 현재 발급 가능 상태인지 여부 | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 11:02:13` | 최종 수정 일시 (수정 반영) | +| created_by | string\|null | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 등록자 식별자 | +| created_by_name | string | `관리자` | 등록자 이름 (미설정 시 `-`) | +| created_by_email | string\|null | `admin@example.com` | 등록자 이메일 | +| creator | object\|null | `{"uuid":"a1e0a91a-…","name":"관리자"}` | 생성자 정보 객체 | +| included_products | array | `[]` | 적용 대상 포함 상품 목록 (수정 시 동기화 결과) | +| excluded_products | array | `[]` | 적용 제외 상품 목록 | +| included_categories | array | `[]` | 적용 대상 포함 카테고리 목록 | +| excluded_categories | array | `[]` | 적용 제외 카테고리 목록 | +| issues_count | integer | `0` | 발급 내역 건수 (집계) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "API 문서 샘플 쿠폰(수정)", + "en": "API Doc Sample Coupon (edited)" + }, + "localized_name": "API 문서 샘플 쿠폰(수정)", + "description": null, + "localized_description": null, + "target_type": "order_amount", + "target_type_label": "주문금액", + "target_type_badge_color": "blue", + "discount_type": "fixed", + "discount_type_label": "정액할인", + "discount_value": 2000, + "discount_max_amount": null, + "min_order_amount": 0, + "benefit_formatted": "2,000원 할인", + "multi_currency_discount_value": { + "KRW": { + "price": 2000, + "formatted": "2,000원", + "is_default": true, + "editable": true + } + }, + "multi_currency_min_order_amount": null, + "multi_currency_discount_max_amount": null, + "issue_method": "download", + "issue_method_label": "다운로드", + "issue_method_badge_color": "teal", + "issue_condition": "manual", + "issue_condition_label": "수동발급", + "issue_condition_badge_color": "orange", + "issue_status": "issuing", + "issue_status_label": "발급중", + "issue_status_badge_color": "blue", + "total_quantity": null, + "issued_count": 0, + "per_user_limit": 1, + "issue_count_formatted": "0/무제한", + "valid_type": "period", + "valid_days": null, + "valid_from": "2026-07-01", + "valid_to": "2026-07-31", + "valid_period_formatted": "2026-07-01 ~ 2026-07-31", + "issue_from": null, + "issue_to": null, + "issue_period_formatted": "상시발급", + "is_combinable": false, + "target_scope": "all", + "target_scope_label": "전체상품", + "is_issuable": true, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 11:02:13", + "created_by": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "created_by_name": "관리자", + "created_by_email": "admin@example.com", + "creator": { + "uuid": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "name": "관리자" + }, + "included_products": [], + "excluded_products": [], + "included_categories": [], + "excluded_categories": [], + "issues_count": 0, + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 수정 처리 중 예외 발생 시 (`exceptions.operation_failed`: "작업 처리 중 오류가 발생했습니다.") — 대상 쿠폰 미존재도 이 경로로 응답 | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -784,7 +833,7 @@ Content-Type: application/json **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1/issue-direct HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id}/issue-direct HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -799,20 +848,48 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CouponService::issueDirectly()` 반환 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| issued | integer | `2` | 실제로 발급에 성공한 회원 수 | +| skipped | array | `[{"user_id":15,"reason":"1인당 발급 가능 수량을 초과했습니다."}]` | 발급이 건너뛰어진 회원 목록 (전체 롤백 없이 회원 단위로 누적) | +| skipped[].user_id | integer | `15` | 건너뛴 회원의 내부 회원 ID | +| skipped[].reason | string | `1인당 발급 가능 수량을 초과했습니다.` | 건너뛴 사유 (발급 검증 예외 메시지 원문) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "2명에게 발급했습니다. (1명은 발급 조건 미충족으로 제외)", + "data": { + "issued": 2, + "skipped": [ + { + "user_id": 15, + "reason": "1인당 발급 가능 수량을 초과했습니다." + } + ] + } +} +``` + +> 건너뛴 회원이 없으면 메시지는 `messages.coupons.direct_issued`(`":issued명에게 쿠폰을 발급했습니다."`)이고 `skipped` 는 `[]` 입니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 쿠폰 미존재 또는 쿠폰 자체가 발급 불가 상태(발급중단/기간 외/수량 소진 등)여서 전원 차단된 경우 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -839,7 +916,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1/issues?user_id=9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d&status=available&per_page=1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id}/issues?user_id=9f8b2c1a-4d3e-4a2b-8c1d-0e1f2a3b4c5d&status=available&per_page=1 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -847,34 +924,34 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ +_목록 응답: `data.data[]` 배열 항목의 필드 (`CouponIssueResource`) + `data.pagination`._ - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 발급 내역 ID (기본 키) | +| coupon_id | integer | `1` | 발급된 쿠폰(정의) ID | +| user_id | string\|null | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 발급 대상 회원의 UUID (내부 정수 ID 대신 UUID 노출) | +| coupon_code | string\|null | `WELCOME2026` | 쿠폰 코드 (다운로드 쿠폰 시 발급되는 코드, 없으면 null) | +| status | string | `available` | 발급 내역 상태: available(사용가능), used(사용완료), expired(만료), cancelled(취소) | +| status_label | string | `사용가능` | `status` 의 다국어 라벨 (사용가능/사용완료/기간만료/취소됨) | +| status_badge_color | string | `blue` | `status` 배지 색상 (available=blue, used=green, expired=gray, cancelled=red) | +| issued_at | string\|null | `2026-07-08 10:50:11` | 발급 일시 (사용자 타임존 포맷) | +| expired_at | string\|null | `2026-07-31 23:59:59` | 만료 일시 (사용자 타임존 포맷, 없으면 null) | +| used_at | string\|null | `null` | 사용 일시 (미사용 시 null) | +| order_id | integer\|null | `null` | 쿠폰이 사용된 주문 ID (미사용 시 null) | +| order_number | string\|null | `20260708-0001` | 사용된 주문의 주문번호 (order 관계 로드 시) | +| discount_amount | number\|null | `null` | 실제 적용된 할인 금액 (기본 통화 자릿수로 반올림, 미사용 시 null) | +| is_used | boolean | `false` | 사용 완료 여부 (`status === 'used'`) | +| is_expired | boolean | `false` | 만료 여부 | +| is_usable | boolean | `true` | 현재 사용 가능 여부 | +| is_cancellable | boolean | `true` | 관리자 취소 가능 여부 (available 이면서 미만료일 때만 true) | +| user_name | string | `홍길동` | 발급 대상 회원 이름 (user 관계 로드 시) | + +`data.pagination` 은 `current_page`, `last_page`, `per_page`, `total`, `from`, `to`, `has_more_pages` 를 포함합니다. **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "쿠폰 발급 내역을 조회했습니다.", - "data": { - "data": [], - "pagination": { - "current_page": 1, - "last_page": 1, - "per_page": 25, - "total": 0, - "from": null, - "to": null, - "has_more_pages": false - } - } -} -``` + **에러 응답** @@ -882,8 +959,8 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.read`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -906,7 +983,7 @@ HTTP/1.1 200 **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/promotion-coupons/1/issues/{issueId} HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/promotion-coupons/{id}/issues/{issueId} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -914,16 +991,65 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (취소 처리된 발급 내역 = `CouponIssueResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 취소된 발급 내역 ID | +| coupon_id | integer | `1` | 발급된 쿠폰(정의) ID | +| user_id | string\|null | `a1e0a91a-fba6-491c-a53e-7285a5686857` | 발급 대상 회원의 UUID | +| coupon_code | string\|null | `WELCOME2026` | 쿠폰 코드 (다운로드 쿠폰 시) | +| status | string | `cancelled` | 취소 처리 후 상태 (항상 `cancelled`) | +| status_label | string | `취소됨` | `status` 의 다국어 라벨 | +| status_badge_color | string | `red` | `status` 배지 색상 (cancelled=red) | +| issued_at | string\|null | `2026-07-08 10:50:11` | 발급 일시 | +| expired_at | string\|null | `2026-07-31 23:59:59` | 만료 일시 | +| used_at | string\|null | `null` | 사용 일시 (미사용 건만 취소 가능하므로 항상 null) | +| order_id | integer\|null | `null` | 사용된 주문 ID (미사용이므로 null) | +| discount_amount | number\|null | `null` | 실제 적용된 할인 금액 (미사용이므로 null) | +| is_used | boolean | `false` | 사용 완료 여부 | +| is_expired | boolean | `false` | 만료 여부 | +| is_usable | boolean | `false` | 현재 사용 가능 여부 (취소되었으므로 false) | +| is_cancellable | boolean | `false` | 추가 취소 가능 여부 (이미 취소되어 false) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰 발급이 취소되었습니다.", + "data": { + "id": 1, + "coupon_id": 1, + "user_id": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "coupon_code": "WELCOME2026", + "status": "cancelled", + "status_label": "취소됨", + "status_badge_color": "red", + "issued_at": "2026-07-08 10:50:11", + "expired_at": "2026-07-31 23:59:59", + "used_at": null, + "order_id": null, + "discount_amount": null, + "is_used": false, + "is_expired": false, + "is_usable": false, + "is_cancellable": false + } +} +``` + +> `order_number` / `user_name` 은 관계가 로드된 경우에만 포함됩니다 (취소 응답에서는 관계를 별도 로드하지 않으므로 생략될 수 있음). **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 발급 내역 미존재(쿠폰-발급 조합 불일치 포함) 또는 미사용(available) 상태가 아니어서 취소 불가한 경우. `error.detail` 에 사유 원문("쿠폰 발급 내역을 찾을 수 없습니다." / "미사용 상태의 발급 건만 취소할 수 있습니다.")이 담김 | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md index 2d66acb9..33091217 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/review-image.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Review Image 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -39,17 +39,41 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투(`data`)를 반환하지 않습니다. 성공 시 이미지 바이너리 스트림(`StreamedResponse`)을 그대로 반환합니다 (`ProductReviewImageService::download()` → `StorageInterface::response()`)._ + +성공 응답의 헤더는 다음과 같습니다. + +| 헤더 | 값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `image/jpeg` | 업로드 시 저장된 이미지의 MIME 타입 (`product_review_images.mime_type`) | +| Cache-Control | `public, max-age=31536000` | 공개 이미지 장기 캐싱 (1년) | +| Content-Disposition | `attachment; filename="review.jpg"` | 업로드 시의 원본 파일명 (`original_filename`) | **응답 예시** - +```http +HTTP/1.1 200 OK +Content-Type: image/jpeg +Cache-Control: public, max-age=31536000 +Content-Disposition: attachment; filename="review.jpg" + +<이미지 바이너리 스트림> +``` + +이미지를 찾지 못한 경우에만 JSON 봉투가 반환됩니다. + +```json +{ + "success": false, + "message": "이미지를 찾을 수 없습니다." +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 404 | Not Found | 해시에 해당하는 리뷰 이미지가 없거나(`findByHash()` → null), 레코드는 있으나 스토리지에 실제 파일이 없는 경우 (`messages.reviews.image_not_found`) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md b/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md index 9b9f8036..c5ba3335 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Reviews 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -65,7 +65,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | user_id | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | user 식별자 (연관 리소스 참조) | | user | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 정보 (uuid·name·email, `user` 관계 로드 시) | | product | object | `{"id":320,"name":"API 문서 샘플 상품","thumbnail_url":null}` | 리뷰 대상 상품 정보 (id·현지화 상품명·썸네일 URL) | -| option_snapshot | null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | +| option_snapshot | string | `{"id":104,"option_code":"FWACBAVCBKCD…` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | | option_snapshot_label | string | `` | `option_snapshot` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | rating | integer | `5` | 별점 (1~5) | | content | string | `Molestiae repellendus accusantium omn…` | 리뷰 내용 | @@ -79,12 +79,12 @@ _목록 응답: `data.data[]` 배열 항목의 필드._ | has_reply | boolean | `false` | reply 여부 | | has_reply_label | string | `미답변` | `has_reply` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | has_reply_badge_color | string | `gray` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | -| reply_content | null | `null` | 판매자 답변 내용 (없으면 null) | +| reply_content | string | `소중한 리뷰 감사드립니다! 항상 최선을 다하겠습니다.` | 판매자 답변 내용 (없으면 null) | | reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | -| reply_admin_uuid | null | `null` | 답변 작성 관리자 UUID (`replyAdmin` 관계 로드 시) | -| reply_admin | null | `null` | 답변 작성 관리자 정보 (uuid·name·email, `replyAdmin` 관계 로드 시) | -| replied_at | null | `null` | replied 일시 | -| reply_updated_at | null | `null` | reply updated 일시 | +| reply_admin_uuid | string | `a2397737-f8da-4451-a9c0-2616e7cd2002` | 답변 작성 관리자 UUID (`replyAdmin` 관계 로드 시) | +| reply_admin | object | `{"uuid":"a2397737-f8da-4451-a9c0-2616e7cd2002","name":"관리…` | 답변 작성 관리자 정보 (uuid·name·email, `replyAdmin` 관계 로드 시) | +| replied_at | string | `2026-07-06 19:08:45` | replied 일시 | +| reply_updated_at | string | `2026-07-07 19:08:45` | reply updated 일시 | | created_at | string | `2026-07-07 14:47:31` | 생성 일시 | | updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | | abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -100,56 +100,7 @@ HTTP/1.1 200 "success": true, "message": "리뷰 목록을 조회했습니다.", "data": { - "data": [ - { - "id": 1, - "product_id": 1, - "order_option_id": 1, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자", - "email": "apidoc-sample-user@example.com" - }, - "product": { - "id": 1, - "name": "API 문서 샘플 상품", - "thumbnail_url": null - }, - "option_snapshot": null, - "option_snapshot_label": "", - "rating": 5, - "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", - "content_mode": "text", - "status": "visible", - "status_label": "전시중", - "status_badge_color": "blue", - "images": [], - "image_count": 0, - "orderOption": { - "id": 1, - "order_id": 5, - "order_number": "ORD-20260708-000002", - "quantity": 2, - "created_at": "2026-07-08 10:44:49" - }, - "has_reply": false, - "has_reply_label": "미답변", - "has_reply_badge_color": "gray", - "reply_content": null, - "reply_content_mode": "text", - "reply_admin_uuid": null, - "reply_admin": null, - "replied_at": null, - "reply_updated_at": null, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_update": true, - "can_delete": true - } - } - ], + "data": [], "links": { "first": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/reviews?page=1", "last": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/reviews?page=1", @@ -158,7 +109,7 @@ HTTP/1.1 200 }, "meta": { "current_page": 1, - "from": 1, + "from": null, "last_page": 1, "links": [ { @@ -182,8 +133,8 @@ HTTP/1.1 200 ], "path": "https://api.example.com/api/modules/sirsoft-ecommerce/admin/reviews", "per_page": 25, - "to": 1, - "total": 1 + "to": null, + "total": 0 } } } @@ -238,11 +189,38 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드. `action` 값에 따라 반환 키가 달라집니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `3` | 삭제된 리뷰 건수 (`action=delete` 일 때만 반환) | +| updated_count | integer | `3` | 상태가 변경된 리뷰 건수 (`action=change_status` 일 때만 반환) | **응답 예시** - +`action=delete` (message: `messages.reviews.bulk_deleted`) + +```json +{ + "success": true, + "message": "선택한 리뷰가 삭제되었습니다.", + "data": { + "deleted_count": 3 + } +} +``` + +`action=change_status` (message: `messages.reviews.bulk_updated`) + +```json +{ + "success": true, + "message": "선택한 리뷰의 상태가 변경되었습니다.", + "data": { + "updated_count": 3 + } +} +``` **에러 응답** @@ -250,7 +228,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`ids` 누락/빈 배열, `ids.*` 가 존재하지 않는 리뷰 ID, `action` 이 `delete`/`change_status` 외의 값, `action=change_status` 인데 `status` 누락, `status` 가 `visible`/`hidden` 외의 값) | +| 500 | Internal Server Error | 일괄 처리 중 예외 발생 (`messages.reviews.bulk_failed` — "일괄 처리에 실패했습니다.") | @@ -272,7 +251,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/reviews/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/reviews/{review} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -280,27 +259,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| deleted | boolean | `true` | 삭제 처리 성공 여부 (true 이면 리뷰와 첨부 이미지가 제거됨) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "리뷰가 삭제되었습니다.", - "data": { - "deleted": true - } -} -``` + **에러 응답** @@ -330,7 +293,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/reviews/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/reviews/{review} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -348,7 +311,7 @@ _단건 응답: `data` 객체의 필드._ | user_id | string | `a231747f-e82e-4cf2-9ae1-a261849dce40` | 작성자 ID | | user | object | `{"uuid":"a231747f-e82e-4cf2-9ae1-a261849dce40","name":"AP…` | 작성자 정보 (uuid·name·email, `user` 관계 로드 시) | | product | object | `{"id":320,"name":"API 문서 샘플 상품","thumbnail_url":null}` | 리뷰 대상 상품 정보 (id·현지화 상품명·썸네일 URL) | -| option_snapshot | null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | +| option_snapshot | string | `{"id":104,"option_code":"FWACBAVCBKCD…` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | | option_snapshot_label | string | `` | `option_snapshot` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | rating | integer | `5` | 별점 (1~5) | | content | string | `Molestiae repellendus accusantium omn…` | 리뷰 내용 | @@ -374,64 +337,7 @@ _단건 응답: `data` 객체의 필드._ **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "리뷰 목록을 조회했습니다.", - "data": { - "id": 1, - "product_id": 1, - "order_option_id": 1, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자", - "email": "apidoc-sample-user@example.com" - }, - "product": { - "id": 1, - "name": "API 문서 샘플 상품", - "thumbnail_url": null - }, - "option_snapshot": null, - "option_snapshot_label": "", - "rating": 5, - "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", - "content_mode": "text", - "status": "visible", - "status_label": "전시중", - "status_badge_color": "blue", - "images": [], - "image_count": 0, - "orderOption": { - "id": 1, - "order_id": 5, - "order_number": "ORD-20260708-000002", - "quantity": 2, - "created_at": "2026-07-08 10:44:49" - }, - "has_reply": false, - "has_reply_label": "미답변", - "has_reply_badge_color": "gray", - "reply_content": null, - "reply_content_mode": "text", - "reply_admin_uuid": null, - "reply_admin": null, - "replied_at": null, - "reply_updated_at": null, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -461,7 +367,7 @@ HTTP/1.1 200 **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/reviews/1/reply HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/reviews/{review}/reply HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -478,7 +384,7 @@ _단건 응답: `data` 객체의 필드._ | order_option_id | integer | `1` | order option 식별자 (연관 리소스 참조) | | user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | | user | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) | -| option_snapshot | null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | +| option_snapshot | string | `{"id":104,"option_code":"FWACBAVCBKCD…` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | | option_snapshot_label | string | `` | `option_snapshot` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | rating | integer | `5` | 별점 (1~5) | | content | string | `Alias quas iusto dolorem eum eveniet …` | 본문 내용 | @@ -499,48 +405,7 @@ _단건 응답: `data` 객체의 필드._ **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "답변이 삭제되었습니다.", - "data": { - "id": 1, - "product_id": 1, - "order_option_id": 1, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자", - "email": "apidoc-sample-user@example.com" - }, - "option_snapshot": null, - "option_snapshot_label": "", - "rating": 5, - "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", - "content_mode": "text", - "status": "visible", - "status_label": "전시중", - "status_badge_color": "blue", - "has_reply": false, - "has_reply_label": "미답변", - "has_reply_badge_color": "gray", - "reply_content": null, - "reply_content_mode": "text", - "replied_at": null, - "reply_updated_at": null, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -574,7 +439,7 @@ HTTP/1.1 200 **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/admin/reviews/1/reply HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/admin/reviews/{review}/reply HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -597,7 +462,7 @@ _단건 응답: `data` 객체의 필드._ | order_option_id | integer | `1` | order option 식별자 (연관 리소스 참조) | | user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | user 식별자 (연관 리소스 참조) | | user | object | `{"uuid":"a234c2b1-cde8-437f-b28b-23323be2b98d","name":"AP…` | 대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생) | -| option_snapshot | null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | +| option_snapshot | string | `{"id":104,"option_code":"FWACBAVCBKCD…` | 주문 시점 옵션 스냅샷 (옵션명 보존용) | | option_snapshot_label | string | `` | `option_snapshot` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | | rating | integer | `5` | 별점 (1~5) | | content | string | `Alias quas iusto dolorem eum eveniet …` | 본문 내용 | @@ -618,48 +483,7 @@ _단건 응답: `data` 객체의 필드._ **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "답변이 저장되었습니다.", - "data": { - "id": 1, - "product_id": 1, - "order_option_id": 1, - "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "user": { - "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", - "name": "API 문서 샘플 사용자", - "email": "apidoc-sample-user@example.com" - }, - "option_snapshot": null, - "option_snapshot_label": "", - "rating": 5, - "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut. Enim cum mollitia nisi. Adipisci sunt tenetur et tempora tempora eius rerum.", - "content_mode": "text", - "status": "visible", - "status_label": "전시중", - "status_badge_color": "blue", - "has_reply": true, - "has_reply_label": "답변완료", - "has_reply_badge_color": "green", - "reply_content": "실측 예시값", - "reply_content_mode": "text", - "replied_at": "2026-07-08 15:00:32", - "reply_updated_at": null, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:32", - "abilities": { - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -667,8 +491,8 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -693,7 +517,7 @@ HTTP/1.1 200 **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/reviews/1/status HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/reviews/{review}/status HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -706,11 +530,78 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (상태가 변경된 리뷰 리소스). 컨트롤러가 관계를 추가로 로드하지 않으므로 `product`·`images`·`image_count`·`orderOption`·`reply_admin_uuid`·`reply_admin` 등 관계 의존 키는 응답에 포함되지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 리뷰 ID (기본 키) | +| product_id | integer | `1` | 리뷰 대상 상품 ID | +| order_option_id | integer | `1` | 리뷰가 연결된 주문 옵션 ID | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 작성자 UUID | +| user | object | `{"uuid":"a234c2b1-...","name":"API 문서 샘플 사용자","email":"apidoc-sample-user@example.com"}` | 작성자 정보 (uuid·name·email, `user` 관계 로드 시) | +| option_snapshot | object\|null | `null` | 주문 시점 옵션 스냅샷 (옵션명 보존용, 주문 옵션에서 복사) | +| option_snapshot_label | string | `` | `option_snapshot.option_name` 의 현재 로케일 표시 문자열 | +| rating | integer | `5` | 별점 (1~5) | +| content | string | `Alias quas iusto dolorem eum eveniet …` | 리뷰 내용 | +| content_mode | string | `text` | 콘텐츠 모드: text / html | +| status | string | `hidden` | 변경된 리뷰 상태: `visible`(전시중) / `hidden`(숨김) | +| status_label | string | `숨김` | 상태의 사람이 읽는 라벨 (ReviewStatus::label()) | +| status_badge_color | string | `gray` | 상태 뱃지 색상 (visible=blue / hidden=gray) | +| has_reply | boolean | `false` | 판매자 답변 존재 여부 (`reply_content` 유무) | +| has_reply_label | string | `미답변` | `has_reply` 의 사람이 읽는 라벨 (답변완료 / 미답변) | +| has_reply_badge_color | string | `gray` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | +| reply_content | string\|null | `null` | 판매자 답변 내용 (없으면 null) | +| reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | +| replied_at | string\|null | `null` | 답변 최초 작성 일시 | +| reply_updated_at | string\|null | `null` | 답변 최종 수정 일시 | +| created_at | string | `2026-07-08 10:44:49` | 리뷰 생성 일시 | +| updated_at | string | `2026-07-08 15:10:07` | 리뷰 최종 수정 일시 (상태 변경으로 갱신됨) | +| abilities | object | `{"can_update":true,"can_delete":true}` | 현재 사용자가 이 리뷰에 수행 가능한 작업 불리언 맵 (can_update, can_delete) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰가 수정되었습니다.", + "data": { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "user": { + "uuid": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "name": "API 문서 샘플 사용자", + "email": "apidoc-sample-user@example.com" + }, + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "Alias quas iusto dolorem eum eveniet ad omnis. Id neque consequatur fuga ut.", + "content_mode": "text", + "status": "hidden", + "status_label": "숨김", + "status_badge_color": "gray", + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "reply_content_mode": "text", + "replied_at": null, + "reply_updated_at": null, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:10:07", + "abilities": { + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -718,8 +609,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.reviews.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | `status` 누락 또는 `visible`/`hidden` 외의 값 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 상태 변경 중 예외 발생 (`messages.reviews.update_failed` — "리뷰 수정에 실패했습니다.") | @@ -764,11 +656,72 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (생성된 리뷰 리소스, HTTP 201). 서비스가 생성 직후의 모델을 그대로 반환하므로 `product`·`images`·`image_count`·`orderOption`·`reply_admin_uuid`·`reply_admin` 등 관계 의존 키는 응답에 포함되지 않습니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 생성된 리뷰 ID (기본 키) | +| product_id | integer | `1` | 리뷰 대상 상품 ID (요청값 그대로) | +| order_option_id | integer | `1` | 리뷰가 연결된 주문 옵션 ID (요청값 그대로) | +| user_id | string | `a234c2b1-cde8-437f-b28b-23323be2b98d` | 작성자 UUID (로그인 사용자) | +| option_snapshot | object\|null | `null` | 주문 시점 옵션 스냅샷 (주문 옵션의 `option_snapshot` 을 복사) | +| option_snapshot_label | string | `` | `option_snapshot.option_name` 의 현재 로케일 표시 문자열 | +| rating | integer | `5` | 별점 (1~5, 요청값 그대로) | +| content | string | `예시 내용입니다.` | 리뷰 내용 (10~2000자, 요청값 그대로) | +| content_mode | string | `text` | 콘텐츠 모드: text / html (미지정 시 text) | +| status | string | `visible` | 생성 시 상태 — 항상 `visible`(전시중) | +| status_label | string | `전시중` | 상태의 사람이 읽는 라벨 (ReviewStatus::label()) | +| status_badge_color | string | `blue` | 상태 뱃지 색상 (visible=blue / hidden=gray) | +| has_reply | boolean | `false` | 판매자 답변 존재 여부 (생성 직후는 항상 false) | +| has_reply_label | string | `미답변` | `has_reply` 의 사람이 읽는 라벨 (답변완료 / 미답변) | +| has_reply_badge_color | string | `gray` | 답변 여부 뱃지 색상 (답변완료=green / 미답변=gray) | +| reply_content | null | `null` | 판매자 답변 내용 (생성 직후는 null) | +| reply_content_mode | string | `text` | 답변 콘텐츠 모드: text / html | +| replied_at | null | `null` | 답변 최초 작성 일시 (생성 직후는 null) | +| reply_updated_at | null | `null` | 답변 최종 수정 일시 (생성 직후는 null) | +| created_at | string | `2026-07-08 15:20:11` | 리뷰 생성 일시 | +| updated_at | string | `2026-07-08 15:20:11` | 리뷰 최종 수정 일시 | +| abilities | object | `{"can_update":false,"can_delete":false}` | 현재 사용자가 이 리뷰에 수행 가능한 작업 불리언 맵 (관리자 권한 `sirsoft-ecommerce.reviews.update`/`.delete` 기준이므로 일반 회원은 false) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "리뷰가 작성되었습니다.", + "data": { + "id": 1, + "product_id": 1, + "order_option_id": 1, + "user_id": "a234c2b1-cde8-437f-b28b-23323be2b98d", + "option_snapshot": null, + "option_snapshot_label": "", + "rating": 5, + "content": "예시 내용입니다. 배송도 빠르고 품질도 만족스럽습니다.", + "content_mode": "text", + "status": "visible", + "status_label": "전시중", + "status_badge_color": "blue", + "has_reply": false, + "has_reply_label": "미답변", + "has_reply_badge_color": "gray", + "reply_content": null, + "reply_content_mode": "text", + "replied_at": null, + "reply_updated_at": null, + "created_at": "2026-07-08 15:20:11", + "updated_at": "2026-07-08 15:20:11", + "abilities": { + "can_update": false, + "can_delete": false + } + } +} +``` **에러 응답** @@ -776,7 +729,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 작성 자격 미충족 시 `ReviewNotWritableException`(RuntimeException) 이 발생해 사유 코드가 담긴 메시지로 응답 (`order_option_not_found` / `not_own_order` / `not_confirmed` / `deadline_passed` / `already_written`) | +| 500 | Internal Server Error | 리뷰 생성 중 예외 발생 (`messages.reviews.create_failed` — "리뷰 작성에 실패했습니다.") | @@ -798,7 +752,7 @@ Content-Type: application/json **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/user/reviews/can-write/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/user/reviews/can-write/{orderOptionId} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -806,29 +760,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| can_write | boolean | `false` | write 수행 가능 여부 (권한 기반) | -| reason | string | `not_own_order` | 리뷰 작성 불가 사유 코드 (`can_write` 가 false 일 때만 값이 있고 가능하면 null. order_option_not_found 주문옵션 없음, not_own_order 본인 주문 아님, not_confirmed 구매확정 전, deadline_passed 작성 기한 초과, already_written 이미 작성함) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "리뷰 작성 가능 여부를 확인했습니다.", - "data": { - "can_write": false, - "reason": "not_own_order" - } -} -``` + **에러 응답** @@ -857,7 +793,7 @@ HTTP/1.1 200 **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/user/reviews/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/user/reviews/{review} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -865,27 +801,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| deleted | boolean | `true` | 삭제 처리 성공 여부 (true 이면 리뷰와 첨부 이미지가 제거됨) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "리뷰가 삭제되었습니다.", - "data": { - "deleted": true - } -} -``` + **에러 응답** @@ -916,7 +836,7 @@ HTTP/1.1 200 **요청 예시** ```http -POST /api/modules/sirsoft-ecommerce/user/reviews/1/images HTTP/1.1 +POST /api/modules/sirsoft-ecommerce/user/reviews/{review}/images HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -932,20 +852,65 @@ Content-Type: application/octet-stream **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (업로드된 리뷰 이미지 리소스, HTTP 201)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 리뷰 이미지 ID (기본 키) | +| review_id | integer | `1` | 이미지가 속한 리뷰 ID | +| hash | string | `9f3a1c7b20de` | URL 용 고유 해시 (12자, 생성 시 자동 부여) | +| original_filename | string | `example.jpg` | 업로드된 원본 파일명 | +| download_url | string | `/api/modules/sirsoft-ecommerce/review-image/9f3a1c7b20de` | 해시 기반 이미지 서빙 URL (`review-image/{hash}`) | +| mime_type | string | `image/jpeg` | MIME 타입 (예: image/jpeg, image/webp) | +| file_size | integer | `204800` | 파일 크기 (바이트) | +| width | integer\|null | `1200` | 이미지 너비 (px, 이미지가 아니거나 판별 실패 시 null) | +| height | integer\|null | `800` | 이미지 높이 (px, 이미지가 아니거나 판별 실패 시 null) | +| alt_text | object\|null | `null` | 대체 텍스트 (다국어 JSON, 업로드 시점에는 설정하지 않으므로 null) | +| is_thumbnail | boolean | `true` | 대표 이미지 여부 (해당 리뷰의 첫 이미지면 true) | +| sort_order | integer | `1` | 정렬 순서 (기존 최대값 + 1) | +| created_at | string | `2026-07-08 15:30:02` | 업로드 일시 | +| abilities | object | `{"can_delete":false}` | 현재 사용자가 이 이미지에 수행 가능한 작업 불리언 맵 (관리자 권한 `sirsoft-ecommerce.reviews.delete` 기준이므로 일반 회원은 false) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "리뷰 이미지가 업로드되었습니다.", + "data": { + "id": 1, + "review_id": 1, + "hash": "9f3a1c7b20de", + "original_filename": "example.jpg", + "download_url": "/api/modules/sirsoft-ecommerce/review-image/9f3a1c7b20de", + "mime_type": "image/jpeg", + "file_size": 204800, + "width": 1200, + "height": 800, + "alt_text": null, + "is_thumbnail": true, + "sort_order": 1, + "created_at": "2026-07-08 15:30:02", + "abilities": { + "can_delete": false + } + } +} +``` **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없거나, 대상 리뷰가 로그인 사용자의 리뷰가 아닌 경우 (`messages.reviews.forbidden` — "권한이 없습니다.") | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | `image` 파일 누락·허용 형식/용량 위반 (`error.errors` 에 필드별 메시지) 또는 리뷰당 최대 첨부 개수 초과 시 `ReviewImageUploadLimitException`(RuntimeException) — 최대 개수는 리뷰 설정 `review_settings.max_images`(기본 5) 기준 | +| 500 | Internal Server Error | 업로드 처리 중 예외 발생 (`messages.reviews.image_upload_failed` — "리뷰 이미지 업로드에 실패했습니다.") | @@ -968,7 +933,7 @@ Content-Type: application/octet-stream **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/user/reviews/1/images/{image} HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/user/reviews/{review}/images/{image} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -976,11 +941,29 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted | boolean | `true` | 삭제 처리 성공 여부 (true 이면 리뷰와 첨부 이미지가 제거됨) | **응답 예시** - + + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "리뷰 이미지가 삭제되었습니다.", + "data": { + "deleted": true + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index a2293b6e..16fa1143 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Settings 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(raw HTTP) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -44,16 +44,18 @@ _단건 응답: `data` 객체의 필드._ | --- | --- | --- | --- | | basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) | | language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) | -| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | +| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) | | shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) | | seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) | | review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | -| inquiry | object | `{"board_slug":null}` | 문의 연동 설정 (문의 게시판 slug) | +| inquiry | object | `{"board_slug":"inquiry"}` | 문의 연동 설정 (문의 게시판 slug) | | notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 채널 설정 (채널 ID·활성 여부·정렬 순서) | | mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) | | claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) | | available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) | +| available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) | | abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| _meta | object | `{"limits":{"auto_cancel_days_min":1,"auto_cancel_days_max…` | 설정 화면 전용 메타. `limits` 는 숫자 입력의 경계값 맵(`config('sirsoft-ecommerce.limits')`)으로, 저장 규칙(FormRequest)과 같은 출처다 — 화면이 리터럴 경계를 들면 규칙 변경을 따라가지 못해 "화면은 받는데 저장은 422" 가 되므로 입력 min/max 는 이 값을 바인딩한다 | **응답 예시** @@ -64,125 +66,71 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "message": "설정을 조회했습니다.", "data": { "basic_info": { - "shop_name": "", + "shop_name": "111", "route_path": "shop", "no_route": false, - "company_name": "", + "company_name": null, "business_number": "", - "ceo_name": "", - "business_type": "", - "business_category": "", - "zipcode": "", - "base_address": "", - "detail_address": "", - "phone": "", - "fax": "", - "email": "", - "privacy_officer": "", - "privacy_officer_email": "", - "mail_order_number": "", - "telecom_number": "" + "...": "(13개 키 생략, 총 18개)" }, "language_currency": { - "default_currency": "KRW", + "default_currency": "JPY", "currencies": [ { "code": "KRW", "name": { "ko": "KRW (원)", - "en": "KRW (Won)", - "fr": "KRW (원)" + "en": "KRW (Won)" }, "symbol": "₩", "exchange_rate": null, "base_unit": 1000, - "rounding_unit": "1", - "rounding_method": "floor", - "decimal_places": 0, - "is_default": true, - "locales": [ - "ko" - ], - "flag": "🇰🇷" + "...": "(6개 키 생략, 총 11개)" }, { "code": "USD", "name": { - "ko": "USD (달러)", - "en": "USD (Dollar)", - "fr": "USD (달러)" + "en": "Dollar" }, - "symbol": "$", - "exchange_rate": 0.85, - "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, "is_default": false, - "locales": [ - "en" - ], - "flag": "🇺🇸" + "decimal_places": 2, + "base_unit": 1, + "...": "(3개 키 생략, 총 8개)" }, { "code": "JPY", "name": { - "ko": "JPY (엔)", - "en": "JPY (Yen)", - "fr": "JPY (엔)" + "ja": "円" }, - "symbol": "¥", - "exchange_rate": 115, - "base_unit": 100, - "rounding_unit": "1", - "rounding_method": "floor", + "is_default": true, "decimal_places": 0, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇯🇵" + "base_unit": 100, + "...": "(3개 키 생략, 총 8개)" }, { "code": "CNY", "name": { "ko": "CNY (위안)", - "en": "CNY (Yuan)", - "fr": "CNY (위안)" + "en": "CNY (Yuan)" }, "symbol": "元", "exchange_rate": 5.8, "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇨🇳" + "...": "(6개 키 생략, 총 11개)" }, { "code": "EUR", "name": { "ko": "EUR (유로)", - "en": "EUR (Euro)", - "fr": "EUR (유로)" + "en": "EUR (Euro)" }, "symbol": "€", "exchange_rate": 0.78, "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇪🇺" + "...": "(6개 키 생략, 총 11개)" } ] }, @@ -195,20 +143,15 @@ HTTP/1.1 200 "sort_order": 1, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "신용카드", - "en": "Credit Card", - "fr": "신용카드" - }, - "_cached_description": { - "ko": "신용카드로 안전하게 결제", - "en": "Pay securely with credit card", - "fr": "신용카드로 안전하게 결제" - }, - "_cached_icon": "credit-card", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 1, + "is_active": true, + "min_order_amount": 0, + "...": "(10개 키 생략, 총 15개)" }, { "id": "vbank", @@ -216,269 +159,27 @@ HTTP/1.1 200 "sort_order": 2, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "가상계좌", - "en": "Virtual Account", - "fr": "가상계좌" - }, - "_cached_description": { - "ko": "가상계좌로 입금", - "en": "Pay via virtual account", - "fr": "가상계좌로 입금" - }, - "_cached_icon": "money-check", - "_cached_source": "builtin" - }, - { - "id": "dbank", - "pg_provider": null, - "sort_order": 3, - "is_active": true, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "무통장입금", - "en": "Bank Transfer", - "fr": "무통장입금" - }, - "_cached_description": { - "ko": "지정 계좌로 직접 입금", - "en": "Direct bank transfer", - "fr": "지정 계좌로 직접 입금" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "bank", - "pg_provider": null, - "sort_order": 4, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "계좌이체", - "en": "Account Transfer", - "fr": "계좌이체" - }, - "_cached_description": { - "ko": "실시간 계좌이체", - "en": "Real-time bank transfer", - "fr": "실시간 계좌이체" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "phone", - "pg_provider": null, - "sort_order": 5, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "휴대폰결제", - "en": "Mobile Payment", - "fr": "휴대폰결제" - }, - "_cached_description": { - "ko": "휴대폰 소액결제", - "en": "Mobile phone payment", - "fr": "휴대폰 소액결제" - }, - "_cached_icon": "mobile-screen-button", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "point", "pg_provider": null, - "sort_order": 6, - "is_active": false, + "sort_order": 2, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "포인트결제", - "en": "Points", - "fr": "포인트결제" - }, - "_cached_description": { - "ko": "적립 포인트로 결제", - "en": "Pay with points", - "fr": "적립 포인트로 결제" - }, - "_cached_icon": "coins", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "deposit", "pg_provider": null, - "sort_order": 7, - "is_active": false, + "sort_order": 3, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "예치금결제", - "en": "Store Credit", - "fr": "예치금결제" - }, - "_cached_description": { - "ko": "예치금으로 결제", - "en": "Pay with store credit", - "fr": "예치금으로 결제" - }, - "_cached_icon": "wallet", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, - { - "id": "free", - "pg_provider": null, - "sort_order": 8, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "무료", - "en": "Free", - "fr": "무료" - }, - "_cached_description": { - "ko": "결제 없이 주문 완료", - "en": "Order without payment", - "fr": "결제 없이 주문 완료" - }, - "_cached_icon": "gift", - "_cached_source": "builtin" - } - ], - "banks": [ - { - "code": "004", - "name": { - "ko": "국민은행", - "en": "Kookmin Bank" - } - }, - { - "code": "088", - "name": { - "ko": "신한은행", - "en": "Shinhan Bank" - } - }, - { - "code": "020", - "name": { - "ko": "우리은행", - "en": "Woori Bank" - } - }, - { - "code": "081", - "name": { - "ko": "하나은행", - "en": "Hana Bank" - } - }, - { - "code": "003", - "name": { - "ko": "IBK기업은행", - "en": "IBK Industrial Bank" - } - }, - { - "code": "011", - "name": { - "ko": "NH농협은행", - "en": "NH Nonghyup Bank" - } - }, - { - "code": "071", - "name": { - "ko": "우체국", - "en": "Korea Post" - } - }, - { - "code": "031", - "name": { - "ko": "DGB대구은행", - "en": "DGB Daegu Bank" - } - }, - { - "code": "032", - "name": { - "ko": "BNK부산은행", - "en": "BNK Busan Bank" - } - }, - { - "code": "034", - "name": { - "ko": "광주은행", - "en": "Kwangju Bank" - } - }, - { - "code": "035", - "name": { - "ko": "제주은행", - "en": "Jeju Bank" - } - }, - { - "code": "037", - "name": { - "ko": "전북은행", - "en": "Jeonbuk Bank" - } - }, - { - "code": "039", - "name": { - "ko": "BNK경남은행", - "en": "BNK Kyongnam Bank" - } - }, - { - "code": "045", - "name": { - "ko": "새마을금고", - "en": "MG Community Credit Cooperatives" - } - }, - { - "code": "048", - "name": { - "ko": "신협", - "en": "KFCC" - } - }, - { - "code": "090", - "name": { - "ko": "카카오뱅크", - "en": "Kakao Bank" - } - }, - { - "code": "092", - "name": { - "ko": "토스뱅크", - "en": "Toss Bank" - } - } + "... (총 25건 중 5건 표시)" ], + "banks": [], "bank_accounts": [ { "bank_code": "004", @@ -489,16 +190,7 @@ HTTP/1.1 200 } ], "auto_cancel_expired": true, - "auto_cancel_days": 3, - "cart_expiry_days": 30, - "stock_restore_on_cancel": true, - "cancellable_statuses": [ - "payment_complete" - ], - "confirmable_statuses": [ - "shipping", - "delivered" - ] + "...": "(5개 키 생략, 총 10개)" }, "shipping": { "default_country": "KR", @@ -507,8 +199,7 @@ HTTP/1.1 200 "code": "KR", "name": { "ko": "대한민국", - "en": "South Korea", - "fr": "대한민국" + "en": "South Korea" }, "is_active": true }, @@ -516,304 +207,15 @@ HTTP/1.1 200 "code": "US", "name": { "ko": "미국", - "en": "United States", - "fr": "미국" + "en": "United States" }, - "is_active": false - }, - { - "code": "JP", - "name": { - "ko": "일본", - "en": "Japan", - "fr": "일본" - }, - "is_active": false - }, - { - "code": "CN", - "name": { - "ko": "중국", - "en": "China", - "fr": "중국" - }, - "is_active": false - }, - { - "code": "SG", - "name": { - "ko": "싱가포르", - "en": "Singapore", - "fr": "싱가포르" - }, - "is_active": false - }, - { - "code": "HK", - "name": { - "ko": "홍콩", - "en": "Hong Kong", - "fr": "홍콩" - }, - "is_active": false - }, - { - "code": "TW", - "name": { - "ko": "대만", - "en": "Taiwan", - "fr": "대만" - }, - "is_active": false - }, - { - "code": "VN", - "name": { - "ko": "베트남", - "en": "Vietnam", - "fr": "베트남" - }, - "is_active": false - }, - { - "code": "TH", - "name": { - "ko": "태국", - "en": "Thailand", - "fr": "태국" - }, - "is_active": false - }, - { - "code": "MY", - "name": { - "ko": "말레이시아", - "en": "Malaysia", - "fr": "말레이시아" - }, - "is_active": false + "is_active": true } ], - "international_shipping_enabled": false, + "international_shipping_enabled": true, "free_shipping_threshold": 50000, "free_shipping_enabled": true, - "address_validation_enabled": false, - "address_api_provider": "kakao", - "carriers": [ - { - "id": 13, - "code": "apidoc", - "name": { - "ko": "API 문서 샘플 배송사", - "en": "API Doc Sample Carrier" - }, - "type": "domestic", - "tracking_url": null, - "is_active": true, - "sort_order": 0 - }, - { - "id": 1, - "code": "cj", - "name": { - "ko": "CJ대한통운", - "en": "CJ Logistics" - }, - "type": "domestic", - "tracking_url": "https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}", - "is_active": true, - "sort_order": 1 - }, - { - "id": 2, - "code": "hanjin", - "name": { - "ko": "한진택배", - "en": "Hanjin Express" - }, - "type": "domestic", - "tracking_url": "https://www.hanjin.com/kor/CMS/DeliveryMgr/WaybillResult.do?wblnb={tracking_number}", - "is_active": true, - "sort_order": 2 - }, - { - "id": 3, - "code": "lotte", - "name": { - "ko": "롯데택배", - "en": "Lotte Global Logistics" - }, - "type": "domestic", - "tracking_url": "https://www.lotteglogis.com/home/reservation/tracking/link498?InvNo={tracking_number}", - "is_active": true, - "sort_order": 3 - }, - { - "id": 4, - "code": "logen", - "name": { - "ko": "로젠택배", - "en": "Logen Logistics" - }, - "type": "domestic", - "tracking_url": "https://www.ilogen.com/web/personal/trace/{tracking_number}", - "is_active": true, - "sort_order": 4 - }, - { - "id": 5, - "code": "ups", - "name": { - "ko": "UPS", - "en": "UPS" - }, - "type": "international", - "tracking_url": "https://www.ups.com/track?tracknum={tracking_number}", - "is_active": true, - "sort_order": 5 - }, - { - "id": 6, - "code": "ems", - "name": { - "ko": "EMS", - "en": "EMS" - }, - "type": "international", - "tracking_url": "https://service.epost.go.kr/trace.RetrieveEmsRi498.postal?POST_CODE={tracking_number}", - "is_active": true, - "sort_order": 6 - }, - { - "id": 7, - "code": "dhl", - "name": { - "ko": "DHL", - "en": "DHL" - }, - "type": "international", - "tracking_url": "https://www.dhl.com/kr-ko/home/tracking/tracking-express.html?submit=1&tracking-id={tracking_number}", - "is_active": true, - "sort_order": 7 - }, - { - "id": 8, - "code": "fedex", - "name": { - "ko": "FedEx", - "en": "FedEx" - }, - "type": "international", - "tracking_url": "https://www.fedex.com/fedextrack/?tracknumbers={tracking_number}", - "is_active": true, - "sort_order": 8 - }, - { - "id": 9, - "code": "sf", - "name": { - "ko": "SF Express", - "en": "SF Express" - }, - "type": "international", - "tracking_url": null, - "is_active": true, - "sort_order": 9 - }, - { - "id": 10, - "code": "yamato", - "name": { - "ko": "야마토운수", - "en": "Yamato Transport" - }, - "type": "international", - "tracking_url": null, - "is_active": true, - "sort_order": 10 - }, - { - "id": 11, - "code": "sagawa", - "name": { - "ko": "사가와익스프레스", - "en": "Sagawa Express" - }, - "type": "international", - "tracking_url": null, - "is_active": true, - "sort_order": 11 - }, - { - "id": 12, - "code": "other", - "name": { - "ko": "기타", - "en": "Other" - }, - "type": "domestic", - "tracking_url": null, - "is_active": true, - "sort_order": 99 - } - ], - "types": [], - "api_request_fields": [ - { - "value": "policy_id", - "label": "배송정책 ID" - }, - { - "value": "country_code", - "label": "국가 코드" - }, - { - "value": "items", - "label": "주문 항목" - }, - { - "value": "group_total", - "label": "그룹 합계 금액" - }, - { - "value": "total_quantity", - "label": "총 수량" - } - ], - "api_http_methods": [ - { - "value": "GET", - "label": "GET" - }, - { - "value": "POST", - "label": "POST" - } - ], - "api_auth_types": [ - { - "value": "none", - "label": "인증 없음" - }, - { - "value": "bearer", - "label": "Bearer 토큰" - }, - { - "value": "custom_header", - "label": "커스텀 헤더" - } - ], - "api_response_types": [ - { - "value": "json", - "label": "JSON" - }, - { - "value": "text", - "label": "텍스트" - } - ] + "...": "(8개 키 생략, 총 13개)" }, "seo": { "meta_category_title": "{commerce_name} - {category_name}", @@ -821,13 +223,7 @@ HTTP/1.1 200 "meta_search_title": "{commerce_name} - {keyword_name}", "meta_search_description": "", "meta_product_title": "{commerce_name} - {product_name}", - "meta_product_description": "", - "meta_shop_index_title": "{commerce_name}", - "meta_shop_index_description": "", - "seo_category": true, - "seo_search_result": true, - "seo_product_detail": true, - "seo_shop_index": true + "...": "(7개 키 생략, 총 12개)" }, "review_settings": { "write_deadline_days": 90, @@ -864,7 +260,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, @@ -1024,15 +422,90 @@ HTTP/1.1 200 | --- | --- | --- | --- | --- | --- | | _tab | body | string | 아니오 | `basic_info`, `language_currency`, `seo`, `order_settings`, `claim`, `shipping`, `review_settings`, `notification_definitions`, `notifications`, `inquiry`, `mileage` | 저장할 설정 탭(카테고리) 지정 (탭별 부분 저장 식별용) | | notifications | body | array | 아니오 | — | 알림 채널 설정 배열 (채널 ID·활성 여부·정렬 순서) | +| notifications.channels | body | array | 아니오 | — | 알림 채널 목록. 항목별 `id`(문자열, max 50, 필수), `is_active`(boolean, 필수), `sort_order`(integer, min 0) | | basic_info | body | array | 아니오 | — | 쇼핑몰 기본 정보 섹션 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처 등) | +| basic_info.shop_name | body | string | 아니오 | max 255 | 쇼핑몰 이름 (`basic_info` 전달 시 필수) | +| basic_info.route_path | body | string | 아니오 | max 100 | 쇼핑몰 라우트 경로 (예: `shop`). `no_route: true` 이면 검증에서 제외 | +| basic_info.no_route | body | boolean | 아니오 | — | 라우트 경로를 사용하지 않음 (쇼핑몰만 단독 운영하는 경우) | +| basic_info.company_name | body | string | 아니오 | max 255 | 상호(회사명) | +| basic_info.business_number_1 | body | string | 아니오 | max 3 | 사업자등록번호 앞 3자리 | +| basic_info.business_number_2 | body | string | 아니오 | max 2 | 사업자등록번호 가운데 2자리 | +| basic_info.business_number_3 | body | string | 아니오 | max 5 | 사업자등록번호 뒤 5자리 | +| basic_info.ceo_name | body | string | 아니오 | max 100 | 대표자 이름 | +| basic_info.business_type | body | string | 아니오 | max 100 | 업태 (예: 도매, 소매) | +| basic_info.business_category | body | string | 아니오 | max 255 | 종목 (예: 전자상거래) | +| basic_info.zipcode | body | string | 아니오 | max 10 | 사업장 우편번호 | +| basic_info.base_address | body | string | 아니오 | max 500 | 사업장 기본주소 | +| basic_info.detail_address | body | string | 아니오 | max 255 | 사업장 상세주소 | +| basic_info.phone_1 | body | string | 아니오 | max 4 | 대표 전화번호 첫 번째 마디 | +| basic_info.phone_2 | body | string | 아니오 | max 4 | 대표 전화번호 두 번째 마디 | +| basic_info.phone_3 | body | string | 아니오 | max 4 | 대표 전화번호 세 번째 마디 | +| basic_info.fax_1 | body | string | 아니오 | max 4 | 대표 팩스번호 첫 번째 마디 | +| basic_info.fax_2 | body | string | 아니오 | max 4 | 대표 팩스번호 두 번째 마디 | +| basic_info.fax_3 | body | string | 아니오 | max 4 | 대표 팩스번호 세 번째 마디 | +| basic_info.email_id | body | string | 아니오 | max 100 | 대표 E-mail 의 아이디 부분 (`@` 앞) | +| basic_info.email_domain | body | string | 아니오 | max 100 | 대표 E-mail 의 도메인 부분 (`@` 뒤) | +| basic_info.privacy_officer | body | string | 아니오 | max 100 | 개인정보 보호책임자 이름 | +| basic_info.privacy_officer_email | body | email | 아니오 | max 255 | 개인정보 보호책임자 E-mail | +| basic_info.mail_order_number | body | string | 아니오 | max 100 | 통신판매업 신고번호 | +| basic_info.telecom_number | body | string | 아니오 | max 100 | 부가통신 사업자번호 | | language_currency | body | array | 아니오 | — | 통화 설정 섹션 (기본 통화·통화 목록: 코드·다국어명·환율·반올림 규칙·통화별 로케일) | +| language_currency.default_currency | body | string | 아니오 | max 10 | 쇼핑몰 기본(base) 통화 코드. 상품/주문이 1건이라도 생성된 뒤에는 변경 불가 | +| language_currency.currencies | body | array | 아니오 | — | 등록 통화 목록. 항목별 `code`(ISO 4217 3자리 대문자, 필수)·`name`(다국어 배열, 필수)·`symbol`·`exchange_rate`·`base_unit`·`rounding_unit`·`rounding_method`(`floor`\|`round`\|`ceil`)·`decimal_places`·`is_default`·`locales` | | seo | body | array | 아니오 | — | SEO 메타 설정 섹션 (페이지 유형별 메타 타이틀/설명·SEO 활성 토글) | +| seo.meta_category_title | body | string | 아니오 | max 500 | 카테고리 페이지 메타 Title (`{commerce_name}`·`{category_name}` 등 변수 사용 가능) | +| seo.meta_category_description | body | string | 아니오 | max 1000 | 카테고리 페이지 메타 Description | +| seo.meta_search_title | body | string | 아니오 | max 500 | 검색 결과 페이지 메타 Title (`{keyword_name}` 변수 사용 가능) | +| seo.meta_search_description | body | string | 아니오 | max 1000 | 검색 결과 페이지 메타 Description | +| seo.meta_product_title | body | string | 아니오 | max 500 | 상품 상세 페이지 메타 Title (`{product_name}` 변수 사용 가능) | +| seo.meta_product_description | body | string | 아니오 | max 1000 | 상품 상세 페이지 메타 Description | +| seo.meta_shop_index_title | body | string | 아니오 | max 500 | 쇼핑몰 메인 페이지 메타 Title | +| seo.meta_shop_index_description | body | string | 아니오 | max 1000 | 쇼핑몰 메인 페이지 메타 Description | +| seo.seo_category | body | boolean | 아니오 | — | 카테고리 페이지에 검색엔진 친화적 페이지 제공 여부 | +| seo.seo_search_result | body | boolean | 아니오 | — | 검색결과 페이지에 검색엔진 친화적 페이지 제공 여부 | +| seo.seo_product_detail | body | boolean | 아니오 | — | 상품 상세페이지에 검색엔진 친화적 페이지 제공 여부 | +| seo.seo_shop_index | body | boolean | 아니오 | — | 쇼핑몰 메인 페이지에 검색엔진 친화적 페이지 제공 여부 | | inquiry | body | array | 아니오 | — | 문의 연동 설정 섹션 (문의 게시판 slug) | +| inquiry.board_slug | body | string | 아니오 | max 255 | 상품 1:1 문의를 연동할 게시판 slug (게시판 모듈 활성화 시에만 선택 가능) | | order_settings | body | array | 아니오 | — | 주문/결제 설정 섹션 (기본 PG·결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | +| order_settings.default_pg_provider | body | string | 아니오 | max 50 | 결제 처리에 사용할 기본 PG사 ID (결제수단별 `pg_provider` 미지정 시 적용) | +| order_settings.cash_receipt_provider | body | string | 아니오 | max 50 | 현금영수증 발급 프로바이더 ID. 결제 PG 와 독립 선택하며 빈 문자열이면 미사용 | +| order_settings.cash_receipt_self_issue | body | boolean | 아니오 | — | 현금영수증 자진발급 사용 (구매자가 신청하지 않은 무통장 입금 건도 자동 발급) | +| order_settings.shipping_fee_tax_policy | body | string | 아니오 | `proportional`, `taxable`, `follow_main_item` | 현금영수증의 배송비 과세 방식 (안분 / 전액 과세 / 주된 재화 기준) | +| order_settings.payment_methods | body | array | 아니오 | — | 결제수단 목록. 항목별 `id`(필수)·`pg_provider`·`sort_order`(min 1)·`is_active`·`min_order_amount`(min 0)·`stock_deduction_timing`(`order_placed`\|`payment_complete`\|`none`)·`mileage_deduction_timing`(`order_placed`\|`payment_complete`). 최소 1개는 활성이어야 하며, PG 가 필요한 결제수단을 활성화하려면 PG사 지정 필수 | +| order_settings.banks | body | array | 아니오 | — | 무통장입금용 은행 목록. 항목별 `code`(max 10, 필수)·`name`(다국어 배열, 현재 로케일 필수) | +| order_settings.bank_accounts | body | array | 아니오 | — | 무통장 입금 계좌 목록. 항목별 `bank_code`·`account_number`·`account_holder`(모두 필수)·`is_active`·`is_default`. 계좌가 있으면 최소 1건은 사용+기본 상태여야 함 | +| order_settings.auto_cancel_expired | body | boolean | 아니오 | — | 입금대기 상태 주문의 자동취소 사용 여부 | +| order_settings.auto_cancel_days | body | integer | 아니오 | min 0, max 30 | 자동취소 기한(일). 주문일 포함 이 일수 경과 시 입금대기 주문을 자동 취소 | +| order_settings.cart_expiry_days | body | integer | 아니오 | min 1, max 365 | 장바구니 보관기간(일). 경과 시 담긴 상품 자동 삭제 | +| order_settings.stock_restore_on_cancel | body | boolean | 아니오 | — | 주문 취소 시 차감된 재고 자동 복구 여부 (반품/교환에도 적용) | +| order_settings.confirmable_statuses | body | array | 아니오 | — | 사용자가 구매확정할 수 있는 주문 옵션 상태 목록 (`payment_complete`, `shipping_hold`, `preparing`, `shipping_ready`, `shipping`, `delivered` 중 선택) | | claim | body | array | 아니오 | — | 클레임 설정 섹션 (환불 사유 목록, DB 동기화 대상으로 분리 저장) | +| claim.refund_reasons | body | array | 아니오 | — | 환불 사유 목록. 항목별 `id`·`code`(소문자·언더스코어, 필수)·`name`(다국어 배열, 현재 로케일 필수)·`fault_type`(`customer`\|`seller`\|`carrier`, 필수)·`is_user_selectable`·`is_active`·`sort_order`. 코드 중복 불가 | | review_settings | body | array | 아니오 | — | 리뷰 정책 섹션 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | +| review_settings.write_deadline_days | body | integer | 아니오 | min 1, max 365 | 구매 확정 후 리뷰를 작성할 수 있는 기간(일) | +| review_settings.max_images | body | integer | 아니오 | min 0, max 20 | 리뷰 1건당 첨부 가능한 이미지 수 (0 = 이미지 첨부 불가) | +| review_settings.max_image_size_mb | body | integer | 아니오 | min 1, max 50 | 리뷰 이미지 파일 1개의 최대 용량(MB) | | mileage | body | array | 아니오 | — | 마일리지 설정 섹션 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림) | +| mileage.enabled | body | boolean | 아니오 | — | 마일리지 적립·사용 기능 사용 여부 (`mileage` 전달 시 필수) | +| mileage.default_earn_rate | body | number | 아니오 | min 0, max 100 | 기본 적립률(%). 상품 등록 시 기본값으로 사용되며, `enabled: true` 인 경우 0 초과여야 함 | +| mileage.earn_trigger | body | string | 아니오 | `delivered`, `confirmed` | 마일리지 적립 시점 (배송완료 / 구매확정) | +| mileage.earn_delay_days | body | integer | 아니오 | min 0, max 365 | 적립 시점 이후 실제 적립까지의 지연일 | +| mileage.currency_rules | body | array | 아니오 | — | 통화별 사용 규칙. 항목별 `currency_code`(ISO 4217, 필수)·`point_value`(1점당 금액, min 0.001)·`min_use_amount`·`use_unit`(min 1)·`max_use_type`(`percent`\|`fixed`)·`max_use_percent`·`max_use_value`. 첫 행은 기본 통화여야 하고, 등록 통화만 허용하며 중복 불가 | +| mileage.expiry_enabled | body | boolean | 아니오 | — | 마일리지 유효기간 사용 여부 (경과 시 미사용 마일리지 자동 소멸) | +| mileage.expiry_days | body | integer | 아니오 | min 1, max 3650 | 마일리지 유효기간(일) | +| mileage.expiry_notification_enabled | body | boolean | 아니오 | — | 소멸 예정 알림 발송 여부 | +| mileage.expiry_notification_days_before | body | integer | 아니오 | min 1, max 365 | 유효기간 만료 며칠 전에 소멸 예정 알림을 발송할지 | | shipping | body | array | 아니오 | — | 배송 설정 섹션 (기본 국가·배송 가능 국가·무료배송·배송사(carriers)·배송유형(types) — carriers/types는 DB 동기화 대상으로 분리 저장) | +| shipping.default_country | body | string | 아니오 | max 10 | 기본 배송 국가 코드. 새 배송정책 등록 시 기본으로 추가되며, `available_countries` 에 존재해야 함 | +| shipping.available_countries | body | array | 아니오 | — | 배송 가능 국가 목록. 항목별 `code`(max 10, 필수)·`name`(다국어 배열, 필수)·`is_active`. 코드 중복 불가 | +| shipping.international_shipping_enabled | body | boolean | 아니오 | — | 해외배송 기능 사용 여부 | +| shipping.free_shipping_threshold | body | integer | 아니오 | min 0 | 무료배송 기준 금액 (이 금액 이상 주문 시 무료배송) | +| shipping.free_shipping_enabled | body | boolean | 아니오 | — | 무료배송 기준금액 적용 여부 | +| shipping.address_validation_enabled | body | boolean | 아니오 | — | 주소 검증(주소찾기 API) 사용 여부 | +| shipping.address_api_provider | body | string | 아니오 | max 50 | 주소찾기 API 제공자 식별자 (기본값 `kakao`) | +| shipping.carriers | body | array | 아니오 | — | 배송사 목록(DB 동기화 대상). 항목별 `id`·`code`(소문자/숫자/하이픈, 필수)·`name`(다국어 배열, 현재 로케일 필수)·`type`(`domestic`\|`international`, 필수)·`tracking_url`(`{tracking_number}` 치환)·`is_active`·`sort_order`. 코드 중복 불가 | +| shipping.types | body | array | 아니오 | — | 배송유형 목록(DB 동기화 대상). 항목별 `id`·`code`(소문자/숫자/하이픈, 필수)·`name`(다국어 배열, 현재 로케일 필수)·`category`(`domestic`\|`international`\|`other`, 필수)·`is_active`·`sort_order`. 코드 중복 불가 | **요청 예시** @@ -1048,32 +521,129 @@ Content-Type: application/json "notifications": [ "예시값" ], + "notifications.channels": [ + "예시값" + ], "basic_info": [ "예시값" ], + "basic_info.shop_name": "예시 이름", + "basic_info.route_path": "예시값", + "basic_info.no_route": true, + "basic_info.company_name": "예시 이름", + "basic_info.business_number_1": "예시값", + "basic_info.business_number_2": "예시값", + "basic_info.business_number_3": "예시값", + "basic_info.ceo_name": "예시 이름", + "basic_info.business_type": "예시값", + "basic_info.business_category": "예시값", + "basic_info.zipcode": "06234", + "basic_info.base_address": "서울특별시 강남구 테헤란로 1", + "basic_info.detail_address": "서울특별시 강남구 테헤란로 1", + "basic_info.phone_1": "010-1234-5678", + "basic_info.phone_2": "010-1234-5678", + "basic_info.phone_3": "010-1234-5678", + "basic_info.fax_1": "예시값", + "basic_info.fax_2": "예시값", + "basic_info.fax_3": "예시값", + "basic_info.email_id": "user@example.com", + "basic_info.email_domain": "user@example.com", + "basic_info.privacy_officer": "예시값", + "basic_info.privacy_officer_email": "user@example.com", + "basic_info.mail_order_number": "예시값", + "basic_info.telecom_number": "예시값", "language_currency": [ "예시값" ], + "language_currency.default_currency": "예시값", + "language_currency.currencies": [ + "예시값" + ], "seo": [ "예시값" ], + "seo.meta_category_title": "예시 제목", + "seo.meta_category_description": "예시 내용입니다.", + "seo.meta_search_title": "예시 제목", + "seo.meta_search_description": "예시 내용입니다.", + "seo.meta_product_title": "예시 제목", + "seo.meta_product_description": "예시 내용입니다.", + "seo.meta_shop_index_title": "예시 제목", + "seo.meta_shop_index_description": "예시 내용입니다.", + "seo.seo_category": true, + "seo.seo_search_result": true, + "seo.seo_product_detail": true, + "seo.seo_shop_index": true, "inquiry": [ "예시값" ], + "inquiry.board_slug": "example-key", "order_settings": [ "예시값" ], + "order_settings.default_pg_provider": "예시값", + "order_settings.cash_receipt_provider": "예시값", + "order_settings.cash_receipt_self_issue": true, + "order_settings.shipping_fee_tax_policy": "proportional", + "order_settings.payment_methods": [ + "예시값" + ], + "order_settings.banks": [ + "예시값" + ], + "order_settings.bank_accounts": [ + "예시값" + ], + "order_settings.auto_cancel_expired": true, + "order_settings.auto_cancel_days": 1, + "order_settings.cart_expiry_days": 1, + "order_settings.stock_restore_on_cancel": true, + "order_settings.confirmable_statuses": [ + "예시값" + ], "claim": [ "예시값" ], + "claim.refund_reasons": [ + "예시값" + ], "review_settings": [ "예시값" ], + "review_settings.write_deadline_days": 1, + "review_settings.max_images": 1, + "review_settings.max_image_size_mb": 1, "mileage": [ "예시값" ], + "mileage.enabled": true, + "mileage.default_earn_rate": 1, + "mileage.earn_trigger": "delivered", + "mileage.earn_delay_days": 1, + "mileage.currency_rules": [ + "예시값" + ], + "mileage.expiry_enabled": true, + "mileage.expiry_days": 1, + "mileage.expiry_notification_enabled": true, + "mileage.expiry_notification_days_before": 1, "shipping": [ "예시값" + ], + "shipping.default_country": "KR", + "shipping.available_countries": [ + "KR" + ], + "shipping.international_shipping_enabled": true, + "shipping.free_shipping_threshold": 1, + "shipping.free_shipping_enabled": true, + "shipping.address_validation_enabled": true, + "shipping.address_api_provider": "서울특별시 강남구 테헤란로 1", + "shipping.carriers": [ + "예시값" + ], + "shipping.types": [ + "예시값" ] } ``` @@ -1086,15 +656,16 @@ _단건 응답: `data` 객체의 필드._ | --- | --- | --- | --- | | basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) | | language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) | -| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | +| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) | | shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) | | seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) | | review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | -| inquiry | object | `{"board_slug":null}` | 문의 연동 설정 (문의 게시판 slug) | +| inquiry | object | `{"board_slug":"inquiry"}` | 문의 연동 설정 (문의 게시판 slug) | | notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 채널 설정 (채널 ID·활성 여부·정렬 순서) | | mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) | | claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) | -| available_pg_providers | array | `[]` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) | +| available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) | +| available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) | **응답 예시** @@ -1105,125 +676,71 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.save_success", + "message": "설정이 저장되었습니다.", "data": { "basic_info": { - "shop_name": "", + "shop_name": "111", "route_path": "shop", "no_route": false, - "company_name": "", + "company_name": null, "business_number": "", - "ceo_name": "", - "business_type": "", - "business_category": "", - "zipcode": "", - "base_address": "", - "detail_address": "", - "phone": "", - "fax": "", - "email": "", - "privacy_officer": "", - "privacy_officer_email": "", - "mail_order_number": "", - "telecom_number": "" + "...": "(13개 키 생략, 총 18개)" }, "language_currency": { - "default_currency": "KRW", + "default_currency": "JPY", "currencies": [ { "code": "KRW", "name": { "ko": "KRW (원)", - "en": "KRW (Won)", - "fr": "KRW (원)" + "en": "KRW (Won)" }, "symbol": "₩", "exchange_rate": null, "base_unit": 1000, - "rounding_unit": "1", - "rounding_method": "floor", - "decimal_places": 0, - "is_default": true, - "locales": [ - "ko" - ], - "flag": "🇰🇷" + "...": "(6개 키 생략, 총 11개)" }, { "code": "USD", "name": { - "ko": "USD (달러)", - "en": "USD (Dollar)", - "fr": "USD (달러)" + "en": "Dollar" }, - "symbol": "$", - "exchange_rate": 0.85, - "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, "is_default": false, - "locales": [ - "en" - ], - "flag": "🇺🇸" + "decimal_places": 2, + "base_unit": 1, + "...": "(3개 키 생략, 총 8개)" }, { "code": "JPY", "name": { - "ko": "JPY (엔)", - "en": "JPY (Yen)", - "fr": "JPY (엔)" + "ja": "円" }, - "symbol": "¥", - "exchange_rate": 115, - "base_unit": 100, - "rounding_unit": "1", - "rounding_method": "floor", + "is_default": true, "decimal_places": 0, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇯🇵" + "base_unit": 100, + "...": "(3개 키 생략, 총 8개)" }, { "code": "CNY", "name": { "ko": "CNY (위안)", - "en": "CNY (Yuan)", - "fr": "CNY (위안)" + "en": "CNY (Yuan)" }, "symbol": "元", "exchange_rate": 5.8, "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇨🇳" + "...": "(6개 키 생략, 총 11개)" }, { "code": "EUR", "name": { "ko": "EUR (유로)", - "en": "EUR (Euro)", - "fr": "EUR (유로)" + "en": "EUR (Euro)" }, "symbol": "€", "exchange_rate": 0.78, "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇪🇺" + "...": "(6개 키 생략, 총 11개)" } ] }, @@ -1236,20 +753,15 @@ HTTP/1.1 200 "sort_order": 1, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "신용카드", - "en": "Credit Card", - "fr": "신용카드" - }, - "_cached_description": { - "ko": "신용카드로 안전하게 결제", - "en": "Pay securely with credit card", - "fr": "신용카드로 안전하게 결제" - }, - "_cached_icon": "credit-card", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 1, + "is_active": true, + "min_order_amount": 0, + "...": "(10개 키 생략, 총 15개)" }, { "id": "vbank", @@ -1257,269 +769,27 @@ HTTP/1.1 200 "sort_order": 2, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "가상계좌", - "en": "Virtual Account", - "fr": "가상계좌" - }, - "_cached_description": { - "ko": "가상계좌로 입금", - "en": "Pay via virtual account", - "fr": "가상계좌로 입금" - }, - "_cached_icon": "money-check", - "_cached_source": "builtin" - }, - { - "id": "dbank", - "pg_provider": null, - "sort_order": 3, - "is_active": true, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "무통장입금", - "en": "Bank Transfer", - "fr": "무통장입금" - }, - "_cached_description": { - "ko": "지정 계좌로 직접 입금", - "en": "Direct bank transfer", - "fr": "지정 계좌로 직접 입금" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "bank", - "pg_provider": null, - "sort_order": 4, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "계좌이체", - "en": "Account Transfer", - "fr": "계좌이체" - }, - "_cached_description": { - "ko": "실시간 계좌이체", - "en": "Real-time bank transfer", - "fr": "실시간 계좌이체" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "phone", - "pg_provider": null, - "sort_order": 5, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "휴대폰결제", - "en": "Mobile Payment", - "fr": "휴대폰결제" - }, - "_cached_description": { - "ko": "휴대폰 소액결제", - "en": "Mobile phone payment", - "fr": "휴대폰 소액결제" - }, - "_cached_icon": "mobile-screen-button", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "point", "pg_provider": null, - "sort_order": 6, - "is_active": false, + "sort_order": 2, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "포인트결제", - "en": "Points", - "fr": "포인트결제" - }, - "_cached_description": { - "ko": "적립 포인트로 결제", - "en": "Pay with points", - "fr": "적립 포인트로 결제" - }, - "_cached_icon": "coins", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "deposit", "pg_provider": null, - "sort_order": 7, - "is_active": false, + "sort_order": 3, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "예치금결제", - "en": "Store Credit", - "fr": "예치금결제" - }, - "_cached_description": { - "ko": "예치금으로 결제", - "en": "Pay with store credit", - "fr": "예치금으로 결제" - }, - "_cached_icon": "wallet", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, - { - "id": "free", - "pg_provider": null, - "sort_order": 8, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "무료", - "en": "Free", - "fr": "무료" - }, - "_cached_description": { - "ko": "결제 없이 주문 완료", - "en": "Order without payment", - "fr": "결제 없이 주문 완료" - }, - "_cached_icon": "gift", - "_cached_source": "builtin" - } - ], - "banks": [ - { - "code": "004", - "name": { - "ko": "국민은행", - "en": "Kookmin Bank" - } - }, - { - "code": "088", - "name": { - "ko": "신한은행", - "en": "Shinhan Bank" - } - }, - { - "code": "020", - "name": { - "ko": "우리은행", - "en": "Woori Bank" - } - }, - { - "code": "081", - "name": { - "ko": "하나은행", - "en": "Hana Bank" - } - }, - { - "code": "003", - "name": { - "ko": "IBK기업은행", - "en": "IBK Industrial Bank" - } - }, - { - "code": "011", - "name": { - "ko": "NH농협은행", - "en": "NH Nonghyup Bank" - } - }, - { - "code": "071", - "name": { - "ko": "우체국", - "en": "Korea Post" - } - }, - { - "code": "031", - "name": { - "ko": "DGB대구은행", - "en": "DGB Daegu Bank" - } - }, - { - "code": "032", - "name": { - "ko": "BNK부산은행", - "en": "BNK Busan Bank" - } - }, - { - "code": "034", - "name": { - "ko": "광주은행", - "en": "Kwangju Bank" - } - }, - { - "code": "035", - "name": { - "ko": "제주은행", - "en": "Jeju Bank" - } - }, - { - "code": "037", - "name": { - "ko": "전북은행", - "en": "Jeonbuk Bank" - } - }, - { - "code": "039", - "name": { - "ko": "BNK경남은행", - "en": "BNK Kyongnam Bank" - } - }, - { - "code": "045", - "name": { - "ko": "새마을금고", - "en": "MG Community Credit Cooperatives" - } - }, - { - "code": "048", - "name": { - "ko": "신협", - "en": "KFCC" - } - }, - { - "code": "090", - "name": { - "ko": "카카오뱅크", - "en": "Kakao Bank" - } - }, - { - "code": "092", - "name": { - "ko": "토스뱅크", - "en": "Toss Bank" - } - } + "... (총 25건 중 5건 표시)" ], + "banks": [], "bank_accounts": [ { "bank_code": "004", @@ -1530,16 +800,7 @@ HTTP/1.1 200 } ], "auto_cancel_expired": true, - "auto_cancel_days": 3, - "cart_expiry_days": 30, - "stock_restore_on_cancel": true, - "cancellable_statuses": [ - "payment_complete" - ], - "confirmable_statuses": [ - "shipping", - "delivered" - ] + "...": "(5개 키 생략, 총 10개)" }, "shipping": { "default_country": "KR", @@ -1548,8 +809,7 @@ HTTP/1.1 200 "code": "KR", "name": { "ko": "대한민국", - "en": "South Korea", - "fr": "대한민국" + "en": "South Korea" }, "is_active": true }, @@ -1557,304 +817,15 @@ HTTP/1.1 200 "code": "US", "name": { "ko": "미국", - "en": "United States", - "fr": "미국" + "en": "United States" }, - "is_active": false - }, - { - "code": "JP", - "name": { - "ko": "일본", - "en": "Japan", - "fr": "일본" - }, - "is_active": false - }, - { - "code": "CN", - "name": { - "ko": "중국", - "en": "China", - "fr": "중국" - }, - "is_active": false - }, - { - "code": "SG", - "name": { - "ko": "싱가포르", - "en": "Singapore", - "fr": "싱가포르" - }, - "is_active": false - }, - { - "code": "HK", - "name": { - "ko": "홍콩", - "en": "Hong Kong", - "fr": "홍콩" - }, - "is_active": false - }, - { - "code": "TW", - "name": { - "ko": "대만", - "en": "Taiwan", - "fr": "대만" - }, - "is_active": false - }, - { - "code": "VN", - "name": { - "ko": "베트남", - "en": "Vietnam", - "fr": "베트남" - }, - "is_active": false - }, - { - "code": "TH", - "name": { - "ko": "태국", - "en": "Thailand", - "fr": "태국" - }, - "is_active": false - }, - { - "code": "MY", - "name": { - "ko": "말레이시아", - "en": "Malaysia", - "fr": "말레이시아" - }, - "is_active": false + "is_active": true } ], - "international_shipping_enabled": false, + "international_shipping_enabled": true, "free_shipping_threshold": 50000, "free_shipping_enabled": true, - "address_validation_enabled": false, - "address_api_provider": "kakao", - "carriers": [ - { - "id": 13, - "code": "apidoc", - "name": { - "ko": "API 문서 샘플 배송사", - "en": "API Doc Sample Carrier" - }, - "type": "domestic", - "tracking_url": null, - "is_active": true, - "sort_order": 0 - }, - { - "id": 1, - "code": "cj", - "name": { - "ko": "CJ대한통운", - "en": "CJ Logistics" - }, - "type": "domestic", - "tracking_url": "https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}", - "is_active": true, - "sort_order": 1 - }, - { - "id": 2, - "code": "hanjin", - "name": { - "ko": "한진택배", - "en": "Hanjin Express" - }, - "type": "domestic", - "tracking_url": "https://www.hanjin.com/kor/CMS/DeliveryMgr/WaybillResult.do?wblnb={tracking_number}", - "is_active": true, - "sort_order": 2 - }, - { - "id": 3, - "code": "lotte", - "name": { - "ko": "롯데택배", - "en": "Lotte Global Logistics" - }, - "type": "domestic", - "tracking_url": "https://www.lotteglogis.com/home/reservation/tracking/link498?InvNo={tracking_number}", - "is_active": true, - "sort_order": 3 - }, - { - "id": 4, - "code": "logen", - "name": { - "ko": "로젠택배", - "en": "Logen Logistics" - }, - "type": "domestic", - "tracking_url": "https://www.ilogen.com/web/personal/trace/{tracking_number}", - "is_active": true, - "sort_order": 4 - }, - { - "id": 5, - "code": "ups", - "name": { - "ko": "UPS", - "en": "UPS" - }, - "type": "international", - "tracking_url": "https://www.ups.com/track?tracknum={tracking_number}", - "is_active": true, - "sort_order": 5 - }, - { - "id": 6, - "code": "ems", - "name": { - "ko": "EMS", - "en": "EMS" - }, - "type": "international", - "tracking_url": "https://service.epost.go.kr/trace.RetrieveEmsRi498.postal?POST_CODE={tracking_number}", - "is_active": true, - "sort_order": 6 - }, - { - "id": 7, - "code": "dhl", - "name": { - "ko": "DHL", - "en": "DHL" - }, - "type": "international", - "tracking_url": "https://www.dhl.com/kr-ko/home/tracking/tracking-express.html?submit=1&tracking-id={tracking_number}", - "is_active": true, - "sort_order": 7 - }, - { - "id": 8, - "code": "fedex", - "name": { - "ko": "FedEx", - "en": "FedEx" - }, - "type": "international", - "tracking_url": "https://www.fedex.com/fedextrack/?tracknumbers={tracking_number}", - "is_active": true, - "sort_order": 8 - }, - { - "id": 9, - "code": "sf", - "name": { - "ko": "SF Express", - "en": "SF Express" - }, - "type": "international", - "tracking_url": null, - "is_active": true, - "sort_order": 9 - }, - { - "id": 10, - "code": "yamato", - "name": { - "ko": "야마토운수", - "en": "Yamato Transport" - }, - "type": "international", - "tracking_url": null, - "is_active": true, - "sort_order": 10 - }, - { - "id": 11, - "code": "sagawa", - "name": { - "ko": "사가와익스프레스", - "en": "Sagawa Express" - }, - "type": "international", - "tracking_url": null, - "is_active": true, - "sort_order": 11 - }, - { - "id": 12, - "code": "other", - "name": { - "ko": "기타", - "en": "Other" - }, - "type": "domestic", - "tracking_url": null, - "is_active": true, - "sort_order": 99 - } - ], - "types": [], - "api_request_fields": [ - { - "value": "policy_id", - "label": "배송정책 ID" - }, - { - "value": "country_code", - "label": "국가 코드" - }, - { - "value": "items", - "label": "주문 항목" - }, - { - "value": "group_total", - "label": "그룹 합계 금액" - }, - { - "value": "total_quantity", - "label": "총 수량" - } - ], - "api_http_methods": [ - { - "value": "GET", - "label": "GET" - }, - { - "value": "POST", - "label": "POST" - } - ], - "api_auth_types": [ - { - "value": "none", - "label": "인증 없음" - }, - { - "value": "bearer", - "label": "Bearer 토큰" - }, - { - "value": "custom_header", - "label": "커스텀 헤더" - } - ], - "api_response_types": [ - { - "value": "json", - "label": "JSON" - }, - { - "value": "text", - "label": "텍스트" - } - ] + "...": "(8개 키 생략, 총 13개)" }, "seo": { "meta_category_title": "{commerce_name} - {category_name}", @@ -1862,13 +833,7 @@ HTTP/1.1 200 "meta_search_title": "{commerce_name} - {keyword_name}", "meta_search_description": "", "meta_product_title": "{commerce_name} - {product_name}", - "meta_product_description": "", - "meta_shop_index_title": "{commerce_name}", - "meta_shop_index_description": "", - "seo_category": true, - "seo_search_result": true, - "seo_product_detail": true, - "seo_shop_index": true + "...": "(7개 키 생략, 총 12개)" }, "review_settings": { "write_deadline_days": 90, @@ -1905,7 +870,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, @@ -2050,6 +1017,24 @@ HTTP/1.1 200 **설명** 관리자가 이커머스 환경설정을 저장합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, `_tab` 으로 저장할 카테고리를 지정하고 각 섹션(`basic_info`·`shipping`·`claim` 등)을 배열로 전달합니다. `EcommerceSettingsService::saveSettings()`가 JSON 설정을 저장하되, DB 관리 대상인 `shipping.carriers`·`shipping.types`·`claim.refund_reasons` 는 분리해 각 Service 의 sync 메서드로 동기화합니다. 저장 성공 시 `sirsoft-ecommerce.settings.after_save` 훅을 발화하고, 관리자 UI 상태 갱신을 위해 병합된 전체 설정을 다시 반환합니다. +**숫자 필드 정규화** 검증 규칙에 `integer` / `numeric` 이 선언된 모든 필드는 검증 직전에 숫자 타입으로 캐스트되어 저장됩니다(중첩 배열의 와일드카드 경로 포함 — 예: `mileage.currency_rules.*.use_unit`). HTML `number` 입력의 값은 문자열(`"5"`)로 전송되고 Laravel 의 `integer` 규칙은 숫자 문자열을 통과시키되 캐스트하지 않으므로, 정규화가 없으면 문자열이 그대로 설정 파일에 영속되어 이후 날짜/수치 연산에서 타입 오류를 유발합니다. + +- 정규화 대상: 정수 표기 문자열(`"5"`, `"05"`) → `int`, 소수 표기 문자열(`"1.5"`) → `float`(단, `numeric` 필드에 한함) +- 정규화 제외: 비숫자 문자열(`"abc"`), 빈 문자열, `null`, 불리언, 그리고 `integer` 필드에 전달된 소수 문자열(`"3.7"`) — 검증을 느슨하게 만들지 않기 위해 캐스트하지 않고 그대로 검증 실패시킵니다 +- 조회(`GET`) 응답도 `defaults.json` 스키마의 숫자 타입으로 정규화되어 반환되므로, 저장 시점의 표현 형태와 무관하게 숫자로 수신됩니다 + +**마일리지 적립 절사 기준** `mileage.currency_rules.*` 의 두 필드가 적립 포인트 산출·안분의 절사 기준을 정합니다. + +| 필드 | 타입 | 허용값 | 기본값 | 설명 | +| --- | --- | --- | --- | --- | +| `earn_rounding_unit` | string | `1` · `10` · `100` | `1` | 적립 포인트를 맞출 단위(점). 마일리지는 원장에 정수로 확정되므로 소수 단위는 허용하지 않습니다 | +| `earn_rounding_method` | string | `floor` · `round` · `ceil` | `floor` | 단위에 맞출 방식(버림 / 반올림 / 올림) | + +- 적용 지점: 옵션 정액 적립 · 옵션 정률 적립 · 기본 적립률 세 갈래 전부, 그리고 부분취소로 주문옵션이 분할될 때의 적립액 안분 +- 기준 통화 선택: 마일리지는 표시 통화가 아니라 기준 통화(`currency_snapshot.base_currency`)로 적립·정산되므로, 그 통화의 규칙을 사용하고 없으면 첫 규칙(기본 통화)으로 폴백합니다 +- 주문 시점 고정: 주문 생성 시 `mileage_policy_snapshot.rule` 에 함께 기록되며, 부분취소·추가결제 재계산은 현재 설정이 아니라 이 스냅샷을 사용합니다. 그렇지 않으면 이후 설정 변경이 과거 주문에 소급돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라집니다 +- 값이 없는 경우(이 필드 도입 이전 설치본·주문): 기본값 `1` / `floor` 로 해석되며 이는 도입 이전 동작과 동일한 금액을 산출합니다 +- 통화 환산 절사(`language_currency.currencies.*.rounding_unit` / `rounding_method`)와는 별개입니다 — 그쪽은 외화 표시 환산에만 적용되어 기본 통화에는 적용되지 않으므로 적립 규칙으로 쓸 수 없습니다 ### PUT /api/modules/sirsoft-ecommerce/admin/settings/banks @@ -2087,11 +1072,11 @@ _단건 응답: `data` 객체의 필드._ | --- | --- | --- | --- | | basic_info | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 쇼핑몰 기본 정보 (쇼핑몰명·라우트 경로·상호·사업자번호·주소·연락처·이메일 등) | | language_currency | object | `{"default_currency":"KRW","currencies":[{"code":"KRW","na…` | 통화 설정 (기본 통화 + 등록 통화 목록: 코드·다국어명·환율·기호·국기·반올림 규칙) | -| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료 등) | +| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 주문/결제 설정 (기본 PG·병합된 결제수단·은행/무통장 계좌·자동취소·장바구니 만료·현금영수증 발급 제공자·자진발급·배송비 과세 방식 등) | | shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 배송 설정 (기본 국가·배송 가능 국가·무료배송·DB 관리 배송사(carriers)·배송유형(types)·계산 API 후보 필드 포함) | | seo | object | `{"meta_category_title":"{commerce_name} - {category_name}…` | SEO 메타 설정 (카테고리·검색·상품·쇼핑몰 인덱스별 메타 타이틀/설명 및 SEO 활성 토글) | | review_settings | object | `{"write_deadline_days":90,"max_images":5,"max_image_size_…` | 리뷰 정책 (작성 기한일·이미지 최대 개수·이미지 최대 용량 MB) | -| inquiry | object | `{"board_slug":null}` | 문의 연동 설정 (문의 게시판 slug) | +| inquiry | object | `{"board_slug":"inquiry"}` | 문의 연동 설정 (문의 게시판 slug) | | notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 채널 설정 (채널 ID·활성 여부·정렬 순서) | | mileage | object | `{"enabled":false,"default_earn_rate":1,"earn_trigger":"co…` | 마일리지 설정 (사용 여부·기본 적립률·적립 트리거·통화별 규칙·소멸/소멸 알림·실제 활성 알림 채널 포함) | @@ -2104,125 +1089,71 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.save_success", + "message": "설정이 저장되었습니다.", "data": { "basic_info": { - "shop_name": "", + "shop_name": "111", "route_path": "shop", "no_route": false, - "company_name": "", + "company_name": null, "business_number": "", - "ceo_name": "", - "business_type": "", - "business_category": "", - "zipcode": "", - "base_address": "", - "detail_address": "", - "phone": "", - "fax": "", - "email": "", - "privacy_officer": "", - "privacy_officer_email": "", - "mail_order_number": "", - "telecom_number": "" + "...": "(13개 키 생략, 총 18개)" }, "language_currency": { - "default_currency": "KRW", + "default_currency": "JPY", "currencies": [ { "code": "KRW", "name": { "ko": "KRW (원)", - "en": "KRW (Won)", - "fr": "KRW (원)" + "en": "KRW (Won)" }, "symbol": "₩", "exchange_rate": null, "base_unit": 1000, - "rounding_unit": "1", - "rounding_method": "floor", - "decimal_places": 0, - "is_default": true, - "locales": [ - "ko" - ], - "flag": "🇰🇷" + "...": "(6개 키 생략, 총 11개)" }, { "code": "USD", "name": { - "ko": "USD (달러)", - "en": "USD (Dollar)", - "fr": "USD (달러)" + "en": "Dollar" }, - "symbol": "$", - "exchange_rate": 0.85, - "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, "is_default": false, - "locales": [ - "en" - ], - "flag": "🇺🇸" + "decimal_places": 2, + "base_unit": 1, + "...": "(3개 키 생략, 총 8개)" }, { "code": "JPY", "name": { - "ko": "JPY (엔)", - "en": "JPY (Yen)", - "fr": "JPY (엔)" + "ja": "円" }, - "symbol": "¥", - "exchange_rate": 115, - "base_unit": 100, - "rounding_unit": "1", - "rounding_method": "floor", + "is_default": true, "decimal_places": 0, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇯🇵" + "base_unit": 100, + "...": "(3개 키 생략, 총 8개)" }, { "code": "CNY", "name": { "ko": "CNY (위안)", - "en": "CNY (Yuan)", - "fr": "CNY (위안)" + "en": "CNY (Yuan)" }, "symbol": "元", "exchange_rate": 5.8, "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇨🇳" + "...": "(6개 키 생략, 총 11개)" }, { "code": "EUR", "name": { "ko": "EUR (유로)", - "en": "EUR (Euro)", - "fr": "EUR (유로)" + "en": "EUR (Euro)" }, "symbol": "€", "exchange_rate": 0.78, "base_unit": 1, - "rounding_unit": "0.01", - "rounding_method": "round", - "decimal_places": 2, - "is_default": false, - "locales": [ - "en" - ], - "flag": "🇪🇺" + "...": "(6개 키 생략, 총 11개)" } ] }, @@ -2235,20 +1166,15 @@ HTTP/1.1 200 "sort_order": 1, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "신용카드", - "en": "Credit Card", - "fr": "신용카드" - }, - "_cached_description": { - "ko": "신용카드로 안전하게 결제", - "en": "Pay securely with credit card", - "fr": "신용카드로 안전하게 결제" - }, - "_cached_icon": "credit-card", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 1, + "is_active": true, + "min_order_amount": 0, + "...": "(10개 키 생략, 총 15개)" }, { "id": "vbank", @@ -2256,147 +1182,25 @@ HTTP/1.1 200 "sort_order": 2, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "가상계좌", - "en": "Virtual Account", - "fr": "가상계좌" - }, - "_cached_description": { - "ko": "가상계좌로 입금", - "en": "Pay via virtual account", - "fr": "가상계좌로 입금" - }, - "_cached_icon": "money-check", - "_cached_source": "builtin" - }, - { - "id": "dbank", - "pg_provider": null, - "sort_order": 3, - "is_active": true, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "무통장입금", - "en": "Bank Transfer", - "fr": "무통장입금" - }, - "_cached_description": { - "ko": "지정 계좌로 직접 입금", - "en": "Direct bank transfer", - "fr": "지정 계좌로 직접 입금" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "bank", - "pg_provider": null, - "sort_order": 4, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "계좌이체", - "en": "Account Transfer", - "fr": "계좌이체" - }, - "_cached_description": { - "ko": "실시간 계좌이체", - "en": "Real-time bank transfer", - "fr": "실시간 계좌이체" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "phone", - "pg_provider": null, - "sort_order": 5, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "휴대폰결제", - "en": "Mobile Payment", - "fr": "휴대폰결제" - }, - "_cached_description": { - "ko": "휴대폰 소액결제", - "en": "Mobile phone payment", - "fr": "휴대폰 소액결제" - }, - "_cached_icon": "mobile-screen-button", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "point", "pg_provider": null, - "sort_order": 6, - "is_active": false, + "sort_order": 2, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "포인트결제", - "en": "Points", - "fr": "포인트결제" - }, - "_cached_description": { - "ko": "적립 포인트로 결제", - "en": "Pay with points", - "fr": "적립 포인트로 결제" - }, - "_cached_icon": "coins", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "deposit", "pg_provider": null, - "sort_order": 7, - "is_active": false, + "sort_order": 3, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "예치금결제", - "en": "Store Credit", - "fr": "예치금결제" - }, - "_cached_description": { - "ko": "예치금으로 결제", - "en": "Pay with store credit", - "fr": "예치금으로 결제" - }, - "_cached_icon": "wallet", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, - { - "id": "free", - "pg_provider": null, - "sort_order": 8, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "무료", - "en": "Free", - "fr": "무료" - }, - "_cached_description": { - "ko": "결제 없이 주문 완료", - "en": "Order without payment", - "fr": "결제 없이 주문 완료" - }, - "_cached_icon": "gift", - "_cached_source": "builtin" - } + "... (총 25건 중 5건 표시)" ], "banks": [], "bank_accounts": [ @@ -2409,16 +1213,7 @@ HTTP/1.1 200 } ], "auto_cancel_expired": true, - "auto_cancel_days": 3, - "cart_expiry_days": 30, - "stock_restore_on_cancel": true, - "cancellable_statuses": [ - "payment_complete" - ], - "confirmable_statuses": [ - "shipping", - "delivered" - ] + "...": "(5개 키 생략, 총 10개)" }, "shipping": { "default_country": "KR", @@ -2427,8 +1222,7 @@ HTTP/1.1 200 "code": "KR", "name": { "ko": "대한민국", - "en": "South Korea", - "fr": "대한민국" + "en": "South Korea" }, "is_active": true }, @@ -2436,89 +1230,15 @@ HTTP/1.1 200 "code": "US", "name": { "ko": "미국", - "en": "United States", - "fr": "미국" + "en": "United States" }, - "is_active": false - }, - { - "code": "JP", - "name": { - "ko": "일본", - "en": "Japan", - "fr": "일본" - }, - "is_active": false - }, - { - "code": "CN", - "name": { - "ko": "중국", - "en": "China", - "fr": "중국" - }, - "is_active": false - }, - { - "code": "SG", - "name": { - "ko": "싱가포르", - "en": "Singapore", - "fr": "싱가포르" - }, - "is_active": false - }, - { - "code": "HK", - "name": { - "ko": "홍콩", - "en": "Hong Kong", - "fr": "홍콩" - }, - "is_active": false - }, - { - "code": "TW", - "name": { - "ko": "대만", - "en": "Taiwan", - "fr": "대만" - }, - "is_active": false - }, - { - "code": "VN", - "name": { - "ko": "베트남", - "en": "Vietnam", - "fr": "베트남" - }, - "is_active": false - }, - { - "code": "TH", - "name": { - "ko": "태국", - "en": "Thailand", - "fr": "태국" - }, - "is_active": false - }, - { - "code": "MY", - "name": { - "ko": "말레이시아", - "en": "Malaysia", - "fr": "말레이시아" - }, - "is_active": false + "is_active": true } ], - "international_shipping_enabled": false, + "international_shipping_enabled": true, "free_shipping_threshold": 50000, "free_shipping_enabled": true, - "address_validation_enabled": false, - "address_api_provider": "kakao" + "...": "(2개 키 생략, 총 7개)" }, "seo": { "meta_category_title": "{commerce_name} - {category_name}", @@ -2526,13 +1246,7 @@ HTTP/1.1 200 "meta_search_title": "{commerce_name} - {keyword_name}", "meta_search_description": "", "meta_product_title": "{commerce_name} - {product_name}", - "meta_product_description": "", - "meta_shop_index_title": "{commerce_name}", - "meta_shop_index_description": "", - "seo_category": true, - "seo_search_result": true, - "seo_product_detail": true, - "seo_shop_index": true + "...": "(7개 키 생략, 총 12개)" }, "review_settings": { "write_deadline_days": 90, @@ -2569,7 +1283,9 @@ HTTP/1.1 200 "use_unit": 10, "max_use_type": "fixed", "max_use_percent": 30, - "max_use_value": 50000 + "max_use_value": 50000, + "earn_rounding_unit": "1", + "earn_rounding_method": "floor" } ], "expiry_enabled": true, @@ -2615,11 +1331,27 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| cleared | boolean | `true` | 이커머스 설정 캐시 + SEO 렌더 캐시 초기화 성공 여부 (성공 시 항상 `true`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "sirsoft-ecommerce::messages.settings.cache_clear_success", + "data": { + "cleared": true + } +} +``` **에러 응답** @@ -2671,7 +1403,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "message": "설정을 조회했습니다.", "data": { "count": 0, "size_bytes": 0, @@ -2707,7 +1439,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/settings/basic_info HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/settings/{category} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -2719,9 +1451,9 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| category | string | `basic_info` | 조회한 설정 분류(탭) 식별자 (`basic_info`·`language_currency`·`shipping` 등 요청 경로의 category 값 반영) | -| settings | object | `{"shop_name":"","route_path":"shop","no_route":false,"com…` | 해당 분류의 설정 값 객체 (분류에 따라 구조가 달라지며, 배송·클레임 등은 DB 관리 대상이 병합되어 반환) | -| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | +| category | string | `"basic_info"` | 조회한 설정 카테고리 (path 파라미터를 그대로 반향) | +| settings | object | `{"shop_name":"","route_path":"shop","no_route":false}` | 해당 카테고리의 설정값. 구조는 카테고리마다 다르며 index 응답의 동명 최상위 키와 동일하다 (`order_settings` 조회 시 결제수단은 병합된 목록 — 공급 확장이 빠진 고아 항목 포함) | +| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update — 권한 맵 기반) | **응답 예시** @@ -2732,28 +1464,13 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "message": "설정을 조회했습니다.", "data": { "category": "basic_info", "settings": { "shop_name": "", "route_path": "shop", - "no_route": false, - "company_name": "", - "business_number": "", - "ceo_name": "", - "business_type": "", - "business_category": "", - "zipcode": "", - "base_address": "", - "detail_address": "", - "phone": "", - "fax": "", - "email": "", - "privacy_officer": "", - "privacy_officer_email": "", - "mail_order_number": "", - "telecom_number": "" + "no_route": false }, "abilities": { "can_update": true @@ -2800,7 +1517,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | | shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 체크아웃용 배송 설정 (기본 국가·배송 가능 국가·무료배송·배송유형 등) | -| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등) | +| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등). `payment_methods` 는 현재 제공 가능한 결제수단만 포함하며, 공급 확장이 더 이상 제공하지 않는 결제수단(관리자 화면의 고아 항목)은 `is_active` 가 참이어도 제외된다 | **응답 예시** @@ -2811,7 +1528,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "message": "설정을 조회했습니다.", "data": { "shipping": { "default_country": "KR", @@ -2820,8 +1537,7 @@ HTTP/1.1 200 "code": "KR", "name": { "ko": "대한민국", - "en": "South Korea", - "fr": "대한민국" + "en": "South Korea" }, "is_active": true }, @@ -2829,89 +1545,15 @@ HTTP/1.1 200 "code": "US", "name": { "ko": "미국", - "en": "United States", - "fr": "미국" + "en": "United States" }, - "is_active": false - }, - { - "code": "JP", - "name": { - "ko": "일본", - "en": "Japan", - "fr": "일본" - }, - "is_active": false - }, - { - "code": "CN", - "name": { - "ko": "중국", - "en": "China", - "fr": "중국" - }, - "is_active": false - }, - { - "code": "SG", - "name": { - "ko": "싱가포르", - "en": "Singapore", - "fr": "싱가포르" - }, - "is_active": false - }, - { - "code": "HK", - "name": { - "ko": "홍콩", - "en": "Hong Kong", - "fr": "홍콩" - }, - "is_active": false - }, - { - "code": "TW", - "name": { - "ko": "대만", - "en": "Taiwan", - "fr": "대만" - }, - "is_active": false - }, - { - "code": "VN", - "name": { - "ko": "베트남", - "en": "Vietnam", - "fr": "베트남" - }, - "is_active": false - }, - { - "code": "TH", - "name": { - "ko": "태국", - "en": "Thailand", - "fr": "태국" - }, - "is_active": false - }, - { - "code": "MY", - "name": { - "ko": "말레이시아", - "en": "Malaysia", - "fr": "말레이시아" - }, - "is_active": false + "is_active": true } ], - "international_shipping_enabled": false, + "international_shipping_enabled": true, "free_shipping_threshold": 50000, "free_shipping_enabled": true, - "address_validation_enabled": false, - "address_api_provider": "kakao" + "...": "(2개 키 생략, 총 7개)" }, "order_settings": { "default_pg_provider": null, @@ -2922,20 +1564,15 @@ HTTP/1.1 200 "sort_order": 1, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "신용카드", - "en": "Credit Card", - "fr": "신용카드" - }, - "_cached_description": { - "ko": "신용카드로 안전하게 결제", - "en": "Pay securely with credit card", - "fr": "신용카드로 안전하게 결제" - }, - "_cached_icon": "credit-card", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 1, + "is_active": true, + "min_order_amount": 0, + "...": "(10개 키 생략, 총 15개)" }, { "id": "vbank", @@ -2943,147 +1580,25 @@ HTTP/1.1 200 "sort_order": 2, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "가상계좌", - "en": "Virtual Account", - "fr": "가상계좌" - }, - "_cached_description": { - "ko": "가상계좌로 입금", - "en": "Pay via virtual account", - "fr": "가상계좌로 입금" - }, - "_cached_icon": "money-check", - "_cached_source": "builtin" - }, - { - "id": "dbank", - "pg_provider": null, - "sort_order": 3, - "is_active": true, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "무통장입금", - "en": "Bank Transfer", - "fr": "무통장입금" - }, - "_cached_description": { - "ko": "지정 계좌로 직접 입금", - "en": "Direct bank transfer", - "fr": "지정 계좌로 직접 입금" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "bank", - "pg_provider": null, - "sort_order": 4, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "계좌이체", - "en": "Account Transfer", - "fr": "계좌이체" - }, - "_cached_description": { - "ko": "실시간 계좌이체", - "en": "Real-time bank transfer", - "fr": "실시간 계좌이체" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "phone", - "pg_provider": null, - "sort_order": 5, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "휴대폰결제", - "en": "Mobile Payment", - "fr": "휴대폰결제" - }, - "_cached_description": { - "ko": "휴대폰 소액결제", - "en": "Mobile phone payment", - "fr": "휴대폰 소액결제" - }, - "_cached_icon": "mobile-screen-button", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "point", "pg_provider": null, - "sort_order": 6, - "is_active": false, + "sort_order": 2, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "포인트결제", - "en": "Points", - "fr": "포인트결제" - }, - "_cached_description": { - "ko": "적립 포인트로 결제", - "en": "Pay with points", - "fr": "적립 포인트로 결제" - }, - "_cached_icon": "coins", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "deposit", "pg_provider": null, - "sort_order": 7, - "is_active": false, + "sort_order": 3, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "예치금결제", - "en": "Store Credit", - "fr": "예치금결제" - }, - "_cached_description": { - "ko": "예치금으로 결제", - "en": "Pay with store credit", - "fr": "예치금으로 결제" - }, - "_cached_icon": "wallet", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, - { - "id": "free", - "pg_provider": null, - "sort_order": 8, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "무료", - "en": "Free", - "fr": "무료" - }, - "_cached_description": { - "ko": "결제 없이 주문 완료", - "en": "Order without payment", - "fr": "결제 없이 주문 완료" - }, - "_cached_icon": "gift", - "_cached_source": "builtin" - } + "... (총 25건 중 5건 표시)" ], "banks": [], "bank_accounts": [ @@ -3096,16 +1611,7 @@ HTTP/1.1 200 } ], "auto_cancel_expired": true, - "auto_cancel_days": 3, - "cart_expiry_days": 30, - "stock_restore_on_cancel": true, - "cancellable_statuses": [ - "payment_complete" - ], - "confirmable_statuses": [ - "shipping", - "delivered" - ] + "...": "(5개 키 생략, 총 10개)" } } } @@ -3113,7 +1619,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 설정 조회 중 예외 발생 (`messages.settings.fetch_failed`) | @@ -3144,7 +1652,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| order_settings | object | `{"default_pg_provider":null,"payment_methods":[{"id":"car…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외) | +| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외). `payment_methods` 는 현재 제공 가능한 결제수단만 포함하며, 공급 확장이 더 이상 제공하지 않는 결제수단(관리자 화면의 고아 항목)은 `is_active` 가 참이어도 제외된다 | **응답 예시** @@ -3155,7 +1663,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "message": "설정을 조회했습니다.", "data": { "order_settings": { "default_pg_provider": null, @@ -3166,20 +1674,15 @@ HTTP/1.1 200 "sort_order": 1, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "신용카드", - "en": "Credit Card", - "fr": "신용카드" - }, - "_cached_description": { - "ko": "신용카드로 안전하게 결제", - "en": "Pay securely with credit card", - "fr": "신용카드로 안전하게 결제" - }, - "_cached_icon": "credit-card", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" + }, + { + "id": "dbank", + "pg_provider": null, + "sort_order": 1, + "is_active": true, + "min_order_amount": 0, + "...": "(10개 키 생략, 총 15개)" }, { "id": "vbank", @@ -3187,147 +1690,25 @@ HTTP/1.1 200 "sort_order": 2, "is_active": false, "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "가상계좌", - "en": "Virtual Account", - "fr": "가상계좌" - }, - "_cached_description": { - "ko": "가상계좌로 입금", - "en": "Pay via virtual account", - "fr": "가상계좌로 입금" - }, - "_cached_icon": "money-check", - "_cached_source": "builtin" - }, - { - "id": "dbank", - "pg_provider": null, - "sort_order": 3, - "is_active": true, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "order_placed", - "_cached_name": { - "ko": "무통장입금", - "en": "Bank Transfer", - "fr": "무통장입금" - }, - "_cached_description": { - "ko": "지정 계좌로 직접 입금", - "en": "Direct bank transfer", - "fr": "지정 계좌로 직접 입금" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "bank", - "pg_provider": null, - "sort_order": 4, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "계좌이체", - "en": "Account Transfer", - "fr": "계좌이체" - }, - "_cached_description": { - "ko": "실시간 계좌이체", - "en": "Real-time bank transfer", - "fr": "실시간 계좌이체" - }, - "_cached_icon": "building-columns", - "_cached_source": "builtin" - }, - { - "id": "phone", - "pg_provider": null, - "sort_order": 5, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "payment_complete", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "휴대폰결제", - "en": "Mobile Payment", - "fr": "휴대폰결제" - }, - "_cached_description": { - "ko": "휴대폰 소액결제", - "en": "Mobile phone payment", - "fr": "휴대폰 소액결제" - }, - "_cached_icon": "mobile-screen-button", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "point", "pg_provider": null, - "sort_order": 6, - "is_active": false, + "sort_order": 2, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "포인트결제", - "en": "Points", - "fr": "포인트결제" - }, - "_cached_description": { - "ko": "적립 포인트로 결제", - "en": "Pay with points", - "fr": "적립 포인트로 결제" - }, - "_cached_icon": "coins", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, { "id": "deposit", "pg_provider": null, - "sort_order": 7, - "is_active": false, + "sort_order": 3, + "is_active": true, "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "예치금결제", - "en": "Store Credit", - "fr": "예치금결제" - }, - "_cached_description": { - "ko": "예치금으로 결제", - "en": "Pay with store credit", - "fr": "예치금으로 결제" - }, - "_cached_icon": "wallet", - "_cached_source": "builtin" + "...": "(10개 키 생략, 총 15개)" }, - { - "id": "free", - "pg_provider": null, - "sort_order": 8, - "is_active": false, - "min_order_amount": 0, - "stock_deduction_timing": "order_placed", - "mileage_deduction_timing": "payment_complete", - "_cached_name": { - "ko": "무료", - "en": "Free", - "fr": "무료" - }, - "_cached_description": { - "ko": "결제 없이 주문 완료", - "en": "Order without payment", - "fr": "결제 없이 주문 완료" - }, - "_cached_icon": "gift", - "_cached_source": "builtin" - } + "... (총 25건 중 5건 표시)" ], "banks": [], "bank_accounts": [ @@ -3337,20 +1718,11 @@ HTTP/1.1 200 "account_holder": "", "is_active": false, "is_default": false, - "bank_name": "004" + "...": "(1개 키 생략, 총 6개)" } ], "auto_cancel_expired": true, - "auto_cancel_days": 3, - "cart_expiry_days": 30, - "stock_restore_on_cancel": true, - "cancellable_statuses": [ - "payment_complete" - ], - "confirmable_statuses": [ - "shipping", - "delivered" - ] + "...": "(5개 키 생략, 총 10개)" } } } @@ -3358,7 +1730,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 설정 조회 중 예외 발생 (`messages.settings.fetch_failed`) | @@ -3400,7 +1774,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "message": "설정을 조회했습니다.", "data": { "review_settings": { "write_deadline_days": 90, @@ -3413,7 +1787,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 설정 조회 중 예외 발생 (`messages.settings.fetch_failed`) | @@ -3455,7 +1831,7 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.settings.fetch_success", + "message": "설정을 조회했습니다.", "data": { "shipping": { "default_country": "KR", @@ -3464,8 +1840,7 @@ HTTP/1.1 200 "code": "KR", "name": { "ko": "대한민국", - "en": "South Korea", - "fr": "대한민국" + "en": "South Korea" }, "is_active": true }, @@ -3473,85 +1848,12 @@ HTTP/1.1 200 "code": "US", "name": { "ko": "미국", - "en": "United States", - "fr": "미국" + "en": "United States" }, - "is_active": false - }, - { - "code": "JP", - "name": { - "ko": "일본", - "en": "Japan", - "fr": "일본" - }, - "is_active": false - }, - { - "code": "CN", - "name": { - "ko": "중국", - "en": "China", - "fr": "중국" - }, - "is_active": false - }, - { - "code": "SG", - "name": { - "ko": "싱가포르", - "en": "Singapore", - "fr": "싱가포르" - }, - "is_active": false - }, - { - "code": "HK", - "name": { - "ko": "홍콩", - "en": "Hong Kong", - "fr": "홍콩" - }, - "is_active": false - }, - { - "code": "TW", - "name": { - "ko": "대만", - "en": "Taiwan", - "fr": "대만" - }, - "is_active": false - }, - { - "code": "VN", - "name": { - "ko": "베트남", - "en": "Vietnam", - "fr": "베트남" - }, - "is_active": false - }, - { - "code": "TH", - "name": { - "ko": "태국", - "en": "Thailand", - "fr": "태국" - }, - "is_active": false - }, - { - "code": "MY", - "name": { - "ko": "말레이시아", - "en": "Malaysia", - "fr": "말레이시아" - }, - "is_active": false + "is_active": true } ], - "international_shipping_enabled": false, + "international_shipping_enabled": true, "free_shipping_threshold": 50000, "free_shipping_enabled": true, "address_validation_enabled": false, @@ -3563,7 +1865,9 @@ HTTP/1.1 200 **에러 응답** -_대표 에러 없음 (공개 조회). _ +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 500 | Internal Server Error | 설정 조회 중 예외 발생 (`messages.settings.fetch_failed`) | @@ -3600,3 +1904,5 @@ _대표 에러 없음 (공개 조회). @@ -183,11 +188,58 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ShippingCarrierResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `13` | 생성된 배송사의 기본 키 (내부 식별자) | +| code | string | `apidoc` | 배송사 고유 코드 (cj, fedex 등 시스템 식별용) | +| name | object | `{"ko":"API 문서 샘플 배송사","en":"API Doc Sample Carrier"}` | 다국어 배송사명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 배송사` | `name` 의 현재 로케일 해석 값 (표시용 문자열) | +| type | string | `domestic` | 배송사 유형 (`domestic` 국내 / `international` 해외) | +| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (`국내` / `국제`) | +| tracking_url | string\|null | `https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}` | 배송 추적 URL 템플릿 (`{tracking_number}` 치환자를 운송장 번호로 대체) | +| is_active | boolean | `true` | 활성 여부 (true 활성 / false 비활성) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| creator | object | `{"id":1,"name":"관리자"}` | 생성자 정보 (creator 관계 로드 시에만 포함) | +| updater | object | `{"id":1,"name":"관리자"}` | 최종 수정자 정보 (updater 관계 로드 시에만 포함) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `sirsoft-ecommerce.settings.update` 권한 기준) | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "배송사가 등록되었습니다.", + "data": { + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사", + "en": "API Doc Sample Carrier" + }, + "localized_name": "API 문서 샘플 배송사", + "type": "domestic", + "type_label": "국내", + "tracking_url": null, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -210,12 +262,14 @@ Content-Type: application/json **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| type | query | string | 아니오 | max 50 | 유형 필터 (해당 유형의 항목만 조회) | **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers/active HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers/active?type=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -242,13 +296,6 @@ HTTP/1.1 200 "success": true, "message": "배송사 목록을 조회했습니다.", "data": [ - { - "value": 13, - "label": "API 문서 샘플 배송사", - "code": "apidoc", - "type": "domestic", - "tracking_url": null - }, { "value": 1, "label": "CJ대한통운", @@ -278,56 +325,56 @@ HTTP/1.1 200 "tracking_url": "https://www.ilogen.com/web/personal/trace/{tracking_number}" }, { - "value": 5, + "value": 9, "label": "UPS", "code": "ups", "type": "international", "tracking_url": "https://www.ups.com/track?tracknum={tracking_number}" }, { - "value": 6, + "value": 5, "label": "EMS", "code": "ems", "type": "international", "tracking_url": "https://service.epost.go.kr/trace.RetrieveEmsRi498.postal?POST_CODE={tracking_number}" }, { - "value": 7, + "value": 6, "label": "DHL", "code": "dhl", "type": "international", "tracking_url": "https://www.dhl.com/kr-ko/home/tracking/tracking-express.html?submit=1&tracking-id={tracking_number}" }, { - "value": 8, + "value": 10, "label": "FedEx", "code": "fedex", "type": "international", "tracking_url": "https://www.fedex.com/fedextrack/?tracknumbers={tracking_number}" }, { - "value": 9, + "value": 11, "label": "SF Express", "code": "sf", "type": "international", "tracking_url": null }, { - "value": 10, + "value": 12, "label": "야마토운수", "code": "yamato", "type": "international", "tracking_url": null }, { - "value": 11, + "value": 13, "label": "사가와익스프레스", "code": "sagawa", "type": "international", "tracking_url": null }, { - "value": 12, + "value": 7, "label": "기타", "code": "other", "type": "domestic", @@ -343,6 +390,7 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.read`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -364,7 +412,7 @@ HTTP/1.1 200 **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/shipping-carriers/13 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -372,27 +420,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| carrier_id | integer | `13` | carrier 식별자 (연관 리소스 참조) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "배송사가 삭제되었습니다.", - "data": { - "carrier_id": 13 - } -} -``` + **에러 응답** @@ -422,7 +454,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers/13 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -430,56 +462,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `13` | 기본 키 (내부 식별자) | -| code | string | `apidoc` | 고유 코드 (cj, fedex 등) | -| name | object | `{"ko":"API 문서 샘플 배송사","en":"API Doc Sample Carrier"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 배송사` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| type | string | `domestic` | 배송사 유형: domestic(국내), international(해외) | -| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| tracking_url | null | `null` | tracking URL | -| is_active | boolean | `true` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "배송사 정보를 조회했습니다.", - "data": { - "id": 13, - "code": "apidoc", - "name": { - "ko": "API 문서 샘플 배송사", - "en": "API Doc Sample Carrier" - }, - "localized_name": "API 문서 샘플 배송사", - "type": "domestic", - "type_label": "국내", - "tracking_url": null, - "is_active": true, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -517,7 +504,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/shipping-carriers/13 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -537,11 +524,58 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ShippingCarrierResource`)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `13` | 수정된 배송사의 기본 키 (내부 식별자) | +| code | string | `apidoc` | 배송사 고유 코드 (cj, fedex 등 시스템 식별용) | +| name | object | `{"ko":"API 문서 샘플 배송사(수정)","en":"API Doc Sample Carrier"}` | 다국어 배송사명 (로케일별 값 객체) | +| localized_name | string | `API 문서 샘플 배송사(수정)` | `name` 의 현재 로케일 해석 값 (표시용 문자열) | +| type | string | `domestic` | 배송사 유형 (`domestic` 국내 / `international` 해외) | +| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (`국내` / `국제`) | +| tracking_url | string\|null | `https://trace.cjlogistics.com/next/tracking.html?wblNo={tracking_number}` | 배송 추적 URL 템플릿 (`{tracking_number}` 치환자를 운송장 번호로 대체) | +| is_active | boolean | `true` | 활성 여부 (true 활성 / false 비활성) | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 (사용자 타임존 기준 문자열) | +| updated_at | string | `2026-07-08 15:12:07` | 최종 수정 일시 (사용자 타임존 기준 문자열) | +| creator | object | `{"id":1,"name":"관리자"}` | 생성자 정보 (creator 관계 로드 시에만 포함) | +| updater | object | `{"id":1,"name":"관리자"}` | 최종 수정자 정보 (updater 관계 로드 시에만 포함) | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (모두 `sirsoft-ecommerce.settings.update` 권한 기준) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송사가 수정되었습니다.", + "data": { + "id": 13, + "code": "apidoc", + "name": { + "ko": "API 문서 샘플 배송사(수정)", + "en": "API Doc Sample Carrier" + }, + "localized_name": "API 문서 샘플 배송사(수정)", + "type": "domestic", + "type_label": "국내", + "tracking_url": null, + "is_active": true, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:12:07", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -549,8 +583,8 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -572,7 +606,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/shipping-carriers/13/toggle-status HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id}/toggle-status HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -580,56 +614,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `13` | 기본 키 (내부 식별자) | -| code | string | `apidoc` | 고유 코드 (cj, fedex 등) | -| name | object | `{"ko":"API 문서 샘플 배송사","en":"API Doc Sample Carrier"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| localized_name | string | `API 문서 샘플 배송사` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| type | string | `domestic` | 배송사 유형: domestic(국내), international(해외) | -| type_label | string | `국내` | `type` 값의 사람이 읽는 라벨 (현지화/Enum 파생) | -| tracking_url | null | `null` | tracking URL | -| is_active | boolean | `false` | active 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:34` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "sirsoft-ecommerce::messages.shipping_carriers.status_changed", - "data": { - "id": 13, - "code": "apidoc", - "name": { - "ko": "API 문서 샘플 배송사", - "en": "API Doc Sample Carrier" - }, - "localized_name": "API 문서 샘플 배송사", - "type": "domestic", - "type_label": "국내", - "tracking_url": null, - "is_active": false, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:34", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md index cb406e36..d8157b9f 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-country.md @@ -8,22 +8,14 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Shipping Country 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` --- -## 요청별 배송국가 해석 (ResolveShippingCountry 미들웨어) - -위 두 엔드포인트는 회원이 **저장해 둔** 선호 배송국가를 조회·저장한다. 이와 별개로, 상품 목록·장바구니·결제(`products/*`, `cart/*`, `checkout/*`)와 비회원 주문 생성(`user/orders`) 요청은 요청 시점의 배송국가를 해석하는 `ResolveShippingCountry` 미들웨어를 거친다. 이 미들웨어는 로그인 회원의 저장값·요청 파라미터·기본 배송국가를 순서대로 반영해 이후 배송비/배송가능 여부 계산의 기준 국가를 확정한다. - -이 미들웨어는 코어의 확장 미들웨어 self-gate(`Module::getMiddleware()` 의 `targets` 로 위 라우트명에만 정밀 타게팅)로 부착된다. 라우트 파일에서 직접 부착하던 이전 방식과 적용 대상·동작은 동일하다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../../../../../docs/backend/middleware.md). - ---- - ### GET /api/modules/sirsoft-ecommerce/user/shipping-country diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md index 61858fec..849ff4d1 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Shipping Policies 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -32,6 +32,7 @@ | charge_policies | query | array | 아니오 | — | 배송비 부과정책으로 필터 (free/fixed/conditional_free/range_*/api/per_* 등, 국가별 설정 매치) | | countries | query | array | 아니오 | — | 배송 국가 코드로 필터 (ISO 코드 배열, 해당 국가 설정을 가진 정책만) | | is_active | query | string | 아니오 | ``, `true`, `false` | 활성 여부 (true 활성 / false 비활성) | +| with_country_settings | query | boolean | 아니오 | 기본 `false` | 국가별 설정을 **전체 컬럼**으로 포함할지. 기본값에서도 `country_settings` 는 내려가지만 목록 표시용 필드만 담깁니다(구간 설정·도서산간 설정·계산 API 설정 제외). 편집 폼처럼 전체 값이 필요한 호출자만 켜세요 | | sort_by | query | string | 아니오 | `id`, `name`, `is_active`, `sort_order`, `created_at`, `updated_at` | 정렬 기준 필드명 | | sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) | | per_page | query | integer | 아니오 | min 10, max 100 | 페이지당 항목 수 | @@ -39,10 +40,16 @@ > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-ecommerce.shipping_policy.list_validation_rules`, `sirsoft-ecommerce.shipping_policy.list_validation_messages`). +**목록은 경량 표현입니다.** + +국가별 설정(`country_settings[]`)은 목록 화면이 그리는 필드만 담습니다 — 국가 코드, 배송방법, 부과정책, 배송비, 무료배송 기준액, 도서산간 사용 여부, 활성 여부. 구간 설정(`ranges`), 도서산간 상세(`extra_fee_settings`), 계산 API 설정(`api_*`)은 정책 하나에 국가 수만큼 곱해지는 중첩 데이터라 목록에서 제외합니다. 전체 값이 필요하면 `with_country_settings=1` 을 쓰거나 단건 조회(`GET .../admin/shipping-policies/{id}`)를 이용하세요. + +비활성 국가 설정도 함께 내려갑니다(`is_active: false`) — 목록이 비활성 배지를 그리기 때문입니다. 다만 배송비 요약(`fee_summary`)과 국가 표시(`countries_display`)는 활성 설정만 세므로, 조회 경로와 무관하게 같은 값이 나옵니다. + **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/shipping-policies?search=%EC%98%88%EC%8B%9C%EA%B0%92&shipping_methods=%EC%98%88%EC%8B%9C%EA%B0%92&charge_policies=%EC%98%88%EC%8B%9C%EA%B0%92&countries=KR&is_active=%2C%20&sort_by=id&sort_order=asc&per_page=1&page=1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/shipping-policies?search=%EC%98%88%EC%8B%9C%EA%B0%92&shipping_methods=%EC%98%88%EC%8B%9C%EA%B0%92&charge_policies=%EC%98%88%EC%8B%9C%EA%B0%92&countries=KR&is_active=%2C%20&with_country_settings=1&sort_by=id&sort_order=asc&per_page=1&page=1 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -54,18 +61,15 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| number | integer | `17` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | -| id | integer | `47` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 배송정책","en":"API Doc Sample Shipping Poli…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `API 문서 샘플 배송정책` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| country_settings | array | `[]` | 국가별 배송 설정 목록 (countrySettings 관계 로드 시 각 국가의 배송방식·부과정책·배송비 상세) | -| fee_summary | string | `` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (예: `KR: 3000원 \| US: $20`, 활성 설정 없으면 빈 문자열) | -| countries_display | string | `` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개 노출, 초과분은 `+N` 축약) | +| number | integer | `15` | 목록에서의 순번 (페이지네이션 반영 행 번호 — HasRowNumber 파생) | +| id | integer | `444` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"국내 무료배송","en":"Domestic Free Shipping"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `국내 무료배송` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | | is_active | boolean | `true` | active 여부 | -| is_default | boolean | `false` | default 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-07 14:47:31` | 생성 일시 | -| updated_at | string | `2026-07-07 14:47:31` | 최종 수정 일시 | +| is_default | boolean | `true` | default 여부 | +| sort_order | integer | `1` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-30 23:35:47` | 생성 일시 | +| updated_at | string | `2026-07-30 23:35:47` | 최종 수정 일시 | | abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | **응답 예시** @@ -81,27 +85,44 @@ HTTP/1.1 200 "data": { "data": [ { - "number": 1, - "id": 1, + "number": 15, + "id": 444, "name": { - "ko": "API 문서 샘플 배송정책", - "en": "API Doc Sample Shipping Policy" + "ko": "국내 무료배송", + "en": "Domestic Free Shipping" }, - "name_localized": "API 문서 샘플 배송정책", - "country_settings": [], - "fee_summary": "", - "countries_display": "", + "name_localized": "국내 무료배송", "is_active": true, - "is_default": false, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", + "is_default": true, + "sort_order": 1, + "created_at": "2026-07-30 23:35:47", + "updated_at": "2026-07-30 23:35:47", "abilities": { "can_create": true, "can_update": true, "can_delete": true } - } + }, + { + "number": 14, + "id": 445, + "name": { + "ko": "국내 택배 (고정)", + "en": "Domestic Parcel (Fixed)" + }, + "name_localized": "국내 택배 (고정)", + "is_active": true, + "is_default": false, + "sort_order": 2, + "created_at": "2026-07-30 23:35:47", + "updated_at": "2026-07-30 23:35:47", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + }, + "... (총 15건 중 2건 표시)" ], "abilities": { "can_create": true, @@ -109,19 +130,38 @@ HTTP/1.1 200 "can_delete": true }, "statistics": { - "total": 1, - "active": 1, - "inactive": 0, - "shipping_method": [], - "charge_policy": [] + "total": 15, + "active": 14, + "inactive": 1, + "shipping_method": { + "direct": 2, + "parcel": 12, + "quick": 1 + }, + "charge_policy": { + "api": 1, + "conditional_free": 1, + "fixed": 2, + "free": 1, + "per_amount": 1, + "per_quantity": 1, + "per_volume": 1, + "per_volume_weight": 1, + "per_weight": 1, + "range_amount": 1, + "range_quantity": 1, + "range_volume": 1, + "range_volume_weight": 1, + "range_weight": 2 + } }, "pagination": { "current_page": 1, "last_page": 1, "per_page": 25, - "total": 1, + "total": 15, "from": 1, - "to": 1, + "to": 15, "has_more_pages": false } } @@ -183,11 +223,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ShippingPolicyResource`). 생성 성공 시 HTTP 201._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"기본 배송정책","en":"Default Shipping Policy"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `기본 배송정책` | `name` 의 현재 로케일 해석 값 | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (`countrySettings` 관계가 로드된 경우에만 포함) | +| fee_summary | string | `KR: 배송비: 3,000원` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (활성 설정 없으면 빈 문자열) | +| countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 201 +``` + +```json +{ + "success": true, + "message": "배송정책이 등록되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "기본 배송정책", + "en": "Default Shipping Policy" + }, + "name_localized": "기본 배송정책", + "fee_summary": "KR: 배송비: 3,000원", + "countries_display": "🇰🇷", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 10:44:49", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -196,6 +280,7 @@ Content-Type: application/json | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 생성 처리 중 예외 발생 (`exceptions.operation_failed`) | @@ -225,11 +310,11 @@ Authorization: Bearer {YOUR_TOKEN} | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| value | integer | `46` | 배송정책 ID (Select 옵션의 value) | -| label | string | `sdfsf` | 표시용 라벨 | +| value | integer | `444` | 배송정책 ID (Select 옵션의 value) | +| label | string | `국내 무료배송` | 표시용 라벨 | | countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분 `+N`) | -| fee_summary | string | `KR: 외부 API 연동 (실시간 계산)` | 국가별 배송비 요약 텍스트 (`country_code: fee` 형태를 ` \| ` 로 결합) | -| is_default | boolean | `false` | default 여부 | +| fee_summary | string | `KR: 무료배송` | 국가별 배송비 요약 텍스트 (`country_code: fee` 형태를 ` \| ` 로 결합) | +| is_default | boolean | `true` | default 여부 | **응답 예시** @@ -240,13 +325,104 @@ HTTP/1.1 200 ```json { "success": true, - "message": "sirsoft-ecommerce::messages.shipping_policy.active_list_retrieved", + "message": "사용 중인 배송정책 목록을 조회했습니다.", "data": [ { - "value": 1, - "label": "API 문서 샘플 배송정책", - "countries_display": "", - "fee_summary": "", + "value": 444, + "label": "국내 무료배송", + "countries_display": "🇰🇷", + "fee_summary": "KR: 무료배송", + "is_default": true + }, + { + "value": 445, + "label": "국내 택배 (고정)", + "countries_display": "🇰🇷", + "fee_summary": "KR: 배송비: ¥3,000", + "is_default": false + }, + { + "value": 446, + "label": "조건부 무료배송 (5만원 이상)", + "countries_display": "🇰🇷", + "fee_summary": "KR: ¥50,000 미만 ¥2,500 / ¥50,000 이상 무료", + "is_default": false + }, + { + "value": 447, + "label": "금액별 구간 배송비", + "countries_display": "🇰🇷", + "fee_summary": "KR: ~10000원: ¥5,000 / 10000~30000원: ¥3,000 / 30000~50000원: ¥2,000 / 50000~100000원: ¥1,000 / 100000원~: ¥0", + "is_default": false + }, + { + "value": 448, + "label": "수량별 구간 배송비", + "countries_display": "🇰🇷", + "fee_summary": "KR: 1~5개: ¥3,000 / 6개~: ¥5,000", + "is_default": false + }, + { + "value": 449, + "label": "무게별 구간 배송비", + "countries_display": "🇰🇷", + "fee_summary": "KR: ~2kg: ¥3,000 / 2~5kg: ¥4,000 / 5~10kg: ¥6,000 / 10kg~: ¥8,000", + "is_default": false + }, + { + "value": 450, + "label": "부피별 구간 배송비", + "countries_display": "🇰🇷", + "fee_summary": "KR: ~50L: ¥5,000 / 50~100L: ¥10,000 / 100L~: ¥20,000", + "is_default": false + }, + { + "value": 451, + "label": "부피무게 구간 배송비", + "countries_display": "🇰🇷", + "fee_summary": "KR: ~5kg: ¥3,500 / 5~10kg: ¥5,000 / 10~20kg: ¥8,000 / 20kg~: ¥12,000", + "is_default": false + }, + { + "value": 452, + "label": "해외배송 (DHL)", + "countries_display": "🇨🇳🇯🇵🇺🇸", + "fee_summary": "CN: 외부 API 연동 (실시간 계산) | JP: 외부 API 연동 (실시간 계산) | US: 외부 API 연동 (실시간 계산)", + "is_default": false + }, + { + "value": 454, + "label": "수량당 배송비 (3개당)", + "countries_display": "🇰🇷", + "fee_summary": "KR: 3개당 ¥3,000", + "is_default": false + }, + { + "value": 455, + "label": "무게당 배송비 (1kg당)", + "countries_display": "🇰🇷", + "fee_summary": "KR: 1kg당 ¥1,000", + "is_default": false + }, + { + "value": 456, + "label": "부피당 배송비 (10L당)", + "countries_display": "🇰🇷", + "fee_summary": "KR: 10L당 ¥2,000", + "is_default": false + }, + { + "value": 457, + "label": "국내외 복합 배송 (부피무게당)", + "countries_display": "🇰🇷🇺🇸", + "fee_summary": "KR: 5kg당 ¥3,000 | US: ~2kg: ¥25 / 2~5kg: ¥40 / 5kg~: ¥60", + "is_default": false + }, + { + "value": 458, + "label": "금액당 배송비 (1만원당)", + "countries_display": "🇰🇷", + "fee_summary": "KR: 10,000당 ¥500", "is_default": false } ] @@ -290,16 +466,35 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| deleted_count | integer | `2` | 실제로 삭제된 배송정책 건수 (`ShippingPolicyService::bulkDelete()` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 배송정책이 삭제되었습니다.", + "data": { + "deleted_count": 2 + } +} +``` + +> 컨트롤러가 `messageParams` 를 넘기지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려옵니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 일괄 삭제 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -343,16 +538,35 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| updated_count | integer | `2` | 활성 상태가 실제로 변경된 배송정책 건수 (`ShippingPolicyService::bulkToggleActive()` 반환값) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": ":count개 배송정책의 사용여부가 변경되었습니다.", + "data": { + "updated_count": 2 + } +} +``` + +> 컨트롤러가 `messageParams` 를 넘기지 않으므로 `message` 의 `:count` 자리표시자는 치환되지 않은 채로 내려옵니다. **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 일괄 변경 처리 중 예외 발생 (`exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | @@ -375,7 +589,17 @@ Content-Type: application/json | endpoint | body | string | 예 | max 500 | 테스트로 호출할 외부 배송비 계산 API 엔드포인트 URL. 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)와 userinfo(`https://a@b/`) 위장 주소는 422 로 거부됩니다 — 이 주소는 쇼핑몰 서버가 대신 호출하므로 내부망 접근을 막기 위함(SSRF). 사내 배송비 계산 서버를 쓰려면 코어 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요 | | request_fields | body | array | 아니오 | — | 요청에 실어 보낼 필드명 목록 (후보 SSoT ShippingApiRequestField 5종) | | config | body | array | 아니오 | — | API 호출 고급 설정 (HTTP 메서드·인증방식·필드 매핑·응답 형식/경로 등) | +| config.http_method | body | string | 아니오 | — | 외부 API 호출에 사용할 HTTP 메서드 (GET=query string bracket 표기, POST=JSON body. 미지정 시 POST) | +| config.auth_type | body | string | 아니오 | — | 인증 헤더 부착 방식 (none=인증 없음, bearer=`Authorization: Bearer {token}`, custom_header=커스텀 헤더명 + 값) | +| config.auth_token | body | string | 아니오 | max 1000 | 인증에 사용할 토큰/키 값 (`auth_type` 이 bearer/custom_header 일 때 헤더 값으로 전송) | +| config.auth_header_name | body | string | 아니오 | max 100 | `auth_type` 이 `custom_header` 일 때 토큰을 실어 보낼 헤더 이름 (예: `X-API-Key`. HTTP 헤더 토큰 문자만 허용) | +| config.response_type | body | string | 아니오 | — | 외부 API 응답에서 배송비를 추출하는 방식 (json=`response_path` 점표기 경로로 중첩 값 추출, text=본문에서 숫자/소수점만 추출) | +| config.response_path | body | string | 아니오 | max 200 | JSON 응답에서 배송비 값이 위치한 점표기 경로 (예: `data.shipping_fee`) | +| config.field_map | body | array | 아니오 | — | 요청 필드명 재매핑 표 (내부 필드명 → 외부 API 가 요구하는 파라미터명. 값은 영숫자/`_`/`.`/`-` 만 허용) | | sample | body | array | 아니오 | — | 테스트 계산에 사용할 샘플 주문 데이터 (무게/금액/수량 등) | +| sample.group_total | body | number | 아니오 | min 0 | 테스트용 배송 그룹 합계 금액 (미지정 시 `10000`) | +| sample.total_quantity | body | integer | 아니오 | min 0 | 테스트용 배송 그룹 총 수량 (미지정 시 `1`) | +| sample.country_code | body | string | 아니오 | max 10 | 국가 코드 (ISO 3166-1 alpha-2. 미지정 시 `KR`) | **요청 예시** @@ -394,19 +618,75 @@ Content-Type: application/json "config": [ "예시값" ], + "config.http_method": "예시값", + "config.auth_type": "예시값", + "config.auth_token": "{YOUR_TOKEN}", + "config.auth_header_name": "예시 이름", + "config.response_type": "예시값", + "config.response_path": "예시값", + "config.field_map": [ + "예시값" + ], "sample": [ "예시값" - ] + ], + "sample.group_total": 1, + "sample.total_quantity": 1, + "sample.country_code": "KR" } ``` **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`OrderCalculationService::testApiCall()` 반환 배열). 외부 API 호출 실패도 HTTP 200 으로 내려오며 `ok=false` 로 구분합니다._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| ok | boolean | `true` | 테스트 호출 성공 여부 (외부 API 가 2xx 응답을 준 경우 true. 무응답/요청 실패 시 false) | +| http_ok | boolean | `true` | 외부 API 응답이 2xx 인지 여부 (응답을 받은 경우에만 포함) | +| reason | string | `no_response`, `request_failed` | 실패 사유 코드 (`ok=false` 인 경우에만 포함. no_response=응답 없음, request_failed=연결 실패/타임아웃) | +| error | string | `cURL error 28: Operation timed out` | 예외 메시지 (`reason=request_failed` 인 경우에만 포함) | +| request | object | `{"method":"POST","endpoint":"https://…","data":{…},"body":"{…}"}` | 실제로 보낸 요청 미리보기 (성공/실패 무관하게 항상 포함) | +| request.method | string | `POST` | 실제 사용된 HTTP 메서드 (`config.http_method` 대문자, 미지정 시 `POST`) | +| request.endpoint | string | `https://api.example.com/shipping/quote` | 호출한 외부 API URL | +| request.data | object | `{"country_code":"KR","group_total":10000,"total_quantity":1}` | `request_fields` + `field_map` 을 적용해 구성된 요청 파라미터 | +| request.body | string | `{\n "country_code": "KR"\n}` | `request.data` 를 pretty-print JSON 문자열로 직렬화한 요청 본문 미리보기 | +| response | object | `{"status":200,"body":"{\"fee\":3000}"}` | 외부 API 응답 (응답을 받은 경우에만 포함) | +| response.status | integer | `200` | 외부 API 응답 HTTP 상태코드 | +| response.body | string | `{"fee":3000}` | 외부 API 응답 본문 (과대 응답 방어를 위해 앞 4096자로 잘림) | +| extracted_fee | integer\|null | `3000` | 응답에서 추출된 배송비 (`response_type`/`response_path` 로 추출 실패 시 `null`) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "계산 API 테스트 호출을 완료했습니다.", + "data": { + "ok": true, + "http_ok": true, + "request": { + "method": "POST", + "endpoint": "https://api.example.com/shipping/quote", + "data": { + "country_code": "KR", + "group_total": 10000, + "total_quantity": 1 + }, + "body": "{\n \"country_code\": \"KR\",\n \"group_total\": 10000,\n \"total_quantity\": 1\n}" + }, + "response": { + "status": 200, + "body": "{\"fee\":3000}" + }, + "extracted_fee": 3000 + } +} +``` **에러 응답** @@ -436,7 +716,7 @@ Content-Type: application/json **요청 예시** ```http -DELETE /api/modules/sirsoft-ecommerce/admin/shipping-policies/1 HTTP/1.1 +DELETE /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -444,28 +724,17 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - - - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — 컨트롤러가 `ResponseHelper::moduleSuccess()` 에 데이터를 넘기지 않아 `data` 는 `null`)._ **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "배송정책이 삭제되었습니다.", - "data": null -} -``` + **에러 응답** | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | 삭제 처리 중 예외 발생 (사용 중인 정책 등 — `exceptions.operation_failed`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | @@ -490,7 +759,7 @@ HTTP/1.1 200 **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/admin/shipping-policies/1 HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -498,56 +767,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 배송정책","en":"API Doc Sample Shipping Poli…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `API 문서 샘플 배송정책` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| country_settings | array | `[]` | 국가별 배송 설정 목록 (countrySettings 관계 로드 시 각 국가의 배송방식·부과정책·배송비 상세) | -| fee_summary | string | `` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (예: `KR: 3000원 \| US: $20`, 활성 설정 없으면 빈 문자열) | -| countries_display | string | `` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개 노출, 초과분은 `+N` 축약) | -| is_active | boolean | `true` | active 여부 | -| is_default | boolean | `false` | default 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 10:44:49` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "배송정책 정보를 조회했습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 배송정책", - "en": "API Doc Sample Shipping Policy" - }, - "name_localized": "API 문서 샘플 배송정책", - "country_settings": [], - "fee_summary": "", - "countries_display": "", - "is_active": true, - "is_default": false, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 10:44:49", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -584,7 +808,7 @@ HTTP/1.1 200 **요청 예시** ```http -PUT /api/modules/sirsoft-ecommerce/admin/shipping-policies/1 HTTP/1.1 +PUT /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id} HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -605,11 +829,55 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`ShippingPolicyResource` — show 와 동일 구조)._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 기본 키 (내부 식별자) | +| name | object | `{"ko":"기본 배송정책","en":"Default Shipping Policy"}` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | +| name_localized | string | `기본 배송정책` | `name` 의 현재 로케일 해석 값 | +| country_settings | array | `[]` | 국가별 배송 설정 목록 (`countrySettings` 관계가 로드된 경우에만 포함) | +| fee_summary | string | `KR: 배송비: 3,000원` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (활성 설정 없으면 빈 문자열) | +| countries_display | string | `🇰🇷` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개, 초과분은 `+N` 축약) | +| is_active | boolean | `true` | active 여부 | +| is_default | boolean | `false` | default 여부 | +| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | +| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | +| updated_at | string | `2026-07-08 15:00:35` | 최종 수정 일시 | +| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "배송정책이 수정되었습니다.", + "data": { + "id": 1, + "name": { + "ko": "기본 배송정책", + "en": "Default Shipping Policy" + }, + "name_localized": "기본 배송정책", + "fee_summary": "KR: 배송비: 3,000원", + "countries_display": "🇰🇷", + "is_active": true, + "is_default": false, + "sort_order": 0, + "created_at": "2026-07-08 10:44:49", + "updated_at": "2026-07-08 15:00:35", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` **에러 응답** @@ -617,8 +885,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 500 | Internal Server Error | 수정 처리 중 예외 발생 (`exceptions.operation_failed`) | @@ -640,7 +909,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/1/set-default HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id}/set-default HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -648,54 +917,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 배송정책","en":"API Doc Sample Shipping Poli…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `API 문서 샘플 배송정책` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| fee_summary | string | `` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (예: `KR: 3000원 \| US: $20`, 활성 설정 없으면 빈 문자열) | -| countries_display | string | `` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개 노출, 초과분은 `+N` 축약) | -| is_active | boolean | `true` | active 여부 | -| is_default | boolean | `true` | default 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:35` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "기본 배송정책이 설정되었습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 배송정책", - "en": "API Doc Sample Shipping Policy" - }, - "name_localized": "API 문서 샘플 배송정책", - "fee_summary": "", - "countries_display": "", - "is_active": true, - "is_default": true, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:35", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** @@ -725,7 +951,7 @@ HTTP/1.1 200 **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/1/toggle-active HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/shipping-policies/{id}/toggle-active HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -733,54 +959,11 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| id | integer | `1` | 기본 키 (내부 식별자) | -| name | object | `{"ko":"API 문서 샘플 배송정책","en":"API Doc Sample Shipping Poli…` | 대상의 이름/명칭 (다국어 필드는 로케일별 값 객체) | -| name_localized | string | `API 문서 샘플 배송정책` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) | -| fee_summary | string | `` | 활성 국가별 설정을 종합한 배송비 요약 텍스트 (예: `KR: 3000원 \| US: $20`, 활성 설정 없으면 빈 문자열) | -| countries_display | string | `` | 활성 배송 국가를 국기 이모지로 표시한 문자열 (최대 3개 노출, 초과분은 `+N` 축약) | -| is_active | boolean | `false` | active 여부 | -| is_default | boolean | `false` | default 여부 | -| sort_order | integer | `0` | 표시 정렬 순서 값 (작을수록 우선) | -| created_at | string | `2026-07-08 10:44:49` | 생성 일시 | -| updated_at | string | `2026-07-08 15:00:35` | 최종 수정 일시 | -| abilities | object | `{"can_create":true,"can_update":true,"can_delete":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "배송정책 사용여부가 변경되었습니다.", - "data": { - "id": 1, - "name": { - "ko": "API 문서 샘플 배송정책", - "en": "API Doc Sample Shipping Policy" - }, - "name_localized": "API 문서 샘플 배송정책", - "fee_summary": "", - "countries_display": "", - "is_active": false, - "is_default": false, - "sort_order": 0, - "created_at": "2026-07-08 10:44:49", - "updated_at": "2026-07-08 15:00:35", - "abilities": { - "can_create": true, - "can_update": true, - "can_delete": true - } - } -} -``` + **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/users.md b/modules/_bundled/sirsoft-ecommerce/docs/api/users.md index 70877bc8..0a198729 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/users.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/users.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Users 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -33,7 +33,7 @@ **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d/currency HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/users/{user}/currency HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -46,11 +46,27 @@ Content-Type: application/json **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| preferred_currency | string | `KRW` | 변경 후 저장된 회원 선호 결제 통화 코드 (ISO 4217, 등록 통화 중 하나) | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "결제 통화가 변경되었습니다.", + "data": { + "preferred_currency": "KRW" + } +} +``` **에러 응답** @@ -58,8 +74,9 @@ Content-Type: application/json | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-currency.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `currency` 미전달 또는 등록 통화(is_default \| exchange_rate>0)가 아닌 코드 전달 시 | +| 500 | Internal Server Error | 통화 저장 처리 중 예외 발생 시 (`결제 통화 변경에 실패했습니다.`) | @@ -82,7 +99,7 @@ Content-Type: application/json **요청 예시** ```http -PATCH /api/modules/sirsoft-ecommerce/admin/users/a234c2b1-cde8-437f-b28b-23323be2b98d/shipping-country HTTP/1.1 +PATCH /api/modules/sirsoft-ecommerce/admin/users/{user}/shipping-country HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -123,8 +140,8 @@ HTTP/1.1 200 | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-shipping-country.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | | 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md index b5392d1f..bfd0fd17 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md @@ -8,8 +8,8 @@ ```text 1. 이 문서는 실제 API 호출로 실측한 Wishlist 엔드포인트 레퍼런스입니다 -2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 실측 응답 필드 표 -3. 응답 필드의 예시값은 실제 호출 응답에서 관측된 값입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 5. 설명(TODO) 칸은 사람이 채웁니다 ``` @@ -25,12 +25,15 @@ **요청 파라미터** -_요청 파라미터 없음._ +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) | +| per_page | query | integer | 아니오 | min 1, max 100 | 페이지당 항목 수 | **요청 예시** ```http -GET /api/modules/sirsoft-ecommerce/wishlist HTTP/1.1 +GET /api/modules/sirsoft-ecommerce/wishlist?page=1&per_page=1 HTTP/1.1 Host: api.example.com Accept: application/json Authorization: Bearer {YOUR_TOKEN} @@ -40,7 +43,67 @@ Authorization: Bearer {YOUR_TOKEN} _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 찜(위시리스트) 레코드 식별자 (DELETE `/wishlist/{id}` 의 `id`) | +| product_id | integer | `10` | 찜한 상품의 식별자 | +| created_at | string | `2026-07-14T09:12:33.000000Z` | 찜한 일시 (목록은 최신순 정렬) | +| product | object | `{ "id": 10, ... }` | 찜한 상품 정보 (`ProductListResource` 형식, 아래 하위 필드) | + +`product` 하위 필드 (찜 목록에서는 상품의 브랜드·카테고리·라벨 관계만 로드되므로 옵션/배송정책 관련 필드는 응답에 포함되지 않습니다): + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `10` | 상품 식별자 | +| name | object | `{"ko":"상품명","en":"Product"}` | 상품명 다국어 원본 | +| name_localized | string | `상품명` | 현재 로케일로 해석된 상품명 | +| product_code | string | `P0000001` | 상품 코드 | +| sku | string \| null | `SKU-001` | 재고 관리 코드 | +| thumbnail_url | string \| null | `/storage/products/thumb.jpg` | 대표 썸네일 이미지 URL | +| list_price | number | `30000` | 정가 (기본 통화 기준 반올림) | +| list_price_formatted | string | `30,000원` | 정가 표시용 포맷 문자열 | +| selling_price | number | `24000` | 판매가 (기본 통화 기준 반올림) | +| selling_price_formatted | string | `24,000원` | 판매가 표시용 포맷 문자열 | +| discount_rate | integer | `20` | 정가 대비 할인율(%) | +| multi_currency_list_price | object | `{"KRW":30000,"USD":22.5}` | 통화별 정가 | +| multi_currency_selling_price | object | `{"KRW":24000,"USD":18}` | 통화별 판매가 | +| stock_quantity | integer | `100` | 재고 수량 | +| safe_stock_quantity | integer | `10` | 안전 재고 수량 | +| is_below_safe_stock | boolean | `false` | 재고가 안전 재고 미만인지 여부 | +| sales_status | string | `on_sale` | 판매 상태 값 (`on_sale`/`sold_out`/`stop_sale` 등) | +| sales_status_label | string | `판매중` | 판매 상태 라벨 (다국어) | +| sales_status_variant | string | `success` | 판매 상태 배지 색상 변형 | +| display_status | string | `visible` | 진열 상태 값 | +| display_status_label | string | `진열` | 진열 상태 라벨 (다국어) | +| display_status_variant | string | `success` | 진열 상태 배지 색상 변형 | +| categories | array | `[{"id":3,"name":"의류","is_primary":true}]` | 소속 카테고리 목록 (id/name/is_primary) | +| primary_category | string \| null | `의류` | 대표 카테고리명 | +| categories_with_path | array | `[{"id":3,"path":[...],"path_string":"패션 > 의류","is_primary":true}]` | 카테고리 경로(브레드크럼) 포함 목록 | +| brand_name | string \| null | `나이키` | 브랜드명 (현재 로케일) | +| shipping_policy_id | integer \| null | `1` | 배송 정책 식별자 | +| min_purchase_qty | integer | `1` | 최소 구매 수량 | +| max_purchase_qty | integer \| null | `10` | 최대 구매 수량 (없으면 `null`) | +| has_options | boolean | `true` | 옵션 보유 여부 | +| options | array | `[]` | 옵션 목록 (찜 목록에서는 옵션 관계 미로드로 빈 배열) | +| labels | array | `[{"name":"NEW","color":"#ff0000"}]` | 활성 라벨 목록 (정렬순) | +| review_count | integer | `0` | 리뷰 수 (리뷰 집계 미로드 시 `0`) | +| rating_avg | number | `0.0` | 평균 평점 (리뷰 집계 미로드 시 `0.0`) | +| created_at | string | `2026-07-01 10:00:00` | 상품 등록 일시 (사용자 타임존 기준) | +| updated_at | string | `2026-07-10 15:30:00` | 상품 수정 일시 (사용자 타임존 기준) | +| is_owner | boolean | `false` | 요청자가 상품 등록자(`created_by`)인지 여부 | +| abilities | object | `{"can_update":false,"can_delete":false}` | 상품 수정/삭제 권한 (권한 없으면 키 자체가 생략될 수 있음) | + +`data.pagination` 필드: + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| current_page | integer | `1` | 현재 페이지 번호 | +| last_page | integer | `1` | 마지막 페이지 번호 | +| per_page | integer | `20` | 페이지당 항목 수 (기본 20, 최대 100) | +| total | integer | `0` | 전체 찜 항목 수 | +| from | integer \| null | `null` | 현재 페이지 첫 항목의 순번 (없으면 `null`) | +| to | integer \| null | `null` | 현재 페이지 마지막 항목의 순번 (없으면 `null`) | +| has_more_pages | boolean | `false` | 다음 페이지 존재 여부 | **응답 예시** @@ -53,15 +116,48 @@ HTTP/1.1 200 "success": true, "message": "찜 목록을 불러왔습니다.", "data": { - "data": [], + "data": [ + { + "id": 2, + "product_id": 1732, + "created_at": "2026-07-31T12:45:03.000000Z", + "product": { + "id": 1732, + "name": { + "ko": "면 손수건 3매입 #1", + "en": "Cotton Handkerchief 3pcs #1" + }, + "name_localized": "면 손수건 3매입 #1", + "product_code": "CJTFHBL8SLRQ8ILM", + "sku": "HK-0001", + "...": "(32개 키 생략, 총 37개)" + } + }, + { + "id": 1, + "product_id": 1733, + "created_at": "2026-07-31T12:44:43.000000Z", + "product": { + "id": 1733, + "name": { + "ko": "기본 양말 5족 #2", + "en": "Basic Socks 5 Pairs #2" + }, + "name_localized": "기본 양말 5족 #2", + "product_code": "S0SO3A6SJFYLAKSF", + "sku": "SK-0002", + "...": "(32개 키 생략, 총 37개)" + } + }, + "... (총 25건 중 2건 표시)" + ], "pagination": { "current_page": 1, - "last_page": 1, + "last_page": 2, "per_page": 25, - "total": 0, - "from": null, - "to": null, - "has_more_pages": false + "total": 45, + "from": 1, + "...": "(2개 키 생략, 총 7개)" } } } @@ -72,10 +168,18 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 422 | Unprocessable Entity | `page` < 1, `per_page` 가 1~100 범위를 벗어나거나 정수가 아닌 경우 | -**설명** 로그인한 회원의 찜(위시리스트) 목록을 페이지네이션으로 조회합니다. `auth:sanctum` 인증이 필요하며, `WishlistController@index`가 `ProductWishlistService::getByUser()`로 본인 찜 목록만 가져와 `WishlistCollection`으로 반환합니다. `per_page`는 기본 20건이며 최대 100건으로 제한됩니다. 마이페이지의 찜 목록 화면에서 사용합니다. +**요청 파라미터** + +| 이름 | 타입 | 필수 | 기본값 | 용도 | +| --- | --- | :---: | --- | --- | +| `page` | integer | - | 1 | 페이지 번호 (최소 1) | +| `per_page` | integer | - | 20 | 페이지당 건수 (1~100). 범위를 벗어나면 422 | + +**설명** 로그인한 회원의 찜(위시리스트) 목록을 페이지네이션으로 조회합니다. `auth:sanctum` 인증이 필요하며, `WishlistController@index`가 `ProductWishlistService::getByUser()`로 본인 찜 목록만 가져와 `WishlistCollection`으로 반환합니다. 페이지네이션 파라미터는 `WishlistListRequest` 가 검증하며 `per_page` 는 기본 20건, 허용 범위 1~100건입니다(범위를 벗어나면 422). 소프트 삭제된 상품의 찜 행은 목록에서 제외되므로 `total` 은 찜 테이블의 행 수보다 작을 수 있습니다. 마이페이지의 찜 목록 화면에서 사용합니다. ### POST /api/modules/sirsoft-ecommerce/wishlist/toggle @@ -108,27 +212,11 @@ Content-Type: application/json **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ - -| 필드 | 타입 | 실측 예시값 | 용도/설명 | -| --- | --- | --- | --- | -| added | boolean | `true` | 토글 후 찜 상태 (true 이면 찜 목록에 추가됨 / false 이면 제거됨) | + **응답 예시** -```http -HTTP/1.1 200 -``` - -```json -{ - "success": true, - "message": "상품이 찜 목록에 추가되었습니다.", - "data": { - "added": true - } -} -``` + **에러 응답** @@ -165,11 +253,21 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지만 — `data` 는 `null`)._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "상품이 찜 목록에서 제거되었습니다.", + "data": null +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/editor-spec.json b/modules/_bundled/sirsoft-ecommerce/editor-spec.json index 7882bc1d..324b47d0 100644 --- a/modules/_bundled/sirsoft-ecommerce/editor-spec.json +++ b/modules/_bundled/sirsoft-ecommerce/editor-spec.json @@ -1361,8 +1361,20 @@ } }, "shipping_policy_applied_snapshot": { - "policy_name": "기본 배송 정책", - "shipping_amount": 3000 + "items": [ + { + "product_option_id": 9001, + "policy": { + "policy_name": "기본 배송 정책", + "standalone_shipping_amount": 3000, + "standalone_shipping_amount_formatted": "3,000원" + } + } + ], + "address": { + "country_code": "KR", + "zipcode": "06234" + } } } }, @@ -3701,6 +3713,15 @@ "virtual_account", "mobile" ] + }, + { + "id": "tosspayments", + "name_key": "sirsoft-tosspayments::provider.name", + "name": "토스페이먼츠", + "icon": "credit-card", + "supported_methods": [ + "card" + ] } ], "abilities": { @@ -4708,13 +4729,13 @@ } }, "states": { - "comment": "이커머스 도메인 변종. 사용자 shop/* 라우트(템플릿 sirsoft-basic 소속이나 코어 concat 으로 일원화)와 관리자 라우트(*/admin/ecommerce/*) 변종을 모두 포함한다. shop/* scope.match 는 편집기가 동적 path 를 'shop' 으로 평가한 selectedRoute.path 와 일치한다(Chrome MCP 실측).", + "comment": "이커머스 도메인 변종. 사용자 상점 라우트(템플릿 sirsoft-basic 소속이나 코어 concat 으로 일원화)와 관리자 라우트(*/admin/ecommerce/*) 변종을 모두 포함한다. 상점 주소는 운영자 설정이라 편집기가 평가한 selectedRoute.path 의 첫 세그먼트가 사이트마다 다르다 — 기본 'shop', basic_info.route_path 로 바꾸면 그 값, basic_info.no_route 를 켜면 세그먼트가 아예 없다(`/products`). 그래서 scope.match 는 선택 세그먼트 토큰 '/*?'(0개 또는 1개, engine-v1.58.0)를 써 세 경우를 모두 매칭한다. '/*' 로 쓰면 no_route 상점에서만 매칭이 실패해 상태 토글이 조용히 사라진다. 파라미터 토큰(:id 등)은 리터럴로 대조되므로 routes.json 이 쓴 이름을 그대로 적어야 한다 — 이름이 다르면 세그먼트 토큰과 무관하게 세 축 모두에서 매칭되지 않는다.", "groups": [ { - "comment": "shop/show — product.data.sales_status 값(on_sale/sold_out/suspended/coming_soon)으로 판매 상태 배지·버튼을 if 분기한다(본문 line 388/406). 재고는 stock 으로 분기.", + "comment": "shop/show — product.data.sales_status 값(on_sale/sold_out/suspended/coming_soon)으로 판매 상태 배지·버튼을 if 분기한다(본문 line 388/406). 재고는 stock 으로 분기. 파라미터 토큰은 routes.json 의 ':product_code' 를 그대로 쓴다 — ':id' 로 적으면 리터럴 대조라 어떤 상점 주소에서도 매칭되지 않는다.", "scope": { "kind": "route", - "match": "/shop/products/:id" + "match": "/*?/products/:product_code" }, "items": [ { @@ -7078,7 +7099,7 @@ "comment": "shop/index — 목록 유무 + 접근 제한. _local.productAccessDenied=true 시 접근 제한 화면을 if 분기한다(본문 line 192).", "scope": { "kind": "route", - "match": "/shop/products" + "match": "/*?/products" }, "items": [ { @@ -7119,7 +7140,7 @@ "comment": "shop/cart — 담긴 상품 유무 + 주문 처리 중(_local.isOrdering 시 오버레이 잠금, 본문 line 115).", "scope": { "kind": "route", - "match": "/shop/cart" + "match": "/*?/cart" }, "items": [ { @@ -7201,7 +7222,7 @@ "comment": "shop/checkout — 정상/결제오류(_local.orderError·query.error, 본문 line 45/194)/구매불가(_local.unavailableModal, line 102)/입력검증실패 분기. 검증 실패는 주문자/배송지 입력칸별 인라인 에러를 _local.errors 의 점 포함 flat 키(예 'orderer.name')로 읽는다(_checkout_orderer.json line 99/101, _checkout_shipping.json) — formErrors 키를 대괄호 표기로 지정한다.", "scope": { "kind": "route", - "match": "/shop/checkout" + "match": "/*?/checkout" }, "items": [ { @@ -7270,7 +7291,7 @@ "comment": "shop/order_complete — orderData.data.payment.payment_method 값(card/vbank/dbank)으로 결제수단별 안내를 if 분기한다(본문 line 52/95/138/229). card 는 base sampleData(=card)가 흐르므로 오버라이드 없음, vbank/dbank 는 byDataSourceId.orderData 통째 교체.", "scope": { "kind": "route", - "match": "/shop/orders/:id/complete" + "match": "/*?/orders/:id/complete" }, "items": [ { @@ -7726,7 +7747,7 @@ "comment": "shop/reorder — _local.status 값(pending/success/error)으로 재주문 진행 화면을 if 분기한다(본문 line 72/99/225).", "scope": { "kind": "route", - "match": "/shop/reorder/:id" + "match": "/*?/reorder/:id" }, "items": [ { @@ -8373,7 +8394,7 @@ "comment": "shop/guest_order_form — 비회원 주문 조회 폼. 본문이 _global.currentUser.uuid 유무로 양분된다(slots.content[0] 회원→마이페이지 리다이렉트 안내 / content[1] 비회원→조회 폼). 편집기 sampleGlobal 은 로그인 상태(currentUser 시드)라 비회원 조회 폼이 기본 미표시이므로, currentUser 를 null 로 패치한 비회원 상태를 기본으로 노출해 폼을 편집 가능하게 한다.", "scope": { "kind": "route", - "match": "/shop/guest/orders" + "match": "/*?/guest/orders" }, "items": [ { diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 59be2a50..e7b68a12 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,13 +5,13 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.0.4", + "version": "1.1.0", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", "en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management" }, - "g7_version": ">=7.0.5", + "g7_version": ">=7.0.6", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php index 39b125ad..e7cb30b4 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.php +++ b/modules/_bundled/sirsoft-ecommerce/module.php @@ -6,9 +6,11 @@ use App\Extension\AbstractModule; use App\Models\IdentityMessageDefinition; use App\Seo\Concerns\LocalizesSeoValues; use Illuminate\Database\Seeder; +use Modules\Sirsoft\Ecommerce\Benchmark\OrderCreationBenchmark; use Modules\Sirsoft\Ecommerce\Database\Seeders\ClaimReasonSeeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\SequenceSeeder; use Modules\Sirsoft\Ecommerce\Database\Seeders\ShippingCarrierSeeder; +use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Http\Middleware\DetectDevice; use Modules\Sirsoft\Ecommerce\Http\Middleware\ResolveShippingCountry; use Modules\Sirsoft\Ecommerce\Http\Middleware\VerifyGuestOrderToken; @@ -16,6 +18,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\ActivityLogDescriptionResolver; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultCurrencyOnRegisterListener; use Modules\Sirsoft\Ecommerce\Listeners\AssignDefaultShippingCountryOnRegisterListener; use Modules\Sirsoft\Ecommerce\Listeners\CategoryActivityLogListener; +use Modules\Sirsoft\Ecommerce\Listeners\CategoryTreeCacheListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponRestoreListener; use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener; @@ -23,12 +26,14 @@ use Modules\Sirsoft\Ecommerce\Listeners\EcommerceAdminActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\EcommerceNotificationDataListener; use Modules\Sirsoft\Ecommerce\Listeners\EcommerceUserActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\InjectAppConfigDeviceListener; +use Modules\Sirsoft\Ecommerce\Listeners\IssueCashReceiptOnDepositListener; use Modules\Sirsoft\Ecommerce\Listeners\MergeCartOnLoginListener; use Modules\Sirsoft\Ecommerce\Listeners\MileageTransactionListener; use Modules\Sirsoft\Ecommerce\Listeners\OrderActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\OrderStatusNotificationListener; use Modules\Sirsoft\Ecommerce\Listeners\ProductActivityLogListener; use Modules\Sirsoft\Ecommerce\Listeners\ProductInquiryBoardListener; +use Modules\Sirsoft\Ecommerce\Listeners\PurgeCashReceiptIdentifierListener; use Modules\Sirsoft\Ecommerce\Listeners\SearchProductsListener; use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener; use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener; @@ -40,6 +45,7 @@ use Modules\Sirsoft\Ecommerce\Listeners\UserCurrencyInfoListener; use Modules\Sirsoft\Ecommerce\Listeners\UserMileageCleanupListener; use Modules\Sirsoft\Ecommerce\Listeners\UserMileageInfoListener; use Modules\Sirsoft\Ecommerce\Listeners\UserShippingCountryInfoListener; +use Modules\Sirsoft\Ecommerce\Repositories\OrderRepository; class Module extends AbstractModule { @@ -1242,6 +1248,89 @@ class Module extends AbstractModule ]; } + /** + * 성능 계측 프로파일 정의 (`g7:bench`). + * + * 목록 프로파일의 `columns` 는 해당 목록이 실제로 select 하는 컬럼이어야 합니다. 주문 + * 목록은 Repository 가 상수로 들고 있으므로 그 상수를 그대로 참조합니다 — 여기에 컬럼을 + * 다시 적으면 Repository 변경 시 계측이 조용히 낡습니다. 나머지 목록의 `['*']` 는 + * "응답 계약상 전 컬럼을 노출해 프루닝 불가" 선언이며, 이때 비교축은 select * vs select id + * 입니다. + * + * @return array> 프로파일 키 → 정의 + */ + public function getBenchmarkProfiles(): array + { + return [ + 'orders' => [ + 'type' => 'list', + 'label' => '주문 목록', + 'table' => 'ecommerce_orders', + 'columns' => OrderRepository::LIST_COLUMNS, + 'order' => [['ordered_at', 'desc'], ['id', 'desc']], + // 관리자 주문 목록은 상태 미지정 시 임시 주문 상태를 제외한다 + // (OrderRepository::getListWithFilters). 이 술어를 빼고 재면 옵티마이저가 다른 + // 인덱스를 골라 화면에서 일어나는 일과 다른 것을 잰다. 값은 Enum SSoT 를 참조한다. + 'filters' => ['order_status' => ['not in', OrderStatusEnum::listHiddenValues()]], + 'soft_delete' => true, + ], + 'products' => [ + 'type' => 'list', + 'label' => '상품 목록', + 'table' => 'ecommerce_products', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + // 상품 문의는 소프트 삭제 컬럼이 없다 — 실제 스키마 기준 선언 + 'product_inquiries' => [ + 'type' => 'list', + 'label' => '상품 문의 목록', + 'table' => 'ecommerce_product_inquiries', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + 'product_reviews' => [ + 'type' => 'list', + 'label' => '상품 후기 목록', + 'table' => 'ecommerce_product_reviews', + 'columns' => ['*'], + 'order' => [['created_at', 'desc'], ['id', 'desc']], + 'soft_delete' => true, + ], + 'coupon_issues' => [ + 'type' => 'list', + 'label' => '쿠폰 발급 이력 목록', + 'table' => 'ecommerce_promotion_coupon_issues', + 'columns' => ['*'], + 'order' => [['issued_at', 'desc'], ['id', 'desc']], + 'soft_delete' => false, + ], + 'extra_fee_templates' => [ + 'type' => 'list', + 'label' => '추가 배송비 템플릿 목록', + 'table' => 'ecommerce_shipping_policy_extra_fee_templates', + 'columns' => ['*'], + 'order' => [['zipcode', 'asc'], ['id', 'asc']], + 'soft_delete' => false, + ], + 'orders_screen' => [ + 'type' => 'screen', + 'label' => '관리자 주문 목록 화면', + 'route' => 'api.modules.sirsoft-ecommerce.admin.orders.index', + 'query' => ['per_page' => 20], + 'permissions' => ['sirsoft-ecommerce.orders.read'], + ], + 'order_create' => [ + 'type' => 'write', + 'label' => '주문 생성 (임시 주문 → 주문 전환)', + 'prepare' => [OrderCreationBenchmark::class, 'prepare'], + 'callback' => [OrderCreationBenchmark::class, 'create'], + ], + ]; + } + /** * 무통장입금 입금 안내 알림 정의. * @@ -2001,6 +2090,7 @@ class Module extends AbstractModule CouponUseListener::class, SeoProductCacheListener::class, SeoCategoryCacheListener::class, + CategoryTreeCacheListener::class, SeoSettingsCacheListener::class, MileageTransactionListener::class, UserMileageInfoListener::class, @@ -2012,6 +2102,8 @@ class Module extends AbstractModule EcommerceNotificationDataListener::class, OrderStatusNotificationListener::class, InjectAppConfigDeviceListener::class, + IssueCashReceiptOnDepositListener::class, + PurgeCashReceiptIdentifierListener::class, ]; } @@ -2089,8 +2181,13 @@ class Module extends AbstractModule * 타게팅(모든 web 요청 = 기존 web 그룹 append 와 동등). InjectAppConfigDeviceListener 가 * appConfig.isIos 로 주입(체크아웃 애플페이 iOS 게이팅). * - ResolveShippingCountry: 상품/장바구니/체크아웃/주문생성 라우트에만 부착 (배송국가 해석). - * - VerifyGuestOrderToken: 비회원 주문 후속 액션 4개 라우트에만 부착 (verify 는 미부착 — - * 토큰 발급 전 단계라 개별 지정). + * - VerifyGuestOrderToken: 비회원 주문 후속 액션 라우트에 부착 (취소·환불예상·배송지수정·구매확정 + * 개별 지정 + 현금영수증 하위 전체 glob). `guest.orders.verify` 는 조회 토큰을 **발급**하는 + * 엔드포인트라 호출 시점에 토큰이 없다 — 부착하면 최초 인증 요청이 404 로 막혀 비회원 조회가 + * 통째로 죽으므로 제외한다(요청 빈도 제한은 throttle 과 GuestOrderAuthService 의 실패 잠금 담당). + * 부착 대상 개수를 이 주석에 적지 않는다 — 개수를 문서에 박는 것이 다음 누락의 씨앗이다 + * (실제로 현금영수증 라우트 2건이 추가될 때 targets 갱신이 누락돼 기능이 전면 불능이었다). + * 정합성은 `GuestOrderTokenMiddlewareRegistrationTest` 가 라우트 테이블 전수로 강제한다. * * @return array, timing?: string, targets: array}> */ @@ -2120,6 +2217,10 @@ class Module extends AbstractModule 'api.modules.sirsoft-ecommerce.guest.orders.estimate-refund', 'api.modules.sirsoft-ecommerce.guest.orders.update-shipping-address', 'api.modules.sirsoft-ecommerce.guest.orders.confirm-option', + // 현금영수증 하위 전체(show/issue/향후 추가분). glob 은 이 서브트리의 기본값을 + // '보호' 로 만들어 실패 방향을 안전한 쪽으로 뒤집는다. `cash-receipt` 가 리터럴 + // 세그먼트라 구조적으로 `verify` 와 매칭될 수 없다. + 'api.modules.sirsoft-ecommerce.guest.orders.cash-receipt.*', ], ], ]; diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index 63316555..bb1e54fb 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.1.0", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 746ded67..7da219b5 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.1.0", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/admin-user-currency-field.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/admin-user-currency-field.json index 396e488e..5d1bc58b 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/admin-user-currency-field.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/admin-user-currency-field.json @@ -60,7 +60,7 @@ "name": "Select", "props": { "name": "ecommerce_preferred_currency", - "value": "{{_local.form?.ecommerce_preferred_currency ?? user?.data?.ecommerce_preferred_currency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}", + "value": "{{_local.form?.ecommerce_preferred_currency ?? user?.data?.ecommerce_preferred_currency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}", "options": "{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).filter(c => c.is_default || (c.exchange_rate ?? 0) > 0).map(c => ({ value: c.code, label: c.code + ' (' + (c.symbol ?? c.code) + ')' }))}}", "className": "w-full sm:w-64" } diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/checkout_cash_receipt.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/checkout_cash_receipt.json new file mode 100644 index 00000000..6e5fc09c --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/checkout_cash_receipt.json @@ -0,0 +1,269 @@ +{ + "_comment": "체크아웃 현금영수증 신청 폼 (W-1 / #454). 템플릿 sirsoft-basic 의 _checkout_payment.json 이 dbank 블록 내부 말단에 연 슬롯에 주입한다. 슬롯이 무통장 블록 안이므로 결제수단 게이트는 불필요하고, 프로바이더 미설정 시에만 전체를 숨긴다. 발급 프로바이더는 PG 플러그인이 담당하지만 신청 폼은 PG 비종속이라 모듈이 소유한다 — 플러그인이 주입하면 두 PG 동시 활성 시 폼이 2개 렌더된다.", + "extension_point": "shop_checkout_cash_receipt_slot", + "mode": "append", + "priority": 100, + "components": [ + { + "id": "ext_checkout_cash_receipt", + "comment": "프로바이더 미설정이면 슬롯 전체 미렌더 (M2 — DOM 카운트 0)", + "type": "basic", + "name": "Div", + "if": "{{!!paymentSettings?.data?.order_settings?.cash_receipt_provider}}", + "props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-600" }, + "responsive": { + "portable": { + "props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-600" } + } + }, + "children": [ + { + "comment": "신청 체크박스 — 해제 시 하위 3필드를 if 로 숨기고 확장 칸을 비운다", + "type": "basic", + "name": "Div", + "props": { "className": "flex items-center gap-2" }, + "children": [ + { + "id": "ext_checkout_cash_receipt_toggle", + "type": "basic", + "name": "Input", + "props": { + "type": "checkbox", + "name": "cash_receipt_requested", + "checked": "{{_local.cashReceiptRequested ?? false}}", + "className": "w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" + }, + "actions": [ + { + "comment": "체크 시 기본값(소득공제 × 휴대폰번호)으로 확장 칸을 시드하고, 해제 시 확장 칸을 비워 주문 payload 에서 제거한다.", + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "cashReceiptRequested": "{{$event.target.checked}}", + "cashReceiptType": "{{$event.target.checked ? (_local.cashReceiptType ?? 'income') : null}}", + "cashReceiptIdentifierType": "{{$event.target.checked ? (_local.cashReceiptIdentifierType ?? 'phone') : null}}", + "cashReceiptIdentifier": "{{$event.target.checked ? (_local.cashReceiptIdentifier ?? '') : null}}", + "checkoutExtraPayload": "{{$event.target.checked ? { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: (_local.cashReceiptIdentifier ?? '') } : {}}}" + } + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { "className": "text-sm font-medium text-gray-700 dark:text-gray-300" }, + "text": "$t:sirsoft-ecommerce.checkout.cash_receipt.request" + } + ] + }, + { + "id": "ext_checkout_cash_receipt_fields", + "comment": "신청 시에만 렌더 (M4 — 클릭 전후 필드 수 0 → 3)", + "type": "basic", + "name": "Div", + "if": "{{!!_local.cashReceiptRequested}}", + "props": { "className": "mt-3 space-y-3" }, + "children": [ + { + "comment": "용도 — 라디오 2종 (가로 → portable 세로 스택)", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" }, + "text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose" + }, + { + "id": "ext_checkout_cash_receipt_purpose", + "type": "basic", + "name": "Div", + "props": { "className": "flex flex-row gap-4" }, + "responsive": { + "portable": { + "props": { "className": "flex flex-col gap-2" } + } + }, + "children": [ + { + "comment": "소득공제용", + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "radio", + "name": "cash_receipt_type", + "value": "income", + "checked": "{{(_local.cashReceiptType ?? 'income') === 'income'}}", + "className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" + }, + "actions": [ + { + "comment": "소득공제로 전환 — 사업자등록번호는 소득공제에 쓸 수 없으므로 발급수단이 business 면 phone 으로 리셋 (M5)", + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "cashReceiptType": "income", + "cashReceiptIdentifierType": "{{_local.cashReceiptIdentifierType === 'business' ? 'phone' : (_local.cashReceiptIdentifierType ?? 'phone')}}", + "cashReceiptIdentifier": "{{_local.cashReceiptIdentifierType === 'business' ? '' : (_local.cashReceiptIdentifier ?? '')}}", + "checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: 'income', cash_receipt_identifier_type: (_local.cashReceiptIdentifierType === 'business' ? 'phone' : (_local.cashReceiptIdentifierType ?? 'phone')), cash_receipt_identifier: (_local.cashReceiptIdentifierType === 'business' ? '' : (_local.cashReceiptIdentifier ?? '')) } }}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_income" + } + ] + }, + { + "comment": "지출증빙용", + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "radio", + "name": "cash_receipt_type", + "value": "expense", + "checked": "{{_local.cashReceiptType === 'expense'}}", + "className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" + }, + "actions": [ + { + "comment": "지출증빙으로 전환 — 발급수단 3종이 모두 유효하므로 현재 선택을 유지한다 (D10: 휴대폰도 지출증빙 가능)", + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "cashReceiptType": "expense", + "checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: 'expense', cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: (_local.cashReceiptIdentifier ?? '') } }}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_expense" + } + ] + } + ] + } + ] + }, + { + "comment": "발급수단 / 번호 — 2열 → portable 1열", + "id": "ext_checkout_cash_receipt_identifier_row", + "type": "basic", + "name": "Div", + "props": { "className": "grid grid-cols-2 gap-3" }, + "responsive": { + "portable": { + "props": { "className": "grid grid-cols-1 gap-3" } + } + }, + "children": [ + { + "id": "ext_checkout_cash_receipt_identifier_type", + "comment": "발급수단 options — 소득공제는 2종, 지출증빙은 사업자등록번호 포함 3종 (D10). computed 대신 표현식으로 {value,label} 변환 (valueKey/labelKey prop 금지).", + "type": "basic", + "name": "Select", + "props": { + "name": "cash_receipt_identifier_type", + "value": "{{_local.cashReceiptIdentifierType ?? 'phone'}}", + "options": "{{(_local.cashReceiptType === 'expense' ? [{ value: 'business', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_business') }] : []).concat([{ value: 'phone', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_phone') }, { value: 'card', label: $t('sirsoft-ecommerce.checkout.cash_receipt.identifier_card') }])}}", + "className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" + }, + "responsive": { + "portable": { + "props": { + "className": "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm" + } + } + }, + "actions": [ + { + "comment": "발급수단 변경 시 번호를 비운다 — 휴대폰번호를 사업자등록번호 칸에 남겨두면 검증에서 422 가 난다.", + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "cashReceiptIdentifierType": "{{$event.target.value}}", + "cashReceiptIdentifier": "", + "checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: $event.target.value, cash_receipt_identifier: '' } }}" + } + } + ] + }, + { + "id": "ext_checkout_cash_receipt_identifier", + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "cash_receipt_identifier", + "value": "{{_local.cashReceiptIdentifier ?? ''}}", + "placeholder": "$t:sirsoft-ecommerce.checkout.cash_receipt.identifier_placeholder", + "className": "{{_local.errors?.cash_receipt_identifier ? 'w-full px-4 py-2 border-2 border-red-500 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500' : 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500'}}" + }, + "responsive": { + "portable": { + "props": { + "className": "{{_local.errors?.cash_receipt_identifier ? 'w-full px-3 py-2 border-2 border-red-500 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 text-sm' : 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 text-sm'}}" + } + } + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "cashReceiptIdentifier": "{{$event.target.value}}", + "checkoutExtraPayload": "{{ { cash_receipt_requested: true, cash_receipt_type: (_local.cashReceiptType ?? 'income'), cash_receipt_identifier_type: (_local.cashReceiptIdentifierType ?? 'phone'), cash_receipt_identifier: $event.target.value } }}" + } + } + ] + } + ] + }, + { + "comment": "서버 검증 오류 인라인 노출 (M6 — 사업자번호 체크섬 오류 등)", + "type": "basic", + "name": "Span", + "if": "{{!!_local.errors?.cash_receipt_identifier}}", + "props": { "className": "block text-red-500 text-xs" }, + "text": "{{_local.errors?.cash_receipt_identifier?.[0] ?? ''}}" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-ecommerce.checkout.cash_receipt.purpose_help" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-ecommerce.checkout.cash_receipt.immutable_help" + } + ] + } + ] + } + ] +} diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json index d0282f3c..84d522bf 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-admin.json @@ -5,7 +5,7 @@ "comment": "관리자 헤더 표시 통화 복원 — 호스트 _admin_base 는 통화/이커머스를 모른다. 이커머스가 이 init 단계를 layout_extensions(init_actions 병합)로 기여한다. localStorage(g7_preferred_currency) → _global.preferredCurrency, 없으면 default_currency. 표시 전용(영속 저장 없음). 핸들러는 이커머스 모듈 소유.", "handler": "sirsoft-ecommerce.initPreferredCurrency", "params": { - "defaultCurrency": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}" + "defaultCurrency": "{{_global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}" } }, { @@ -87,7 +87,7 @@ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, - "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}" + "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}" }, { "comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만).", @@ -120,7 +120,7 @@ "text": "{{_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}" }, { "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "text-base" } }, - { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}" }, + { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}" }, { "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "text-xs" } } ], "actions": [ diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json index 1e2dbf97..be8d1dda 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/header-currency-selector-user.json @@ -55,7 +55,7 @@ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, - "text": "{{_global.preferredCurrency ?? _global.defaultCurrency ?? 'KRW'}}" + "text": "{{_global.preferredCurrency ?? _global.defaultCurrency}}" }, { "comment": "접힘 상태 요약 ② 현재 배송국가명 (해외배송 활성 + 국가 2개 이상일 때만). 통화와 시각적으로 구분되도록 옅은 색.", @@ -88,7 +88,7 @@ "text": "{{_global.preferredShippingCountry ?? _global.modules?.['sirsoft-ecommerce']?.shipping?.default_country ?? 'KR'}}" }, { "type": "basic", "name": "Icon", "props": { "name": "coins", "className": "text-base" } }, - { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.defaultCurrency ?? 'KRW'}}" }, + { "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{_global.preferredCurrency ?? _global.defaultCurrency}}" }, { "type": "basic", "name": "Icon", "props": { "name": "chevron-down", "className": "text-xs" } } ], "actions": [ @@ -205,7 +205,16 @@ "params": { "method": "PUT", "body": { "currency": "{{currency.code}}" } - } + }, + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] } } ] @@ -310,7 +319,16 @@ "params": { "method": "PUT", "body": { "shipping_country": "{{shipCountry.code}}" } - } + }, + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message}}" + } + } + ] } } ] diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/mypage_order_cash_receipt.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/mypage_order_cash_receipt.json new file mode 100644 index 00000000..ae3c827d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/mypage_order_cash_receipt.json @@ -0,0 +1,470 @@ +{ + "_comment": "유저 주문상세 현금영수증 카드 (W-2 / #454). 템플릿 sirsoft-basic 의 partials/mypage/orders/_payment.json 말단 슬롯에 주입한다. 상태머신 5종: ①무통장 아님/프로바이더 미설정→미렌더 ②입금전→안내 ③입금완료+미발급→발급 버튼 ④발급완료→링크 ⑤재발급 실패→경고(유저에겐 수동 재발급 버튼을 주지 않는다 — 관리자 전용). 발급 POST 는 로드된 주문의 실제 id(order.data.id)를 쓴다.", + "extension_point": "mypage_order_cash_receipt_slot", + "mode": "append", + "priority": 100, + "modals": [ + { + "id": "ext_cash_receipt_issue_modal", + "comment": "발급 모달 — 상태 ③에서 열린다. 모달은 별도 컨텍스트라 부모 _local 을 참조할 수 없으므로 데이터소스(order)와 _global 만 쓴다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-ecommerce.order.cash_receipt.issue_title", + "width": "480px" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "space-y-4" }, + "children": [ + { + "comment": "용도", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose" + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex flex-row gap-4" }, + "responsive": { + "portable": { "props": { "className": "flex flex-col gap-2" } } + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "radio", + "name": "receipt_type", + "value": "income", + "checked": "{{(_global.cashReceiptForm?.receipt_type ?? 'income') === 'income'}}", + "className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" + }, + "actions": [ + { + "comment": "소득공제 전환 시 사업자등록번호 선택을 phone 으로 리셋 (소득공제에 사용 불가)", + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "cashReceiptForm.receipt_type": "income", + "cashReceiptForm.identifier_type": "{{_global.cashReceiptForm?.identifier_type === 'business' ? 'phone' : (_global.cashReceiptForm?.identifier_type ?? 'phone')}}", + "cashReceiptForm.identifier": "{{_global.cashReceiptForm?.identifier_type === 'business' ? '' : (_global.cashReceiptForm?.identifier ?? '')}}" + } + } + ] + }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose_income" } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "radio", + "name": "receipt_type", + "value": "expense", + "checked": "{{_global.cashReceiptForm?.receipt_type === 'expense'}}", + "className": "w-4 h-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { "target": "global", "cashReceiptForm.receipt_type": "expense" } + } + ] + }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose_expense" } + ] + } + ] + } + ] + }, + { + "comment": "발급수단 — 지출증빙일 때만 사업자등록번호 추가 (D10)", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier_type" + }, + { + "type": "basic", + "name": "Select", + "props": { + "name": "identifier_type", + "value": "{{_global.cashReceiptForm?.identifier_type ?? 'phone'}}", + "options": "{{(_global.cashReceiptForm?.receipt_type === 'expense' ? [{ value: 'business', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_business') }] : []).concat([{ value: 'phone', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_phone') }, { value: 'card', label: $t('sirsoft-ecommerce.order.cash_receipt.identifier_card') }])}}", + "className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" + }, + "actions": [ + { + "comment": "발급수단 변경 시 번호를 비운다 (형식이 달라 검증 실패를 유발)", + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "cashReceiptForm.identifier_type": "{{$event.target.value}}", + "cashReceiptForm.identifier": "" + } + } + ] + } + ] + }, + { + "comment": "번호", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "identifier", + "value": "{{_global.cashReceiptForm?.identifier ?? ''}}", + "placeholder": "$t:sirsoft-ecommerce.order.cash_receipt.identifier_placeholder", + "className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { "target": "global", "cashReceiptForm.identifier": "{{$event.target.value}}" } + } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{!!_global.cashReceiptForm?.error}}", + "props": { "className": "block text-red-500 text-xs mt-1" }, + "text": "{{_global.cashReceiptForm?.error ?? ''}}" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.immutable_help" + }, + { + "comment": "액션 — 데스크톱 가로 우측정렬, portable 세로 full-width", + "type": "basic", + "name": "Div", + "props": { "className": "flex flex-row justify-end gap-2 pt-2" }, + "responsive": { + "portable": { "props": { "className": "flex flex-col gap-2 w-full pt-2" } } + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" + }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.cancel", + "actions": [{ "type": "click", "handler": "closeModal" }] + }, + { + "id": "ext_cash_receipt_issue_submit", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed", + "disabled": "{{!(_global.cashReceiptForm?.identifier ?? '')}}" + }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.issue", + "actions": [ + { + "comment": "회원은 user/orders/{id}, 비회원은 guest/orders/{orderNumber} + X-Guest-Order-Token. 주문 응답의 실제 id 를 쓴다(라우트 파라미터가 주문번호일 수 있으므로).", + "type": "click", + "handler": "apiCall", + "target": "{{_global.currentUser?.uuid ? ('/api/modules/sirsoft-ecommerce/user/orders/' + order.data.id + '/cash-receipt') : ('/api/modules/sirsoft-ecommerce/guest/orders/' + order.data.order_number + '/cash-receipt')}}", + "auth_mode": "optional", + "params": { + "method": "POST", + "headers": { "X-Guest-Order-Token": "{{_global.guestOrderToken ?? ''}}" }, + "body": { + "receipt_type": "{{_global.cashReceiptForm?.receipt_type ?? 'income'}}", + "identifier_type": "{{_global.cashReceiptForm?.identifier_type ?? 'phone'}}", + "identifier": "{{_global.cashReceiptForm?.identifier ?? ''}}" + } + }, + "onSuccess": [ + { "handler": "setState", "params": { "target": "global", "cashReceiptForm": null } }, + { "handler": "closeModal" }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "order" } }, + { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-ecommerce.order.cash_receipt.issue_success" } } + ], + "onError": [ + { + "comment": "서버 검증 메시지를 그대로 노출한다 (사업자번호 체크섬·용도×수단 조합 등)", + "handler": "setState", + "params": { + "target": "global", + "cashReceiptForm.error": "{{error.errors?.identifier?.[0] ?? error.message ?? $t('sirsoft-ecommerce.order.cash_receipt.issue_failed')}}" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "components": [ + { + "id": "ext_mypage_cash_receipt_card", + "comment": "상태 ① — 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다", + "type": "basic", + "name": "Div", + "if": "{{order.data.payment?.payment_method === 'dbank' && !!order.data.payment?.cash_receipt_provider}}", + "props": { "className": "mt-4 pt-4 border-t border-gray-200 dark:border-gray-700" }, + "responsive": { + "portable": { "props": { "className": "mt-3 pt-3 border-t border-gray-200 dark:border-gray-700" } } + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex items-center gap-2 mb-2" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { "name": "receipt", "size": "sm", "className": "text-gray-700 dark:text-gray-300" } + }, + { + "type": "basic", + "name": "H4", + "props": { "className": "text-sm font-semibold text-gray-900 dark:text-white" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.title" + } + ] + }, + { + "id": "ext_mypage_cash_receipt_awaiting", + "comment": "상태 ② 입금 전 — 발급 불가 안내 + 신청내역(신청했을 때만). 입금 전 상태는 두 가지다: 무통장 주문은 ready 로 생성되고 가상계좌는 waiting_deposit 이다 (PaymentStatusEnum::isAwaitingDeposit() 이 둘 다 true). waiting_deposit 만 검사하면 무통장 주문 전부에서 카드 본문이 사라진다.", + "type": "basic", + "name": "Div", + "if": "{{['ready', 'waiting_deposit'].includes(order.data.payment?.payment_status)}}", + "props": { "className": "space-y-1" }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.awaiting_deposit" + }, + { + "type": "basic", + "name": "P", + "if": "{{!!order.data.payment?.is_cash_receipt_requested}}", + "props": { "className": "text-sm text-gray-700 dark:text-gray-300" }, + "text": "{{order.data.payment?.cash_receipt_type_label ?? ''}} · {{order.data.payment?.cash_receipt_identifier ?? ''}}" + } + ] + }, + { + "id": "ext_mypage_cash_receipt_issuable", + "comment": "상태 ③ 입금완료 + 미발급 — 발급 버튼. 재발급 실패 이력이 있으면 상태 ⑤(경고)가 담당하므로 여기서 제외한다(두 상태는 배타적이어야 한다).", + "type": "basic", + "name": "Div", + "if": "{{order.data.payment?.payment_status === 'paid' && !order.data.cash_receipt && String((order.data.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() !== 'failed'}}", + "props": { "className": "flex flex-row items-center justify-between gap-2" }, + "responsive": { + "portable": { "props": { "className": "flex flex-col items-start gap-2 w-full" } } + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.none" + }, + { + "id": "ext_mypage_cash_receipt_issue_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white" + }, + "responsive": { + "portable": { + "props": { + "type": "button", + "className": "w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white" + } + } + }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.issue", + "actions": [ + { + "comment": "모달을 열기 전에 신청내역으로 폼을 시드한다 (미신청이면 기본값)", + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "cashReceiptForm": "{{ { receipt_type: (order.data.payment?.cash_receipt_type ?? 'income'), identifier_type: (order.data.payment?.cash_receipt_identifier_type ?? 'phone'), identifier: '', error: null } }}" + } + }, + { "handler": "openModal", "target": "ext_cash_receipt_issue_modal" } + ] + } + } + ] + } + ] + }, + { + "id": "ext_mypage_cash_receipt_issued", + "comment": "상태 ④ 발급완료 — 발급일시/용도/식별번호 + 영수증 링크", + "type": "basic", + "name": "Div", + "if": "{{!!order.data.cash_receipt}}", + "props": { "className": "space-y-1" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex items-center justify-between" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.issued_at" }, + { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.issued_at_formatted ?? ''}}" } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex items-center justify-between" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.purpose" }, + { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.receipt_type_label ?? ''}}" } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex items-center justify-between" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-ecommerce.order.cash_receipt.identifier" }, + { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-900 dark:text-white" }, "text": "{{order.data.cash_receipt?.identifier_masked ?? ''}}" } + ] + }, + { + "comment": "영수증 링크 — 이력 테이블 소유(payment.pg_receipt_url 은 카드 매출전표라 별개)", + "type": "basic", + "name": "Div", + "if": "{{!!order.data.cash_receipt?.receipt_url}}", + "props": { "className": "flex flex-row justify-end pt-2" }, + "responsive": { + "portable": { "props": { "className": "flex flex-col w-full pt-2" } } + }, + "children": [ + { + "id": "ext_mypage_cash_receipt_link", + "type": "basic", + "name": "A", + "props": { + "href": "{{order.data.cash_receipt?.receipt_url}}", + "target": "_blank", + "rel": "noopener noreferrer", + "className": "inline-flex items-center justify-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" + }, + "responsive": { + "portable": { + "props": { + "href": "{{order.data.cash_receipt?.receipt_url}}", + "target": "_blank", + "rel": "noopener noreferrer", + "className": "inline-flex w-full items-center justify-center gap-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" + } + } + }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.view_receipt" + } + ] + } + ] + }, + { + "id": "ext_mypage_cash_receipt_failed", + "comment": "상태 ⑤ 취소 성공 + 재발급 실패 — 유저에게는 경고만 노출한다(수동 재발급은 관리자 전용). issue_status backing value 는 대문자(FAILED)라 대소문자 무관 비교한다.", + "type": "basic", + "name": "Div", + "if": "{{!order.data.cash_receipt && (order.data.cash_receipts ?? []).length > 0 && String((order.data.cash_receipts ?? [])[0]?.issue_status ?? '').toLowerCase() === 'failed'}}", + "props": { "className": "rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 space-y-1" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex items-center gap-2" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { "name": "triangle-exclamation", "size": "sm", "className": "text-amber-600 dark:text-amber-400" } + }, + { + "type": "basic", + "name": "Span", + "props": { "className": "text-sm font-medium text-amber-800 dark:text-amber-300" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.reissue_failed" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs text-amber-700 dark:text-amber-400" }, + "text": "$t:sirsoft-ecommerce.order.cash_receipt.reissue_failed_help" + } + ] + } + ] + } + ] +} diff --git a/modules/_bundled/sirsoft-ecommerce/resources/extensions/register-currency-field.json b/modules/_bundled/sirsoft-ecommerce/resources/extensions/register-currency-field.json index 41a3b91e..89584cef 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/extensions/register-currency-field.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/extensions/register-currency-field.json @@ -25,7 +25,7 @@ "name": "Select", "props": { "name": "preferred_currency", - "value": "{{_local?.registerForm?.preferred_currency ?? _global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency ?? 'KRW'}}", + "value": "{{_local?.registerForm?.preferred_currency ?? _global.preferredCurrency ?? _global.modules?.['sirsoft-ecommerce']?.language_currency?.default_currency}}", "options": "{{(_global.modules?.['sirsoft-ecommerce']?.language_currency?.currencies ?? []).filter(c => c.is_default || (c.exchange_rate ?? 0) > 0).map(c => ({ value: c.code, label: c.code + ' (' + (c.symbol ?? c.code) + ')' }))}}", "className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500", "disabled": "{{_local?.isRegistering}}" diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_promotion_coupon_form.json b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_promotion_coupon_form.json index 3277eb5b..99ab456f 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_promotion_coupon_form.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_promotion_coupon_form.json @@ -159,7 +159,7 @@ "name": "Icon", "props": { "name": "{{_local.isSaving ? 'loader' : 'save'}}", - "className": "w-4 h-4 mr-2 {{_local.isSaving ? 'animate-spin' : ''}}" + "className": "text-base mr-2 {{_local.isSaving ? 'animate-spin' : ''}}" } }, { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_promotion_coupon_list.json b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_promotion_coupon_list.json index 104c0034..f7f35067 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_promotion_coupon_list.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_promotion_coupon_list.json @@ -244,7 +244,7 @@ "name": "Icon", "props": { "name": "plus", - "className": "w-4 h-4 mr-2" + "className": "text-base mr-2" } }, { @@ -748,7 +748,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 text-gray-400" + "className": "text-base text-gray-400" } }, { @@ -1734,7 +1734,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 text-gray-400" + "className": "text-base text-gray-400" } }, { @@ -1850,7 +1850,7 @@ "name": "Icon", "props": { "name": "calendar", - "className": "w-4 h-4 text-gray-400" + "className": "text-base text-gray-400" } }, { @@ -2161,7 +2161,7 @@ "name": "Icon", "props": { "name": "{{_local.showDetailFilter ? 'chevron-up' : 'chevron-down'}}", - "className": "w-4 h-4" + "className": "text-base" } } ], @@ -2258,7 +2258,7 @@ "name": "Icon", "props": { "name": "sliders", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -2436,7 +2436,7 @@ "name": "Icon", "props": { "name": "sliders", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -2923,7 +2923,7 @@ "name": "Icon", "props": { "name": "user", - "className": "w-4 h-4 text-gray-400" + "className": "text-base text-gray-400" } }, { @@ -2962,7 +2962,7 @@ "name": "Icon", "props": { "name": "more-horizontal", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -3050,7 +3050,7 @@ "name": "Icon", "props": { "name": "more-horizontal", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -3218,7 +3218,7 @@ "name": "Icon", "props": { "name": "more-vertical", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -3693,7 +3693,7 @@ "if": "{{_global.isChangingStatus}}", "props": { "name": "spinner", - "className": "w-4 h-4 animate-spin" + "className": "text-base animate-spin" } }, { @@ -3799,7 +3799,7 @@ "name": "Icon", "props": { "name": "x", - "className": "w-3 h-3" + "className": "text-xs" } } ] @@ -3899,7 +3899,7 @@ "name": "Icon", "props": { "name": "user", - "className": "w-4 h-4 text-gray-400" + "className": "text-base text-gray-400" } }, { @@ -3938,7 +3938,7 @@ "name": "Icon", "props": { "name": "more-horizontal", - "className": "w-4 h-4" + "className": "text-base" } } ] @@ -4255,7 +4255,7 @@ "if": "{{_global.isDeleting}}", "props": { "name": "spinner", - "className": "w-4 h-4 animate-spin" + "className": "text-base animate-spin" } }, { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_shipping_policy_list.json b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_shipping_policy_list.json index 0297c834..56d08a93 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_shipping_policy_list.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/fixtures/admin_ecommerce_shipping_policy_list.json @@ -121,7 +121,7 @@ "name": "Icon", "props": { "name": "plus", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -174,7 +174,7 @@ "name": "Icon", "props": { "name": "search", - "className": "absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" + "className": "absolute left-3 top-1/2 -translate-y-1/2 text-xl text-gray-400" } }, { @@ -1948,7 +1948,7 @@ "name": "Icon", "props": { "name": "ellipsis-vertical", - "className": "w-5 h-5 text-gray-500" + "className": "text-xl text-gray-500" } } ] @@ -2026,7 +2026,7 @@ "name": "Icon", "props": { "name": "truck", - "className": "w-4 h-4 text-gray-400" + "className": "text-base text-gray-400" } }, { @@ -2162,7 +2162,7 @@ "name": "Icon", "props": { "name": "plus", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -2266,7 +2266,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" } }, { @@ -2333,7 +2333,7 @@ "if": "{{_global.isDeleting}}", "props": { "name": "spinner", - "className": "w-4 h-4 animate-spin" + "className": "text-base animate-spin" } }, { @@ -2593,7 +2593,7 @@ "name": "Icon", "props": { "name": "copy", - "className": "w-4 h-4" + "className": "text-base" } }, { @@ -2667,7 +2667,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" } }, { @@ -2734,7 +2734,7 @@ "if": "{{_global.isBulkDeleting}}", "props": { "name": "spinner", - "className": "w-4 h-4 animate-spin" + "className": "text-base animate-spin" } }, { @@ -2924,7 +2924,7 @@ "if": "{{_global.isBulkToggling}}", "props": { "name": "spinner", - "className": "w-4 h-4 animate-spin" + "className": "text-base animate-spin" } }, { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/bulkUpdateHandlers.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/bulkUpdateHandlers.test.ts index 308a7acb..e4db4143 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/bulkUpdateHandlers.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/bulkUpdateHandlers.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { buildConfirmDataHandler, bulkUpdateHandler } from '../../handlers/bulkUpdateHandlers'; import { updateOptionFieldHandler } from '../../handlers/updateOptionField'; import { updateProductFieldHandler } from '../../handlers/updateProductField'; +import { __resetExpandedOptionsState } from '../../handlers/expandedOptionsHandlers'; // G7Core mock let mockLocalState: Record = {}; @@ -88,6 +89,7 @@ const mockG7Core = { }, api: { patch: vi.fn(), + get: vi.fn(), }, createLogger: () => ({ log: vi.fn(), @@ -144,6 +146,9 @@ beforeEach(() => { mockGlobalState = {}; mockDataSources = {}; vi.clearAllMocks(); + __resetExpandedOptionsState(); + mockG7Core.dataSource.refetch.mockResolvedValue(undefined); + mockG7Core.api.get.mockResolvedValue({ data: { options: {}, product_ids: [] } }); (window as any).G7Core = mockG7Core; }); @@ -698,3 +703,192 @@ describe('bulkUpdateHandler - 실패 시 서버 에러 노출', () => { expect(mockG7Core.toast.error).toHaveBeenCalledWith('Network Error'); }); }); + +// ============================================================================ +// 목록 옵션 지연 로딩과의 연계 +// ============================================================================ + +/** + * 옵션 2개짜리 상품을 만듭니다. + * + * @param id 상품 ID + * @param expanded true = 목록에서 펼쳐 옵션이 로드된 상태, false = 미펼침(옵션 미로드) + */ +function makeProduct(id: number, expanded: boolean) { + const base: Record = { + id, + name: { ko: `상품${id}`, en: `Product ${id}` }, + sales_status: 'on_sale', + display_status: 'visible', + list_price: 10000, + selling_price: 8000, + stock_quantity: 10, + // 비활성 포함 전체 옵션 수 — 서버가 전개하는 모집단과 같다 + options_total_count: 2, + options_count: 1, + }; + + if (expanded) { + base.options = [ + { id: id * 10 + 1, option_name: { ko: 'A' }, option_name_localized: 'A', stock_quantity: 5, is_active: true }, + { id: id * 10 + 2, option_name: { ko: 'B' }, option_name_localized: 'B', stock_quantity: 5, is_active: false }, + ]; + } + + return base; +} + +describe('buildConfirmData - 미펼침 상품 옵션 집계', () => { + /** 재고 일괄 변경 조건을 건 상태로 확인 데이터를 만듭니다. */ + function buildWith(products: any[]) { + mockDataSources.products = { data: { data: products } }; + mockLocalState = { selectedItems: products.map((p) => p.id) }; + mockGlobalState = { + bulkSelectedItems: products.map((p) => p.id), + bulkStockCondition: '+10개', + bulkStockMethod: 'increase', + bulkStockValue: 10, + }; + + buildConfirmDataHandler({ handler: 'sirsoft-ecommerce.buildConfirmData' }, mockContext); + + return mockGlobalState.bulkConfirmData; + } + + // @scenario selection=product_header_all,target=stock,source=global_only + // @effects summary_count_matches_regardless_of_expansion + it('전부 펼친 경우와 전부 미펼침인 경우의 optionCount 가 같다', () => { + const expanded = buildWith([makeProduct(1, true), makeProduct(2, true)]); + const expandedCount = expanded.summary.optionCount; + + // 상태 초기화 후 미펼침으로 동일 구성 재계산 + mockGlobalState = {}; + const collapsed = buildWith([makeProduct(1, false), makeProduct(2, false)]); + + expect(expandedCount).toBe(4); + expect(collapsed.summary.optionCount).toBe(4); + }); + + // @scenario selection=product_header_all,target=price,source=global_only + // @effects aggregate_entry_for_unloaded_options + it('미펼침 상품은 집계 엔트리 1건으로 표현된다', () => { + const data = buildWith([makeProduct(1, false)]); + + expect(data.options).toHaveLength(1); + expect(data.options[0].aggregate).toBe(true); + expect(data.options[0].optionCount).toBe(2); + expect(data.options[0].productId).toBe(1); + // 집계 엔트리도 무엇이 바뀌는지 보여준다 + expect(data.options[0].changes).toContain('재고'); + }); + + it('펼침 + 미펼침 혼합에서 중복 없이 합산된다', () => { + const data = buildWith([makeProduct(1, true), makeProduct(2, false)]); + + // 배열은 열거 2건 + 집계 1건 = 3칸이지만 수치는 4건 + expect(data.options).toHaveLength(3); + expect(data.summary.optionCount).toBe(4); + }); + + it('집계는 비활성 포함 전체 수(options_total_count)를 센다', () => { + const product = makeProduct(1, false); + product.options_count = 1; // 활성만 1건 + product.options_total_count = 5; // 비활성 포함 5건 + + const data = buildWith([product]); + + expect(data.summary.optionCount).toBe(5); + }); + + it('옵션 일괄 조건이 없으면 미펼침 상품에 집계 엔트리를 만들지 않는다', () => { + mockDataSources.products = { data: { data: [makeProduct(1, false)] } }; + mockLocalState = { selectedItems: [1] }; + mockGlobalState = { + bulkSelectedItems: [1], + // 상품 상태만 변경 — 옵션에는 적용되지 않는다 + bulkSalesStatus: 'suspended', + }; + + buildConfirmDataHandler({ handler: 'sirsoft-ecommerce.buildConfirmData' }, mockContext); + + expect(mockGlobalState.bulkConfirmData.options).toHaveLength(0); + expect(mockGlobalState.bulkConfirmData.summary.optionCount).toBe(0); + }); +}); + +describe('bulkUpdate - 저장 후 펼친 행 옵션 재로드', () => { + it('저장 성공 시 목록 리페치 후 펼친 행의 옵션을 다시 가져온다', async () => { + mockDataSources.products = { data: { data: [makeProduct(1, true)] } }; + mockLocalState = { + selectedItems: [1], + expandedRows: [1], + modifiedOptionIds: [], + modifiedOptionFields: {}, + }; + mockGlobalState = { bulkSelectedItems: [1], bulkSalesStatus: 'suspended' }; + + mockG7Core.api.patch.mockResolvedValueOnce({ data: { products_updated: 1, options_updated: 2 } }); + mockG7Core.api.get.mockResolvedValueOnce({ + data: { options: { '1': [{ id: 11 }, { id: 12 }] }, product_ids: [1] }, + }); + + bulkUpdateHandler({ handler: 'sirsoft-ecommerce.bulkUpdate' }, mockContext); + + await vi.waitFor(() => { + expect(mockG7Core.dataSource.refetch).toHaveBeenCalledWith('products'); + }); + + await vi.waitFor(() => { + expect(mockG7Core.api.get).toHaveBeenCalledWith( + '/api/modules/sirsoft-ecommerce/admin/products/options', + { params: { product_ids: [1] } } + ); + }); + }); + + it('펼친 행이 없으면 옵션 재로드를 하지 않는다', async () => { + mockDataSources.products = { data: { data: [makeProduct(1, false)] } }; + mockLocalState = { selectedItems: [1], expandedRows: [] }; + mockGlobalState = { bulkSelectedItems: [1], bulkSalesStatus: 'suspended' }; + + mockG7Core.api.patch.mockResolvedValueOnce({ data: { products_updated: 1, options_updated: 0 } }); + + bulkUpdateHandler({ handler: 'sirsoft-ecommerce.bulkUpdate' }, mockContext); + + await vi.waitFor(() => { + expect(mockG7Core.dataSource.refetch).toHaveBeenCalledWith('products'); + }); + + expect(mockG7Core.api.get).not.toHaveBeenCalled(); + }); + + it('미펼침 상품은 option_items 로 보내지 않는다 (서버가 ids 로 전개)', async () => { + mockDataSources.products = { data: { data: [makeProduct(1, false)] } }; + mockLocalState = { + selectedItems: [1], + expandedRows: [], + modifiedOptionIds: ['1-11'], + modifiedOptionFields: { '1-11': ['stock_quantity'] }, + }; + mockGlobalState = { + bulkSelectedItems: [1], + bulkStockCondition: '+10개', + bulkStockMethod: 'increase', + bulkStockValue: 10, + }; + + mockG7Core.api.patch.mockResolvedValueOnce({ data: { products_updated: 1, options_updated: 2 } }); + + bulkUpdateHandler({ handler: 'sirsoft-ecommerce.bulkUpdate' }, mockContext); + + await vi.waitFor(() => { + expect(mockG7Core.api.patch).toHaveBeenCalled(); + }); + + const payload = mockG7Core.api.patch.mock.calls[0][1]; + + expect(payload.ids).toEqual([1]); + expect(payload.option_bulk_changes).toBeDefined(); + expect(payload.option_items).toBeUndefined(); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts index 526e888a..62431478 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/cancelOrderHandlers.test.ts @@ -157,4 +157,45 @@ describe('executeCancelOrderHandler — 항상 items 전송 (백엔드 FULL 승 expect(body.cancel_pg).toBe(false); expect(body.refund_priority).toBe('points_first'); }); + + describe('환불 계좌 (#454 S3)', () => { + beforeEach(() => { + mockOrderDataSource = { + data: { options: [{ id: 100, quantity: 2, option_status: 'payment_complete' }] }, + }; + mockLocalState.cancelItems = [{ id: 100, quantity: 2, cancel_quantity: 1 }]; + }); + + it('미입력이면 refund_bank 키 자체를 보내지 않는다 (주문 시 입력된 계좌 보존)', async () => { + const body = await runCancel(); + + expect(body.refund_bank).toBeUndefined(); + }); + + it('3필드를 모두 입력하면 refund_bank 로 전송된다', async () => { + mockLocalState.refundBankCode = '004'; + mockLocalState.refundBankAccount = '110-123-456789'; + mockLocalState.refundBankHolder = '홍길동'; + + const body = await runCancel(); + + expect(body.refund_bank).toEqual({ + bank_code: '004', + account_number: '110-123-456789', + holder: '홍길동', + }); + }); + + it('부분 입력도 그대로 전송한다 — 거부 판정은 서버(CancelOrderRequest)의 책임이다', async () => { + mockLocalState.refundBankAccount = '110-123-456789'; + + const body = await runCancel(); + + expect(body.refund_bank).toEqual({ + bank_code: '', + account_number: '110-123-456789', + holder: '', + }); + }); + }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/expandedOptionsHandlers.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/expandedOptionsHandlers.test.ts new file mode 100644 index 00000000..40bc57ef --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/expandedOptionsHandlers.test.ts @@ -0,0 +1,419 @@ +/** + * loadExpandedOptionsHandler 테스트 (상품 목록 확장 행 옵션 지연 로딩) + * + * @description + * 목록 응답이 옵션을 싣지 않게 되면서, 옵션은 행을 펼칠 때 배치 엔드포인트가 공급한다. + * 이 테스트가 고정하는 계약은 다음과 같다. + * + * - 아직 안 불러온 행만 요청한다 (`undefined` = 미로드, `[]` = 로드 완료·옵션 없음) + * - `options_total_count === 0` 이면 네트워크를 쓰지 않는다 + * - 같은 상품에 대한 중복 요청을 병합한다 + * - 병합은 응답 콜백 안에서 데이터소스를 **다시 읽어** 수행한다 (대기 중 인라인 편집 보존) + * - 응답에 없는 요청 ID 도 `[]` 로 확정한다 (무한 스켈레톤 방지) + * - 체크된 상품의 옵션은 선택 상태에 합쳐진다 + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + loadExpandedOptionsHandler, + retryExpandedOptionsHandler, + reloadExpandedOptions, + __resetExpandedOptionsState, +} from '../../handlers/expandedOptionsHandlers'; + +let mockLocalState: Record = {}; +let mockDataSource: Record = {}; +let apiGet: any; +let toastError: any; + +const mockContext = {} as any; + +function makeG7Core() { + return { + state: { + getLocal: () => mockLocalState, + get: () => ({ _local: mockLocalState }), + setLocal: vi.fn((updates: Record) => { + mockLocalState = { ...mockLocalState, ...updates }; + }), + set: vi.fn(), + }, + dataSource: { + get: vi.fn((id: string) => mockDataSource[id]), + set: vi.fn((id: string, value: any) => { + mockDataSource[id] = value; + }), + refetch: vi.fn(() => Promise.resolve()), + }, + api: { + get: apiGet, + }, + toast: { + error: toastError, + success: vi.fn(), + }, + t: (key: string) => key, + createLogger: () => ({ log: vi.fn(), warn: vi.fn(), error: vi.fn() }), + }; +} + +/** + * products 데이터소스를 시드합니다. + * + * @param products 상품 배열 + */ +function seedProducts(products: any[]): void { + mockDataSource['products'] = { + success: true, + data: { + data: products, + pagination: { current_page: 1 }, + }, + }; +} + +/** 데이터소스에서 상품을 찾습니다. */ +function findProduct(id: number): any { + return mockDataSource['products'].data.data.find((p: any) => p.id === id); +} + +beforeEach(() => { + __resetExpandedOptionsState(); + mockLocalState = {}; + mockDataSource = {}; + apiGet = vi.fn(() => + Promise.resolve({ data: { options: {}, product_ids: [] } }) + ); + toastError = vi.fn(); + (window as any).G7Core = makeG7Core(); + vi.clearAllMocks(); +}); + +afterEach(() => { + delete (window as any).G7Core; +}); + +describe('loadExpandedOptionsHandler', () => { + it('확장 상태를 먼저 반영한 뒤 미로드 행만 요청한다', async () => { + seedProducts([ + { id: 1, options_total_count: 2 }, + { id: 2, options_total_count: 1, options: [{ id: 20 }] }, + ]); + + apiGet.mockResolvedValue({ + data: { options: { '1': [{ id: 10 }, { id: 11 }] }, product_ids: [1] }, + }); + + loadExpandedOptionsHandler( + { handler: 'x', params: { expandedRows: [1, 2] } }, + mockContext + ); + + // 확장 상태가 즉시 반영된다 (fetch 결과를 기다리지 않는다) + expect(mockLocalState.expandedRows).toEqual([1, 2]); + + // 이미 options 배열을 가진 상품 2 는 요청 대상이 아니다 + expect(apiGet).toHaveBeenCalledTimes(1); + expect(apiGet.mock.calls[0][1]).toEqual({ params: { product_ids: [1] } }); + + await vi.waitFor(() => { + expect(findProduct(1).options).toHaveLength(2); + }); + }); + + // @scenario row_state=re_expanded,option_profile=mixed + // @effects options_reloaded_after_bulk_save + it('force 면 이미 로드된 행도 다시 요청한다', async () => { + seedProducts([{ id: 1, options_total_count: 1, options: [{ id: 10 }] }]); + + apiGet.mockResolvedValue({ + data: { options: { '1': [{ id: 99 }] }, product_ids: [1] }, + }); + + loadExpandedOptionsHandler( + { handler: 'x', params: { productIds: [1], force: true } }, + mockContext + ); + + expect(apiGet).toHaveBeenCalledTimes(1); + + await vi.waitFor(() => { + expect(findProduct(1).options).toEqual([{ id: 99 }]); + }); + }); + + // @scenario row_state=re_expanded,option_profile=active_only + // @effects no_refetch_on_re_expand + it('빈 배열은 "로드 완료·옵션 없음" 이므로 재요청하지 않는다', () => { + seedProducts([{ id: 1, options_total_count: 0, options: [] }]); + + loadExpandedOptionsHandler( + { handler: 'x', params: { expandedRows: [1] } }, + mockContext + ); + + expect(apiGet).not.toHaveBeenCalled(); + }); + + it('options_total_count 가 0 이면 네트워크 없이 빈 배열로 확정한다', () => { + seedProducts([{ id: 1, options_total_count: 0 }]); + + loadExpandedOptionsHandler( + { handler: 'x', params: { expandedRows: [1] } }, + mockContext + ); + + expect(apiGet).not.toHaveBeenCalled(); + expect(findProduct(1).options).toEqual([]); + }); + + // @scenario row_state=expanding,option_profile=active_only + // @effects inflight_requests_merged + it('진행 중인 요청이 있는 상품은 중복 요청하지 않는다', () => { + seedProducts([{ id: 1, options_total_count: 2 }]); + + // 응답이 오지 않는 요청 (in-flight 유지) + apiGet.mockReturnValue(new Promise(() => {})); + + loadExpandedOptionsHandler({ handler: 'x', params: { expandedRows: [1] } }, mockContext); + loadExpandedOptionsHandler({ handler: 'x', params: { expandedRows: [1] } }, mockContext); + + expect(apiGet).toHaveBeenCalledTimes(1); + }); + + // @scenario row_state=expanding,option_profile=mixed + // @effects merge_reads_datasource_again + it('병합 시 데이터소스를 다시 읽어 대기 중 편집을 보존한다', async () => { + seedProducts([{ id: 1, options_total_count: 1, name: 'before' }]); + + let resolveApi: (value: any) => void = () => {}; + apiGet.mockReturnValue(new Promise((resolve) => { resolveApi = resolve; })); + + loadExpandedOptionsHandler({ handler: 'x', params: { expandedRows: [1] } }, mockContext); + + // 요청이 날아간 뒤 다른 경로가 같은 상품을 수정한다 (인라인 편집 시뮬레이션) + seedProducts([{ id: 1, options_total_count: 1, name: 'edited-while-loading' }]); + + resolveApi({ data: { options: { '1': [{ id: 10 }] }, product_ids: [1] } }); + + await vi.waitFor(() => { + expect(findProduct(1).options).toEqual([{ id: 10 }]); + }); + + // 요청 전 스냅샷을 썼다면 'before' 로 되돌아갔을 것이다 + expect(findProduct(1).name).toBe('edited-while-loading'); + }); + + // @scenario row_state=expanded,option_profile=active_only + // @effects missing_ids_resolved_as_empty + it('응답에 없는 요청 ID 도 빈 배열로 확정한다', async () => { + seedProducts([ + { id: 1, options_total_count: 1 }, + { id: 2, options_total_count: 1 }, + ]); + + // 상품 2 는 권한 밖 — 응답에서 빠진다 + apiGet.mockResolvedValue({ + data: { options: { '1': [{ id: 10 }] }, product_ids: [1] }, + }); + + loadExpandedOptionsHandler( + { handler: 'x', params: { expandedRows: [1, 2] } }, + mockContext + ); + + await vi.waitFor(() => { + expect(findProduct(2).options).toEqual([]); + }); + }); + + // @scenario row_state=expanded,option_profile=mixed + // @effects selected_option_ids_hydrated + it('체크된 상품의 옵션은 선택 상태에 합쳐진다', async () => { + seedProducts([{ id: 1, options_total_count: 2 }]); + mockLocalState.selectedItems = [1]; + mockLocalState.selectedOptionIds = []; + + apiGet.mockResolvedValue({ + data: { options: { '1': [{ id: 10 }, { id: 11 }] }, product_ids: [1] }, + }); + + loadExpandedOptionsHandler( + { handler: 'x', params: { expandedRows: [1] } }, + mockContext + ); + + await vi.waitFor(() => { + expect(mockLocalState.selectedOptionIds).toEqual(['1-10', '1-11']); + }); + }); + + it('체크되지 않은 상품의 옵션은 선택 상태를 건드리지 않는다', async () => { + seedProducts([{ id: 1, options_total_count: 1 }]); + mockLocalState.selectedItems = []; + mockLocalState.selectedOptionIds = []; + + apiGet.mockResolvedValue({ + data: { options: { '1': [{ id: 10 }] }, product_ids: [1] }, + }); + + loadExpandedOptionsHandler({ handler: 'x', params: { expandedRows: [1] } }, mockContext); + + await vi.waitFor(() => { + expect(findProduct(1).options).toHaveLength(1); + }); + + expect(mockLocalState.selectedOptionIds).toEqual([]); + }); + + it('로딩 중에는 optionsLoadingIds 에 담기고 완료 후 비워진다', async () => { + seedProducts([{ id: 1, options_total_count: 1 }]); + + let resolveApi: (value: any) => void = () => {}; + apiGet.mockReturnValue(new Promise((resolve) => { resolveApi = resolve; })); + + loadExpandedOptionsHandler({ handler: 'x', params: { expandedRows: [1] } }, mockContext); + + expect(mockLocalState.optionsLoadingIds).toEqual(['1']); + + resolveApi({ data: { options: { '1': [] }, product_ids: [1] } }); + + await vi.waitFor(() => { + expect(mockLocalState.optionsLoadingIds).toEqual([]); + }); + }); + + // @scenario row_state=error,option_profile=active_only + it('실패 시 optionsErrorIds 에 기록하고 토스트로 알린다', async () => { + seedProducts([{ id: 1, options_total_count: 1 }]); + + apiGet.mockRejectedValue({ response: { data: { message: '서버 오류' } } }); + + loadExpandedOptionsHandler({ handler: 'x', params: { expandedRows: [1] } }, mockContext); + + await vi.waitFor(() => { + expect(mockLocalState.optionsErrorIds).toEqual(['1']); + }); + + expect(mockLocalState.optionsLoadingIds).toEqual([]); + expect(toastError).toHaveBeenCalledWith('서버 오류'); + }); + + // @scenario row_state=error,option_profile=mixed + it('실패 후 재시도가 가능하다 (in-flight 가 해제된다)', async () => { + seedProducts([{ id: 1, options_total_count: 1 }]); + + apiGet.mockRejectedValueOnce({ response: { data: { message: 'boom' } } }); + + loadExpandedOptionsHandler({ handler: 'x', params: { expandedRows: [1] } }, mockContext); + + await vi.waitFor(() => { + expect(mockLocalState.optionsErrorIds).toEqual(['1']); + }); + + apiGet.mockResolvedValue({ data: { options: { '1': [{ id: 10 }] }, product_ids: [1] } }); + + loadExpandedOptionsHandler( + { handler: 'x', params: { productId: 1, force: true } }, + mockContext + ); + + await vi.waitFor(() => { + expect(findProduct(1).options).toEqual([{ id: 10 }]); + }); + + expect(mockLocalState.optionsErrorIds).toEqual([]); + }); + + it('데이터소스에 없는 ID 는 요청하지 않는다', () => { + seedProducts([{ id: 1, options_total_count: 1, options: [] }]); + + loadExpandedOptionsHandler( + { handler: 'x', params: { expandedRows: [999] } }, + mockContext + ); + + expect(apiGet).not.toHaveBeenCalled(); + }); +}); + +describe('retryExpandedOptionsHandler', () => { + it('레이아웃이 보내는 단수 productId 로 그 행만 다시 요청한다', async () => { + // 3행을 펼쳐 두고 그중 2번만 실패한 상황 — 재시도는 2번만 다시 불러야 한다. + seedProducts([ + { id: 1, options_total_count: 1, options: [{ id: 10 }] }, + { id: 2, options_total_count: 1 }, + { id: 3, options_total_count: 1, options: [{ id: 30 }] }, + ]); + mockLocalState.expandedRows = [1, 2, 3]; + mockLocalState.optionsErrorIds = [2]; + + apiGet.mockResolvedValue({ + data: { options: { '2': [{ id: 20 }] }, product_ids: [2] }, + }); + + retryExpandedOptionsHandler( + { handler: 'sirsoft-ecommerce.retryExpandedOptions', params: { productId: 2 } } as any, + mockContext + ); + + expect(apiGet).toHaveBeenCalledTimes(1); + + // 요청 대상이 2번 한 행뿐이어야 한다. 단수 키가 포워딩되지 않으면 expandedRows 폴백으로 + // 떨어져 이미 성공한 1·3번까지 force 로 다시 불린다. + const requested = apiGet.mock.calls[0][1]?.params?.product_ids; + expect(requested).toEqual([2]); + + await vi.waitFor(() => { + expect(findProduct(2).options).toEqual([{ id: 20 }]); + }); + }); + + it('복수 productIds 형태도 그대로 받는다', () => { + seedProducts([ + { id: 1, options_total_count: 1, options: [{ id: 10 }] }, + { id: 2, options_total_count: 1 }, + ]); + mockLocalState.expandedRows = [1, 2]; + + retryExpandedOptionsHandler( + { handler: 'sirsoft-ecommerce.retryExpandedOptions', params: { productIds: [1, 2] } } as any, + mockContext + ); + + expect(apiGet).toHaveBeenCalledTimes(1); + expect(apiGet.mock.calls[0][1]?.params?.product_ids).toEqual([1, 2]); + }); +}); + +describe('reloadExpandedOptions', () => { + it('펼친 행이 없으면 아무것도 하지 않는다', () => { + seedProducts([{ id: 1, options_total_count: 1 }]); + mockLocalState.expandedRows = []; + + reloadExpandedOptions((window as any).G7Core, { force: true }); + + expect(apiGet).not.toHaveBeenCalled(); + }); + + // @scenario selection=individual_partial,target=stock,source=both_global_priority + // @effects options_reloaded_after_save + it('펼친 행을 force 로 다시 가져온다', async () => { + seedProducts([{ id: 1, options_total_count: 1, options: [{ id: 10, stock_quantity: 1 }] }]); + mockLocalState.expandedRows = [1]; + + apiGet.mockResolvedValue({ + data: { options: { '1': [{ id: 10, stock_quantity: 77 }] }, product_ids: [1] }, + }); + + reloadExpandedOptions((window as any).G7Core, { force: true }); + + expect(apiGet).toHaveBeenCalledTimes(1); + + await vi.waitFor(() => { + expect(findProduct(1).options[0].stock_quantity).toBe(77); + }); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/formatAmountInCurrency.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/formatAmountInCurrency.test.ts new file mode 100644 index 00000000..317894ae --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/formatAmountInCurrency.test.ts @@ -0,0 +1,78 @@ +/** + * 설정 통화 기준 금액 포맷 회귀 테스트 + * + * 관리자 주문 취소 모달은 단가·소계·쿠폰 할인액을 `금액.toLocaleString() + '원'` 으로 + * 조립했다. 기본 통화가 원화가 아닌 상점에서는 값은 기준 통화인데 단위만 원으로 표기돼, + * 운영자가 환불 금액을 잘못 읽을 수 있었다. + * + * 통화 단위는 설정(`language_currency.currencies`)이 정한다는 것을 고정한다. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { formatAmountInCurrency } from '../../handlers/calculateCurrencyPrices'; + +/** + * 전역 통화 설정을 심습니다. + * + * @param defaultCode 기본 통화 코드 + */ +function installCurrencies(defaultCode: string): void { + (window as any).G7Core = { + state: { + get: () => ({ + modules: { + 'sirsoft-ecommerce': { + language_currency: { + default_currency: defaultCode, + currencies: [ + { code: 'KRW', symbol: '₩', decimal_places: 0, is_default: defaultCode === 'KRW' }, + { code: 'JPY', symbol: '¥', decimal_places: 0, is_default: defaultCode === 'JPY' }, + { code: 'USD', symbol: '$', decimal_places: 2, is_default: defaultCode === 'USD' }, + ], + }, + }, + }, + }), + }, + }; +} + +describe('formatAmountInCurrency', () => { + afterEach(() => { + delete (window as any).G7Core; + }); + + it('기본 통화가 JPY 면 엔화 기호로 표기한다 (원화 고정 회귀 차단)', () => { + installCurrencies('JPY'); + + const formatted = formatAmountInCurrency(25000); + + expect(formatted).toContain('¥'); + expect(formatted).not.toContain('원'); + }); + + it('기본 통화가 KRW 면 기존과 동일하게 금액 뒤에 원을 붙인다', () => { + installCurrencies('KRW'); + + expect(formatAmountInCurrency(25000)).toBe('25,000원'); + }); + + it('통화 코드를 명시하면 기본 통화가 아니어도 그 통화로 표기한다', () => { + installCurrencies('JPY'); + + const formatted = formatAmountInCurrency(12.5, 'USD'); + + expect(formatted).toBe('$12.50'); + }); + + it('설정을 읽지 못하면 단위를 임의로 붙이지 않고 숫자만 돌려준다', () => { + // 통화 설정 없음 — 원화를 추측해 붙이면 그것이 곧 하드코딩이다. + (window as any).G7Core = { state: { get: () => ({}) } }; + + const formatted = formatAmountInCurrency(3000); + + expect(formatted).not.toContain('원'); + expect(formatted).not.toContain('₩'); + expect(formatted).toBe((3000).toLocaleString()); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/updateOptionField.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/updateOptionField.test.ts index 39c1a861..640bbfcf 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/updateOptionField.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/updateOptionField.test.ts @@ -171,3 +171,95 @@ describe('updateOptionFieldHandler - 비기본 옵션 판매가 변경 (상품 expect(productFields).not.toContain('selling_price'); }); }); + +/** + * 옵션 지연 로딩(#518 / 공개 #76) 이후 생긴 상태: 목록 행의 `options` 는 펼치기 전까지 + * `undefined` 다. 이 핸들러는 그 행을 만나도 **아무것도 바꾸지 않아야** 한다. + * + * 특히 `options` 를 `[]` 로 채워 넣으면 안 된다. 지연 로딩은 `Array.isArray(row.options)` 로 + * 미로드(`undefined`)와 로드완료·옵션없음(`[]`)을 구분하므로, `[]` 로 뒤바뀌면 그 행은 + * "이미 불러왔다" 로 오인되어 펼쳐도 영영 옵션을 받지 못한다 — 에러도 토스트도 없이. + */ +describe('updateOptionFieldHandler - 옵션 미로드 행 (지연 로딩)', () => { + /** 옵션을 아직 불러오지 않은 행을 하나 더 붙인다 */ + function seedUnloadedRow() { + mockDataSource['products'].data.data.push({ + id: 999, + selling_price: 7000, + list_price: 9000, + // options 키 자체가 없다 — 서버가 목록에서 싣지 않는다 + }); + } + + it('options 가 undefined 인 행에 편집이 들어와도 undefined 로 남는다', () => { + seedUnloadedRow(); + + updateOptionFieldHandler( + { handler: 'updateOptionField', params: { productId: 999, optionId: 1, field: 'selling_price', value: 8000 } }, + mockContext + ); + + const unloaded = mockDataSource['products'].data.data.find((p: any) => p.id === 999); + expect(unloaded.options).toBeUndefined(); + expect( + Array.isArray(unloaded.options), + 'options 가 배열이 되면 지연 로딩이 "로드 완료" 로 오인해 영영 불러오지 않는다' + ).toBe(false); + }); + + it('options 가 undefined 인 행의 다른 값도 바뀌지 않는다', () => { + seedUnloadedRow(); + + updateOptionFieldHandler( + { handler: 'updateOptionField', params: { productId: 999, optionId: 1, field: 'selling_price', value: 8000 } }, + mockContext + ); + + const unloaded = mockDataSource['products'].data.data.find((p: any) => p.id === 999); + expect(unloaded.selling_price).toBe(7000); + }); + + it('미로드 행이 섞여 있어도 로드된 행의 편집은 정상 동작한다', () => { + seedUnloadedRow(); + + updateOptionFieldHandler( + { handler: 'updateOptionField', params: { productId: 313, optionId: 1, field: 'selling_price', value: 4500 } }, + mockContext + ); + + expect(getProduct().selling_price).toBe(4500); + // 미로드 행은 그대로 + const unloaded = mockDataSource['products'].data.data.find((p: any) => p.id === 999); + expect(unloaded.options).toBeUndefined(); + }); + + it('미로드 행은 modified 추적에도 들어가지 않는다', () => { + seedUnloadedRow(); + + updateOptionFieldHandler( + { handler: 'updateOptionField', params: { productId: 999, optionId: 1, field: 'selling_price', value: 8000 } }, + mockContext + ); + + const modifiedProducts = mockLocalState.modifiedProductFields ?? {}; + expect(modifiedProducts['999']).toBeUndefined(); + + // 존재하지 않는 옵션이 수정 대상으로 기록되면 일괄 저장이 유령 키를 option_items 에 + // 실어 보낸다. 옵션 지연 로딩 전에는 모든 행이 옵션을 갖고 있어 드러나지 않던 경로다. + expect(mockLocalState.modifiedOptionIds ?? []).not.toContain('999-1'); + expect(mockLocalState.modifiedOptionFields ?? {}).not.toHaveProperty('999-1'); + }); + + it('찾지 못한 옵션에는 데이터소스 쓰기 자체가 일어나지 않는다', () => { + seedUnloadedRow(); + + updateOptionFieldHandler( + { handler: 'updateOptionField', params: { productId: 999, optionId: 1, field: 'selling_price', value: 8000 } }, + mockContext + ); + + // 같은 값을 다시 써서 불필요한 리렌더를 만들지 않는다 + expect(mockG7Core.dataSource.set).not.toHaveBeenCalled(); + expect(mockG7Core.state.setLocal).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-channel-single-source.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-channel-single-source.test.ts new file mode 100644 index 00000000..8da0db84 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-channel-single-source.test.ts @@ -0,0 +1,190 @@ +/** + * @file admin-ecommerce-notification-channel-single-source.test.ts + * @description 커머스 환경설정 알림 정의 탭 — 채널 표현식 단일 출처 회귀 테스트 + * + * 회귀 배경 (#518 3차 실측 결함 #1): + * 알림 정의 탭의 채널은 URL(`query.channel`)과 페이지 로컬 상태(`_local.activeNotificationChannel`) + * 두 곳에서 읽히고 있었다. 두 값은 갱신 시점이 다르다 — 다른 설정 탭에 갔다 돌아오면 URL 의 + * channel 은 사라지지만(탭 전환은 목록 상태를 승계하지 않는다) `_local` 은 이전 채널로 남는다. + * + * 코어 설정 화면은 그 사이에 목록을 기본 채널로 다시 불러오므로, 남아 있는 이전 채널로 템플릿을 + * 찾다가 행마다 「이 채널에 대한 템플릿이 없습니다」 를 띄웠다. + * + * 그래서 조건은 "두 표현식이 같은가" 가 아니라 "출처가 하나인가" 다. + * + * 2차 회귀 (#518 계획서 검증 브라우저 실측, 2026-08-05): + * 목록 프루닝으로 서버가 `template_channel` 을 받은 요청에만 `templates` 를 싣게 됐는데 + * (`NotificationDefinitionRepository::getPaginated`), 코어 화면만 그 파라미터를 보내도록 + * 고쳐지고 커머스 탭 데이터소스는 빠졌다. 그 결과 10건 전 행이 「이 채널에 대한 템플릿이 + * 없습니다」 로 표시되고 수신자·활성 토글·편집 모달 초기값이 통째로 비었다. + * + * 조용히 깨지는 종류다 — `(def.templates ?? [])` 가드 뒤라 예외도 콘솔 경고도 없고, + * 응답은 200 이며 행 수도 정상이라 "템플릿을 아직 안 만들었나 보다" 로 읽힌다. + * 그래서 아래 두 단언은 소비와 전송을 **함께** 묶는다: templates 를 읽는 화면이면 + * 그 채널을 서버에 넘겨야 한다. + * + * @scenario resource=ecommerce_notification_definition,endpoint=admin_settings,observation=consumer_screen + * @effects ecommerce_notification_channel_reads_single_source, ecommerce_notification_list_requests_template_channel, ecommerce_notification_reset_button_uses_server_aggregate + */ + +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +const SETTINGS_LAYOUT = path.resolve( + __dirname, + '../../../layouts/admin/admin_ecommerce_settings.json', +); +const NOTIFICATION_TAB = path.resolve( + __dirname, + '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json', +); + +/** + * JSON 트리에서 `{{ }}` 바인딩 표현식을 모두 수집합니다. + * + * @param node 탐색할 JSON 노드 + * @param acc 수집 결과 누적 배열 + * @returns 수집된 바인딩 표현식 목록 + */ +function collectBindings(node: unknown, acc: string[] = []): string[] { + if (typeof node === 'string') { + const matches = node.match(/\{\{[\s\S]*?\}\}/g); + + if (matches) { + acc.push(...matches); + } + + return acc; + } + + if (Array.isArray(node)) { + node.forEach((child) => collectBindings(child, acc)); + + return acc; + } + + if (node && typeof node === 'object') { + Object.values(node as Record).forEach((child) => + collectBindings(child, acc), + ); + } + + return acc; +} + +/** + * 레이아웃 파일을 읽어 파싱합니다. + * + * @param filePath 레이아웃 파일 절대 경로 + * @returns 파싱된 JSON 객체 + */ +function readLayout(filePath: string): Record { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +describe('커머스 알림 정의 탭 — 채널 단일 출처 (#518)', () => { + it('채널 표현식이 URL 만 읽는다', () => { + const bindings = [ + ...collectBindings(readLayout(SETTINGS_LAYOUT)), + ...collectBindings(readLayout(NOTIFICATION_TAB)), + ]; + + const channelReads = bindings.filter((binding) => /query\.channel/.test(binding)); + + expect( + channelReads.length, + '채널을 읽는 표현식이 있어야 한다 (0건이면 이 단언은 공회전한다)', + ).toBeGreaterThan(0); + + const dualSourced = channelReads.filter((binding) => /_local\./.test(binding)); + + expect( + dualSourced, + 'URL 이 나르지 않는 값(_local)을 채널 출처로 섞으면 데이터와 화면이 서로 다른 채널을 본다', + ).toEqual([]); + }); + + it('채널 전환은 URL 만 갱신한다 (로컬 상태에 복제하지 않는다)', () => { + const raw = fs.readFileSync(NOTIFICATION_TAB, 'utf-8'); + + expect( + raw.includes('activeNotificationChannel'), + '채널을 로컬 상태에 복제하면 URL 과 어긋날 수 있는 두 번째 출처가 다시 생긴다', + ).toBe(false); + + expect( + fs.readFileSync(SETTINGS_LAYOUT, 'utf-8').includes('activeNotificationChannel'), + 'initLocal 에 남겨두면 소비자가 다시 참조하게 된다', + ).toBe(false); + }); +}); + +describe('커머스 알림 정의 탭 — 서버 채널 필터 정합 (#518)', () => { + /** + * 알림 정의 목록 데이터소스를 찾습니다. + * + * @returns 데이터소스 정의 객체 + */ + function findDefinitionsDataSource(): Record { + const layout = readLayout(SETTINGS_LAYOUT); + const source = (layout.data_sources ?? []).find((ds: Record) => + typeof ds.endpoint === 'string' && ds.endpoint.includes('/api/admin/notification-definitions'), + ); + + expect(source, '알림 정의 목록 데이터소스를 찾지 못했다').toBeTruthy(); + + return source; + } + + it('templates 를 그리는 화면이면 목록 요청에 template_channel 을 보낸다', () => { + const consumesTemplates = collectBindings(readLayout(NOTIFICATION_TAB)).some((binding) => + /def\.templates/.test(binding), + ); + + expect( + consumesTemplates, + 'templates 소비가 0건이면 이 단언은 공회전한다 (탭 구조가 바뀌었는지 확인)', + ).toBe(true); + + // 서버는 `template_channel` 을 받은 요청에만 templates 를 싣는다. 화면이 templates 를 + // 읽으면서 이 파라미터를 빼면 전 행이 「이 채널에 대한 템플릿이 없습니다」 가 된다. + expect( + findDefinitionsDataSource().params?.template_channel, + 'templates 를 소비하는 목록이 채널을 서버에 넘기지 않으면 응답에 templates 키 자체가 없다', + ).toBeDefined(); + }); + + it('서버에 넘기는 채널과 화면이 찾는 채널이 같은 표현식이다', () => { + const sent = findDefinitionsDataSource().params.template_channel; + + // 화면이 템플릿을 고를 때 쓰는 채널 표현식을 추출한다. + const screenChannel = collectBindings(readLayout(NOTIFICATION_TAB)) + .find((binding) => /t\.channel === /.test(binding)) + ?.match(/t\.channel === \(([^)]+)\)/)?.[1]; + + expect(screenChannel, '화면의 채널 비교 표현식을 찾지 못했다').toBeTruthy(); + + // 예: 전송 "{{query.channel ?? 'mail'}}" ↔ 화면 "query.channel ?? 'mail'" + expect( + sent.replace(/^\{\{|\}\}$/g, '').trim(), + '두 채널이 어긋나면 서버가 실어 보낸 채널과 화면이 찾는 채널이 달라 빈 값이 된다', + ).toBe(screenChannel!.trim()); + }); + + it('되돌리기 버튼은 서버 집계(has_customized_templates)로 판정한다', () => { + const raw = fs.readFileSync(NOTIFICATION_TAB, 'utf-8'); + + // templates 는 한 채널로 좁혀 실리므로, 배열을 순회해서는 "다른 채널에서 커스터마이즈됨" + // 을 알 수 없다. 채널을 가리지 않는 판정은 서버 집계가 유일한 출처다. + expect( + /def\.templates[\s\S]{0,80}?\.some\(/.test(raw), + '되돌리기 조건을 templates 배열 순회로 판정하면 다른 채널의 커스터마이즈를 놓친다', + ).toBe(false); + + expect( + raw.includes('def.has_customized_templates'), + '되돌리기 노출 조건이 서버 집계를 읽어야 한다', + ).toBe(true); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts new file mode 100644 index 00000000..f0672f66 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts @@ -0,0 +1,344 @@ +/** + * @file admin-list-context-round-trip.test.ts + * @description 관리자 이커머스 목록 컨텍스트 왕복 시 URL 목록 상태 보존 회귀 (#75) + * + * 결함: 주문/상품/쿠폰/배송정책/리뷰/마일리지 목록에서 상세·폼으로 들어갔다 돌아오면 + * navigate 가 `mergeQuery` 없이 실행되어 page 와 필터 20여 종이 URL 에서 사라졌다. + * 필터를 여러 개 건 상태일수록 손실이 크다. + * + * 검증 방식 2단: + * 1. 실 레이아웃 JSON 전수 분석 — 클러스터 내 navigate 가 모두 `mergeQuery: true` 인지 + * 2. 실 액션을 엔진에 태워 실행(`triggerAction`) — 선언이 실제 목적지 URL 로 이어지는지 + * 손으로 쓴 조각이 아니라 실파일을 읽는다. 선언만 검사하면 "JSON 에 리터럴이 적혀 있다" + * 까지만 고정되고 병합 결과는 미검증으로 남는다. + * + * @scenario leg=row_click|detail_return|form_cancel|filter_apply + * @effects navigation_preserves_list_query + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +import { + createLayoutTest, + createMockComponentRegistryWithBasics, +} from '@core/template-engine/__tests__/utils/layoutTestUtils'; + +const LAYOUTS_ROOT = path.resolve(__dirname, '../../../layouts/admin'); + +/** 이 확장이 소유한 페이지네이션 목록 라우트 */ +const LIST_ROUTES = [ + '/admin/ecommerce/orders', + '/admin/ecommerce/products', + '/admin/ecommerce/reviews', + '/admin/ecommerce/promotion-coupons', + '/admin/ecommerce/shipping-policies', + '/admin/ecommerce/mileage-transactions', +]; + +/** 목록 클러스터에 속하는 레이아웃 파일 */ +const CLUSTER_FILES = [ + 'admin_ecommerce_order_detail.json', + 'admin_ecommerce_order_index.json', + 'admin_ecommerce_order_list.json', + 'admin_ecommerce_product_form.json', + 'admin_ecommerce_product_list.json', + 'admin_ecommerce_product_review_index.json', + 'admin_ecommerce_promotion_coupon_form.json', + 'admin_ecommerce_promotion_coupon_list.json', + 'admin_ecommerce_shipping_policy_form.json', + 'admin_ecommerce_shipping_policy_list.json', + 'partials/admin_ecommerce_mileage_transaction_index/_filters.json', + 'partials/admin_ecommerce_mileage_transaction_index/_transactions_table.json', + 'partials/admin_ecommerce_order_detail/_partial_order_info.json', + 'partials/admin_ecommerce_order_list/_partial_filter_section.json', + 'partials/admin_ecommerce_order_list/_partial_order_datagrid.json', + 'partials/admin_ecommerce_order_list/_partial_preset_section.json', + 'partials/admin_ecommerce_product_form/_modal_delete_confirm.json', + 'partials/admin_ecommerce_product_list/_partial_filter_section.json', + 'partials/admin_ecommerce_product_list/_partial_product_datagrid.json', + 'partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json', + 'partials/admin_ecommerce_promotion_coupon_list/_partial_filter_section.json', + 'partials/admin_ecommerce_shipping_policy_list/_modal_copy.json', + 'partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json', + 'partials/admin_ecommerce_shipping_policy_list/_partial_filter.json', +]; + +/** + * 목적지 경로(`path`)가 없는 선택형 navigate/replaceUrl 을 가진 목록 화면. + * + * `path` 를 생략한 액션은 "현재 URL 을 유지한 채 쿼리만 바꾼다" 는 뜻이라 목록 화면 + * 자신에게 작용한다. 그런데 `mergeQuery` 가 없으면 엔진이 대체 분기로 가 쿼리를 + * **통째로** 새 값으로 바꿔치므로, 방금 건 검색어·정렬이 항목 선택 한 번에 사라진다. + * 목적지가 문자열로 안 적혀 있어 경로 기반 검사(`normalizePath`)의 사각지대다. + */ +const SELF_QUERY_FILES = [ + 'partials/admin_ecommerce_brand_index/_panel_brand_list.json', + 'partials/admin_ecommerce_category_index/_panel_category_list.json', + 'partials/admin_ecommerce_product_common_info_index/_panel_list.json', + 'partials/admin_ecommerce_product_notice_index/_panel_list.json', +]; + +type NavAction = { + handler: string; + comment?: string; + params: { path?: string; mergeQuery?: unknown; query?: unknown }; +}; + +/** + * JSON 트리에서 navigate/replaceUrl 액션을 모두 수집합니다. + * + * @param node 순회 시작 노드 + * @returns 액션 목록 + */ +function collectNavActions(node: unknown): NavAction[] { + const out: NavAction[] = []; + const walk = (n: unknown): void => { + if (Array.isArray(n)) { + n.forEach(walk); + return; + } + if (!n || typeof n !== 'object') return; + const obj = n as Record; + if ( + (obj.handler === 'navigate' || obj.handler === 'replaceUrl') && + obj.params && + typeof obj.params === 'object' + ) { + out.push(obj as unknown as NavAction); + } + Object.values(obj).forEach(walk); + }; + walk(node); + return out; +} + +/** + * 목적지 경로를 비교용 표준형(`{{expr}}` → `*`)으로 바꿉니다. + * + * @param raw 원본 경로 + * @returns 표준형 경로 (판정 불가 시 null) + */ +function normalizePath(raw: unknown): string | null { + if (typeof raw !== 'string' || raw === '') return null; + const p = raw.replace(/\{\{[^}]*\}\}/g, '*').split('?')[0].replace(/\/+$/, ''); + return p.startsWith('/') ? p : null; +} + +/** + * 경로가 목록 라우트와 같은 클러스터인지 판정합니다. + * + * @param dest 표준형 목적지 + * @param list 표준형 목록 라우트 + * @returns 같은 클러스터 여부 + */ +function inFamily(dest: string, list: string): boolean { + return dest === list || dest.startsWith(`${list}/`); +} + +/** + * 레이아웃 파일을 읽어 파싱합니다. + * + * @param rel 레이아웃 루트 기준 상대 경로 + * @returns 파싱된 JSON + */ +function readLayout(rel: string): unknown { + return JSON.parse(fs.readFileSync(path.join(LAYOUTS_ROOT, rel), 'utf8')); +} + +describe('#75 관리자 이커머스 목록 컨텍스트 왕복 — mergeQuery 전수', () => { + it.each(CLUSTER_FILES)('%s 의 클러스터 내 navigate 는 모두 mergeQuery: true', (rel) => { + const offenders = collectNavActions(readLayout(rel)) + .filter((a) => { + if (typeof a.comment === 'string' && a.comment.includes('audit:allow')) return false; + const dest = normalizePath(a.params.path); + if (!dest) return false; + if (!LIST_ROUTES.some((l) => inFamily(dest, l))) return false; + return a.params.mergeQuery !== true; + }) + .map((a) => `${a.params.path} (mergeQuery=${JSON.stringify(a.params.mergeQuery)})`); + + expect(offenders, `목록 상태를 떨구는 navigate: \n ${offenders.join('\n ')}`).toEqual([]); + }); +}); + +describe('#75 목적지가 자기 자신인 액션도 목록 상태를 유지한다', () => { + it.each(SELF_QUERY_FILES)('%s 의 path 없는 navigate/replaceUrl 은 mergeQuery: true', (rel) => { + const offenders = collectNavActions(readLayout(rel)) + .filter((a) => { + if (typeof a.comment === 'string' && a.comment.includes('audit:allow')) return false; + if (normalizePath(a.params.path) !== null) return false; + return a.params.mergeQuery !== true; + }) + .map((a) => `${a.handler} query=${JSON.stringify(a.params.query)}`); + + expect( + offenders, + `현재 URL 의 검색어·정렬을 통째로 덮어쓰는 액션: \n ${offenders.join('\n ')}` + ).toEqual([]); + }); + + it('브랜드를 선택해도 걸어둔 검색어와 정렬이 URL 에 남는다', async () => { + window.history.replaceState({}, '', '/admin/ecommerce/brands?search=나이키&sort=name_desc'); + + const action = collectNavActions( + readLayout('partials/admin_ecommerce_brand_index/_panel_brand_list.json') + ).find((a) => a.handler === 'replaceUrl' && normalizePath(a.params.path) === null); + expect(action, '브랜드 선택 replaceUrl 을 찾지 못했습니다.').toBeTruthy(); + + await runAction(action as NavAction); + const q = new URLSearchParams(window.location.search); + + expect(q.get('search')).toBe('나이키'); + expect(q.get('sort')).toBe('name_desc'); + expect(q.get('mode')).toBe('view'); + }); +}); + +describe('#75 주문자명 검색은 회원 지정 필터를 함께 비운다', () => { + /** + * `orderer_uuid`(회원 지정)와 `search_field=orderer_name`(이름 문자열)은 서로 배타적이다. + * `mergeQuery: true` 가 붙은 뒤로는 이전 화면의 `orderer_uuid` 가 살아남으므로, + * 이름 검색을 걸면 두 조건이 AND 로 겹쳐 결과가 비어 보인다. 이름 검색을 실행하는 + * 모든 leg 이 `orderer_uuid` 를 명시적으로 비워야 한다. + */ + const ORDERER_SEARCH_LEGS: Array<[string, string]> = [ + ['목록 데이터그리드', 'partials/admin_ecommerce_order_list/_partial_order_datagrid.json'], + ['주문 상세 주문자 카드', 'partials/admin_ecommerce_order_detail/_partial_order_info.json'], + ]; + + it.each(ORDERER_SEARCH_LEGS)('%s 의 주문자명 검색이 orderer_uuid 를 비운다', (_label, rel) => { + const actions = collectNavActions(readLayout(rel)).filter( + (a) => + a.params.query !== null && + typeof a.params.query === 'object' && + (a.params.query as Record).search_field === 'orderer_name' + ); + + expect(actions.length, '주문자명 검색 navigate 를 찾지 못했습니다.').toBeGreaterThan(0); + actions.forEach((a) => { + const query = a.params.query as Record; + expect(Object.keys(query)).toContain('orderer_uuid'); + expect(query.orderer_uuid).toBe(''); + }); + }); +}); + +describe('#75 주문 목록 ↔ 상세 왕복 leg 고정', () => { + it('주문 행 클릭이 필터/페이지를 상세로 나른다', () => { + const actions = collectNavActions( + readLayout('partials/admin_ecommerce_order_list/_partial_order_datagrid.json') + ).filter((a) => String(a.params.path ?? '').startsWith('/admin/ecommerce/orders/{{')); + + expect(actions.length).toBeGreaterThan(0); + actions.forEach((a) => expect(a.params.mergeQuery).toBe(true)); + }); + + it("주문 상세 '목록' 버튼이 필터/페이지를 복원한다", () => { + const a = collectNavActions(readLayout('admin_ecommerce_order_detail.json')).find( + (x) => x.params.path === '/admin/ecommerce/orders' + ); + expect(a, "주문 상세의 '목록' navigate 를 찾지 못했습니다.").toBeTruthy(); + expect(a?.params.mergeQuery).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 실행 검증 — 실 액션을 엔진에 태워 목적지 URL 을 확인한다 +// --------------------------------------------------------------------------- + +/** + * 실 JSON 액션을 현재 URL 상태에서 실행하고 목적지를 돌려줍니다. + * + * @param action 실 레이아웃 JSON 에서 뽑은 navigate 액션 + * @returns navigate 목적지 경로 + */ +async function runNav(action: NavAction): Promise { + return (await runAction(action)) as string; +} + +/** + * 실 JSON 액션을 현재 URL 상태에서 실행합니다. + * + * `navigate` 는 목적지가 navigation history 에 쌓이지만 `replaceUrl` 은 주소만 바꾸므로 + * 히스토리가 비어 있다. 결과 확인은 호출 측이 목적으로 나눠서 한다 — + * navigate 는 반환값, replaceUrl 은 `window.location`. + * + * @param action 실 레이아웃 JSON 에서 뽑은 navigate/replaceUrl 액션 + * @returns navigate 목적지 경로 (replaceUrl 이면 undefined) + */ +async function runAction(action: NavAction): Promise { + const utils = createLayoutTest( + { + version: '1.0.0', + layout_name: 'nav_action_probe', + data_sources: [], + components: [{ type: 'basic', name: 'Div', props: { 'data-testid': 'probe' } }], + } as never, + { componentRegistry: createMockComponentRegistryWithBasics() as never, locale: 'ko' } + ); + await utils.render(); + await utils.triggerAction({ type: 'click', ...action } as never); + const dest = utils.getNavigationHistory()[0]; + utils.cleanup(); + return dest; +} + +/** + * 목적지 URL 의 쿼리스트링을 파싱합니다. + * + * @param dest navigate 목적지 + * @returns URLSearchParams + */ +function queryOf(dest: string): URLSearchParams { + return new URLSearchParams(dest.includes('?') ? dest.slice(dest.indexOf('?')) : ''); +} + +describe('#75 주문 목록 ↔ 상세 왕복 — 실행 결과 URL', () => { + afterEach(() => { + window.history.replaceState({}, '', '/'); + }); + + /** 필터를 여러 개 건 목록 URL — 이 화면이 손실 폭이 가장 크다 */ + const LIST_URL = + '/admin/ecommerce/orders?page=2&per_page=20&search_field=orderer_name&search=홍길동&order_status=confirmed'; + + it('주문 행 클릭이 필터 전부를 상세 URL 로 나른다', async () => { + window.history.replaceState({}, '', LIST_URL); + + const action = collectNavActions( + readLayout('partials/admin_ecommerce_order_list/_partial_order_datagrid.json') + ).find((a) => String(a.params.path ?? '').startsWith('/admin/ecommerce/orders/{{')); + expect(action, '주문 행 클릭 navigate 를 찾지 못했습니다.').toBeTruthy(); + + const q = queryOf(await runNav(action as NavAction)); + + expect(q.get('page')).toBe('2'); + expect(q.get('per_page')).toBe('20'); + expect(q.get('search_field')).toBe('orderer_name'); + expect(q.get('search')).toBe('홍길동'); + expect(q.get('order_status')).toBe('confirmed'); + }); + + it("주문 상세 '목록' 버튼이 필터 전부를 목록으로 복원한다", async () => { + window.history.replaceState( + {}, + '', + '/admin/ecommerce/orders/1024?page=2&per_page=20&search=홍길동&order_status=confirmed' + ); + + const action = collectNavActions(readLayout('admin_ecommerce_order_detail.json')).find( + (x) => x.params.path === '/admin/ecommerce/orders' + ); + + const q = queryOf(await runNav(action as NavAction)); + + expect(q.get('page')).toBe('2'); + expect(q.get('per_page')).toBe('20'); + expect(q.get('search')).toBe('홍길동'); + expect(q.get('order_status')).toBe('confirmed'); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx new file mode 100644 index 00000000..705c72b3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx @@ -0,0 +1,44 @@ +/** + * 이커머스 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 + * + * @description + * 회귀 시나리오 (코어 admin 템플릿과 동일 구조): + * 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은 + * 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송. + * → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는 + * `.some(...) ? .map(...) : [...arr, {id...}]` 형태여야 한다. + * 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨. + * 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만 + * 노출이어야 하므로 `c.source === 'core'` 분기가 있어야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json'; + +const tabJson = JSON.stringify(tab); + +describe('이커머스 알림 채널 토글 저장 upsert', () => { + it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { + // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); + // 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴 + expect( + /\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson), + ).toBe(true); + }); + + it('map-only(기존 엔트리만 변형) 저장 표현식이 남아 있지 않다', () => { + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson), 'upsert(.some) 표현식이 없으면 회귀').toBe(true); + }); +}); + +describe('이커머스 하단 서브탭 필터 source 분기', () => { + it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => { + expect(/c\.source === 'core'/.test(tabJson)).toBe(true); + expect(/is_active !== false/.test(tabJson)).toBe(true); + expect(/is_active === true/.test(tabJson)).toBe(true); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx index 6aa2cfeb..649bc59f 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsOrder.test.tsx @@ -9,6 +9,9 @@ * - 폼 바인딩 및 핸들러 검증 * - 다국어 키 검증 * + * @scenario extension_payment_method method_kind=extension × capability_declared=declared × capability=pg_locked + * @effects admin_shows_pg_locked_badge, admin_hides_pg_select_for_locked, admin_shows_pg_select_for_unlocked + * * @vitest-environment node */ @@ -133,8 +136,19 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => { expect(tab.if).toContain('order_settings'); }); - it('8개 카드 섹션을 포함해야 한다 (기본 PG / 결제수단 / 계좌 / 자동취소 / 취소가능상태 / 확정가능상태 / 장바구니 / 재고)', () => { - expect(tab.children).toHaveLength(8); + it('카드 섹션이 정해진 순서로 배치된다', () => { + // 개수가 아니라 id 를 고정한다 — 카드가 추가·제거되면 어느 카드인지 바로 드러난다. + expect(tab.children.map((c: any) => c.id)).toEqual([ + 'default_pg_card', + 'cash_receipt_card', + 'payment_methods_card', + 'bank_accounts_card', + 'auto_cancel_card', + 'cancellable_statuses_card', + 'confirmable_statuses_card', + 'cart_expiry_card', + 'stock_management_card', + ]); }); }); @@ -245,13 +259,17 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => { expect(daysSection.if).toContain('auto_cancel_expired'); }); - it('자동취소일 Input 이 min=1, max=30 으로 정의되어야 한다 (위치 의존 제거)', () => { + // 경계값은 서버가 내려주는 한계값(_meta.limits)을 바인딩한다 — 화면이 리터럴을 들면 + // 저장 규칙이 바뀔 때 따라오지 못해 "화면은 받는데 저장에서 422" 가 된다. + it('자동취소일 Input 이 서버 한계값을 바인딩해야 한다 (위치 의존 제거)', () => { const input = findFirst(card, (n: any) => n?.name === 'Input' && n?.props?.name === 'order_settings.auto_cancel_days', ); expect(input).not.toBeNull(); - expect(input.props.min).toBe(1); - expect(input.props.max).toBe(30); + expect(String(input.props.min)).toContain('_meta?.limits?.auto_cancel_days_min'); + expect(String(input.props.max)).toContain('_meta?.limits?.auto_cancel_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 30'); }); it('입금기한 단일화: 구 vbank_due_days Input 이 더 이상 존재하지 않는다', () => { @@ -272,13 +290,15 @@ describe('주문설정 탭 구조 검증 (_tab_order_settings.json)', () => { describe('장바구니 유효기간 카드 구조', () => { const card = findById(tab, 'cart_expiry_card'); - it('cart_expiry_days Input이 min=1, max=365이어야 한다', () => { + it('cart_expiry_days Input 이 서버 한계값을 바인딩해야 한다', () => { const input = findFirst(card, (n: any) => n?.name === 'Input' && n?.props?.name === 'order_settings.cart_expiry_days', ); expect(input).not.toBeNull(); - expect(input.props.min).toBe(1); - expect(input.props.max).toBe(365); + expect(String(input.props.min)).toContain('_meta?.limits?.cart_expiry_days_min'); + expect(String(input.props.max)).toContain('_meta?.limits?.cart_expiry_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); }); @@ -409,6 +429,74 @@ describe('결제수단 Sortable 리스트 구조 검증 (_payment_methods_list.j expect(action.params['form.order_settings.payment_methods']).toContain('is_active'); }); + // ─── 필드 열 고정폭 (PG 셀렉트 폭 붕괴 회귀 차단) ─── + // 배경: Select 는 커스텀 드롭다운이라 className 이 내부 Button 에 붙는데, 컴포넌트 + // 기본 클래스의 w-full 이 CSS 출력 순서상 w-N 보다 뒤라 셀렉트에 직접 준 폭 토큰은 + // 무효가 된다(같은 특이도 → 후순위 승리). 그 결과 폭이 내용 폭을 따라가, PG 미선택 + // 상태('---')에서 74px 로 붕괴하고 선택값 길이에 따라 행마다 열 위치가 어긋났다. + // 폭은 열 컨테이너가 책임진다. + const FIELD_COLUMN_LABELS = [ + 'payment_methods.pg_provider', + 'payment_methods.stock_deduction_timing', + 'payment_methods.mileage_deduction_timing', + ]; + + /** 라벨 i18n 키로 필드 열 컨테이너를 찾는다. */ + const findFieldColumn = (labelKey: string) => + findFirst(tpl, (n: any) => + typeof n?.props?.className === 'string' + && n.props.className.includes('row-stack') + && Array.isArray(n?.children) + && n.children.some((c: any) => typeof c?.text === 'string' && c.text.includes(labelKey)), + ); + + it.each(FIELD_COLUMN_LABELS)('필드 열(%s)이 고정폭을 가져야 한다', (labelKey) => { + const column = findFieldColumn(labelKey); + expect(column).not.toBeNull(); + expect( + column.props.className, + `${labelKey} 열에 고정폭이 없으면 셀렉트 폭이 내용 폭을 따라가 미선택 상태에서 붕괴한다`, + ).toMatch(/\bw-\d+\b/); + }); + + it('세 필드 열이 동일한 폭 토큰을 사용해야 한다 (행 간 열 정렬)', () => { + const widths = FIELD_COLUMN_LABELS.map((labelKey) => { + const column = findFieldColumn(labelKey); + return (column.props.className.match(/\bw-\d+\b/) ?? [])[0]; + }); + expect(new Set(widths).size, `열 폭이 서로 다르면 행 간 열이 어긋난다: ${widths.join(', ')}`).toBe(1); + }); + + it('필드 열이 축소 가능해야 한다 (좁은 폭에서 행 넘침 차단)', () => { + // shrink-0 을 붙이면 고정폭 3열(총 432px)이 줄어들지 않아 좁은 뷰포트에서 + // 이름 열이 0 까지 붕괴하고도 행이 넘친다 (1100px 실측 17px 초과). + for (const labelKey of FIELD_COLUMN_LABELS) { + const column = findFieldColumn(labelKey); + expect(column.props.className, `${labelKey} 열`).not.toContain('shrink-0'); + } + }); + + it('필드 열의 Select 에는 폭 클래스를 주지 않아야 한다 (컴포넌트 기본 w-full 에 덮여 무효)', () => { + const selects = [ + 'pg_provider', + 'stock_deduction_timing', + 'mileage_deduction_timing', + ].map((field) => + findFirst(tpl, (n: any) => + n?.name === 'Select' && typeof n?.props?.value === 'string' + && n.props.value.includes(field), + ), + ); + + for (const select of selects) { + expect(select).not.toBeNull(); + expect( + select.props.className ?? '', + 'Select 에 준 w-N 은 컴포넌트 기본 w-full 에 덮여 무효 — 죽은 클래스가 폭을 지정한 것처럼 오해를 준다', + ).not.toMatch(/\bw-\d+\b/); + } + }); + it('고아 항목 삭제 버튼이 _orphaned 조건에서만 표시되어야 한다', () => { const deleteBtn = findFirst(tpl, (n: any) => n?.name === 'Button' && typeof n?.if === 'string' @@ -808,6 +896,91 @@ describe('마일리지 차감 시점 결제수단별 컨트롤', () => { }); }); +// ─── 고아 항목의 편집 컨트롤 차단 ─── + +describe('고아 결제수단 행의 편집 컨트롤 차단', () => { + // 고아 항목 = 저장값은 남아 있으나 공급 확장이 더 이상 제공하지 않는 결제수단 + // (플러그인 삭제·비활성, 또는 그 확장이 자기 기능 토글을 끈 경우). + // 이 행은 "지우세요" 만 제시해야 하므로 편집 컨트롤이 하나도 노출되면 안 된다. + // 삭제 버튼(Button)은 반대로 고아일 때만 나오는 것이 정상이라 대상에서 제외한다. + // + // 회귀 배경: 리스트 파셜의 마일리지 차감 시점 열만 가드가 빠져 있었다. 다른 열이 + // 사라지면서 그 열이 오른쪽으로 밀려 최소 주문금액 자리에 표시됐고, 마일리지 기능을 + // 켠 상점에서는 이미 사라진 결제수단의 값을 바꿔 저장할 수 있었다. 같은 화면의 + // 카드 파셜은 편집 영역을 하나의 가드 컨테이너로 감싸 정상 차단하고 있었다. + const EDIT_CONTROL_NAMES = ['Select', 'Input', 'Toggle', 'Checkbox', 'Textarea']; + + const ORPHAN_GUARD = '!$method._orphaned'; + + /** + * 편집 컨트롤마다 (조상 체인에 고아 가드가 있는가) 를 판정해 수집한다. + * + * 가드가 어느 깊이에 걸려 있든 통과시킨다 — 리스트는 열 컨테이너마다, 카드는 + * 편집 영역 전체를 감싼 컨테이너 한 곳에 걸려 있어 구조가 서로 다르기 때문이다. + * 열 목록을 손으로 열거하지 않으므로 이후 추가되는 열도 자동으로 검사 대상이 된다. + */ + function collectEditControls(node: any, guarded = false, acc: any[] = []): any[] { + if (!node || typeof node !== 'object') return acc; + + const nowGuarded = guarded + || (typeof node.if === 'string' && node.if.includes(ORPHAN_GUARD)); + + if (EDIT_CONTROL_NAMES.includes(node.name)) { + acc.push({ node, guarded: nowGuarded }); + } + + for (const child of node.children ?? []) { + collectEditControls(child, nowGuarded, acc); + } + if (node.itemTemplate) { + collectEditControls(node.itemTemplate, nowGuarded, acc); + } + return acc; + } + + /** 컨트롤을 사람이 읽을 수 있게 식별한다 (실패 메시지용). */ + function describeControl(entry: any): string { + const value = entry.node.props?.value ?? entry.node.props?.checked ?? ''; + return `${entry.node.name}(${String(value).slice(0, 60) || 'no-value'})`; + } + + it.each([ + ['리스트 파셜', paymentMethodsList], + ['카드 파셜', paymentMethodsCards], + ])('%s — 모든 편집 컨트롤이 고아 가드 아래에 있어야 한다', (_label, partial) => { + const controls = collectEditControls(partial); + + // 컨트롤을 하나도 못 찾았다면 탐색이 실패한 것이다 (부재 단언의 거짓 통과 차단) + expect(controls.length).toBeGreaterThan(0); + + const unguarded = controls.filter((c) => !c.guarded).map(describeControl); + expect( + unguarded, + `고아 행에 편집 컨트롤이 노출된다: ${unguarded.join(', ')} — 조상 체인 어딘가에 if "${ORPHAN_GUARD}" 가 필요하다`, + ).toEqual([]); + }); + + it('두 파셜이 같은 수의 편집 컨트롤을 가드해야 한다 (표시 방식 간 동작 일치)', () => { + const listGuarded = collectEditControls(paymentMethodsList).filter((c) => c.guarded); + const cardsGuarded = collectEditControls(paymentMethodsCards).filter((c) => c.guarded); + + // 넓은 화면(리스트)과 좁은 화면(카드)은 같은 데이터를 그린다. 한쪽만 가드가 빠지면 + // 창 너비에 따라 편집 가능 여부가 달라진다. + expect(listGuarded.length).toBeGreaterThan(0); + expect(cardsGuarded.length).toBeGreaterThan(0); + }); + + it('삭제 버튼은 고아일 때만 나오므로 편집 컨트롤 가드 대상이 아니다', () => { + const deleteBtn = findFirst(paymentMethodsList, (n: any) => + n?.name === 'Button' && typeof n?.if === 'string' + && n.if.includes('$method._orphaned') + && !n.if.includes(ORPHAN_GUARD), + ); + expect(deleteBtn).not.toBeNull(); + expect(EDIT_CONTROL_NAMES).not.toContain('Button'); + }); +}); + // ─── 다국어 키 종합 검증 ─── describe('다국어 키 종합 검증', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx index c300a70a..7f4fb022 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsReview.test.tsx @@ -164,12 +164,14 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { ); }); - it('Input이 type=number, min=1, max=365여야 한다', () => { + it('Input이 type=number 이고 허용 범위가 서버 한계값을 바인딩해야 한다', () => { const input = writeDeadlineSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('365'); + expect(String(input.props.min)).toContain('limits?.write_deadline_days_min'); + expect(String(input.props.max)).toContain('limits?.write_deadline_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); it('Input 기본값이 30을 참조해야 한다', () => { @@ -224,12 +226,16 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { // 구분선(Div, index 1) 다음 max_images 항목(index 2) const maxImagesSection = settingsArea.children[2]; - it('Input이 type=number, min=0, max=10여야 한다', () => { + // 경계값은 서버 검증(StoreEcommerceSettingsRequest)이 SSoT — 화면이 더 좁으면 + // 사용자가 저장 가능한 값을 입력조차 못 하고, 더 넓으면 저장 단계에서만 실패한다. + it('Input이 type=number 이고 허용 범위가 서버 한계값을 바인딩해야 한다 (max_images)', () => { const input = maxImagesSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('0'); - expect(String(input.props.max)).toBe('10'); + expect(String(input.props.min)).toContain('limits?.max_images_min'); + expect(String(input.props.max)).toContain('limits?.max_images_max'); + expect(String(input.props.min)).toContain('?? 0'); + expect(String(input.props.max)).toContain('?? 20'); }); it('Input 기본값이 5를 참조해야 한다', () => { @@ -264,13 +270,17 @@ describe('리뷰설정 탭 구조 검증 (_tab_review_settings.json)', () => { // 구분선(index 3) 다음 max_image_size_mb 항목(index 4) const maxSizeSection = settingsArea.children[4]; - it('Input이 type=number, min=1, max=20, step=0.5여야 한다', () => { + // 서버 규칙이 integer 이므로 step 은 1 이어야 한다 — 0.5 는 화면에서만 허용되고 + // 저장 단계에서 422 가 되는 값이다. + it('Input이 type=number, step=1 이고 허용 범위가 서버 한계값을 바인딩해야 한다', () => { const input = maxSizeSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); expect(input.props.type).toBe('number'); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('20'); - expect(String(input.props.step)).toBe('0.5'); + expect(String(input.props.min)).toContain('limits?.max_image_size_mb_min'); + expect(String(input.props.max)).toContain('limits?.max_image_size_mb_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 50'); + expect(String(input.props.step)).toBe('1'); }); it('Input 기본값이 5를 참조해야 한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx index 121d99c6..3d41d1d4 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_mileage_transaction_index.test.tsx @@ -291,9 +291,11 @@ describe('DataGrid (_transactions_table.json)', () => { expect(grid.props.data).toContain('transactions?.data?.data'); }); - it('컬럼 8종(no/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => { + it('컬럼 9종(번호/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => { + // 첫 컬럼 field 는 `number` 다 (표시 헤더는 "NO"). 종전 `no` 표기는 레이아웃에서 + // 이미 바뀌었는데 이 단언만 남아 있던 stale 케이스였다. const fields = grid.props.columns.map((c: any) => c.field); - expect(fields).toEqual(expect.arrayContaining(['no', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by'])); + expect(fields).toEqual(expect.arrayContaining(['number', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by'])); }); it('유효기간 컬럼이 expiry_state 기준 소멸(일부/전체)·만료예정·무기한·없음을 렌더한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx index c896ec5b..19c20c4d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx @@ -247,19 +247,34 @@ describe('통화 규칙 테이블 (_tab_mileage_currency_table.json)', () => { expect(percentInput.props.disabled).toContain("rule.max_use_type !== 'percent'"); }); - it('max_use_type 라디오는 부모 Label 클릭으로 상태를 바꾼다 (게시판 검증 패턴 — 라디오 자동바인딩 회피)', () => { + it('max_use_type 라디오는 라디오 자신과 부모 Label 양쪽에서 상태를 바꾼다 (키보드 조작 가능)', () => { // 회귀: 라디오 자동바인딩은 문자열 값을 value 바인딩으로 처리해 라디오 그룹을 깨뜨린다. - // 게시판 date_display_format 의 검증된 패턴: 라디오는 pointer-events-none(시각 전용), - // 부모 Label 에 click 액션을 달아 해당 행(rule._idx)의 max_use_type 을 배열 map 교체로 갱신. + // 그래서 자동바인딩 대신 명시적 setState 로 해당 행(rule._idx)의 max_use_type 을 + // 배열 map 교체로 갱신한다. + // + // 종전에는 라디오를 `pointer-events-none`(시각 전용)으로 두고 부모 Label click 만 처리했으나, + // 그 형태는 키보드(Tab → Space/방향키)로 값을 바꿀 수 없어 #493 에서 되돌렸다. 지금 계약은 + // **라디오 자신의 change + 부모 Label 의 click 둘 다** 같은 setState 를 수행하는 것이다 + // (같은 값을 쓰므로 이벤트가 겹쳐 두 번 실행돼도 결과가 같다). const radios = findAll(currencyTable, (n) => n.name === 'Input' && n.props?.type === 'radio' && String(n.props?.name).includes('max_use_type')); // currency_rules 행(2) + 인라인 추가 행(2) = 4 expect(radios.length).toBe(4); for (const radio of radios) { - // 라디오는 클릭 이벤트를 받지 않는다 (부모 Label 이 처리) - expect(radio.props.className, `radio(${radio.props.value}) 에 pointer-events-none 누락`).toContain('pointer-events-none'); - // 라디오 자체에는 actions 가 없다 (자동바인딩/onChange 미사용) - expect(radio.actions ?? []).toHaveLength(0); + // 라디오가 포인터 이벤트를 막으면 키보드 포커스 이동 후 조작도 함께 막힌다 + expect( + String(radio.props.className ?? ''), + `radio(${radio.props.value}) 가 pointer-events-none 이면 키보드로 값을 바꿀 수 없다`, + ).not.toContain('pointer-events-none'); + + // 라디오 자신이 change 로 값을 바꾼다 (자동바인딩이 아니라 명시적 setState) + const changeActions = (radio.actions ?? []).filter((a: any) => a.type === 'change'); + expect(changeActions.length, `radio(${radio.props.value}) 에 change 액션 누락`).toBe(1); + expect(changeActions[0].handler).toBe('setState'); + // Label click 과 **동일한 params** 여야 한다 — 대상 저장소(local)와 갱신 키가 갈리면 + // 마우스로 고른 값과 키보드로 고른 값이 서로 다른 곳에 쓰인다 (#493) + expect(changeActions[0].params?.target).toBe('local'); + expect(JSON.stringify(changeActions[0].params ?? {})).toContain('max_use_type'); } // 부모 Label 이 click → setState 로 max_use_type 갱신 @@ -349,6 +364,67 @@ describe('통화 규칙 모바일 카드 (_tab_mileage_currency_cards.json)', () }); }); +describe('적립 절사 기준 (earn_rounding_unit / earn_rounding_method)', () => { + const 절사_컨트롤 = (root: any, 접두: string) => ({ + unit: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_unit`)), + method: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_method`)), + }); + + it('테이블 행에 단위/방식 Select 가 통화 규칙 인덱스로 바인딩된다', () => { + const { unit, method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + // 값이 없는 기존 행도 종전 동작(1점 버림)으로 보이도록 폴백을 둔다 + expect(unit.props.value).toContain("?? '1'"); + expect(method.props.value).toContain("?? 'floor'"); + }); + + it('절사 방식 옵션은 서버 허용 어휘(floor/round/ceil)와 일치한다', () => { + const { method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + + // 화면 옵션이 서버 게이트를 벗어나면 저장이 422 로 막히는데 화면에는 선택지로 보인다 + expect(method.props.options.map((o: any) => o.value)).toEqual(['floor', 'round', 'ceil']); + }); + + it('절사 단위 옵션은 정수 포인트 단위만 제공한다 (통화 환산 절사의 소수 단위와 다름)', () => { + const { unit } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '."); + const opts = String(unit.props.options); + + expect(opts).toContain("value: '1'"); + expect(opts).toContain("value: '10'"); + expect(opts).toContain("value: '100'"); + // 마일리지는 원장에 정수로 확정된다 — 0.01 같은 소수 단위는 선택지가 될 수 없다 + expect(opts).not.toContain("value: '0.01'"); + }); + + it('모바일 카드 뷰에도 동일 항목이 있다 (한쪽만 있으면 모바일에서 설정 불가)', () => { + const { unit, method } = 절사_컨트롤(currencyCards, "mileage.currency_rules.' + rule._idx + '."); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + }); + + it.each([ + ['테이블', currencyTable, 'new_mileage_currency_row'], + ['모바일 카드', currencyCards, 'new_mileage_currency_card'], + ])('%s 의 신규 통화 추가 행에도 절사 컨트롤이 있고 저장 payload 에 포함된다', (_label, root, rowId) => { + const newRow = findById(root, rowId); + const { unit, method } = 절사_컨트롤(newRow, 'newMileageCurrency.'); + + expect(unit).toBeTruthy(); + expect(method).toBeTruthy(); + + const seq = findFirst(newRow, (n) => n.handler === 'sequence'); + const actions = seq.actions ?? seq.params?.actions ?? []; + const push = actions.find((a: any) => a.handler === 'setState' && a.params?.['form.mileage.currency_rules']); + + // 컨트롤만 두고 payload 에 빠뜨리면 입력해도 저장되지 않는다 + expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_unit:'); + expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_method:'); + }); +}); + describe('유효기간 / 소멸 알림 카드', () => { it('expiry_enabled / expiry_notification_enabled 토글 바인딩', () => { const expiryToggle = findFirst(expiryCard, (n) => n.name === 'Toggle' && n.props?.name === 'mileage.expiry_enabled'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx index a0b389cd..fb8e5827 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_review_tab.test.tsx @@ -161,31 +161,39 @@ describe('리뷰 설정 탭 (_tab_review_settings.json) 렌더링', () => { expect(cardBody.children.length).toBeGreaterThanOrEqual(3); }); - it('write_deadline_days Input이 min=1, max=365다', () => { + it('write_deadline_days Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const deadlineSection = cardBody.children[0]; const input = deadlineSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('365'); + expect(String(input.props.min)).toContain('limits?.write_deadline_days_min'); + expect(String(input.props.max)).toContain('limits?.write_deadline_days_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 365'); }); - it('max_images Input이 min=0, max=10이다', () => { + // 경계값은 서버 검증(StoreEcommerceSettingsRequest)이 SSoT — 화면이 더 좁으면 저장 가능한 + // 값을 입력조차 못 하고, 더 넓으면 저장 단계에서만 실패한다. + it('max_images Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const maxImagesSection = cardBody.children[2]; // 구분선(index 1) 건너뜀 const input = maxImagesSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('0'); - expect(String(input.props.max)).toBe('10'); + expect(String(input.props.min)).toContain('limits?.max_images_min'); + expect(String(input.props.max)).toContain('limits?.max_images_max'); + expect(String(input.props.min)).toContain('?? 0'); + expect(String(input.props.max)).toContain('?? 20'); }); - it('max_image_size_mb Input이 min=1, max=20이다', () => { + it('max_image_size_mb Input 범위가 서버 한계값을 바인딩한다', () => { const cardBody = (tabReviewSettings as any).children[0].children[2]; const maxSizeSection = cardBody.children[4]; // 구분선(index 3) 건너뜀 const input = maxSizeSection.children.find((c: any) => c.name === 'Input'); expect(input).toBeDefined(); - expect(String(input.props.min)).toBe('1'); - expect(String(input.props.max)).toBe('20'); + expect(String(input.props.min)).toContain('limits?.max_image_size_mb_min'); + expect(String(input.props.max)).toContain('limits?.max_image_size_mb_max'); + expect(String(input.props.min)).toContain('?? 1'); + expect(String(input.props.max)).toContain('?? 50'); }); it('모든 Input의 onChange가 setState + hasChanges: true를 사용한다', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cartWishlistImageConsumers.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cartWishlistImageConsumers.test.ts new file mode 100644 index 00000000..92fc6054 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cartWishlistImageConsumers.test.ts @@ -0,0 +1,93 @@ +/** + * @file cartWishlistImageConsumers.test.ts + * @description 카트·찜목록 — 상품 이미지 소비 경로 고정 (#518 / 공개 #76) + * + * 배경: + * 카트·찜목록 조회가 `product.images` 를 전량 hydration 하고 있었다. 두 화면 모두 상품 + * 썸네일 1장만 그리므로 **컬럼을 좁혀** 로드하도록 바꿨다(`images:id,product_id,hash, + * is_thumbnail,sort_order`). 응답에는 애초에 배열이 직렬화되지 않으므로 **응답 형태는 불변**이고 + * DB/메모리만 줄어든다. + * + * 이 방식이 안전한 근거는 하나다 — 화면이 배열을 읽지 않는다는 것. 그래서 그 사실을 고정한다. + * 누군가 화면에 `product.images` 를 쓰기 시작하면 좁혀둔 컬럼으로는 부족해질 수 있고, 그때는 + * 이 테스트가 먼저 깨져 알려준다. + * + * 반대로 썸네일 경로(`thumbnail_url`)는 반드시 살아 있어야 한다. 서버는 이 값을 좁혀 읽은 + * 컬럼(`hash`/`is_thumbnail`/`sort_order`)으로 조립하므로, 그 컬럼 집합을 더 줄이면 대표 + * 미지정 상품의 폴백이 깨진다. + * + * 마킹은 아래 describe 블록마다 따로 붙인다 — 한 docblock 에서는 @scenario 가 첫 줄만 읽혀, + * 여기에 두 화면을 나열하면 뒤엣것이 조용히 누락된다. + */ + +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +const REPO_ROOT = path.resolve(__dirname, '../../../../../../..'); + +const CART_ITEM = path.join( + REPO_ROOT, + 'templates/_bundled/sirsoft-basic/layouts/partials/shop/_cart_item.json', +); +const WISHLIST_LIST = path.join( + REPO_ROOT, + 'templates/_bundled/sirsoft-basic/layouts/partials/mypage/wishlist/_list.json', +); +const PRODUCT_CARD = path.join( + REPO_ROOT, + 'templates/_bundled/sirsoft-basic/src/components/composite/ProductCard.tsx', +); + +/** + * 파일 원문을 읽습니다. + * + * @param filePath 대상 파일 절대 경로 + * @returns 파일 원문 + */ +function readRaw(filePath: string): string { + return fs.readFileSync(filePath, 'utf-8'); +} + +/** + * @scenario surface=cart,observation=consumer_screen + * @effects cart_and_wishlist_consume_thumbnail_only + */ +describe('카트 — 상품 이미지 소비 (#518 / 공개 #76)', () => { + it('썸네일 1장만 그린다 (이미지 배열을 순회하지 않는다)', () => { + const raw = readRaw(CART_ITEM); + + expect(raw, '카트 항목이 상품 썸네일을 그려야 한다').toContain( + 'item.product.thumbnail_url', + ); + + // 배열을 읽기 시작하면 좁혀둔 컬럼으로 부족해질 수 있다. + expect(raw).not.toContain('item.product.images'); + }); +}); + +/** + * @scenario surface=wishlist,observation=consumer_screen + * @effects cart_and_wishlist_consume_thumbnail_only + */ +describe('찜목록 — 상품 이미지 소비 (#518 / 공개 #76)', () => { + it('상품 카드에 상품 객체를 넘기고, 배열을 직접 읽지 않는다', () => { + const raw = readRaw(WISHLIST_LIST); + + expect(raw, '찜목록이 상품 카드로 렌더한다').toContain('"product": "{{item.product}}"'); + expect(raw).not.toContain('item.product.images'); + }); + + it('상품 카드가 썸네일 필드로 이미지를 그린다', () => { + const raw = readRaw(PRODUCT_CARD); + + // 찜목록의 이미지는 이 컴포넌트가 그린다 — 레이아웃만 보면 소비 경로가 보이지 않는다. + expect(raw).toContain('product.thumbnail_url'); + }); + + it('상품 카드가 이미지 배열을 쓰지 않는다', () => { + const raw = readRaw(PRODUCT_CARD); + + expect(raw).not.toMatch(/product\.images\b/); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptResponsiveRender.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptResponsiveRender.test.tsx new file mode 100644 index 00000000..36e9d84a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptResponsiveRender.test.tsx @@ -0,0 +1,215 @@ +/** + * @file cashReceiptResponsiveRender.test.tsx + * @description 체크아웃 현금영수증 폼의 반응형 오버라이드 실렌더 검증 (#454 S3, F5) + * + * cashReceiptUi.test.tsx 는 node 환경(렌더 없음)이라 JSON 구조만 대조한다. + * 그래서 "responsive.portable 이 선언되어 있다" 까지만 알 수 있고, + * "좁은 화면에서 실제로 세로 스택으로 렌더된다" 는 확인하지 못한다. + * + * 본 파일은 DynamicRenderer 를 실제로 렌더해 그 간극을 메운다: + * - DynamicRenderer 는 useResponsive().width 를 읽어 responsiveManager.getMatchingKey 로 + * 오버라이드 키를 고른다(DynamicRenderer.tsx:1413, 1445). 그 width 를 mock 으로 주입한다. + * - portable 은 0~1023px 단일 오버라이드다(§6-0). 1024px 이상에서는 base 가 그대로 남아야 한다. + * + * 회귀 방지 대상: 누군가 responsive.portable 을 지우고 Tailwind `md:` 로 되돌리면 + * 위지윅 편집기의 디바이스 전환(overrideWidth)이 먹지 않는다 — 그때 이 테스트가 깨진다. + * + * 주의: 이 docblock 에 vitest 환경 지시자 토큰을 문자열로 적지 말 것. + * vitest 는 파일 상단 주석을 정규식으로 훑어 환경을 결정하므로, 산문 속 언급도 + * 그대로 적용되어 jsdom 대신 node 로 실행된다(그러면 window 부재로 수집 단계에서 죽는다). + * + * @scenario actor=guest, change_mode=manual + * + * @effects portable_override_applies_below_1024_on_render, + * portable_override_absent_at_1024_on_render, + * new_ui_has_no_tailwind_breakpoint + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; +import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +import extensionJson from '../../../extensions/checkout_cash_receipt.json'; + +/** 현재 테스트가 주입할 화면 너비 (아래 mock 이 읽는다) */ +let mockWidth = 1280; + +vi.mock('@core/template-engine/ResponsiveContext', () => ({ + ResponsiveContext: React.createContext(null), + ResponsiveProvider: ({ children }: { children?: React.ReactNode }) => children, + useResponsive: () => ({ + width: mockWidth, + isMobile: mockWidth < 768, + isTablet: mockWidth >= 768 && mockWidth < 1024, + isDesktop: mockWidth >= 1024, + matchedPreset: (mockWidth < 768 ? 'mobile' : mockWidth < 1024 ? 'tablet' : 'desktop') as + | 'mobile' + | 'tablet' + | 'desktop', + }), +})); + +/** id·className 을 그대로 DOM 에 흘리는 스텁 (검사 대상이 그 두 속성이다) */ +const Stub: React.FC> = ({ children, className, id, text }) => ( +
+ {children ?? text} +
+); + +/** + * ComponentRegistry 싱글톤을 스텁으로 채운다. + * + * Fragment 는 createLayoutTest 가 루트 컨테이너로 쓴다 — 빠뜨리면 자식 트리가 + * 통째로 렌더되지 않고 빈 컨테이너만 남는다. + */ +function setupTestRegistry(): void { + const registry = ComponentRegistry.getInstance(); + + (registry as any).registry = { + Div: { component: Stub, metadata: { name: 'Div', type: 'basic' } }, + Span: { component: Stub, metadata: { name: 'Span', type: 'basic' } }, + P: { component: Stub, metadata: { name: 'P', type: 'basic' } }, + Label: { component: Stub, metadata: { name: 'Label', type: 'basic' } }, + Input: { component: Stub, metadata: { name: 'Input', type: 'basic' } }, + Select: { component: Stub, metadata: { name: 'Select', type: 'basic' } }, + Fragment: { + component: ({ children }: { children?: React.ReactNode }) => <>{children}, + metadata: { name: 'Fragment', type: 'layout' }, + }, + }; +} + +/** 슬롯 컴포넌트 트리를 독립 레이아웃으로 감싼다 (확장 JSON 은 extension_point 채움용이다) */ +function layoutWithSlotComponents() { + return { + version: '1.0', + layout_name: 'test_checkout_cash_receipt', + data_sources: [], + components: JSON.parse(JSON.stringify((extensionJson as any).components)), + }; +} + +/** + * 루트 gate 가 읽는 paymentSettings 응답. + * + * 호스트 레이아웃(shop/checkout.json)이 선언하는 데이터소스라 슬롯 JSON 에는 없다. + * 반응형 검증이 목적이므로 fetch 를 태우지 않고 initialData 로 직접 주입한다. + */ +const PAYMENT_SETTINGS = { + data: { order_settings: { cash_receipt_provider: 'tosspayments' } }, +}; + +/** + * 신청 폼이 펼쳐진 상태로 렌더한다. + * + * 두 개의 gate 를 모두 열어야 검사 대상 노드가 마운트된다: + * - 루트: paymentSettings.data.order_settings.cash_receipt_provider 가 truthy + * - 입력 영역: _local.cashReceiptRequested 가 truthy + * + * @param width 주입할 화면 너비 (px) + */ +async function renderAtWidth(width: number) { + mockWidth = width; + setupTestRegistry(); + + const testUtils = createLayoutTest(layoutWithSlotComponents() as any, { + initialData: { paymentSettings: PAYMENT_SETTINGS }, + initialState: { + _local: { + paymentMethod: 'dbank', + cashReceiptRequested: true, + cashReceiptType: 'income', + cashReceiptIdentifierType: 'phone', + }, + }, + }); + + await testUtils.render(); + + // 두 gate 가 모두 열렸는지 확인한다. 트리가 비면 아래 검사들이 조용히 통과해 버린다. + expect( + document.getElementById('ext_checkout_cash_receipt_fields'), + '신청 폼이 펼쳐지지 않았다 — gate 조건이 바뀌었는지 확인할 것' + ).not.toBeNull(); + + return testUtils; +} + +/** + * 렌더된 노드의 클래스 목록을 반환한다. + * + * @param id 레이아웃 JSON 에 선언된 노드 id + * @return 클래스 토큰 배열 + */ +const classesOf = (id: string): string[] => { + const el = document.getElementById(id); + expect(el, `#${id} 가 렌더되지 않았다`).not.toBeNull(); + + return (el!.getAttribute('class') ?? '').split(/\s+/).filter(Boolean); +}; + +describe('현금영수증 신청 폼 — 반응형 실렌더 (F5)', () => { + beforeEach(() => { + mockWidth = 1280; + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('데스크톱(1280px)에서는 용도 라디오가 가로 배치된다', async () => { + const t = await renderAtWidth(1280); + + const cls = classesOf('ext_checkout_cash_receipt_purpose'); + expect(cls).toContain('flex-row'); + expect(cls).not.toContain('flex-col'); + + t.cleanup(); + }); + + it('모바일(375px)에서는 용도 라디오가 세로 스택으로 바뀐다', async () => { + const t = await renderAtWidth(375); + + const cls = classesOf('ext_checkout_cash_receipt_purpose'); + expect(cls).toContain('flex-col'); + expect(cls).not.toContain('flex-row'); + + t.cleanup(); + }); + + it('모바일에서 발급수단·번호 입력행이 1열로 접힌다', async () => { + const t = await renderAtWidth(375); + + const cls = classesOf('ext_checkout_cash_receipt_identifier_row'); + expect(cls).toContain('grid-cols-1'); + expect(cls).not.toContain('grid-cols-2'); + + t.cleanup(); + }); + + it('태블릿(1023px)까지 portable 이 적용되고 1024px 부터 base 로 돌아온다', async () => { + // portable 은 0~1023px 단일 오버라이드 — 경계값을 양쪽에서 고정한다. + const tablet = await renderAtWidth(1023); + expect(classesOf('ext_checkout_cash_receipt_purpose')).toContain('flex-col'); + tablet.cleanup(); + cleanup(); + + const desktop = await renderAtWidth(1024); + expect(classesOf('ext_checkout_cash_receipt_purpose')).toContain('flex-row'); + desktop.cleanup(); + }); + + it('어떤 너비에서도 Tailwind 브레이크포인트 접두사를 쓰지 않는다 (§6-0)', async () => { + const t = await renderAtWidth(375); + + const rendered = document.body.innerHTML; + expect(rendered).not.toMatch(/\bmd:/); + expect(rendered).not.toMatch(/\blg:/); + expect(rendered).not.toMatch(/\bsm:/); + + t.cleanup(); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptUi.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptUi.test.tsx new file mode 100644 index 00000000..bd2f5bbe --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/cashReceiptUi.test.tsx @@ -0,0 +1,588 @@ +/** + * 현금영수증 UI 구조·조건 검증 (#454 S3) + * + * @description + * 다섯 개 표면을 한 곳에서 고정한다: + * - W-1 체크아웃 신청 폼 (extension_point 주입) — 확장 병합 칸(_local.checkoutExtraPayload) 기입 + * - W-2 유저 주문상세 카드 (extension_point 주입) — 상태머신 5종 + * - W-3 관리자 주문상세 카드 + 발급 모달 + * - W-4 취소 모달 환불계좌 + * - W-5 환경설정 (프로바이더 / 배송비 과세 / 자진발급) + * + * 조건식은 문자열 매칭이 아니라 실제 엔진(evaluateStringCondition)으로 평가한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; +import { DataBindingEngine } from '@core/template-engine/DataBindingEngine'; +import { evaluateStringCondition } from '@core/template-engine/helpers/ConditionEvaluator'; + +import checkoutExt from '../../../extensions/checkout_cash_receipt.json'; +import mypageExt from '../../../extensions/mypage_order_cash_receipt.json'; +import paymentInfo from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_partial_payment_info.json'; +import issueModal from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json'; +import cancelModal from '../../../layouts/admin/partials/admin_ecommerce_order_detail/_modal_cancel_order.json'; +import orderSettingsTab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json'; + +const engine = new DataBindingEngine(); +const evalIf = (expr: string, ctx: Record) => evaluateStringCondition(expr, ctx, engine); + +/** 트리 전체를 평탄화 (children 뿐 아니라 모든 객체 값을 순회) */ +function flatten(node: any, acc: any[] = []): any[] { + if (!node || typeof node !== 'object') return acc; + acc.push(node); + for (const v of Object.values(node)) { + if (Array.isArray(v)) v.forEach((x) => flatten(x, acc)); + else if (v && typeof v === 'object') flatten(v, acc); + } + return acc; +} + +const byId = (root: any, id: string) => flatten(root).find((n) => n.id === id); + +/** + * 결제행 iteration 안의 노드를 id 로 찾는다. + * + * iteration 안에서는 같은 id 가 행마다 중복되므로 index_var(payIdx)로 행마다 고유해야 한다 + * (audit `layout-iteration-static-id`). 조회 키에도 접미사를 붙여 그 규약을 함께 고정한다 — + * 누군가 id 를 정적으로 되돌리면 이 헬퍼가 찾지 못해 테스트가 깨진다. + */ +const byRowId = (root: any, id: string) => byId(root, `${id}_{{payIdx}}`); + +// ------------------------------------------------------------------ W-1 체크아웃 + +describe('W-1 체크아웃 현금영수증 신청 폼', () => { + const root = byId(checkoutExt, 'ext_checkout_cash_receipt'); + + it('올바른 확장점에 append 로 주입한다', () => { + expect(checkoutExt.extension_point).toBe('shop_checkout_cash_receipt_slot'); + expect(checkoutExt.mode).toBe('append'); + }); + + it('프로바이더 미설정이면 슬롯 전체가 렌더되지 않는다 (M2)', () => { + const ctx = (provider: string) => ({ paymentSettings: { data: { order_settings: { cash_receipt_provider: provider } } } }); + + expect(evalIf(root.if, ctx('tosspayments'))).toBe(true); + expect(evalIf(root.if, ctx(''))).toBe(false); + expect(evalIf(root.if, { paymentSettings: { data: { order_settings: {} } } })).toBe(false); + }); + + it('신청 전에는 하위 필드가 렌더되지 않는다 (M4: 0 → 3)', () => { + const fields = byId(checkoutExt, 'ext_checkout_cash_receipt_fields'); + + expect(evalIf(fields.if, { _local: {} })).toBe(false); + expect(evalIf(fields.if, { _local: { cashReceiptRequested: true } })).toBe(true); + }); + + it('신청 체크박스는 확장 병합 칸에 4키를 기입한다 (직접 _local 기입은 서버 미도달)', () => { + const toggle = byId(checkoutExt, 'ext_checkout_cash_receipt_toggle'); + const params = toggle.actions[0].params; + + expect(params.checkoutExtraPayload).toBeDefined(); + + // 체크 시: 4키 편입 + const on = engine.evaluateExpression( + String(params.checkoutExtraPayload).slice(2, -2), + { $event: { target: { checked: true } }, _local: {} } + ); + expect(on).toEqual({ + cash_receipt_requested: true, + cash_receipt_type: 'income', + cash_receipt_identifier_type: 'phone', + cash_receipt_identifier: '', + }); + + // 해제 시: 빈 객체 → 주문 payload 에서 사라진다 + const off = engine.evaluateExpression( + String(params.checkoutExtraPayload).slice(2, -2), + { $event: { target: { checked: false } }, _local: {} } + ); + expect(off).toEqual({}); + }); + + it('소득공제로 전환하면 사업자등록번호 선택이 휴대폰번호로 리셋된다 (M5)', () => { + const purpose = byId(checkoutExt, 'ext_checkout_cash_receipt_purpose'); + const incomeRadio = flatten(purpose).find((n) => n.props?.value === 'income'); + const params = incomeRadio.actions[0].params; + + const ctx = { _local: { cashReceiptIdentifierType: 'business', cashReceiptIdentifier: '1234567890' } }; + const nextType = engine.evaluateExpression(String(params.cashReceiptIdentifierType).slice(2, -2), ctx); + const nextId = engine.evaluateExpression(String(params.cashReceiptIdentifier).slice(2, -2), ctx); + + expect(nextType).toBe('phone'); + expect(nextId).toBe(''); + }); + + it('지출증빙 전환 시에는 휴대폰번호 선택을 유지한다 (D10 — 휴대폰은 양쪽 유효)', () => { + const purpose = byId(checkoutExt, 'ext_checkout_cash_receipt_purpose'); + const expenseRadio = flatten(purpose).find((n) => n.props?.value === 'expense'); + const payload = engine.evaluateExpression( + String(expenseRadio.actions[0].params.checkoutExtraPayload).slice(2, -2), + { _local: { cashReceiptIdentifierType: 'phone', cashReceiptIdentifier: '01012345678' } } + ); + + expect(payload.cash_receipt_type).toBe('expense'); + expect(payload.cash_receipt_identifier_type).toBe('phone'); + expect(payload.cash_receipt_identifier).toBe('01012345678'); + }); + + it('발급수단 options 는 용도에 따라 2종 / 3종이다', () => { + const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type'); + const expr = String(select.props.options).slice(2, -2); + const t = (k: string) => k; + + const income = engine.evaluateExpression(expr, { _local: { cashReceiptType: 'income' }, $t: t }); + const expense = engine.evaluateExpression(expr, { _local: { cashReceiptType: 'expense' }, $t: t }); + + expect(income.map((o: any) => o.value)).toEqual(['phone', 'card']); + expect(expense.map((o: any) => o.value)).toEqual(['business', 'phone', 'card']); + }); + + it('발급수단을 바꾸면 번호를 비운다 (형식 불일치로 인한 422 방지)', () => { + const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type'); + expect(select.actions[0].params.cashReceiptIdentifier).toBe(''); + }); + + it('Select 는 valueKey/labelKey prop 을 쓰지 않는다', () => { + const select = byId(checkoutExt, 'ext_checkout_cash_receipt_identifier_type'); + expect(select.props.valueKey).toBeUndefined(); + expect(select.props.labelKey).toBeUndefined(); + }); +}); + +// ------------------------------------------------------------------ W-2 유저 주문상세 + +describe('W-2 유저 주문상세 현금영수증 카드 — 상태머신 5종', () => { + const card = byId(mypageExt, 'ext_mypage_cash_receipt_card'); + + const payment = (over: Record = {}) => ({ + payment_method: 'dbank', + cash_receipt_provider: 'tosspayments', + payment_status: 'paid', + ...over, + }); + + it('① 무통장이 아니거나 프로바이더 미설정이면 카드 자체를 렌더하지 않는다', () => { + expect(evalIf(card.if, { order: { data: { payment: payment() } } })).toBe(true); + expect(evalIf(card.if, { order: { data: { payment: payment({ payment_method: 'card' }) } } })).toBe(false); + expect(evalIf(card.if, { order: { data: { payment: payment({ cash_receipt_provider: null }) } } })).toBe(false); + }); + + it('② 입금 전에는 안내만 노출한다 — 무통장은 ready, 가상계좌는 waiting_deposit', () => { + const node = byId(mypageExt, 'ext_mypage_cash_receipt_awaiting'); + + // PaymentStatusEnum::isAwaitingDeposit() 이 true 인 두 상태 모두 "입금 전" 이다. + // 무통장 주문은 ready 로 생성된다 — waiting_deposit 만 검사하면 무통장 전부에서 안내가 사라진다. + expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'ready' }) } } })).toBe(true); + expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'waiting_deposit' }) } } })).toBe(true); + + expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'paid' }) } } })).toBe(false); + expect(evalIf(node.if, { order: { data: { payment: payment({ payment_status: 'cancelled' }) } } })).toBe(false); + }); + + it('입금 전(ready) 주문은 5개 상태 중 정확히 하나에 매칭된다 (빈 카드 방지)', () => { + const ctx = { order: { data: { payment: payment({ payment_status: 'ready' }), cash_receipt: null, cash_receipts: [] } } }; + const states = [ + 'ext_mypage_cash_receipt_awaiting', + 'ext_mypage_cash_receipt_issuable', + 'ext_mypage_cash_receipt_issued', + 'ext_mypage_cash_receipt_failed', + ]; + const matched = states.filter((id) => evalIf(byId(mypageExt, id).if, ctx)); + expect(matched).toEqual(['ext_mypage_cash_receipt_awaiting']); + }); + + it('③ 입금완료 + 미발급 → 발급 버튼', () => { + const node = byId(mypageExt, 'ext_mypage_cash_receipt_issuable'); + expect(evalIf(node.if, { order: { data: { payment: payment(), cash_receipt: null, cash_receipts: [] } } })).toBe(true); + expect(evalIf(node.if, { order: { data: { payment: payment(), cash_receipt: { id: 1 }, cash_receipts: [] } } })).toBe(false); + }); + + it('입금완료 주문의 상태 ③ 과 ⑤ 는 동시에 매칭되지 않는다 (배타)', () => { + const failedCtx = { order: { data: { payment: payment(), cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } }; + + const issuable = byId(mypageExt, 'ext_mypage_cash_receipt_issuable'); + const failed = byId(mypageExt, 'ext_mypage_cash_receipt_failed'); + + expect(evalIf(failed.if, failedCtx)).toBe(true); + expect(evalIf(issuable.if, failedCtx)).toBe(false); + }); + + it('④ 발급완료 → 영수증 링크', () => { + const node = byId(mypageExt, 'ext_mypage_cash_receipt_issued'); + expect(evalIf(node.if, { order: { data: { cash_receipt: { id: 1 } } } })).toBe(true); + expect(evalIf(node.if, { order: { data: { cash_receipt: null } } })).toBe(false); + }); + + it('⑤ 취소 성공 + 재발급 실패 → 경고 (issue_status 는 대문자 FAILED)', () => { + const node = byId(mypageExt, 'ext_mypage_cash_receipt_failed'); + + const failed = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } }; + const ok = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'COMPLETED' }] } } }; + const none = { order: { data: { cash_receipt: null, cash_receipts: [] } } }; + + expect(evalIf(node.if, failed)).toBe(true); + expect(evalIf(node.if, ok)).toBe(false); + expect(evalIf(node.if, none)).toBe(false); + }); + + it('유저에게는 발급취소·수동 재발급 버튼을 주지 않는다 (관리자 전용)', () => { + // 'reissue_failed' 안내 문구는 정상 — 금지 대상은 재발급/발급취소를 실행하는 액션이다. + const actions = flatten(mypageExt).flatMap((n: any) => n.actions ?? []); + const apiCalls = actions.filter((a: any) => a.handler === 'apiCall'); + + expect(apiCalls.some((a: any) => String(a.target).includes('/reissue'))).toBe(false); + expect(apiCalls.some((a: any) => a.params?.method === 'DELETE')).toBe(false); + + // 남은 apiCall 은 발급(POST) 하나뿐이어야 한다 + expect(apiCalls.map((a: any) => a.params?.method)).toEqual(['POST']); + }); + + it('발급 모달은 modals 섹션에 등록한다 (인라인 Modal 금지)', () => { + expect(Array.isArray(mypageExt.modals)).toBe(true); + expect(mypageExt.modals[0].id).toBe('ext_cash_receipt_issue_modal'); + }); + + it('회원은 user/orders/{id}, 비회원은 guest/orders/{orderNumber} 로 발급한다', () => { + const submit = byId(mypageExt, 'ext_cash_receipt_issue_submit'); + const expr = String(submit.actions[0].target).slice(2, -2); + + const member = engine.evaluateExpression(expr, { + _global: { currentUser: { uuid: 'u1' } }, + order: { data: { id: 42, order_number: 'ORD-1' } }, + }); + const guest = engine.evaluateExpression(expr, { + _global: { currentUser: null }, + order: { data: { id: 42, order_number: 'ORD-1' } }, + }); + + expect(member).toBe('/api/modules/sirsoft-ecommerce/user/orders/42/cash-receipt'); + expect(guest).toBe('/api/modules/sirsoft-ecommerce/guest/orders/ORD-1/cash-receipt'); + }); +}); + +// ------------------------------------------------------------------ W-3 관리자 주문상세 + +describe('W-3 관리자 주문상세 현금영수증 카드', () => { + const card = byRowId(paymentInfo, 'payment_cash_receipt_card'); + + it('결제카드 반복(payment) 컨텍스트에서 무통장 + 프로바이더 설정 시에만 렌더된다', () => { + expect(evalIf(card.if, { payment: { payment_method: 'dbank', cash_receipt_provider: 'toss' } })).toBe(true); + expect(evalIf(card.if, { payment: { payment_method: 'vbank', cash_receipt_provider: 'toss' } })).toBe(false); + expect(evalIf(card.if, { payment: { payment_method: 'dbank', cash_receipt_provider: '' } })).toBe(false); + }); + + it('입금 전 안내는 무통장(ready)과 가상계좌(waiting_deposit) 모두에서 노출된다', () => { + const node = byRowId(paymentInfo, 'payment_cash_receipt_awaiting'); + + expect(evalIf(node.if, { payment: { payment_status: 'ready' } })).toBe(true); + expect(evalIf(node.if, { payment: { payment_status: 'waiting_deposit' } })).toBe(true); + expect(evalIf(node.if, { payment: { payment_status: 'paid' } })).toBe(false); + }); + + it('관리자 전용 버튼 3종(발급/발급취소/수동 재발급)이 존재한다', () => { + expect(byRowId(paymentInfo, 'payment_cash_receipt_issue_button')).toBeDefined(); + expect(byRowId(paymentInfo, 'payment_cash_receipt_cancel_button')).toBeDefined(); + expect(byRowId(paymentInfo, 'payment_cash_receipt_reissue_button')).toBeDefined(); + }); + + it('재발급 실패 상태에서 발급 버튼과 재발급 버튼이 동시에 노출되지 않는다', () => { + // 실패 이력이 있으면 복구는 "수동 재발급" 경로다. 신규 발급 버튼을 함께 띄우면 + // 관리자가 어느 쪽을 눌러야 하는지 알 수 없다. + const failed = { payment: { payment_status: 'paid' }, order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } }; + + const issue = byRowId(paymentInfo, 'payment_cash_receipt_issue_button'); + const reissue = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button'); + + expect(evalIf(reissue.if, failed)).toBe(true); + expect(evalIf(issue.if, failed)).toBe(false); + }); + + it('이력이 전혀 없는 입금완료 주문에서는 발급 버튼만 노출된다', () => { + const fresh = { payment: { payment_status: 'paid' }, order: { data: { cash_receipt: null, cash_receipts: [] } } }; + + expect(evalIf(byRowId(paymentInfo, 'payment_cash_receipt_issue_button').if, fresh)).toBe(true); + expect(evalIf(byRowId(paymentInfo, 'payment_cash_receipt_reissue_button').if, fresh)).toBe(false); + }); + + it('수동 재발급 버튼은 재발급 실패 상태에서만 노출된다', () => { + const btn = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button'); + const failed = { order: { data: { cash_receipt: null, cash_receipts: [{ issue_status: 'FAILED' }] } } }; + const issued = { order: { data: { cash_receipt: { id: 1 }, cash_receipts: [{ issue_status: 'COMPLETED' }] } } }; + + expect(evalIf(btn.if, failed)).toBe(true); + expect(evalIf(btn.if, issued)).toBe(false); + }); + + it('관리자 apiCall 은 모두 auth_required 를 선언한다 (미선언 시 Bearer 토큰 미부착 → 401)', () => { + const adminApiCalls = [ + ...flatten(paymentInfo).flatMap((n: any) => n.actions ?? []), + ...flatten(issueModal).flatMap((n: any) => n.actions ?? []), + ].filter((a: any) => a.handler === 'apiCall' && String(a.target).includes('/admin/')); + + expect(adminApiCalls.length).toBeGreaterThan(0); + for (const a of adminApiCalls) { + expect(a.auth_required, `auth_required 누락: ${a.target}`).toBe(true); + } + }); + + it('발급취소는 DELETE, 재발급은 reissue 엔드포인트를 호출한다', () => { + const cancel = byRowId(paymentInfo, 'payment_cash_receipt_cancel_button'); + const reissue = byRowId(paymentInfo, 'payment_cash_receipt_reissue_button'); + + expect(cancel.actions[0].params.method).toBe('DELETE'); + expect(reissue.actions[0].target).toContain('/cash-receipt/reissue'); + }); + + it('발급 이력표는 이력이 있을 때만, 그리고 기본 접힌 상태로 존재한다', () => { + const wrap = byRowId(paymentInfo, 'payment_cash_receipt_history'); + const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body'); + + // 껍데기: 이력 0건이면 토글 헤더조차 띄우지 않는다 + expect(evalIf(wrap.if, { order: { data: { cash_receipts: [] } } })).toBe(false); + expect(evalIf(wrap.if, { order: { data: { cash_receipts: [{ id: 1 }] } } })).toBe(true); + + // 본문: 초기 상태(_local.expandedCashReceiptHistory = [])에서 접혀 있어야 한다 + expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: [] } })).toBe(false); + expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: [0] } })).toBe(true); + + // 상태 키가 아직 초기화되지 않은 순간에도 터지지 않는다 (?? [] fallback) + expect(evalIf(body.if, { payIdx: 0, _local: {} })).toBe(false); + }); + + it('이력 토글은 결제 건마다 독립적으로 접히고 펼쳐진다', () => { + // 한 주문에 결제행이 여러 개일 수 있다. payIdx 를 배열에 넣고 빼는 방식이라야 + // 0번 행을 펼쳐도 1번 행이 함께 펼쳐지지 않는다. + const toggle = byRowId(paymentInfo, 'payment_cash_receipt_history_toggle'); + const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body'); + const expr = String(toggle.actions[0].params.expandedCashReceiptHistory).slice(2, -2); + + // 0번 행 펼치기 + const afterOpen0 = engine.evaluateExpression(expr, { payIdx: 0, _local: { expandedCashReceiptHistory: [] } }); + expect(afterOpen0).toEqual([0]); + + // 그 상태에서 1번 행 펼치기 → 0번은 그대로 열려 있다 + const afterOpen1 = engine.evaluateExpression(expr, { payIdx: 1, _local: { expandedCashReceiptHistory: afterOpen0 } }); + expect(afterOpen1).toEqual([0, 1]); + expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: afterOpen1 } })).toBe(true); + + // 0번 행 다시 접기 → 1번은 열린 채 남는다 + const afterClose0 = engine.evaluateExpression(expr, { payIdx: 0, _local: { expandedCashReceiptHistory: afterOpen1 } }); + expect(afterClose0).toEqual([1]); + expect(evalIf(body.if, { payIdx: 0, _local: { expandedCashReceiptHistory: afterClose0 } })).toBe(false); + expect(evalIf(body.if, { payIdx: 1, _local: { expandedCashReceiptHistory: afterClose0 } })).toBe(true); + }); + + it('토글 버튼은 접힘 상태를 아이콘과 aria-expanded 로 함께 알린다', () => { + const toggle = byRowId(paymentInfo, 'payment_cash_receipt_history_toggle'); + const body = byRowId(paymentInfo, 'payment_cash_receipt_history_body'); + + // Form 밖이지만 submit 오작동 방지 규약을 따른다 + expect(toggle.props.type).toBe('button'); + // 스크린리더가 본문 존재를 알 수 있어야 한다 + expect(toggle.props['aria-controls']).toBe(body.id); + + const icon = flatten(toggle).find((n: any) => n.name === 'Icon'); + const iconExpr = String(icon.props.name).slice(2, -2); + const ariaExpr = String(toggle.props['aria-expanded']).slice(2, -2); + + const closed = { payIdx: 0, _local: { expandedCashReceiptHistory: [] } }; + const opened = { payIdx: 0, _local: { expandedCashReceiptHistory: [0] } }; + + expect(engine.evaluateExpression(iconExpr, closed)).toBe('chevron-right'); + expect(engine.evaluateExpression(iconExpr, opened)).toBe('chevron-down'); + expect(engine.evaluateExpression(ariaExpr, closed)).toBe(false); + expect(engine.evaluateExpression(ariaExpr, opened)).toBe(true); + + // Icon 은 w-N/h-N 클래스가 아니라 size prop 으로 크기를 지정한다 + expect(icon.props.size).toBeDefined(); + expect(icon.props.className ?? '').not.toMatch(/\b[wh]-\d/); + }); + + it('액션 영역은 데스크톱 가로 → portable 세로 스택 (§6-0)', () => { + const actions = byRowId(paymentInfo, 'payment_cash_receipt_actions'); + expect(actions.props.className).toContain('flex-row'); + expect(actions.responsive.portable.props.className).toContain('flex-col'); + expect(Object.keys(actions.responsive)).toEqual(['portable']); + }); + + it('신설 카드에 Tailwind breakpoint(md:/lg:) 를 쓰지 않는다', () => { + for (const n of flatten(card)) { + expect(n.props?.className ?? '').not.toMatch(/(^|\s)(md|lg|xl|2xl):/); + } + }); +}); + +describe('W-3 관리자 발급 모달', () => { + it('modals 파트너 id 로 등록되고 발급 버튼이 이를 연다', () => { + expect(issueModal.id).toBe('modal_issue_cash_receipt'); + + const btn = byRowId(paymentInfo, 'payment_cash_receipt_issue_button'); + const open = flatten(btn.actions).find((a: any) => a.handler === 'openModal'); + expect(open.target).toBe('modal_issue_cash_receipt'); + }); + + it('발급수단 options 는 용도에 따라 2종 / 3종이다', () => { + const select = byId(issueModal, 'modal_cash_receipt_identifier_type'); + const expr = String(select.props.options).slice(2, -2); + const t = (k: string) => k; + + const income = engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { receipt_type: 'income' } }, $t: t }); + const expense = engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { receipt_type: 'expense' } }, $t: t }); + + expect(income.map((o: any) => o.value)).toEqual(['phone', 'card']); + expect(expense.map((o: any) => o.value)).toEqual(['business', 'phone', 'card']); + }); + + it('번호가 비어 있으면 발급 버튼이 비활성화된다', () => { + const submit = byId(issueModal, 'modal_cash_receipt_submit'); + const expr = String(submit.props.disabled).slice(2, -2); + + expect(engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { identifier: '' } } })).toBe(true); + expect(engine.evaluateExpression(expr, { _global: { adminCashReceiptForm: { identifier: '01012345678' } } })).toBe(false); + }); + + it('성공 시 setState 를 closeModal 보다 먼저 실행한다 (순서 역전 시 상태 잔존)', () => { + const submit = byId(issueModal, 'modal_cash_receipt_submit'); + const onSuccess = submit.actions[0].onSuccess.map((a: any) => a.handler); + + expect(onSuccess.indexOf('setState')).toBeLessThan(onSuccess.indexOf('closeModal')); + expect(onSuccess).toContain('refetchDataSource'); + }); +}); + +// ------------------------------------------------------------------ W-4 취소 모달 + +describe('W-4 취소 모달 환불계좌', () => { + const section = byId(cancelModal, 'cancel_refund_bank_section'); + + it('가상계좌·무통장에서만 노출한다 (카드·간편결제는 계좌 불필요)', () => { + const ctx = (m: string) => ({ order: { data: { payment: { payment_method: m } } } }); + + expect(evalIf(section.if, ctx('vbank'))).toBe(true); + expect(evalIf(section.if, ctx('dbank'))).toBe(true); + expect(evalIf(section.if, ctx('card'))).toBe(false); + expect(evalIf(section.if, ctx('point'))).toBe(false); + }); + + it('3필드가 _local.refundBank* 에 저장된다 (cancelOrderHandlers 가 읽는 키)', () => { + const keys = flatten(section) + .flatMap((n: any) => n.actions ?? []) + .filter((a: any) => a.handler === 'setState') + .flatMap((a: any) => Object.keys(a.params).filter((k) => k !== 'target')); + + expect(keys).toEqual(expect.arrayContaining(['refundBankCode', 'refundBankAccount', 'refundBankHolder'])); + }); + + it('가상계좌 입금완료 건에만 필수 안내를 띄운다', () => { + const hint = flatten(section).find((n: any) => String(n.text ?? '').includes('refund_bank_required_hint')); + const ctx = (m: string, s: string) => ({ order: { data: { payment: { payment_method: m, payment_status: s } } } }); + + expect(evalIf(hint.if, ctx('vbank', 'paid'))).toBe(true); + expect(evalIf(hint.if, ctx('vbank', 'waiting_deposit'))).toBe(false); + expect(evalIf(hint.if, ctx('dbank', 'paid'))).toBe(false); + }); + + it('3열 → portable 1열로 접힌다', () => { + const grid = flatten(section).find((n: any) => (n.props?.className ?? '').includes('grid-cols-3')); + expect(grid.responsive.portable.props.className).toContain('grid-cols-1'); + }); +}); + +// ------------------------------------------------------------------ W-5 환경설정 + +describe('W-5 환경설정 — 현금영수증', () => { + const card = byId(orderSettingsTab, 'cash_receipt_card'); + + it('카드가 주문설정 탭에 존재한다', () => { + expect(card).toBeDefined(); + }); + + it('등록된 프로바이더가 없으면 Select 대신 안내를 노출한다', () => { + const select = byId(orderSettingsTab, 'cash_receipt_provider_select'); + + expect(evalIf(select.if, { _local: { form: { available_cash_receipt_providers: [{ id: 'toss' }] } } })).toBe(true); + expect(evalIf(select.if, { _local: { form: { available_cash_receipt_providers: [] } } })).toBe(false); + }); + + it('배송비 과세 3정책의 value 가 백엔드 Enum 과 일치한다', () => { + const select = byId(orderSettingsTab, 'shipping_fee_tax_policy_select'); + const opts = engine.evaluateExpression(String(select.props.options).slice(2, -2), { $t: (k: string) => k }); + + expect(opts.map((o: any) => o.value)).toEqual(['proportional', 'taxable', 'follow_main_item']); + }); + + it('배송비 과세 기본값은 안분(proportional) 이다', () => { + const select = byId(orderSettingsTab, 'shipping_fee_tax_policy_select'); + const value = engine.evaluateExpression(String(select.props.value).slice(2, -2), { _local: { form: { order_settings: {} } } }); + + expect(value).toBe('proportional'); + }); + + it('자진발급 토글의 기본값은 OFF 다 (D14)', () => { + const toggle = byId(orderSettingsTab, 'cash_receipt_self_issue_toggle'); + const checked = engine.evaluateExpression(String(toggle.props.checked).slice(2, -2), { _local: { form: { order_settings: {} } } }); + + expect(checked).toBe(false); + }); + + it('3개 컨트롤이 저장 대상 키에 setState 하고 hasChanges 를 세운다', () => { + const targets = flatten(card) + .flatMap((n: any) => n.actions ?? []) + .filter((a: any) => a.handler === 'setState') + .map((a: any) => a.params); + + const keys = targets.flatMap((p: any) => Object.keys(p)); + expect(keys).toEqual(expect.arrayContaining([ + 'form.order_settings.cash_receipt_provider', + 'form.order_settings.shipping_fee_tax_policy', + 'form.order_settings.cash_receipt_self_issue', + ])); + expect(targets.every((p: any) => p.hasChanges === true)).toBe(true); + }); + + it('자진발급 안내에 국세청 의무발행 업종 링크가 있다', () => { + // 의무발행 업종인지 판단하라고 안내만 하고 확인 경로를 주지 않으면 설정을 켤 수 없다. + const link = byId(card, 'cash_receipt_mandatory_industry_link'); + + expect(link.name).toBe('A'); + expect(link.props.href).toMatch(/^https:\/\/www\.nts\.go\.kr\//); + expect(link.props.target).toBe('_blank'); + // 외부 링크는 opener 유출을 막는다. + expect(link.props.rel).toBe('noopener noreferrer'); + + const labels = flatten(link).map((n: any) => n.text).filter(Boolean); + expect(labels).toContain( + '$t:sirsoft-ecommerce.admin.settings.order_settings.cash_receipt.self_issue_mandatory_link' + ); + }); + + it('신설 카드에 Tailwind breakpoint(md:/lg:) 를 쓰지 않는다 (§6-0)', () => { + for (const n of flatten(card)) { + expect(n.props?.className ?? '').not.toMatch(/(^|\s)(md|lg|xl|2xl):/); + } + }); +}); + +// ------------------------------------------------------------ 5개 표면 공통 규약 + +describe('현금영수증 5개 표면 공통 규약', () => { + const SURFACES: Array<[string, any]> = [ + ['W-1 체크아웃', checkoutExt], + ['W-2 유저 주문상세', mypageExt], + ['W-3 관리자 주문상세', paymentInfo], + ['W-3 발급 모달', issueModal], + ['W-5 환경설정', orderSettingsTab], + ]; + + it.each(SURFACES)('%s — Icon 크기는 size prop 으로 지정한다 (w-N/h-N 금지)', (_label, root) => { + // audit `layout-icon-classname-size` 와 같은 규약. 그 룰의 appliesTo 는 + // `**/resources/layouts/**` 라서 확장점 파일(resources/extensions/**)을 보지 못한다. + // 그 사각을 여기서 덮는다 — 실제로 mypage 확장점의 Icon 2개가 이 규약을 어기고 있었다. + for (const icon of flatten(root).filter((n: any) => n.name === 'Icon')) { + const cls = String(icon.props?.className ?? ''); + expect(cls, `Icon(${icon.props?.name}) 에 w-N/h-N 클래스`).not.toMatch(/(^|\s)[wh]-\d/); + } + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutNormalStateMarker.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutNormalStateMarker.test.ts index daab64b0..d8874038 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutNormalStateMarker.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutNormalStateMarker.test.ts @@ -28,7 +28,7 @@ const spec = JSON.parse( fs.readFileSync(path.join(REPO_ROOT, 'modules/_bundled/sirsoft-ecommerce/editor-spec.json'), 'utf-8'), ); const checkoutGroup = spec.states.groups.find( - (g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/checkout', + (g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/checkout', ); const normal = checkoutGroup?.items.find((i: any) => i.id === 'normal'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutPaymentMethodRegression.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutPaymentMethodRegression.test.tsx index 7d856304..1d9fa972 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutPaymentMethodRegression.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/checkoutPaymentMethodRegression.test.tsx @@ -12,27 +12,32 @@ import fs from 'fs'; import path from 'path'; import { describe, it, expect } from 'vitest'; +import { DataBindingEngine } from '@core/template-engine/DataBindingEngine'; const templatesRoot = path.resolve(__dirname, '../../../../../../../templates/_bundled/sirsoft-basic'); +const engine = new DataBindingEngine(); /** - * 재귀적으로 JSON 노드에서 apiCall 핸들러의 body를 찾는다 + * 재귀적으로 JSON 노드에서 주문 생성 apiCall 액션을 찾는다. + * + * body 는 통짜 표현식 문자열이다(#454 S3 — 확장 병합 칸). 예전처럼 객체가 아니므로 + * `params.body.payment_method` 로는 찾을 수 없고, endpoint 로 식별한다. */ -function findApiCallBody(node: any): any { - if (node.handler === 'apiCall' && node.params?.body?.payment_method) { - return node.params.body; +function findOrderApiCall(node: any): any { + if (node.handler === 'apiCall' && typeof node.target === 'string' && node.target.endsWith('/user/orders')) { + return node; } for (const key of ['actions', 'children']) { if (Array.isArray(node[key])) { for (const child of node[key]) { - const found = findApiCallBody(child); + const found = findOrderApiCall(child); if (found) return found; } } } if (Array.isArray(node.params?.actions)) { for (const action of node.params.actions) { - const found = findApiCallBody(action); + const found = findOrderApiCall(action); if (found) return found; } } @@ -40,7 +45,7 @@ function findApiCallBody(node: any): any { for (const slotChildren of Object.values(node.slots)) { if (Array.isArray(slotChildren)) { for (const child of slotChildren as any[]) { - const found = findApiCallBody(child); + const found = findOrderApiCall(child); if (found) return found; } } @@ -58,28 +63,62 @@ describe('체크아웃 결제수단 회귀 테스트', () => { ); describe('_checkout_summary.json apiCall body', () => { - const apiCallBody = findApiCallBody(summaryJson); + const orderCall = findOrderApiCall(summaryJson); - it('apiCall body가 존재한다', () => { - expect(apiCallBody).not.toBeNull(); + /** + * body 표현식을 실제로 평가해 전송 payload 를 얻는다. + * + * 문자열 매칭 대신 평가하는 이유: 원래 이 테스트가 고정하려던 것은 "무통장을 고르면 + * dbank 가 실려 나가는가" 이지 body 가 객체 리터럴인가가 아니다. 표현식으로 바뀐 뒤에도 + * 그 행위는 그대로 검증되어야 한다. + */ + const evalBody = (ctx: Record) => + engine.evaluateExpression(String(orderCall.params.body).slice(2, -2), ctx); + + const context = (paymentMethod: string, localPaymentMethod?: string) => ({ + checkoutData: { data: { temp_order_id: 'T-1', calculation: { summary: { final_amount: 1000 } } } }, + // body 는 _computed.selectedCorePaymentMethod 를 싣는다(#454 S3 — 결제수단 id → + // core_payment_method 매핑). selectedPaymentMethod 는 선택된 결제수단 id, core 는 + // 그 id 가 가리키는 코어 결제수단(dbank/card/vbank …)이다. 이 회귀 테스트의 관심사인 + // "고른 결제수단이 그대로 실려 나가는가" 는 core 값으로 검증한다. + _computed: { + selectedPaymentMethod: paymentMethod, + selectedCorePaymentMethod: paymentMethod, + ordererDefaults: { name: '홍길동' }, + }, + _local: { + paymentMethod: localPaymentMethod, + shipping: {}, + selectedDbank: { bank_code: '088', account_number: '110-1', account_holder: '홍길동' }, + }, + _global: { currentUser: { uuid: 'u-1' } }, }); - it('payment_method가 _computed.selectedPaymentMethod를 사용한다', () => { - expect(apiCallBody.payment_method).toBe('{{_computed.selectedPaymentMethod}}'); + it('주문 생성 apiCall 이 존재한다', () => { + expect(orderCall).not.toBeNull(); + expect(typeof orderCall.params.body).toBe('string'); }); - it('payment_method가 _local.paymentMethod를 직접 사용하지 않는다', () => { - // _local.paymentMethod는 사용자가 결제수단 버튼을 클릭해야만 설정됨 - // 클릭 전에는 undefined이므로 직접 참조하면 안 됨 - expect(apiCallBody.payment_method).not.toContain('_local.paymentMethod'); + it('payment_method 는 _computed.selectedPaymentMethod 를 그대로 싣는다', () => { + expect(evalBody(context('dbank')).payment_method).toBe('dbank'); + expect(evalBody(context('card')).payment_method).toBe('card'); }); - it('dbank 조건이 _computed.selectedPaymentMethod를 사용한다', () => { - expect(apiCallBody.dbank).toContain('_computed.selectedPaymentMethod'); + it('_local.paymentMethod 가 비어 있어도 computed 값이 실린다 (기본값 card 로 새지 않는다)', () => { + // 회귀 원인: 사용자가 결제수단 버튼을 누르기 전에는 _local.paymentMethod 가 undefined 다. + // body 가 그것을 직접 참조하면 무통장을 골라도 card 가 전송됐다. + expect(evalBody(context('dbank', undefined)).payment_method).toBe('dbank'); }); - it('dbank 조건이 _local.paymentMethod를 직접 사용하지 않는다', () => { - expect(apiCallBody.dbank).not.toContain('_local.paymentMethod'); + it('dbank 블록은 무통장일 때만 채워지고 그 외에는 null 이다', () => { + expect(evalBody(context('dbank')).dbank).toMatchObject({ bank_code: '088' }); + expect(evalBody(context('card')).dbank).toBeNull(); + }); + + it('_local.paymentMethod 가 computed 와 어긋나도 computed 가 우선한다', () => { + const body = evalBody(context('dbank', 'card')); + expect(body.payment_method).toBe('dbank'); + expect(body.dbank).not.toBeNull(); }); }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts new file mode 100644 index 00000000..60b548a5 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerce-settings-range-contract.test.ts @@ -0,0 +1,237 @@ +/** + * 설정 화면 ↔ 서버 검증 규칙 경계값 계약 테스트 (E10) + * + * @description + * 설정 입력 UI 의 min/max/step 은 서버 검증 규칙(StoreEcommerceSettingsRequest)과 같은 값을 + * 말해야 한다. 화면이 더 좁으면 저장 가능한 값을 입력조차 못 하고, 더 넓으면 입력은 되는데 + * 저장 단계에서만 422 가 난다. 어느 쪽도 화면만 보고는 알 수 없어 조용히 어긋난 채로 남는다. + * + * 양쪽 모두 `config('sirsoft-ecommerce.limits')` 한 곳을 읽도록 배선했으므로, 세 지점 + * (설정 파일 · 서버 규칙 · 화면 바인딩)이 같은 한계값 키와 같은 폴백을 가리키는지 대조한다. + * 어느 한 쪽만 고쳐도 실패한다. + * + * @vitest-environment node + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import tabMileageBasic from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_mileage_basic_card.json'; +import tabOrderSettings from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_order_settings.json'; +import tabReviewSettings from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_review_settings.json'; + +const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const requestSource = fs.readFileSync( + path.join(moduleRoot, 'src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php'), + 'utf-8', +); +const configSource = fs.readFileSync(path.join(moduleRoot, 'config/ecommerce.php'), 'utf-8'); + +/** 한 경계(min 또는 max)의 출처: 어떤 한계값 키를 읽고, 그 키가 없을 때 무엇으로 떨어지는가. */ +interface Bound { + limitKey: string | null; + fallback: number; +} + +/** + * `config/ecommerce.php` 의 `limits` 블록을 키 → 값으로 읽는다. + * + * @returns 한계값 키 맵 + */ +function configuredLimits(): Record { + const block = /'limits'\s*=>\s*\[([\s\S]*?)\n\s*\],/.exec(configSource); + expect(block, "config/ecommerce.php 에서 'limits' 블록을 찾지 못했습니다.").not.toBeNull(); + + const limits: Record = {}; + + for (const line of block![1].split('\n')) { + const matched = /'([a-z0-9_]+)'\s*=>\s*(-?[\d.]+)\s*,/.exec(line); + if (matched) { + limits[matched[1]] = Number(matched[2]); + } + } + + expect(Object.keys(limits).length).toBeGreaterThan(0); + + return limits; +} + +const LIMITS = configuredLimits(); + +/** + * 규칙/바인딩 문자열에서 한 경계의 출처를 뽑는다. + * + * 서버 규칙: `'min:'.config('sirsoft-ecommerce.limits.KEY', N)` 또는 `'min:N'` + * 화면 바인딩: `{{_local?.form?._meta?.limits?.KEY ?? N}}` 또는 `"N"` + * + * @param source 규칙 배열 본문 또는 prop 값 + * @param bound 'min' | 'max' + * @param isRule 서버 규칙 문자열이면 true + * @returns 경계 출처 (해당 경계가 없으면 null) + */ +function extractBound(source: string, bound: 'min' | 'max', isRule: boolean): Bound | null { + if (isRule) { + const viaConfig = new RegExp( + `'${bound}:'\\s*\\.\\s*config\\(\\s*'sirsoft-ecommerce\\.limits\\.([a-z0-9_]+)'\\s*,\\s*(-?[\\d.]+)\\s*\\)`, + ).exec(source); + + if (viaConfig) { + return { limitKey: viaConfig[1], fallback: Number(viaConfig[2]) }; + } + + const literal = new RegExp(`'${bound}:(-?[\\d.]+)'`).exec(source); + + return literal ? { limitKey: null, fallback: Number(literal[1]) } : null; + } + + if (source === undefined || source === null || source === '') { + return null; + } + + const viaBinding = /limits\?\.([a-z0-9_]+)\s*\?\?\s*(-?[\d.]+)/.exec(source); + + if (viaBinding) { + return { limitKey: viaBinding[1], fallback: Number(viaBinding[2]) }; + } + + return Number.isNaN(Number(source)) ? null : { limitKey: null, fallback: Number(source) }; +} + +/** + * FormRequest 원본에서 해당 설정 키의 규칙 배열 본문을 읽는다. + * + * @param key 설정 키 + * @returns 규칙 배열 본문 문자열 + */ +function ruleBody(key: string): string { + const escaped = key.replace(/[.]/g, '\\.'); + const matched = new RegExp(`'${escaped}'\\s*=>\\s*\\[(.*)\\],\\n`).exec(requestSource); + + expect(matched, `${key} 규칙을 FormRequest 에서 찾지 못했습니다.`).not.toBeNull(); + + return matched![1]; +} + +/** + * 레이아웃 트리에서 해당 설정 키를 다루는 Input 노드를 찾는다. + * + * 자동 바인딩을 쓰는 화면은 `props.name`, 수동 바인딩 화면은 `props.value` 의 표현식 경로로 + * 필드를 식별한다 — 두 형태가 같은 탭 안에 섞여 있으므로 둘 다 본다. + * + * @param node 탐색 시작 노드 + * @param key 설정 키 + * @returns 매칭된 Input 노드 (없으면 null) + */ +function findInput(node: any, key: string): any { + if (!node || typeof node !== 'object') return null; + + const boundPath = key.split('.').join('?.'); + const value = node.props?.value; + + if ( + node.name === 'Input' && + (node.props?.name === key || (typeof value === 'string' && value.includes(boundPath))) + ) { + return node; + } + + for (const child of Object.values(node)) { + if (Array.isArray(child)) { + for (const item of child) { + const found = findInput(item, key); + if (found) return found; + } + } else if (child && typeof child === 'object') { + const found = findInput(child, key); + if (found) return found; + } + } + + return null; +} + +const FIELDS: Array<{ key: string; layout: any }> = [ + { key: 'review_settings.write_deadline_days', layout: tabReviewSettings }, + { key: 'review_settings.max_images', layout: tabReviewSettings }, + { key: 'review_settings.max_image_size_mb', layout: tabReviewSettings }, + { key: 'order_settings.auto_cancel_days', layout: tabOrderSettings }, + { key: 'order_settings.cart_expiry_days', layout: tabOrderSettings }, + { key: 'mileage.default_earn_rate', layout: tabMileageBasic }, +]; + +describe('설정 화면 경계값 ↔ 서버 규칙 계약', () => { + it.each(FIELDS)('$key 의 min/max 가 서버 규칙과 같은 한계값을 가리킨다', ({ key, layout }) => { + const input = findInput(layout, key); + expect(input, `${key} Input 을 레이아웃에서 찾지 못했습니다.`).not.toBeNull(); + + const body = ruleBody(key); + let compared = 0; + + for (const bound of ['min', 'max'] as const) { + const server = extractBound(body, bound, true); + + if (server === null) { + continue; + } + + const screen = extractBound(String(input.props[bound] ?? ''), bound, false); + + expect(screen, `${key} 화면에 ${bound} 이 없습니다 — 서버는 ${bound} 을 강제합니다.`).not.toBeNull(); + expect(screen!.limitKey, `${key} 의 ${bound} 이 서버 규칙과 다른 한계값을 읽습니다.`).toBe( + server.limitKey, + ); + expect(screen!.fallback, `${key} 의 ${bound} 폴백이 서버 규칙과 다릅니다.`).toBe(server.fallback); + + compared++; + } + + // 규칙 형태가 바뀌어 아무 경계도 못 읽으면 이 테스트는 아무것도 검증하지 못한 채 통과한다. + expect(compared, `${key} 에서 서버 경계를 하나도 읽지 못했습니다 — 규칙 추출이 깨졌습니다.`).toBeGreaterThan(0); + }); + + it.each(FIELDS)('$key 의 폴백이 설정 파일의 한계값과 같다', ({ key }) => { + const body = ruleBody(key); + let checked = 0; + + for (const bound of ['min', 'max'] as const) { + const server = extractBound(body, bound, true); + + if (server === null || server.limitKey === null) { + continue; + } + + expect(LIMITS, `${server.limitKey} 가 config/ecommerce.php 의 limits 에 없습니다.`).toHaveProperty( + server.limitKey, + ); + expect(LIMITS[server.limitKey], `${key} 의 ${bound} 폴백이 설정 파일 값과 다릅니다.`).toBe( + server.fallback, + ); + + checked++; + } + + expect(checked).toBeGreaterThan(0); + }); + + it.each(FIELDS)('$key 의 step 이 서버 규칙의 정수 여부와 어긋나지 않는다', ({ key, layout }) => { + const input = findInput(layout, key); + const body = ruleBody(key); + + if (!/'integer'/.test(body)) { + return; + } + + // step 자체가 사라지면 브라우저 기본값 1 로 동작해 당장은 문제가 없지만, + // 다음 사람이 소수 step 을 다시 넣어도 막을 근거가 없어진다. + // 부재를 통과시키면 이 가드가 그 순간 무력화되므로 명시 실패시킨다. + expect( + input.props.step, + `${key} 는 서버가 integer 를 요구하므로 step 을 명시해야 합니다 — 부재는 소수 step 회귀를 막지 못합니다.`, + ).toBeDefined(); + + // 서버가 integer 를 요구하는데 소수 step 을 주면, 화면이 권하는 값이 곧 422 가 된다. + expect(Number(input.props.step)).toBe(1); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/guestOrderFormStates.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/guestOrderFormStates.test.ts index 5361de5f..120decd8 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/guestOrderFormStates.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/guestOrderFormStates.test.ts @@ -8,7 +8,7 @@ * 그룹이 없으면 비회원 조회 폼이 캔버스에 영영 미표시되어 편집 불가였다. * * 본 테스트는 develop 신규 화면에 대응해 추가한 상태 그룹의 계약을 가드한다: - * - scope.match 가 정규화 라우트 path(`/shop/guest/orders`)와 일치 + * - scope.match 가 상점 주소 설정과 무관하게 정규화 라우트 path 와 일치 (공개 #85) * - 기본(guest) 상태가 `global.currentUser: null` 패치로 비회원 폼을 노출 * - 회원(member) 상태가 `global.currentUser.uuid` 명시 시드로 마이페이지 안내 분기를 노출 * - 두 상태 라벨이 `$t:` 친화 키 @@ -25,6 +25,8 @@ import { describe, it, expect } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { matchStateItems } from '../../../../../../../resources/js/core/template-engine/layout-editor/utils/matchStateScope'; + function findProjectRoot(startDir: string): string { let dir = startDir; while (dir !== path.dirname(dir)) { @@ -39,18 +41,31 @@ const spec = JSON.parse( fs.readFileSync(path.join(REPO_ROOT, 'modules/_bundled/sirsoft-ecommerce/editor-spec.json'), 'utf-8'), ); const group = spec.states.groups.find( - (g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/guest/orders', + (g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/guest/orders', ); const guest = group?.items.find((i: any) => i.id === 'guest'); const member = group?.items.find((i: any) => i.id === 'member'); describe('guest_order_form 상태 그룹', () => { - it('정규화 라우트 path(`/shop/guest/orders`)에 상태 그룹이 존재한다', () => { + it('정규화 라우트 path 에 상태 그룹이 존재한다', () => { expect(group).toBeTruthy(); // items 2개 이상이어야 편집기 캔버스에 상태 드롭다운(PageStateSwitcher)이 표시된다. expect(group.items.length).toBeGreaterThanOrEqual(2); }); + // 공개 #85 — 상점 주소는 운영자 설정이라 편집기가 평가한 라우트 path 의 첫 세그먼트가 + // 사이트마다 다르다. scope.match 를 `/shop/...` 리터럴로 두면 주소를 바꾼 상점에서 + // 상태 그룹이 매칭되지 않아 상태 토글이 조용히 사라진다(예외·경고 없음). + // 실제 소비자(matchStateItems)로 판정해 정규식 의미까지 고정한다. + it.each([ + ['기본 상점 주소', '/shop/guest/orders'], + ['운영자가 바꾼 주소', '/store/guest/orders'], + ['주소 없이 운영하는 상점(no_route)', '/guest/orders'], + ])('%s 라우트에서 상태 그룹이 매칭된다', (_label, routePath) => { + const items = matchStateItems(spec.states.groups, { kind: 'route', match: routePath }); + expect(items.map((i) => i.id)).toEqual(expect.arrayContaining(['guest', 'member'])); + }); + it('기본(guest) 상태가 currentUser 를 null 로 패치해 비회원 조회 폼을 노출한다', () => { expect(guest?.default).toBe(true); // global.currentUser === null → `_global.currentUser?.uuid` undefined → content[1] 폼 활성. diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypage-orders-list.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypage-orders-list.test.tsx index 6dc1983c..371c7ab3 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypage-orders-list.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypage-orders-list.test.tsx @@ -92,7 +92,7 @@ const orderListLayout = { className: 'text-lg font-bold', 'data-testid': 'order-total', }, - text: "{{order.mc_total_amount?.[_global.preferredCurrency ?? 'KRW']?.formatted ?? order.total_amount_formatted}}", + text: "{{order.mc_total_amount?.[_global.preferredCurrency ?? _global.defaultCurrency]?.formatted ?? order.total_amount_formatted}}", }, { id: 'order-total-mc-{{orderIdx}}', @@ -105,7 +105,7 @@ const orderListLayout = { }, iteration: { source: - "{{Object.entries(order.mc_total_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? 'KRW'))}}", + "{{Object.entries(order.mc_total_amount ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}", item_var: 'currency', }, children: [ @@ -190,6 +190,9 @@ describe('마이페이지 주문내역 - 다통화 표시', () => { initialData: { orders: mockOrdersResponse, }, + // 표시 통화 미선택 상태 — 실제 화면도 init_actions 가 설정의 기본 통화를 주입한다. + // 레이아웃이 특정 통화를 리터럴 폴백으로 갖지 않으므로 테스트도 같은 입력을 준다. + initialState: { _global: { defaultCurrency: 'KRW' } }, }); await testUtils.render(); @@ -207,6 +210,9 @@ describe('마이페이지 주문내역 - 다통화 표시', () => { initialData: { orders: mockOrdersResponse, }, + // 표시 통화 미선택 상태 — 실제 화면도 init_actions 가 설정의 기본 통화를 주입한다. + // 레이아웃이 특정 통화를 리터럴 폴백으로 갖지 않으므로 테스트도 같은 입력을 준다. + initialState: { _global: { defaultCurrency: 'KRW' } }, }); await testUtils.render(); @@ -230,6 +236,9 @@ describe('마이페이지 주문내역 - 다통화 표시', () => { initialData: { orders: mockOrdersResponse, }, + // 표시 통화 미선택 상태 — 실제 화면도 init_actions 가 설정의 기본 통화를 주입한다. + // 레이아웃이 특정 통화를 리터럴 폴백으로 갖지 않으므로 테스트도 같은 입력을 준다. + initialState: { _global: { defaultCurrency: 'KRW' } }, }); testUtils.mockApi('orders', { response: mockOrdersResponse }); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx index e9f761ee..f55c00ec 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/mypageOrders.test.tsx @@ -208,11 +208,13 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => { expect(listJsonStr).not.toContain('order.multi_currency_total'); }); - it('단가가 item.unit_price_formatted를 사용해야 함', () => { - // 마이페이지 주문목록은 옵션 소계(mc_subtotal_price) 로 다통화를 표시하고 - // 단가는 포맷 문자열(unit_price_formatted) 로만 노출한다. 단가 단위의 + it('단가는 결제 통화로 굳은 unit_price_formatted 를 그대로 쓴다', () => { + // 이미 결제가 끝난 주문의 금액은 결제 시점 통화로 고정이다. 사용자가 지금 보고 있는 + // 통화(_global.preferredCurrency)로 환산해 보여주면 실제 결제액과 달라진다. + // 서버가 formatOrderCurrency 로 확정한 문자열을 표시만 한다. 단가 단위의 // 다통화 필드(mc_unit_price)는 이 레이아웃에 존재하지 않는다(주문완료 화면 전용). expect(listJsonStr).toContain('item.unit_price_formatted'); + expect(listJsonStr).not.toContain('item.mc_unit_price'); expect(listJsonStr).not.toContain('item.multi_currency_unit_price'); }); @@ -220,12 +222,15 @@ describe('주문 목록 partial 구조 검증 (_list.json)', () => { expect(listJsonStr).toContain('item.subtotal_price_formatted'); }); - it('다통화 소계가 item.mc_subtotal_price를 사용해야 함', () => { + it('다통화 소계 목록은 결제 통화를 제외하고 나열한다', () => { + // 소계만 다통화 병기를 남긴다 — 참고 표시이므로 결제 통화 행은 중복이라 걸러낸다. expect(listJsonStr).toContain('item.mc_subtotal_price'); + expect(listJsonStr).toContain('order.base_currency'); }); - it('배송비가 order.total_shipping_amount를 사용해야 함', () => { + it('배송비는 결제 통화로 굳은 total_shipping_amount_formatted 를 그대로 쓴다', () => { expect(listJsonStr).toContain('order.total_shipping_amount'); + expect(listJsonStr).toContain('order.total_shipping_amount_formatted'); }); it('다통화 총액이 order.mc_total_amount를 사용해야 함', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx new file mode 100644 index 00000000..3284bcfc --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx @@ -0,0 +1,78 @@ +// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는 +// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 +/** + * 이커머스 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 + * + * @description + * 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면, + * 확장이 이 슬롯에 주입하는 버튼이 정보 컬럼과 토글·편집 버튼 컬럼 + * (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다 + * (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림). + * + * extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야 + * 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json'; + +type AnyNode = Record & { + id?: string; + type?: string; + name?: string; + props?: { className?: string }; + children?: AnyNode[]; +}; + +function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] { + if (!node) return results; + if (pred(node)) results.push(node); + if (Array.isArray(node.children)) { + for (const c of node.children) findAll(c, pred, results); + } + return results; +} + +describe('이커머스 알림 행 footer extension_point 위치', () => { + it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => { + const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0'); + expect(infoColumns.length).toBeGreaterThan(0); + + const infoColumnHasEp = infoColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'), + ); + expect(infoColumnHasEp).toBe(true); + }); + + it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => { + const buttonColumns = findAll( + tab as AnyNode, + (n) => n.props?.className === 'flex-center gap-3 flex-shrink-0', + ); + expect(buttonColumns.length).toBeGreaterThan(0); + + const buttonColumnHasEp = buttonColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point'), + ); + expect(buttonColumnHasEp).toBe(false); + }); + + it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => { + const rowWrappers = findAll( + tab as AnyNode, + (n) => + Array.isArray(n.children) && + n.children.some((c) => c.props?.className === 'flex-1 min-w-0') && + n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'), + ); + expect(rowWrappers.length).toBeGreaterThan(0); + + for (const wrapper of rowWrappers) { + const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point'); + expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false); + } + }); +}); \ No newline at end of file diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/orderDetailLayouts.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/orderDetailLayouts.test.tsx index db90bf55..147468e6 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/orderDetailLayouts.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/orderDetailLayouts.test.tsx @@ -416,17 +416,24 @@ describe('admin_ecommerce_order_detail.json (메인 레이아웃)', () => { expect(Object.keys(layout.computed)).toHaveLength(0); }); - it('modals에 6개의 모달 partial이 정의되어 있다 (reset_guest_password + confirm_deposit 추가)', () => { + it('modals에 등록된 모달 partial 집합이 정확히 일치한다', () => { const layout = mainLayout as any; expect(Array.isArray(layout.modals)).toBe(true); - expect(layout.modals).toHaveLength(6); + + // 개수와 이름을 따로 단언하면 모달 추가 시 개수만 어긋나 무엇이 늘었는지 드러나지 않는다. + // 집합 자체를 고정해 추가·삭제가 진단 가능하도록 한다. const partialPaths = layout.modals.map((m: any) => m.partial); - expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json'); - expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_send_sms.json'); - expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_send_email.json'); - expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_cancel_order.json'); - expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_reset_guest_password.json'); - expect(partialPaths).toContain('partials/admin_ecommerce_order_detail/_modal_confirm_deposit.json'); + expect(new Set(partialPaths)).toEqual( + new Set([ + 'partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json', + 'partials/admin_ecommerce_order_detail/_modal_send_sms.json', + 'partials/admin_ecommerce_order_detail/_modal_send_email.json', + 'partials/admin_ecommerce_order_detail/_modal_cancel_order.json', + 'partials/admin_ecommerce_order_detail/_modal_issue_cash_receipt.json', + 'partials/admin_ecommerce_order_detail/_modal_reset_guest_password.json', + 'partials/admin_ecommerce_order_detail/_modal_confirm_deposit.json', + ]) + ); }); }); @@ -772,7 +779,7 @@ describe('Partial 레이아웃 구조 검증', () => { it('다통화 표시에 preferredCurrency 필터가 적용된다', () => { const json = JSON.stringify(orderInfoPartial); // 보조 통화만 표시 (기본 통화 제외) - expect(json).toContain("_global.preferredCurrency ?? 'KRW'"); + expect(json).toContain("_global.preferredCurrency ?? _global.defaultCurrency"); }); it('다통화 표시에 iteration 패턴이 사용된다 (engine-v1.19.1)', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productDomainSampleData.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productDomainSampleData.test.ts index 3600dcb5..85834486 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productDomainSampleData.test.ts +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productDomainSampleData.test.ts @@ -238,13 +238,13 @@ describe('상품 도메인 편집기 샘플 — sirsoft-ecommerce admin', () => }); }); - describe('/shop/products/:id 상태 override — 통째 교체이므로 충실 shape 유지', () => { + describe('상품 상세 상태 override — 통째 교체이므로 충실 shape 유지', () => { // Chrome MCP 실측 회귀(2026-06-03): 비-기본 상태 override 가 빈약 stub(name:"샘플 상품 1", // price, stock_status, images:[], options:[])이라 캔버스가 붕괴(277자)했다. override 는 // base sampleData 를 "통째 교체"(pageStateSimulator.resolveSampleOverride)하므로 // 누락 필드는 undefined → 렌더 붕괴. base 와 동일 충실 shape 를 유지해야 한다. const group = spec.states.groups.find( - (g: any) => g.scope?.kind === 'route' && g.scope?.match === '/shop/products/:id', + (g: any) => g.scope?.kind === 'route' && g.scope?.match === '/*?/products/:product_code', ); const PRODUCT_EP = '/api/modules/sirsoft-ecommerce/products/*'; const stateOf = (id: string) => diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListLayout.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListLayout.test.tsx index 6cb1c727..fe90aaad 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListLayout.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListLayout.test.tsx @@ -150,13 +150,21 @@ describe('상품 목록 레이아웃 named_actions 검증', () => { }); it('searchProducts named_action이 올바른 navigate 핸들러를 가져야 함', () => { + // 검색 실행은 "펼침 상태 초기화 → 이동" 두 단계다. 이동 정의는 searchProductsNavigate 에 있다. const searchProducts = (productList as any).named_actions.searchProducts; - expect(searchProducts.handler).toBe('navigate'); - expect(searchProducts.params.path).toBe('/admin/ecommerce/products'); - expect(searchProducts.params.replace).toBe(true); - expect(searchProducts.params.mergeQuery).toBe(true); - expect(searchProducts.params.query.search_keyword).toBeDefined(); - expect(searchProducts.params.query.page).toBe(1); + expect(searchProducts.handler).toBe('sequence'); + expect(searchProducts.actions.map((a: any) => a.actionRef)).toEqual([ + 'resetExpandedOptionState', + 'searchProductsNavigate', + ]); + + const navigate = (productList as any).named_actions.searchProductsNavigate; + expect(navigate.handler).toBe('navigate'); + expect(navigate.params.path).toBe('/admin/ecommerce/products'); + expect(navigate.params.replace).toBe(true); + expect(navigate.params.mergeQuery).toBe(true); + expect(navigate.params.query.search_keyword).toBeDefined(); + expect(navigate.params.query.page).toBe(1); }); it('검색 버튼도 actionRef로 searchProducts를 참조해야 함', () => { diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListNoCategoryBoolean.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListNoCategoryBoolean.test.tsx index c5d92a9d..8830775c 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListNoCategoryBoolean.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListNoCategoryBoolean.test.tsx @@ -15,7 +15,8 @@ import productList from '../../../layouts/admin/admin_ecommerce_product_list.jso describe('상품 목록 no_category/no_brand boolean 바인딩 (A19②)', () => { it('searchProducts named_action 의 no_category 표현식이 boolean true/null 로 평가되어야 함', () => { - const query = (productList as any).named_actions.searchProducts.params.query; + // 이동 정의는 searchProductsNavigate — searchProducts 는 초기화 후 이를 참조하는 sequence 다 + const query = (productList as any).named_actions.searchProductsNavigate.params.query; // 문자열 'true' 리터럴을 결과로 쓰지 않아야 함 (? true : null 형태) expect(query.no_category).toContain('? true : null'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListOptionsLazy.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListOptionsLazy.test.ts new file mode 100644 index 00000000..9b345b3d --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productListOptionsLazy.test.ts @@ -0,0 +1,311 @@ +/** + * @file productListOptionsLazy.test.ts + * @description 관리자 상품목록 확장 행 옵션 지연 로딩 레이아웃 회귀 (#518 / 공개 #76) + * + * 목록 응답이 옵션을 더 이상 싣지 않으므로, 행을 펼치는 순간 옵션을 가져오는 경로가 레이아웃에 + * 선언돼 있어야 한다. 이 테스트는 실 레이아웃 JSON 을 읽어 그 선언을 고정한다. + * + * 특히 조용히 깨지는 두 가지를 검사한다. + * 1. `expandContext` 값이 `{{...}}` **단일 바인딩** 형태여야 한다. 엔진의 판정 정규식은 + * `}` 를 허용하지 않아, 맵 조회 같은 표현식은 미매칭 → 문자열 보간으로 떨어져 배열/객체가 + * JSON 문자열로 굳는다 (예외 없이 값만 망가진다) + * 2. "옵션 없음" 조건이 `Array.isArray` 로 로드 완료를 확인해야 한다. `!row.options` 로 두면 + * 아직 안 불러온 상태(=undefined)에서도 참이 되어 로딩 중 "옵션 없음" 이 깜빡인다 + * + * @scenario row_state=expanding,option_profile=mixed + * @effects list_response_omits_options, no_fetch_when_total_count_zero + * + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const PARTIALS_ROOT = path.resolve( + __dirname, + '../../../layouts/admin/partials/admin_ecommerce_product_list' +); + +const DATAGRID_PATH = path.join(PARTIALS_ROOT, '_partial_product_datagrid.json'); +const CONFIRM_MODAL_PATH = path.join(PARTIALS_ROOT, '_modal_bulk_confirm.json'); + +/** 엔진이 "식으로 평가"할 수 있는 단일 바인딩 형태 (G7CoreGlobals 의 판정과 동일) */ +const SINGLE_BINDING = /^\{\{([^}]+)\}\}$/; + +/** + * 레이아웃 JSON 을 읽습니다. + * + * @param file 파일 경로 + * @return 파싱된 JSON + */ +function readLayout(file: string): any { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +/** + * 트리를 순회하며 조건에 맞는 노드를 모읍니다. + * + * @param node 시작 노드 + * @param predicate 수집 조건 + * @return 일치 노드 목록 + */ +function collectNodes(node: any, predicate: (n: any) => boolean): any[] { + const found: any[] = []; + + const walk = (current: any): void => { + if (Array.isArray(current)) { + current.forEach(walk); + + return; + } + + if (!current || typeof current !== 'object') { + return; + } + + if (predicate(current)) { + found.push(current); + } + + Object.values(current).forEach(walk); + }; + + walk(node); + + return found; +} + +/** DataGrid 노드를 찾습니다. */ +function findDataGrid(layout: any): any { + const grids = collectNodes(layout, (n) => n?.name === 'DataGrid' && !!n?.props?.expandChildren); + + expect(grids.length).toBe(1); + + return grids[0]; +} + +describe('상품목록 확장 행 옵션 지연 로딩 — 레이아웃 선언', () => { + const datagrid = readLayout(DATAGRID_PATH); + const grid = findDataGrid(datagrid); + + it('onExpandChange 가 옵션 로드 핸들러로 연결된다', () => { + const actions = collectNodes(datagrid, (n) => n?.event === 'onExpandChange'); + + expect(actions).toHaveLength(1); + expect(actions[0].handler).toBe('sirsoft-ecommerce.loadExpandedOptions'); + expect(actions[0].params).toEqual({ expandedRows: '{{$args[0]}}' }); + }); + + it('확장 상태 갱신과 옵션 로드가 한 액션으로 묶여 있다', () => { + // setState 로 expandedRows 를 **갱신**하는 액션이 남아 있으면 순서에 따라 갓 펼친 행이 + // 누락된다. 목록 구성이 바뀔 때의 **초기화**(빈 배열 대입)는 별개 목적이라 제외한다. + const staleSetState = collectNodes( + datagrid, + (n) => + n?.handler === 'setState' + && n?.params?.expandedRows !== undefined + && !(Array.isArray(n.params.expandedRows) && n.params.expandedRows.length === 0) + ); + + expect(staleSetState).toHaveLength(0); + }); + + it('expandContext 값이 모두 단일 바인딩 형태다', () => { + const context = grid.props.expandContext; + + expect(context).toBeDefined(); + + Object.entries(context).forEach(([key, value]) => { + expect(typeof value).toBe('string'); + expect( + SINGLE_BINDING.test(value as string), + `expandContext.${key} 가 단일 바인딩이 아니다: ${value}` + ).toBe(true); + }); + }); + + it('expandContext 에 로딩/에러 표시용 키가 노출된다', () => { + const context = grid.props.expandContext; + + expect(context.optionsLoadingIds).toBe('{{_local.optionsLoadingIds ?? []}}'); + expect(context.optionsErrorIds).toBe('{{_local.optionsErrorIds ?? []}}'); + }); + + it('"옵션 없음" 은 로드 완료(Array.isArray)를 확인한 뒤에만 표시된다', () => { + const noOptions = collectNodes( + datagrid, + (n) => typeof n?.id === 'string' && n.id.startsWith('no_options_message_') + ); + + expect(noOptions).toHaveLength(1); + expect(noOptions[0].if).toContain('Array.isArray(row.options)'); + expect(noOptions[0].if).toContain('row.options.length === 0'); + }); + + it('로딩/에러 블록이 존재하고 조건이 단일 바인딩이다', () => { + const loading = collectNodes( + datagrid, + (n) => typeof n?.id === 'string' && n.id.startsWith('options_loading_') + ); + const errored = collectNodes( + datagrid, + (n) => typeof n?.id === 'string' && n.id.startsWith('options_error_') + ); + + expect(loading).toHaveLength(1); + expect(errored).toHaveLength(1); + + [loading[0], errored[0]].forEach((node) => { + expect(SINGLE_BINDING.test(node.if), `if 가 단일 바인딩이 아니다: ${node.if}`).toBe(true); + }); + + expect(loading[0].if).toContain('optionsLoadingIds'); + expect(errored[0].if).toContain('optionsErrorIds'); + }); + + it('옵션 테이블은 로딩/에러 상태가 아닐 때만 렌더된다', () => { + const table = collectNodes( + datagrid, + (n) => n?.props?.className === 'overflow-x-auto' && typeof n?.if === 'string' + ); + + expect(table.length).toBeGreaterThanOrEqual(1); + + const condition = table[0].if; + + expect(SINGLE_BINDING.test(condition)).toBe(true); + expect(condition).toContain('optionsLoadingIds'); + expect(condition).toContain('optionsErrorIds'); + }); + + it('재시도 버튼이 재로드 핸들러로 연결된다', () => { + const retry = collectNodes( + datagrid, + (n) => n?.handler === 'sirsoft-ecommerce.retryExpandedOptions' + ); + + expect(retry).toHaveLength(1); + expect(retry[0].params).toEqual({ productId: '{{row.id}}' }); + }); +}); + +describe('일괄 변경 확인 모달 — 집계 표기', () => { + const modal = readLayout(CONFIRM_MODAL_PATH); + + it('옵션 개수는 배열 길이가 아니라 summary.optionCount 를 쓴다', () => { + const raw = JSON.stringify(modal); + + expect(raw).toContain('_global.bulkConfirmData.summary.optionCount'); + expect(raw).not.toContain('options_section|count={{_global.bulkConfirmData.options.length}}'); + }); + + it('집계 엔트리와 개별 엔트리가 서로 배타적으로 표시된다', () => { + const aggregate = collectNodes( + modal, + (n) => typeof n?.text === 'string' && n.text.includes('bulk_confirm.options_aggregate') + ); + + expect(aggregate).toHaveLength(1); + expect(aggregate[0].if).toBe('{{option.aggregate === true}}'); + + const individual = collectNodes( + modal, + (n) => n?.text === '{{option.productName}} > {{option.optionName}}' + ); + + expect(individual).toHaveLength(1); + expect(individual[0].if).toBe('{{option.aggregate !== true}}'); + }); +}); + +/** + * 목록 구성이 바뀌는 이동에서 펼침 상태가 초기화되는지 고정한다. + * + * 옵션은 목록 응답에 없고 펼칠 때만 채워지므로, 정렬·페이지·검색·프리셋으로 행 구성이 바뀐 뒤에도 + * 이전 펼침 상태가 남아 있으면 그 행은 옵션이 없는 채로 펼쳐진다 — 로딩도 "옵션 없음" 도 아닌 + * 빈 영역이 남는다. + * + * @scenario row_state=expanded,option_profile=active_only + * @effects expanded_state_reset_on_list_change + */ +describe('목록 구성 변경 시 펼침 상태 초기화', () => { + const LIST_PATH = path.resolve( + __dirname, + '../../../layouts/admin/admin_ecommerce_product_list.json' + ); + const FILTER_PATH = path.join(PARTIALS_ROOT, '_partial_filter_section.json'); + + /** 초기화 대상 키 — 하나라도 빠지면 그 흔적이 다음 목록으로 새어 나간다 */ + const RESET_KEYS = ['expandedRows', 'optionsLoadingIds', 'optionsErrorIds', 'selectedOptionIds']; + + /** + * setState 로 펼침 상태를 비우는 액션인지 판정합니다. + * + * @param node 검사 노드 + * @return 초기화 액션 여부 + */ + const isResetAction = (node: any): boolean => + node?.handler === 'setState' + && node?.params?.target === 'local' + && RESET_KEYS.every((key) => Array.isArray(node.params[key]) && node.params[key].length === 0); + + it('초기화 액션이 named_actions 에 단일 정의로 존재한다', () => { + const layout = readLayout(LIST_PATH); + const reset = layout.named_actions?.resetExpandedOptionState; + + expect(reset).toBeDefined(); + expect(isResetAction(reset)).toBe(true); + }); + + it('검색 실행이 초기화를 거친 뒤 이동한다', () => { + const layout = readLayout(LIST_PATH); + const search = layout.named_actions?.searchProducts; + + expect(search?.handler).toBe('sequence'); + expect(search.actions[0].actionRef).toBe('resetExpandedOptionState'); + expect(search.actions[1].actionRef).toBe('searchProductsNavigate'); + }); + + it('정렬 변경이 초기화를 거친 뒤 이동한다', () => { + const layout = readLayout(LIST_PATH); + + const sortSequences = collectNodes( + layout, + (n) => + n?.handler === 'sequence' + && Array.isArray(n.actions) + && n.actions.some( + (a: any) => typeof a?.params?.query?.sort_by === 'string' + ) + ); + + expect(sortSequences).toHaveLength(1); + expect(isResetAction(sortSequences[0].actions[0])).toBe(true); + }); + + it('페이지 이동이 초기화를 거친 뒤 이동한다', () => { + const grid = findDataGrid(readLayout(DATAGRID_PATH)); + + const onPageChange = (grid.actions ?? []).find((a: any) => a.event === 'onPageChange'); + + expect(onPageChange?.handler).toBe('sequence'); + expect(isResetAction(onPageChange.actions[0])).toBe(true); + expect(onPageChange.actions[1].handler).toBe('navigate'); + }); + + it('프리셋 적용과 필터 초기화도 초기화를 거친다', () => { + const filter = readLayout(FILTER_PATH); + + const navigates = collectNodes( + filter, + (n) => n?.handler === 'navigate' && n?.params?.path === '/admin/ecommerce/products' + ); + + expect(navigates.length).toBeGreaterThan(0); + + const refs = collectNodes(filter, (n) => n?.actionRef === 'resetExpandedOptionState'); + + expect(refs).toHaveLength(navigates.length); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productOptionsAdditionalToggle.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productOptionsAdditionalToggle.test.tsx index 9af8b47d..3e04ae1d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productOptionsAdditionalToggle.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/productOptionsAdditionalToggle.test.tsx @@ -88,8 +88,10 @@ describe('A23/§B-FAIL-1 — 추가옵션 확인 모달은 openModal/closeModal const openAction = btn.actions.find((a: any) => a.handler === 'openModal'); expect(openAction).toBeDefined(); expect(openAction.target).toBe('additional_options_clear_modal'); - // 행이 있을 때만 - expect(openAction.condition).toContain('(_local.form.additional_options ?? []).length > 0'); + // 행이 있을 때만. + // 액션 게이트 키는 `if` 다 — `condition` 은 컴포넌트 레벨 별칭이라 액션에서는 조용히 무시된다 + // (ActionDispatcher 는 action.if 만 본다). #78 후속으로 condition → if 로 교정됨. + expect(openAction.if).toContain('(_local.form.additional_options ?? []).length > 0'); }); it('"미사용" 버튼은 setState ui.showAdditionalOptionsClearModal 플래그를 더이상 쓰지 않는다', () => { @@ -484,8 +486,9 @@ describe('회귀: 상품 생성/저장 후 navigate 는 id 기반 (product_code productForm, (n) => n.handler === 'navigate' && typeof n.params?.path === 'string' && /\/admin\/ecommerce\/products\//.test(n.params.path) && /\/edit/.test(n.params.path), ); - // 생성모드 redirect 노드 (condition: !route.itemCode) 존재 - const createNav = navs => navs.find((n: any) => typeof n.condition === 'string' && /!route\.itemCode/.test(n.condition)); + // 생성모드 redirect 노드 (if: !route.itemCode) 존재 + // `condition` 은 액션에서 무시되므로 게이트가 성립하지 않는다 → `if` 로 교정됨 (#78 후속) + const createNav = navs => navs.find((n: any) => typeof n.if === 'string' && /!route\.itemCode/.test(n.if)); const node = createNav(navs); expect(node).toBeTruthy(); expect(node.params.path).toContain('result.data.id'); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/prunedListFieldConsumers.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/prunedListFieldConsumers.test.ts new file mode 100644 index 00000000..b0e0404a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/prunedListFieldConsumers.test.ts @@ -0,0 +1,123 @@ +/** + * 목록에서 뺀 값을 화면이 여전히 쓰는지 고정하는 회귀 테스트 (#518 / 공개 #76) + * + * 이번 변경은 여러 목록에서 "화면이 안 쓴다" 는 판단으로 값을 뺐다. 그 판단이 틀리면 응답만 + * 보는 테스트로는 잡히지 않는다 — 메뉴 목록에서 실제로 그렇게 데이터가 지워졌고, 배송정책 + * 목록에서는 국가 칩·배송비 요약 열이 조용히 비었다. + * + * 그래서 여기서는 **레이아웃 JSON 원본**을 읽어 두 가지를 함께 고정한다. + * 1. 화면이 그 필드를 그리고 있다 (소비 사실) + * 2. 그 화면의 목록 요청이 그 필드를 받아오도록 되어 있다 (공급 경로) + * 둘 중 하나만 바뀌면 실패한다. + * + * @vitest-environment node + */ + +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + +const LAYOUT_ROOT = path.resolve(__dirname, '../../../layouts/admin'); +// 저장소 루트 — 이 모듈의 목록 계약을 소비하는 사용자 템플릿까지 함께 고정한다. +const REPO_ROOT = path.resolve(__dirname, '../../../../../../..'); + +function readLayout(relativePath: string): any { + return JSON.parse(fs.readFileSync(path.join(LAYOUT_ROOT, relativePath), 'utf-8')); +} + +function readRaw(relativePath: string): string { + return fs.readFileSync(path.join(LAYOUT_ROOT, relativePath), 'utf-8'); +} + +/** + * @scenario endpoint=active_list,opt_in=default + * @effects product_form_selector_still_draws_country_chips + */ +describe('배송정책 목록 — 국가별 설정 공급 경로', () => { + const LIST = 'admin_ecommerce_shipping_policy_list.json'; + const DATAGRID = 'partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json'; + + it('화면이 국가 칩·배송비 요약·국가별 설정을 그린다 (소비 사실)', () => { + const raw = readRaw(DATAGRID); + + // 존재를 먼저 확정한다 — 화면이 이 값을 쓰지 않게 되면 아래 공급 요구도 함께 풀어야 한다. + expect(raw).toContain('countries_display'); + expect(raw).toContain('fee_summary'); + expect(raw).toContain('country_settings'); + }); + + it('목록 요청이 무거운 전체 컬럼을 켜지 않는다 (기본이 표시용 필드를 공급한다)', () => { + // 서버 기본 응답이 목록 표시용 국가별 설정을 이미 싣는다. 여기서 opt-in 을 켜면 + // 화면이 쓰지도 않는 구간 설정·도서산간 설정·계산 API 설정까지 되받아, 프루닝 + // 효과가 0 이 된다 — 실제로 한 번 그렇게 되돌아갔다. + const layout = readLayout(LIST); + const source = (layout.data_sources ?? []).find( + (ds: any) => ds.id === 'shipping_policies', + ); + + expect(source).toBeDefined(); + expect(source.params ?? {}).not.toHaveProperty('with_country_settings'); + }); + + it('상품 등록/수정의 배송정책 선택기도 전체 컬럼을 켜지 않는다', () => { + const layout = readLayout('admin_ecommerce_product_form.json'); + const source = (layout.data_sources ?? []).find((ds: any) => + String(ds.endpoint ?? '').includes('/admin/shipping-policies'), + ); + + expect(source, '배송정책 선택기 데이터소스가 있어야 한다').toBeDefined(); + expect(source.params ?? {}).not.toHaveProperty('with_country_settings'); + }); +}); + +describe('마이페이지 주문내역 — 주문상품 공급 경로', () => { + const USER_LAYOUT = path.join(REPO_ROOT, 'templates/_bundled/sirsoft-basic/layouts/mypage/orders.json'); + + it('전량 나열 화면은 with_items 를 명시적으로 켠다 (공급 경로)', () => { + // 서버 기본은 대표 1건이다. 이 화면은 주문상품을 전부 나열하므로 켜야 한다 — + // 빠지면 주문마다 상품 한 줄만 남는다. + const layout = JSON.parse(fs.readFileSync(USER_LAYOUT, 'utf-8')); + const source = (layout.data_sources ?? []).find((ds: any) => + String(ds.endpoint ?? '').includes('/user/orders'), + ); + + expect(source, '주문내역 데이터소스가 있어야 한다').toBeDefined(); + expect(source.params).toHaveProperty('with_items'); + expect(String(source.params.with_items)).toBe('1'); + }); + + it('params 에 주석 키를 두지 않는다 (전부 쿼리스트링으로 전송된다)', () => { + // `_comment_*` 를 params 안에 두면 그 문장이 그대로 URL 쿼리로 나간다 — + // 브라우저 실측에서 한글 주석 한 문단이 요청 URL 에 실려 있는 것을 확인했다. + // 설명은 데이터소스 레벨 `comment` 에 둔다. + const layout = JSON.parse(fs.readFileSync(USER_LAYOUT, 'utf-8')); + const source = (layout.data_sources ?? []).find((ds: any) => + String(ds.endpoint ?? '').includes('/user/orders'), + ); + + const commentKeys = Object.keys(source.params ?? {}).filter((k) => k.startsWith('_comment')); + expect(commentKeys, `params 안의 주석 키: ${commentKeys.join(', ')}`).toEqual([]); + }); + + it('화면이 주문상품 배열을 실제로 순회한다 (소비 사실)', () => { + const partial = path.join(REPO_ROOT, 'templates/_bundled/sirsoft-basic/layouts/partials/mypage/orders/_list.json'); + + // 소비가 사라지면 위의 공급 요구도 함께 풀어야 한다. + expect(fs.readFileSync(partial, 'utf-8')).toContain('order.items'); + }); +}); + +describe('주문 목록 — 대표 상품/배송 표시', () => { + const DATAGRID = 'partials/admin_ecommerce_order_list/_partial_order_datagrid.json'; + + it('목록은 대표 1건과 "외 N건" 집계만 쓴다 (옵션 배열 순회 없음)', () => { + const raw = readRaw(DATAGRID); + + // 집계 필드를 실제로 쓰고 있어야 대표 1건 축약이 성립한다. + expect(raw).toContain('options_count'); + + // 옵션 배열 자체를 순회하면 목록이 다시 전량을 필요로 한다는 뜻이다. + expect(raw).not.toContain('row.options.map'); + expect(raw).not.toContain('"source": "row.options'); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts new file mode 100644 index 00000000..5d68b937 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/radio-keyboard-operable.test.ts @@ -0,0 +1,118 @@ +/** + * 라디오 선택이 키보드로도 조작 가능한지 계약 테스트 (#493 후속) + * + * 배경: 라디오를 커스텀 스타일로 그리기 위해 `