diff --git a/AGENTS.md b/AGENTS.md
index da4fa5b8..2b9917dc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -540,6 +540,26 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통
레이아웃 치환 방식은 코어가 그 리터럴을 버리는 순간 조용히 사문화된다 — 합성 입력으로만 검증한 테스트는 계속 통과하므로 사문화가 드러나지 않는다. 정적 검사가 능력 선언 누락을 차단한다.
+### 예외를 응답으로 바꾸는 자리
+
+`catch (\Exception)` / `catch (\Throwable)` 는 **도메인 예외가 아닌 것**을 잡는 자리다. 여기서 4xx 를 돌려주면 인프라 장애·코드 결함이 "입력 오류" 로 위장되어 사용자는 고칠 수 없는 안내를 보고 운영자는 장애를 늦게 안다. 그리고 이미 번역된 `$e->getMessage()` 를 응답의 메시지 **키** 자리에 넘기면 키 해석에 실패해 원문(SQL 상태코드·경로 포함 가능)이 그대로 화면에 나간다. 둘 다 예외도 로그도 남기지 않는다.
+
+| 금지 | 올바른 사용 |
+|--------|---------------|
+| generic catch 가 4xx 반환 | 5xx — 의도적 4xx 는 사유와 함께 판정 테스트의 상수에 선언 |
+| generic catch 에서 상태코드 인자 생략 (`moduleError($mod,'key')`) | 상태코드 명시 — 생략 시 `ResponseHelper` 기본값 **400** 이 조용히 적용된다 |
+| 도메인 예외를 typed 로 승격한 뒤에도 `catch (\RuntimeException)` 유지 | 승격한 예외로 좁힌다 — 도메인 예외의 **부모**를 잡으면 남는 것은 인프라 예외뿐인데 그것까지 4xx 가 된다 |
+| `error($e->getMessage(), 422)` | `error($e->getMessageKey(), 422, null, $e->getMessageParams())` |
+| 도메인 예외가 번역문만 들고 다님 | 생성자에 키+치환 파라미터 보관 → `getMessageKey()` / `getMessageParams()` |
+| 서비스를 typed 예외로 승격하고 컨트롤러는 generic 만 유지 | 그 서비스 메서드를 호출하는 **컨트롤러 메서드 전수**에 typed catch 추가 (없으면 도메인 사유가 500 이 된다) |
+| typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 |
+| 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 |
+| 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 |
+
+`message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다.
+
+상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다.
+
### Listener 데이터 접근
| 금지 | 올바른 사용 |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ad229ae0..12601015 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@
- 설정을 항목 단위로 저장할 때 한 번 입력한 값을 빈 칸으로 되돌릴 수 없던 문제를 수정했습니다. 켜기/끄기 설정과 숫자 설정이 문자열로 저장되던 문제도 함께 고쳤으며, 해당 설정이 받을 수 없는 값은 저장 단계에서 안내와 함께 거부됩니다.
- 삭제된 본인인증 플러그인의 식별자가 정책 조회 응답과 공개 페이지에 그대로 노출되던 문제를 수정했습니다. 조회 응답은 실제 사용 가능한 인증 수단으로 해석해 내려주고, 공개 페이지에는 이 값을 싣지 않습니다.
- 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다.
+- 언어팩 관리(설치·활성화·삭제·업데이트·캐시 갱신)가 실패했을 때 응답에 내부 오류 원문이 그대로 실리던 문제를 수정했습니다. 설치 전 파일 검증 실패도 마찬가지로 안내 문구만 표시되며, 상세 내용은 디버그 모드에서만 확인할 수 있습니다.
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
diff --git a/README.ko.md b/README.ko.md
index bdf795ed..7ed5fe4c 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -515,8 +515,8 @@ cp .env.example .env
-
+
diff --git a/README.md b/README.md
index f1a6dd4a..1f9e8479 100644
--- a/README.md
+++ b/README.md
@@ -529,8 +529,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
-
+
diff --git a/app/Http/Controllers/Api/Admin/LanguagePackController.php b/app/Http/Controllers/Api/Admin/LanguagePackController.php
index ec29467d..4dbcc9d9 100644
--- a/app/Http/Controllers/Api/Admin/LanguagePackController.php
+++ b/app/Http/Controllers/Api/Admin/LanguagePackController.php
@@ -3,15 +3,16 @@
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\LanguagePackSlotConflictException;
+use App\Extension\Helpers\ChangelogParser;
use App\Http\Controllers\Api\Base\AdminBaseController;
-use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
+use App\Http\Requests\LanguagePack\BulkActivateRequest;
use App\Http\Requests\LanguagePack\IndexLanguagePackRequest;
+use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
use App\Http\Requests\LanguagePack\InstallFromFileRequest;
use App\Http\Requests\LanguagePack\InstallFromGithubRequest;
use App\Http\Requests\LanguagePack\InstallFromUrlRequest;
use App\Http\Requests\LanguagePack\ManifestPreviewRequest;
use App\Http\Requests\LanguagePack\UninstallLanguagePackRequest;
-use App\Extension\Helpers\ChangelogParser;
use App\Http\Resources\LanguagePackCollection;
use App\Http\Resources\LanguagePackResource;
use App\Services\LanguagePackService;
@@ -66,7 +67,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.fetch_success', $payload);
} catch (\Throwable $e) {
- return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.fetch_failed', 500, $e);
}
}
@@ -117,7 +118,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
- return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -145,7 +146,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
- return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -173,7 +174,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
- return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -202,7 +203,7 @@ class LanguagePackController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
} catch (\Throwable $e) {
- return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.install_failed', 500, $e);
}
}
@@ -237,17 +238,17 @@ class LanguagePackController extends AdminBaseController
'target' => (new LanguagePackResource($e->target))->toArray($request),
]);
} catch (\Throwable $e) {
- return $this->error('language_packs.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.activate_failed', 500, $e);
}
}
/**
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
*
- * @param \App\Http\Requests\LanguagePack\BulkActivateRequest $request 요청
+ * @param BulkActivateRequest $request 요청
* @return JsonResponse 결과 (succeeded/failed 분리)
*/
- public function bulkActivate(\App\Http\Requests\LanguagePack\BulkActivateRequest $request): JsonResponse
+ public function bulkActivate(BulkActivateRequest $request): JsonResponse
{
$ids = $request->validated('ids');
$result = $this->service->bulkActivate($ids);
@@ -278,7 +279,7 @@ class LanguagePackController extends AdminBaseController
(new LanguagePackResource($pack))->toArray($request)
);
} catch (\Throwable $e) {
- return $this->error('language_packs.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.deactivate_failed', 500, $e);
}
}
@@ -302,7 +303,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.uninstall_success');
} catch (\Throwable $e) {
- return $this->error('language_packs.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.uninstall_failed', 500, $e);
}
}
@@ -318,7 +319,7 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.check_updates_success', $result);
} catch (\Throwable $e) {
- return $this->error('language_packs.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.check_updates_failed', 500, $e);
}
}
@@ -345,7 +346,7 @@ class LanguagePackController extends AdminBaseController
(new LanguagePackResource($updated))->toArray($request)
);
} catch (\Throwable $e) {
- return $this->error('language_packs.update_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.update_failed', 500, $e);
}
}
@@ -361,7 +362,9 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.refresh_cache_success', $result);
} catch (\Throwable $e) {
- return $this->error('language_packs.refresh_cache_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ // 예외 원문을 응답에 싣지 않는다 — 디버그 노출은 ResponseHelper 가
+ // app.debug 에서만 Throwable 을 펼치는 기존 메커니즘에 위임한다.
+ return $this->error('language_packs.refresh_cache_failed', 500, $e);
}
}
@@ -378,7 +381,9 @@ class LanguagePackController extends AdminBaseController
return $this->success('language_packs.preview_success', $result);
} catch (\Throwable $e) {
- return $this->error('language_packs.preview_failed', 422, $e->getMessage(), ['error' => $e->getMessage()]);
+ // 업로드 ZIP 검증 실패는 의도적 422 로 존치하되, 예외 원문은 싣지 않는다
+ // (다른 manifestPreview 컨트롤러 3종과 동형).
+ return $this->error('language_packs.preview_failed', 422, $e);
}
}
@@ -410,7 +415,7 @@ class LanguagePackController extends AdminBaseController
'has_changelog' => $entries !== [] || $rawContent !== '',
]);
} catch (\Throwable $e) {
- return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ return $this->error('language_packs.fetch_failed', 500, $e);
}
}
}
diff --git a/docs/backend/api/language-packs.md b/docs/backend/api/language-packs.md
index a8cbb02f..abc9a9f3 100644
--- a/docs/backend/api/language-packs.md
+++ b/docs/backend/api/language-packs.md
@@ -1005,6 +1005,8 @@ HTTP/1.1 200
**설명** 업로드된 ZIP을 실제로 설치하지 않고 manifest 와 검증 결과만 미리 조회합니다. `core.language_packs.install` 권한이 필요합니다. 부수 효과 없이 읽기만 수행하며 검증 실패 시 422로 응답합니다. 설치 확인 모달에서 대상 언어팩의 메타데이터와 유효성을 미리 보여줄 때 사용합니다.
+검증 실패 응답에는 예외 원문이 실리지 않습니다. 상세 진단 정보는 `app.debug` 가 켜진 환경에서만 `debug` 필드로 노출됩니다 — 이 규칙은 이 컨트롤러의 모든 실패 응답(설치·활성화·비활성화·삭제·업데이트 확인·업데이트 수행·캐시 갱신·목록 조회)에 동일하게 적용됩니다.
+
### POST /api/admin/language-packs/refresh-cache
@@ -1147,11 +1149,55 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`LanguagePackResource::toDetailArray`). 목록 응답(`GET /api/admin/language-packs`)의 항목 필드에 아래 상세 전용 필드가 더해집니다._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| manifest | object\|null | `{"identifier":"g7-core-ja","locale":"ja","version":"1.0.6"}` | 언어팩 `language-pack.json` 원문 |
+| validation_summary | object | — | 설치 정합성 요약 |
+| validation_summary.target_version_mismatch | boolean | `false` | 대상(코어/확장) 버전과 언어팩이 맞지 않는지 여부 |
+| validation_summary.depends_on_core_locale | string\|null | `ja` | manifest `requires.depends_on_core_locale` — 이 팩이 전제하는 코어 로케일 |
+| source_meta | object | — | 설치 출처 메타 |
+| source_meta.type | string | `zip` | 설치 방식 (`zip`, `github`, `url`, `bundled`) |
+| source_meta.url | string\|null | `null` | 원격 설치 시 원본 주소 |
+| source_meta.installed_by | integer\|null | `1` | 설치를 수행한 사용자 ID |
+| source_meta.latest_version | string\|null | `1.0.6` | 업데이트 확인으로 파악된 최신 버전 |
+| source_meta.directory_path | string\|null | `lang-packs/g7-core-ja` | 프로젝트 루트 기준 설치 디렉토리 경로 (해석 실패 시 `null`) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "언어팩 목록을 조회했습니다.",
+ "data": {
+ "identifier": "g7-core-ja",
+ "locale": "ja",
+ "version": "1.0.6",
+ "status": "active",
+ "manifest": {
+ "identifier": "g7-core-ja",
+ "locale": "ja",
+ "version": "1.0.6"
+ },
+ "validation_summary": {
+ "target_version_mismatch": false,
+ "depends_on_core_locale": null
+ },
+ "source_meta": {
+ "type": "bundled",
+ "url": null,
+ "installed_by": 1,
+ "latest_version": "1.0.6",
+ "directory_path": "lang-packs/g7-core-ja"
+ }
+ }
+}
+```
**에러 응답**
@@ -1330,11 +1376,41 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드. `{id}` 가 정수면 설치된 언어팩을, 문자열(번들 식별자)이면 `lang-packs/_bundled/{id}` 를 대상으로 합니다. `CHANGELOG.md` 가 없으면 예외가 아니라 빈 결과로 응답합니다._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| identifier | string | `g7-core-ja` | 대상 언어팩 식별자 |
+| entries | array | `[{"version":"1.0.6","date":"2026-08-12","changes":{"Added":["..."]}}]` | `CHANGELOG.md` 를 파싱한 버전별 항목 목록 (Keep a Changelog 기준). 파일이 없으면 빈 배열 |
+| changelog | string | `"# Changelog\n\n## [1.0.6] - 2026-08-12\n..."` | `CHANGELOG.md` 원문. 파일이 없으면 빈 문자열 |
+| has_changelog | boolean | `true` | 표시할 변경 이력이 있는지 여부 (`entries` 가 비어 있지 않거나 원문이 빈 문자열이 아니면 `true`) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "언어팩 목록을 조회했습니다.",
+ "data": {
+ "identifier": "g7-core-ja",
+ "entries": [
+ {
+ "version": "1.0.6",
+ "date": "2026-08-12",
+ "changes": {
+ "Added": ["배송지 변경 오류 안내 문구의 일본어 번역을 추가했습니다."]
+ }
+ }
+ ],
+ "changelog": "# Changelog\n\n## [1.0.6] - 2026-08-12\n\n### Added\n\n- ...\n",
+ "has_changelog": true
+ }
+}
+```
**에러 응답**
diff --git a/docs/backend/exceptions.md b/docs/backend/exceptions.md
index 26298cf4..b0cf9f89 100644
--- a/docs/backend/exceptions.md
+++ b/docs/backend/exceptions.md
@@ -12,6 +12,8 @@
3. 파라미터 치환: __('exceptions.key', ['param' => $value])
4. 코어 예외: exceptions.template_engine.* 형식
5. 모듈 예외: vendor-module::exceptions.* 형식
+6. 응답에는 `getMessage()` 가 아니라 **메시지 키**를 넘긴다 (예외는 키를 들고 다닌다)
+7. typed 예외 → 기존 4xx 유지 / generic catch → 5xx (장애를 입력 오류로 위장하지 않는다)
```
---
@@ -21,9 +23,10 @@
1. [핵심 원칙](#핵심-원칙)
2. [다국어 파일 위치 및 구조](#다국어-파일-위치-및-구조)
3. [파라미터 치환 패턴](#파라미터-치환-패턴)
-4. [템플릿 엔진 Custom Exception 예시](#템플릿-엔진-custom-exception-예시)
-5. [Custom Exception 개발 체크리스트](#custom-exception-개발-체크리스트)
-6. [관련 문서](#관련-문서)
+4. [예외 → 응답 매핑](#예외--응답-매핑)
+5. [템플릿 엔진 Custom Exception 예시](#템플릿-엔진-custom-exception-예시)
+6. [Custom Exception 개발 체크리스트](#custom-exception-개발-체크리스트)
+7. [관련 문서](#관련-문서)
---
@@ -163,6 +166,92 @@ class MaxDepthExceededException extends Exception
---
+## 예외 → 응답 매핑
+
+예외를 응답으로 바꾸는 자리에서 두 가지가 자주 어긋난다. 둘 다 오류도 로그도 남기지 않아 코드를 나란히 놓고 보기 전에는 드러나지 않는다.
+
+시작하기 전에 통로부터 나눈다. `ResponseHelper::error($messageKey, $statusCode, $errors, $messageParams)` 는 인자마다 노출 규칙이 다르다.
+
+| 인자 | 통로 | 노출 규칙 |
+|---|---|---|
+| `$messageKey` (1번째) | 사용자에게 보이는 안내 문구 | **다국어 키 전용.** 번역문·예외 원문을 넣으면 키 해석에 실패해 원문이 그대로 나간다 |
+| `$errors` (3번째) — Throwable | 진단 | `app.debug` 에서만 `debug` 블록으로 펼쳐진다 |
+| `$errors` (3번째) — 문자열 | 진단 | `500` 이상 + 비디버그에서 **차단**, 그 외 노출 |
+| `$errors` (3번째) — 배열 | 구조화 페이로드 | **항상 노출** (검증 오류 구조가 이 통로를 쓴다) |
+| `$messageParams` (4번째) | 문구 치환 | 응답 본문에 직접 실리지 않는다 |
+
+그래서 "예외 원문을 응답에 싣느냐" 는 하나의 규칙으로 답할 수 없다. 판단 축은 셋이다.
+
+- **누구에게** — 관리자 전용 면인가, 공개(비인증) 엔드포인트인가. 공개 면에 SQL 상태코드·경로가 나가면 정보 노출이다. 반대로 관리자에게 실패 사유를 감추면, 서버 로그 접근 권한이 없는 운영자는 "실패했습니다" 외에 아무 근거도 얻지 못한다.
+- **무엇의 원문인가** — 우리 인프라 예외인가, 외부 시스템(결제대행사·배송사 API)이 돌려준 도메인 사유인가. 후자는 우리 스택 정보가 아니고 다국어 키로 옮길 수도 없다. 감추면 조치 가능한 정보가 사라진다.
+- **어느 통로인가** — 위 표. 노출 폭은 호출부가 문자열로 조립하지 말고 헬퍼에 맡긴다.
+
+다국어 키는 유한하고 실패 사유는 무한하다. 키만 고집하면 예상 못 한 실패가 전부 "작업에 실패했습니다" 하나로 붕괴하는데, 이는 아래 2번이 막으려는 "장애가 입력 오류로 위장" 과 같은 종류의 정보 손실이다.
+
+### 1. 응답에는 메시지 키를 넘긴다
+
+`ResponseHelper::error()` 의 첫 인자는 **다국어 키**다. 이미 번역된 `$e->getMessage()` 를 그 자리에 넘기면 키 해석에 실패해 원문이 그대로 사용자 화면에 나간다 — 원문에는 SQL 상태코드·경로·클래스명이 섞여 있을 수 있다.
+
+그래서 도메인 예외는 **번역문이 아니라 키를 들고 다닌다**. 생성자에서 키와 치환 파라미터를 보관하고 `getMessageKey()` / `getMessageParams()` 로 노출한다.
+
+```php
+class BoardTypeOperationException extends Exception
+{
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ public function getMessageKey(): string { return $this->messageKey; }
+
+ public function getMessageParams(): array { return $this->replace; }
+}
+```
+
+```php
+// ❌ 번역문을 키 자리에 — 해석 실패 → 원문 노출
+return $this->error($e->getMessage(), 422);
+
+// ✅ 키 + 치환 파라미터
+return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
+```
+
+사유 식별자만 들고 다니는 예외(`getReason()`)라면 호출부가 그 식별자로 키를 조립한다 — 이때도 응답에 들어가는 것은 키다.
+
+### 2. typed 는 기존 4xx, generic 은 5xx
+
+`catch (\Exception)` / `catch (\Throwable)` 는 **도메인 예외가 아닌 것**을 잡는 자리다. 여기서 4xx 를 돌려주면 인프라 장애와 코드 결함이 "입력 오류" 로 위장되어, 사용자는 고칠 수 없는 안내를 보고 운영자는 장애를 늦게 안다.
+
+```php
+try {
+ $this->service->doSomething($order, $data);
+} catch (OrderModificationException $e) {
+ // 도메인 규칙 위반 — 사용자가 고칠 수 있다. 기존 상태코드를 그대로 유지한다.
+ return ResponseHelper::error($e->getMessageKey(), 422, null, $e->getMessageParams());
+} catch (\Exception $e) {
+ // 그 외 — 서버 결함/인프라 장애. 메시지 키 자리에는 원문을 넣지 않는다.
+ // (진단이 필요하면 $errors 인자로 Throwable 을 넘겨 헬퍼의 debug 게이트에 맡긴다)
+ Log::error('...', ['error' => $e->getMessage()]);
+
+ return ResponseHelper::error('sirsoft-ecommerce::exceptions.operation_failed', 500);
+}
+```
+
+규칙 셋:
+
+- typed 예외를 잡는 분기의 상태코드는 **바꾸지 않는다**. 예외를 도입하는 작업이 사용자에게 보이는 계약을 함께 바꾸면 회귀다.
+- 서비스가 typed 예외를 던지는데 컨트롤러에 그 typed catch 가 없으면, 도메인 사유가 generic 으로 흘러 500 이 된다. 서비스를 typed 로 승격할 때는 **호출하는 컨트롤러 메서드를 전수로** 함께 본다.
+- 상태코드 인자를 생략한 `ResponseHelper::error('key')` / `moduleError($mod, 'key')` 는 기본값 **400** 이다. generic catch 안에서는 인자를 생략하지 않는다.
+- **도메인 예외의 부모를 잡지 않는다.** 도메인 예외는 대개 `\RuntimeException` 을 상속하므로, 서비스를 typed 로 승격한 뒤에도 `catch (\RuntimeException)` 을 남겨 두면 그 자리는 도메인 예외를 잡는 것처럼 보이지만 실제로 남는 것은 인프라 예외뿐이다. 그것까지 4xx 로 나가면 승격 작업이 아무것도 고치지 못한 셈이 된다. 승격한 예외 이름으로 좁힌다.
+
+의도적으로 4xx 를 유지하는 generic catch 도 있다 — 업로드 파일 해석처럼 실패 원인이 대부분 클라이언트 입력인 경우다. 그런 지점은 사유와 함께 명시적으로 선언하고, 판정에서 조용히 빠지게 두지 않는다.
+
+`tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 훑어 이 두 규칙을 고정한다. 예외 목록을 상수로 선언해 두었으므로, 새 예외를 만들려면 목록에 사유와 함께 추가해야 한다.
+
+---
+
## 템플릿 엔진 Custom Exception 예시
### CircularReferenceException (레이아웃 순환 참조)
@@ -303,6 +392,8 @@ class LayoutService
- [ ] `/lang/en/exceptions.php`에 영어 메시지 추가
- [ ] Exception 생성자에서 모든 메시지는 `__()` 함수 사용
- [ ] 동적 값은 파라미터 배열로 전달 (예: `['trace' => $stackTrace]`)
+- [ ] **메시지 키와 치환 파라미터를 보관하고 `getMessageKey()` / `getMessageParams()` 로 노출** (응답에 번역문 대신 키를 넘기기 위함)
+- [ ] **이 예외를 던지는 서비스 메서드를 호출하는 컨트롤러 메서드 전수에 typed catch 가 있는지 확인** (없으면 도메인 사유가 generic 으로 흘러 500 이 된다)
- [ ] 두 언어 모두에서 테스트 수행
- [ ] 예외 메시지가 사용자에게 노출될 경우 보안 정보 포함 금지
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
index 4c241eda..0ee29cfc 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
@@ -6,6 +6,10 @@
## [1.0.3] - 2026-08-12
+### Added
+
+- 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
+
### Changed
- 신고 반려 누적 제한 안내 문구의 일본어 번역을 실제 동작(설정 건수 도달 시 차단)에 맞게 정정했습니다.
diff --git a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
index 49e239e9..56f6afea 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
@@ -181,6 +181,7 @@ return [
'permission_denied' => '権限がありません。',
'validation_failed' => '入力値が正しくありません。',
'duplicate_report' => '既に通報された履歴があります。',
+ 'operation_failed' => '処理中にエラーが発生しました。',
],
'warnings' => [
'category_removal_attempted' => '使用中のカテゴリーの削除が試みられました。',
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
index 6eff3ad0..477c1b3a 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
@@ -10,6 +10,7 @@
- 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`).
- 관리자 주문설정 결제수단 목록의 「지정 PG 삭제됨」 배지 일본어 번역을 추가했습니다.
+- 배송지 변경 처리 중 서버 오류가 발생했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
- 배송정책 구간 설정의 새 검증 안내(구간 미등록, 중간 구간 종료값 누락, 수량 구간의 정수 제한, 단위값·무료배송 기준금액 누락, 도서산간 우편번호 형식) 일본어 번역을 추가했습니다.
- 배송정책 편집 화면에서 구간 입력 오류를 안내하는 문구(중간 구간 종료값 누락, 배송비 미입력, 수량 구간 정수 제한, 단위값·무료배송 기준금액 미입력) 일본어 번역을 추가했습니다.
- 배송비 요약에 표시되는 구간 단위(개/kg/L)와 구간 입력란의 단위 라벨 일본어 번역을 추가했습니다.
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
index 2251139a..bfa64a9d 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
@@ -83,4 +83,5 @@ return [
'coupon_issue_not_found' => 'クーポン発行履歴が見つかりません。',
'coupon_issue_not_cancellable' => '未使用状態の発行分のみキャンセルできます。',
'country_not_shippable' => '選択された配送先国には配送できない商品です。',
+ 'order_shipping_address_update_failed' => '配送先変更処理中にエラーが発生しました。',
];
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md
index 5a79c78b..a7bd54db 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.1] - 2026-08-14
+
+### Added
+
+- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
+
## [1.1.0] - 2026-08-10
### Added
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php
index 4fb37e19..76a3629b 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/backend/ja/messages.php
@@ -5,6 +5,7 @@ return [
'cash_receipt_issue_failed' => '現金領収書の発行に失敗しました。',
'order_not_found' => '注文が見つかりません。',
'cbt_failed' => '海外決済の処理に失敗しました。',
+ 'payment_failed' => '決済処理に失敗しました。しばらく後にもう一度お試しください。',
],
'refund' => [
'missing_tid' => 'トランザクション ID(TID)がないため、返金を進めることができません。',
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json
index 1e5be7a0..b01d3975 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_kginicis-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
},
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-pay_kginicis",
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
index 782858b9..02659028 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.3] - 2026-08-14
+
+### Added
+
+- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
+
## [1.0.2] - 2026-08-10
### Changed
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php
index e520bb91..46f045c1 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/backend/ja/messages.php
@@ -7,6 +7,7 @@ return [
'in_progress' => 'NHN KCP 返金はすでに処理中です。',
],
'errors' => [
+ 'payment_failed' => '決済処理に失敗しました。しばらく後にもう一度お試しください。',
'wsdl_missing' => 'KCP WSDL ファイルがありません: :file',
'approval_key_error' => 'KCP 承認キーエラー [:code]: :message',
'soap_error' => 'KCP SOAP 連携エラー: :message',
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json
index 8dff9e27..0ea371e2 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-pay_nhnkcp-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-pay_nhnkcp) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-pay_nhnkcp) 日本語 言語パック(バンドル)"
},
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-pay_nhnkcp",
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md
index ff1db298..b456268f 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.2] - 2026-08-14
+
+### Added
+
+- 결제 처리 실패 안내 문구의 일본어 번역을 추가했습니다 — 일본어 로케일에서 결제가 실패했을 때 한국어 안내가 보이던 문제가 해소됩니다.
+
## [1.0.1] - 2026-08-10
### Changed
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php
index 33071568..14d56527 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php
@@ -16,4 +16,7 @@ return [
'invalid_order_id' => '現金領収書発行識別子がToss Payments形式(英数字·ハイフン·アンダースコア 6~64文字)に一致していません。',
'cancel_reason' => '注文金額変更に伴う再発行',
],
+ 'errors' => [
+ 'payment_failed' => '決済処理に失敗しました。しばらく後に再度お試しください。',
+ ],
];
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json
index 10faf0d2..058b2364 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-tosspayments) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック(バンドル)"
},
- "version": "1.0.1",
+ "version": "1.0.2",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-tosspayments",
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 898d926a..d117e103 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -16,6 +16,8 @@
- 관리자 게시판 설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 정보가 계속 노출되던 문제를 수정했습니다. 환경설정 기본값 일괄 적용에도 같은 처리가 적용됩니다.
- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 저장 직후 조회가 저장 전 값을 돌려주거나 값의 형식이 정리되지 않은 채 남던 문제를 수정했습니다.
+- 게시판 유형 삭제가 실패했을 때 내부 오류 원문이 그대로 표시되던 문제를 수정했습니다. 이제 사용 중인 유형·기본 유형이라 삭제할 수 없는 경우에는 그 사유가 안내되고, 그 밖의 오류는 서버 오류로 구분됩니다.
+- 첨부 개수 상한 초과·대댓글 깊이 초과·댓글을 달 수 없는 글·메뉴 중복 안내도 같은 방식으로 정리했습니다. 안내 문구를 사용자의 언어 설정으로 만드는 시점이 응답을 만드는 시점과 어긋나 있던 것을 맞췄습니다. 표시되는 문구와 상태는 종전과 동일합니다.
- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다.
- 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
diff --git a/modules/_bundled/sirsoft-board/docs/api/board-types.md b/modules/_bundled/sirsoft-board/docs/api/board-types.md
index f19e409a..7d89b98c 100644
--- a/modules/_bundled/sirsoft-board/docs/api/board-types.md
+++ b/modules/_bundled/sirsoft-board/docs/api/board-types.md
@@ -206,6 +206,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| 403 | Forbidden | 요구 권한(`sirsoft-board.boards.create`)이 없는 경우 |
| 404 | Not Found | 해당 `id` 의 게시판 유형이 없는 경우 (`게시판 유형을 찾을 수 없습니다.`) |
| 422 | Unprocessable Entity | 해당 유형을 사용 중인 게시판이 있는 경우 (`:count개 게시판에서 사용 중인 유형은 삭제할 수 없습니다.`) 또는 게시판 기본 유형(`basic_defaults.type`)으로 설정된 경우 (`기본값으로 설정된 유형은 삭제할 수 없습니다.`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`작업 처리 중 오류가 발생했습니다.`) — 도메인 규칙 위반이 아닌 예외는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php b/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php
index fea8598d..afeb2b67 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/AttachmentLimitExceededException.php
@@ -52,6 +52,29 @@ class AttachmentLimitExceededException extends Exception
return $this->attempted;
}
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 응답을 만들 때 이미 번역된 `getMessage()` 대신 이 키를 넘긴다 — 번역문을 키로
+ * 해석하려 하면 실패해 원문이 그대로 노출된다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return 'sirsoft-board::messages.errors.attachment_limit_exceeded';
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return ['limit' => $this->limit, 'attempted' => $this->attempted];
+ }
+
/**
* 컨트롤러 catch 와 동일한 422 응답으로 렌더링합니다.
*
@@ -59,6 +82,11 @@ class AttachmentLimitExceededException extends Exception
*/
public function render(): JsonResponse
{
- return ResponseHelper::error($this->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return ResponseHelper::error(
+ $this->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $this->getMessageParams()
+ );
}
}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/BoardTypeOperationException.php b/modules/_bundled/sirsoft-board/src/Exceptions/BoardTypeOperationException.php
new file mode 100644
index 00000000..396aab70
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/BoardTypeOperationException.php
@@ -0,0 +1,50 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php b/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php
index 2f2c4046..17e520ca 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/CommentDepthExceededException.php
@@ -48,4 +48,24 @@ class CommentDepthExceededException extends Exception
{
return $this->attempted;
}
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return 'sirsoft-board::validation.comment.depth.exceeded';
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return ['max' => $this->limit];
+ }
}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/MenuAlreadyExistsException.php b/modules/_bundled/sirsoft-board/src/Exceptions/MenuAlreadyExistsException.php
index 46af07d5..754d4240 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/MenuAlreadyExistsException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/MenuAlreadyExistsException.php
@@ -11,19 +11,55 @@ use Exception;
*/
class MenuAlreadyExistsException extends Exception
{
+ /**
+ * 기본 안내 문구의 다국어 키
+ */
+ private const DEFAULT_MESSAGE_KEY = 'sirsoft-board::messages.boards.menu_already_exists';
+
+ /**
+ * 메시지 키 (직접 문구를 받은 경우 null)
+ */
+ private ?string $messageKey;
+
/**
* 메뉴 중복 예외 생성자
*
- * @param string $message 예외 메시지
+ * @param string $message 예외 메시지 (생략 시 기본 키로 해석)
* @param int $code 예외 코드
* @param \Throwable|null $previous 이전 예외
*/
public function __construct(string $message = '', int $code = 409, ?\Throwable $previous = null)
{
- if (empty($message)) {
- $message = __('sirsoft-board::messages.boards.menu_already_exists');
+ $this->messageKey = $message === '' ? self::DEFAULT_MESSAGE_KEY : null;
+
+ if ($message === '') {
+ $message = __(self::DEFAULT_MESSAGE_KEY);
}
parent::__construct($message, $code, $previous);
}
-}
\ No newline at end of file
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 호출부가 직접 문구를 넘긴 경우엔 키가 없으므로 기본 키를 돌려준다 — 응답에는
+ * 언제나 키가 전달되어야 하며, 이미 번역된 문구가 키 자리에 들어가면 해석에
+ * 실패해 원문이 그대로 노출된다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey ?? self::DEFAULT_MESSAGE_KEY;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return [];
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
index bce1a68f..3dbb57c8 100644
--- a/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
+++ b/modules/_bundled/sirsoft-board/src/Exceptions/PostNotCommentableException.php
@@ -18,13 +18,13 @@ class PostNotCommentableException extends Exception
{
/**
* @param string $reason 거절 사유 (blinded | deleted)
- * @param string $message 사용자에게 보일 메시지
+ * @param string $messageKey 사용자에게 보일 문구의 다국어 키
*/
public function __construct(
private string $reason,
- string $message,
+ private string $messageKey,
) {
- parent::__construct($message);
+ parent::__construct(__($messageKey));
}
/**
@@ -34,7 +34,7 @@ class PostNotCommentableException extends Exception
*/
public static function blinded(): self
{
- return new self('blinded', __('sirsoft-board::messages.comment.post_blinded'));
+ return new self('blinded', 'sirsoft-board::messages.comment.post_blinded');
}
/**
@@ -44,7 +44,27 @@ class PostNotCommentableException extends Exception
*/
public static function deleted(): self
{
- return new self('deleted', __('sirsoft-board::messages.comment.post_deleted'));
+ return new self('deleted', 'sirsoft-board::messages.comment.post_deleted');
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return [];
}
/**
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php
index db7a56da..4926220b 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/AttachmentController.php
@@ -84,7 +84,12 @@ class AttachmentController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (BoardNotFoundException $e) {
return $this->error('sirsoft-board::messages.boards.not_found', 404);
} catch (\Exception $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php
index b4b1bee9..b6491e59 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardController.php
@@ -335,7 +335,7 @@ class BoardController extends AdminBaseController
return $this->success('sirsoft-board::messages.boards.menu_added_success', ['menu' => $menu]);
} catch (MenuAlreadyExistsException $e) {
- return $this->error($e->getMessage(), $e->getCode());
+ return $this->error($e->getMessageKey(), $e->getCode(), null, $e->getMessageParams());
} catch (ModelNotFoundException $e) {
return $this->notFound('sirsoft-board::messages.boards.error_404');
} catch (AccessDeniedHttpException $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php
index d1ac36f9..d3d24a77 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Http\Controllers\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Board\Exceptions\BoardTypeOperationException;
use Modules\Sirsoft\Board\Http\Requests\StoreBoardTypeRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateBoardTypeRequest;
use Modules\Sirsoft\Board\Http\Resources\BoardTypeResource;
@@ -32,7 +33,7 @@ class BoardTypeController extends AdminBaseController
}
/**
- * @param StoreBoardTypeRequest $request
+ * @param StoreBoardTypeRequest $request
* @return JsonResponse
*/
public function store(StoreBoardTypeRequest $request): JsonResponse
@@ -47,8 +48,8 @@ class BoardTypeController extends AdminBaseController
}
/**
- * @param UpdateBoardTypeRequest $request
- * @param int $id
+ * @param UpdateBoardTypeRequest $request
+ * @param int $id
* @return JsonResponse
*/
public function update(UpdateBoardTypeRequest $request, int $id): JsonResponse
@@ -66,7 +67,7 @@ class BoardTypeController extends AdminBaseController
}
/**
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function destroy(int $id): JsonResponse
@@ -77,8 +78,13 @@ class BoardTypeController extends AdminBaseController
return $this->success('sirsoft-board::messages.board_type.deleted');
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-board::messages.board_type.not_found');
+ } catch (BoardTypeOperationException $e) {
+ // 삭제 불가 사유(사용 중/기본 유형)를 전용 키로 안내 — 예외 메시지 원문을
+ // 키 자리에 넘기면(종전 동작) 키 해석에 실패해 원문이 그대로 노출된다.
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (\Exception $e) {
- return $this->error($e->getMessage(), 422);
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return $this->error('sirsoft-board::messages.errors.operation_failed', 500);
}
}
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php
index 20943d3b..1201e600 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/CommentController.php
@@ -114,10 +114,10 @@ class CommentController extends AdminBaseController
return $this->notFound('sirsoft-board::messages.boards.not_found');
} catch (CommentDepthExceededException $e) {
// 요청 단계 검증을 우회해 Service 관문에 걸린 경우 — 사용자 입력 문제이므로 422
- return $this->error($e->getMessage(), 422);
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (PostNotCommentableException $e) {
// 블라인드·삭제된 게시글 — 서버 오류가 아니라 게시글 상태 문제이므로 422 + 사유 전달
- return $this->error($e->getMessage(), 422);
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.comment.create_failed', 500, $e->getMessage());
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php
index 0af8fe5a..0ef5576d 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/PostController.php
@@ -197,7 +197,12 @@ class PostController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.posts.create_failed', 500, $e->getMessage());
}
@@ -252,7 +257,12 @@ class PostController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (ModelNotFoundException $e) {
throw new PostNotFoundException($id);
} catch (AccessDeniedHttpException $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php
index f51568c6..b01f2bd0 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/AttachmentController.php
@@ -178,7 +178,12 @@ class AttachmentController extends PublicBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (BoardNotFoundException $e) {
return $this->notFound(__('sirsoft-board::messages.boards.not_found'));
} catch (\Exception $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php
index bcd13397..608772d4 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/CommentController.php
@@ -118,10 +118,10 @@ class CommentController extends PublicBaseController
return $this->error('sirsoft-board::messages.boards.not_found', 404);
} catch (CommentDepthExceededException $e) {
// 요청 단계 검증을 우회해 Service 관문에 걸린 경우 — 사용자 입력 문제이므로 422
- return $this->error($e->getMessage(), 422);
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (PostNotCommentableException $e) {
// 블라인드·삭제된 게시글 — 서버 오류가 아니라 게시글 상태 문제이므로 422 + 사유 전달
- return $this->error($e->getMessage(), 422);
+ return $this->error($e->getMessageKey(), 422, null, $e->getMessageParams());
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.comment.create_failed', 500);
}
diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
index 0be7a176..59ea1536 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/PostController.php
@@ -400,7 +400,12 @@ class PostController extends PublicBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (BoardNotFoundException $e) {
throw $e;
} catch (\Exception $e) {
@@ -455,7 +460,12 @@ class PostController extends PublicBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 게시판 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (AccessDeniedHttpException $e) {
return $this->error('auth.scope_denied', 403);
} catch (ModelNotFoundException $e) {
diff --git a/modules/_bundled/sirsoft-board/src/Services/BoardTypeService.php b/modules/_bundled/sirsoft-board/src/Services/BoardTypeService.php
index 8b5f26f1..d33df028 100644
--- a/modules/_bundled/sirsoft-board/src/Services/BoardTypeService.php
+++ b/modules/_bundled/sirsoft-board/src/Services/BoardTypeService.php
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Board\Exceptions\BoardTypeOperationException;
use Modules\Sirsoft\Board\Models\BoardType;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
use Modules\Sirsoft\Board\Repositories\Contracts\BoardTypeRepositoryInterface;
@@ -27,7 +28,7 @@ class BoardTypeService
}
/**
- * @param array $data
+ * @param array $data
* @return BoardType
*/
public function createBoardType(array $data): BoardType
@@ -46,8 +47,8 @@ class BoardTypeService
}
/**
- * @param int $id
- * @param array $data
+ * @param int $id
+ * @param array $data
* @return BoardType
*/
public function updateBoardType(int $id, array $data): BoardType
@@ -55,7 +56,7 @@ class BoardTypeService
$boardType = $this->boardTypeRepository->findById($id);
if (! $boardType) {
- throw (new ModelNotFoundException())->setModel(BoardType::class, $id);
+ throw (new ModelNotFoundException)->setModel(BoardType::class, $id);
}
HookManager::doAction('sirsoft-board.board_type.before_update', $boardType, $data);
@@ -74,7 +75,7 @@ class BoardTypeService
}
/**
- * @param int $id
+ * @param int $id
* @return bool
*
* @throws \Exception 사용 중인 유형 삭제 시도 시
@@ -84,23 +85,19 @@ class BoardTypeService
$boardType = $this->boardTypeRepository->findById($id);
if (! $boardType) {
- throw (new ModelNotFoundException())->setModel(BoardType::class, $id);
+ throw (new ModelNotFoundException)->setModel(BoardType::class, $id);
}
$usageCount = $this->boardRepository->countByType($boardType->slug);
if ($usageCount > 0) {
- throw new \Exception(
- __('sirsoft-board::messages.board_type.delete_in_use', [
- 'count' => $usageCount,
- ])
- );
+ throw new BoardTypeOperationException('sirsoft-board::messages.board_type.delete_in_use', [
+ 'count' => $usageCount,
+ ]);
}
$defaultType = $this->boardSettingsService->getSetting('basic_defaults.type');
if ($defaultType === $boardType->slug) {
- throw new \Exception(
- __('sirsoft-board::messages.board_type.delete_is_default')
- );
+ throw new BoardTypeOperationException('sirsoft-board::messages.board_type.delete_is_default');
}
HookManager::doAction('sirsoft-board.board_type.before_delete', $boardType);
diff --git a/modules/_bundled/sirsoft-board/src/lang/en/messages.php b/modules/_bundled/sirsoft-board/src/lang/en/messages.php
index 925196f5..8ba56284 100644
--- a/modules/_bundled/sirsoft-board/src/lang/en/messages.php
+++ b/modules/_bundled/sirsoft-board/src/lang/en/messages.php
@@ -225,6 +225,7 @@ return [
'permission_denied' => 'Permission denied.',
'validation_failed' => 'Validation failed.',
'duplicate_report' => 'You have already reported this content.',
+ 'operation_failed' => 'An error occurred while processing the operation.',
],
// Warning messages
diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
index 10041c1e..c9c24c49 100644
--- a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
+++ b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php
@@ -225,6 +225,7 @@ return [
'permission_denied' => '권한이 없습니다.',
'validation_failed' => '입력값이 올바르지 않습니다.',
'duplicate_report' => '이미 신고한 내역이 있습니다.',
+ 'operation_failed' => '작업 처리 중 오류가 발생했습니다.',
],
// 경고 메시지 (Warning)
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php
index 13c85a40..221fcbe7 100644
--- a/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Schema;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\BoardType;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
+use Modules\Sirsoft\Board\Services\BoardTypeService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
class BoardTypeManagementTest extends ModuleTestCase
@@ -258,6 +259,64 @@ class BoardTypeManagementTest extends ModuleTestCase
}
}
+ /**
+ * 삭제 불가 응답의 메시지가 예외 원문이 아니라 해석된 다국어 문구여야 한다.
+ *
+ * 종전에는 예외 메시지(이미 번역된 문장)를 다시 메시지 키 자리에 넘겨서, 키 해석에
+ * 실패한 원문이 그대로 사용자 화면에 나갔다. 상태코드만 보는 단언은 이 결함을
+ * 통과시킨다 — 422 는 그대로였기 때문이다.
+ *
+ * @scenario error_class=domain
+ *
+ * @effects board_type_delete_domain_exception_returns_422_with_resolved_message
+ */
+ public function test_delete_in_use_response_message_is_resolved_key_not_exception_text(): void
+ {
+ $boardType = BoardType::create([
+ 'slug' => 'test_in_use_msg',
+ 'name' => ['ko' => '사용중 유형'],
+ ]);
+
+ Board::factory()->create(['type' => 'test_in_use_msg']);
+
+ $response = $this->actingAs($this->adminUser)
+ ->deleteJson("/api/modules/sirsoft-board/admin/board-types/{$boardType->id}");
+
+ $response->assertStatus(422);
+
+ $expected = __('sirsoft-board::messages.board_type.delete_in_use', ['count' => 1]);
+ $this->assertSame($expected, $response->json('message'));
+ // 키 문자열 자체가 노출되면(해석 실패) 그것도 결함이다
+ $this->assertStringNotContainsString('sirsoft-board::', (string) $response->json('message'));
+ }
+
+ /**
+ * 인프라 예외는 422 로 뭉개지 않고 500 으로 구분하며 예외 원문을 노출하지 않는다.
+ *
+ * @scenario error_class=infrastructure
+ *
+ * @effects board_type_delete_infrastructure_exception_returns_500
+ */
+ public function test_delete_infrastructure_exception_returns_500(): void
+ {
+ $boardType = BoardType::create([
+ 'slug' => 'test_infra_500',
+ 'name' => ['ko' => '인프라 예외 유형'],
+ ]);
+
+ $this->mock(BoardTypeService::class, function ($mock) {
+ $mock->shouldReceive('deleteBoardType')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->adminUser)
+ ->deleteJson("/api/modules/sirsoft-board/admin/board-types/{$boardType->id}");
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
/**
* 존재하지 않는 유형 삭제 시 404 테스트
*/
diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-type-exception-status.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-type-exception-status.yaml
new file mode 100644
index 00000000..149bf20d
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-type-exception-status.yaml
@@ -0,0 +1,47 @@
+feature: 게시판 유형 삭제의 예외 → 상태코드 매핑 (Board Type Exception Status)
+
+description: |
+ 게시판 유형 삭제가 실패했을 때, 그 원인이 "운영자가 고칠 수 있는 도메인 사유"
+ (사용 중인 유형 / 기본 유형)인지 "서버 결함·인프라 장애"인지를 응답 상태코드로 구분한다.
+
+ 배경:
+ 종전에는 서비스가 `\Exception` 을 던지고 컨트롤러가 그 `getMessage()` 를 응답의
+ 메시지 **키 자리**에 넘겼다. 키 해석에 실패해 예외 원문이 그대로 화면에 나갔고,
+ 인프라 장애까지 같은 422 로 묶여 도메인 사유처럼 보였다.
+
+ 핵심 설계:
+ - `BoardTypeOperationException` 이 번역문이 아니라 **키와 치환 파라미터**를 들고 다닌다
+ (`getMessageKey()` / `getMessageParams()`)
+ - typed 도메인 예외 → **기존 422 유지** (예외 승격이 사용자 계약을 함께 바꾸면 회귀다)
+ - 그 외 예외 → 500
+
+ 도메인 축의 단언은 상태코드가 아니라 **문구**다. 상태코드만 보는 단언은 키 자리에
+ 원문이 실린 결함을 그대로 통과시킨다 — 종전 테스트가 실제로 그랬다. 그래서 응답
+ `message` 가 해석된 다국어 문구와 일치하고 키 네임스페이스(`sirsoft-board::`)가
+ 본문에 남아 있지 않음을 함께 단언한다.
+
+ 인프라 500 축은 서비스 mock 으로만 유발할 수 있어 PHPUnit 이 SSoT 다.
+
+axes:
+ error_class: [domain, infrastructure]
+
+exclusions: []
+
+effects:
+ - board_type_delete_domain_exception_returns_422_with_resolved_message
+ - board_type_delete_infrastructure_exception_returns_500
+
+scenarios:
+ - id: board_type_delete_failure_distinguishes_domain_from_infrastructure
+ description: 사용 중인 유형 삭제는 422 와 해석된 안내 문구로, 인프라 장애는 500 으로 구분된다
+ effects:
+ - board_type_delete_domain_exception_returns_422_with_resolved_message
+ - board_type_delete_infrastructure_exception_returns_500
+
+test_files:
+ - modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardTypeManagementTest.php
+
+notes: |
+ 이 매니페스트는 5차 감사에서 신설됐다. 두 테스트는 브랜치가 추가했지만
+ `@scenario`/`@effects` 가 없어 어느 매니페스트에도 묶이지 않았고, 선언이 아예 없는
+ effect 는 test-scenario-coverage 의 셀 대상이 아니라 조용히 통과했다.
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index 43838646..8ac8c5ae 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -14,25 +14,60 @@
### Changed
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
+- 환불 예상금액 계산이 쿠폰 사용 상태를 일시적으로 변경하지 않습니다. 계산 과정의 쿠폰 상태를 참조하던 외부 확장이 있다면 동작을 확인해 주세요.
### Fixed
+#### 삭제된 PG·현금영수증 발급사
+
- 결제수단에 지정한 PG 사의 플러그인을 삭제해도 그 결제수단이 주문서에 계속 노출되던 문제를 수정했습니다. 이전에는 구매자가 그 수단을 고르면 결제창이 뜨지 않은 채 주문완료로 넘어갔습니다. 이제 해당 결제수단은 주문서에서 제외되고, 관리자 주문설정 화면에는 「지정 PG 삭제됨」 표시가 붙습니다. 살아 있는 PG 로 다시 지정하면 즉시 복구되며, 주문서를 거치지 않고 직접 주문을 시도하는 경우에도 안내와 함께 거절됩니다.
- 현금영수증 발급사 플러그인을 삭제해도 주문서의 현금영수증 신청 폼과 마이페이지 발급 버튼이 계속 표시되던 문제를 수정했습니다. 신청해도 발급 실패로만 기록됐습니다. 이미 발급했거나 발급을 시도한 이력이 있는 주문은 종전대로 내역과 영수증 링크가 계속 표시됩니다.
-- 비회원 주문 상세와 관리자 주문 상세에서 현금영수증 금액의 소수 자릿수가 주문 시점이 아닌 현재 통화 설정을 따르던 문제를 수정했습니다.
+
+#### 설정 저장 반영
+
- 관리자 환경설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 메타 정보가 계속 노출되던 문제를 수정했습니다. 쇼핑몰 SEO 설정 저장 시 관련 캐시가 즉시 정리됩니다.
- 환경설정 저장 직후 같은 요청 안에서 결제수단·통화 정보가 저장 전 값으로 계산되던 문제를 수정했습니다. (#116 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 배송정책을 저장·삭제하거나 기본 배송정책을 바꾼 직후, 같은 요청 안에서 변경 전 배송정책으로 배송비가 계산될 수 있던 문제를 수정했습니다.
- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 설정 파일 안에서 값이 서로 어긋난 채 남을 수 있던 문제를 수정했습니다. 기본 통화를 바꿨는데 통화 목록의 기본 표시가 따라오지 않거나, 은행 목록 저장 시 은행명이 다국어 형식으로 정리되지 않는 등의 경우입니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
+
+#### 주문 배송지 변경
+
+- 비회원·회원 주문의 배송지 변경에서 서버 내부 오류가 「배송 전 상태에서만 배송지를 변경할 수 있습니다.」 라는 안내로 표시되던 문제를 수정했습니다. 이제 변경할 수 없는 상태일 때만 그 안내가 나오고, 그 밖의 오류는 서버 오류로 구분되어 장애를 바로 알아차릴 수 있습니다. (#104 @Tuwasduliebst 님께서 제보해주셨습니다.)
+- 해외 배송지로 주문 배송지를 변경할 때 국가를 함께 지정하지 않으면 저장에 실패하던 문제를 수정했습니다. 이제 국가 코드를 기준으로 국내·해외에 필요한 항목을 안내하며, 국내↔해외를 전환하면 반대편 주소가 남지 않습니다.
+- 주소록에서 해외 배송지를 골라 주문 배송지를 바꾸면 주소가 통째로 비워지던 문제를 수정했습니다. 국내 항목만 옮겨 담고 있어 해외 주소·도시·우편번호가 모두 사라졌으며, 저장은 정상 완료된 것처럼 처리돼 주문 상세를 다시 열어야 알 수 있었습니다.
+- 주소록에서 배송지를 고르면서 함께 입력한 배송 메모가 저장되지 않던 문제를 수정했습니다.
+
+#### 환불 예상금액
+
+- 환불 예상금액 조회가 실제로는 쿠폰 사용 이력을 잠시 변경했다가 되돌리던 문제를 수정했습니다. 이제 조회만 수행하므로, 계산 도중 서버가 중단되어도 사용한 쿠폰이 미사용으로 남지 않습니다. (#108 @Tuwasduliebst 님께서 제보해주셨습니다.)
+
+#### 오류 안내 구분
+
+- 관리자 화면 여러 곳에서 서버 내부 오류가 입력 오류처럼 표시되던 문제를 수정했습니다. 카테고리·브랜드·쿠폰·배송사·배송정책·추가배송비·상품 라벨·상품 공통정보·클레임 사유·상품 안내 템플릿·상품 이미지 관리에 적용됩니다.
+- 찜 목록 조회·찜 추가/해제·찜 삭제가 실패했을 때 서버 오류가 입력 오류로 표시되던 문제를 수정했습니다.
+- 배송설정에서 주문에 사용 중인 배송유형·배송사를 지우려 할 때 「설정 저장 중 오류가 발생했습니다.」 라고만 표시되어 이유를 알 수 없던 문제를 수정했습니다. 이제 어떤 항목이 몇 건의 주문에서 사용 중인지 안내되며, 서버 오류와도 구분됩니다.
+- 상품 문의 등록이 서버 오류로 실패해도 그 원문이 입력 오류 안내에 섞여 표시되던 문제를 수정했습니다. 이제 문의 게시판 미설정처럼 안내 가능한 사유만 그 자리에 표시되고, 그 밖의 오류는 서버 오류로 구분됩니다.
+- 마이페이지의 문의 수정·삭제와 답변 등록·수정·삭제에서도 같은 문제를 수정했습니다. 아울러 문의 게시판이 설정되지 않아 삭제할 수 없을 때 그 사유가 전달되지 않고 일반 서버 오류로만 표시되던 문제를 함께 수정해, 운영자가 원인을 바로 알 수 있습니다.
+- 일부 오류 응답에 내부 오류 원문이 그대로 표시되던 문제를 수정했습니다. 쿠폰 다운로드·클레임 사유 삭제·배송사 삭제·상품 문의 답변 관리·리뷰 작성·리뷰 이미지 업로드에서 상황에 맞는 안내 문구가 나옵니다.
+
+#### 쿠폰·통화
+
+- 비회원 주문 상세와 관리자 주문 상세에서 현금영수증 금액의 소수 자릿수가 주문 시점이 아닌 현재 통화 설정을 따르던 문제를 수정했습니다.
- 관리자 쿠폰 등록·수정에서 「타 쿠폰과 중복 사용」을 선택하면 `is combinable 필드는 true 또는 false여야 합니다.` 오류로 저장이 실패하던 문제를 수정했습니다. (#97 @lyg-kaban 님께서 제보해주셨습니다.)
- 언어/통화 설정에서 기본 제공 통화(달러·엔·위안·유로)를 삭제해도 저장 직후 다시 나타나던 문제를 수정했습니다. 삭제한 통화는 설정 화면과 쇼핑몰 화면 양쪽에서 유지되며, 통화 추가로 다시 등록하면 복원됩니다. 사용 중지된 통화를 표시 통화로 갖고 있던 구매자의 주문은 기본 통화로 안전하게 진행됩니다. (#91 @koojunho 님께서 제보해주셨습니다.)
- 통화를 삭제한 뒤 그 통화로 결제된 과거 주문의 금액 표기가 달라지던 문제를 수정했습니다. 주문·환불 금액의 소수 자릿수는 주문 시점 기준을 유지하므로, 엔화처럼 소수점을 쓰지 않는 통화가 `¥14,835.00` 으로 바뀌거나 소수점 이하 자릿수가 많은 통화의 표시 금액이 잘리지 않습니다. (#91 @koojunho 님께서 제보해주셨습니다.)
+
+#### 관리자 화면
+
- 관리자 엑셀 다운로드 관리의 다운로드 이력 모달에서, 이력 항목이 아직 선택되지 않은 순간에 파일 정보·상태 배지가 비정상적으로 처리되던 문제를 보완했습니다. 항목을 선택한 뒤의 표시는 종전과 동일합니다.
- 같은 모달의 "다운로드 기록" 목록이 항상 비어 보이던 문제를 수정했습니다. 이제 누가 언제 몇 번째로 내려받았는지 기록이 표시됩니다.
- 같은 모달의 "처리중"·"실패"·"만료" 상태 배지에서 아이콘이 표시되지 않던 문제를 수정했습니다.
-- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
- 관리자 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다. 주문·상품·쿠폰·배송정책·마일리지 내역·리뷰 목록에 적용됩니다. 쿠폰 적용 대상 고르기처럼 여러 페이지에 걸쳐 선택하는 화면은 종전대로 선택이 유지됩니다.
+#### 기타
+
+- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
+
#### 배송비 구간
- 구간별 배송비의 종료값이 그 구간에 포함되도록 수정했습니다. "1~5개 3,000원 / 6개~ 5,000원"으로 설정했을 때 수량이 정확히 5개면 어느 구간에도 해당하지 않아 배송비가 0원(무료배송)으로 계산되던 문제입니다. 이미 저장해 둔 정책은 그대로 두어도 정상 계산됩니다. (#94 @koojunho 님께서 제보해주셨습니다.)
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md
index 91c9fa62..60445626 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/addresses.md
@@ -199,7 +199,7 @@ HTTP/1.1 201
-**설명** 로그인한 회원 본인의 새 배송지를 등록합니다. `auth:sanctum` 인증이 필요하며, `UserAddressService::createAddress()`가 검증된 요청에 현재 사용자 ID를 결합해 배송지를 생성하고 성공 시 `201`로 반환합니다. 국내(우편번호/도로명·지번)·해외(intl_* 필드) 주소를 모두 지원하고 `is_default`로 기본 배송지 지정이 가능합니다. 같은 이름의 배송지가 있으면 `409`(중복 ID 포함)를, `force_overwrite`로 덮어쓰기를 허용할 수 있으며, 최대 배송지 개수를 초과하면 `422`를 반환합니다.
+**설명** 로그인한 회원 본인의 새 배송지를 등록합니다. `auth:sanctum` 인증이 필요하며, `UserAddressService::createAddress()`가 검증된 요청에 현재 사용자 ID를 결합해 배송지를 생성하고 성공 시 `201`로 반환합니다. 국내(우편번호/도로명·지번)·해외(intl_* 필드) 주소를 모두 지원하고 `is_default`로 기본 배송지 지정이 가능합니다. 같은 이름의 배송지가 있으면 `409`(중복 ID 포함)를 반환하며, `force_overwrite`로 덮어쓰기를 허용할 수 있습니다. 그 밖의 예외는 서버 결함으로 보아 `500` 으로 응답합니다 — 개수 상한은 현재 도입되어 있지 않습니다.
### DELETE /api/modules/sirsoft-ecommerce/user/addresses/{id}
@@ -229,7 +229,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "배송지가 삭제되었습니다.",
+ "data": null
+}
+```
**에러 응답**
@@ -266,11 +276,57 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| address | object | `{"id":99,"user_id":"a26219fc-94a0-4f63-9404-04c2a6ac99e4"…` | 조회한 배송지 단건 (`UserAddressResource`) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "배송지를 조회했습니다.",
+ "data": {
+ "address": {
+ "id": 99,
+ "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4",
+ "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-08-05 09:46:56",
+ "updated_at": "2026-08-05 09:46:56",
+ "abilities": {
+ "can_update": true,
+ "can_delete": false,
+ "can_set_default": false
+ }
+ }
+ }
+}
+```
**에러 응답**
@@ -345,11 +401,57 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| address | object | `{"id":99,"user_id":"a26219fc-94a0-4f63-9404-04c2a6ac99e4"…` | 수정된 배송지 (`UserAddressResource`) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "배송지가 수정되었습니다.",
+ "data": {
+ "address": {
+ "id": 99,
+ "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4",
+ "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-08-05 09:46:56",
+ "updated_at": "2026-08-05 09:46:56",
+ "abilities": {
+ "can_update": true,
+ "can_delete": false,
+ "can_set_default": false
+ }
+ }
+ }
+}
+```
**에러 응답**
@@ -391,7 +493,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
-
+```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 e168d6d8..ad399777 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/brands.md
@@ -283,11 +283,29 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: 삭제 결과 요약 (`data` 객체)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| brand_id | integer | `2` | 삭제된 브랜드의 기본 키 |
+| products_count | integer | `0` | 삭제 시점에 이 브랜드를 쓰던 상품 수 (연결 상품이 있으면 삭제가 거부되므로 언제나 0) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "브랜드가 삭제되었습니다.",
+ "data": {
+ "brand_id": 2,
+ "products_count": 0
+ }
+}
+```
**에러 응답**
@@ -296,6 +314,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -406,6 +425,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -435,11 +455,42 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`BrandResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/brands/{id} (브랜드 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -477,11 +528,42 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`BrandResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/brands/{id} (브랜드 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -490,6 +572,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.brands.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md
index a0e2d144..1a45e887 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md
@@ -406,6 +406,7 @@ HTTP/1.1 201
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `file` 은 필수이며 `jpeg,png,jpg,gif,svg,webp` 이미지만 허용, 최대 10MB |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -469,6 +470,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `order` 는 1건 이상 필수이며 각 항목에 정수 `id` 와 0 이상의 정수 `order` 가 필요 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -523,6 +525,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
| 404 | Not Found | path 의 이미지 `id` 에 해당하는 카테고리 이미지가 없는 경우 (`messages.category_images.not_found`) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -590,6 +593,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `parent_menus` / `child_menus` 중 최소 하나가 필요하며, 각 항목의 `id` 는 실제 존재하는 카테고리여야 하고 `order` 는 0 이상의 정수 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -817,6 +821,7 @@ HTTP/1.1 201
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `file` 은 필수이며 `jpeg,png,jpg,gif,svg,webp` 이미지만 허용, 최대 10MB |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -846,11 +851,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: 삭제 결과 요약 (`data` 객체)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| category_id | integer | `12` | 삭제된 카테고리의 기본 키 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "카테고리가 삭제되었습니다.",
+ "data": {
+ "category_id": 12
+ }
+}
+```
**에러 응답**
@@ -859,6 +880,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -990,6 +1012,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1019,11 +1042,49 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`CategoryResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/categories/{id} (카테고리 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -1061,11 +1122,49 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`CategoryResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/categories/{id} (카테고리 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -1074,6 +1173,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.categories.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1215,11 +1315,49 @@ Accept: application/json
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`CategoryResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/categories/{id} (카테고리 수정)** 응답 필드 표와 동일합니다._ 공개 API 는 비활성 카테고리를 노출하지 않습니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md
index 9a6b948d..f418e995 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/claim-reasons.md
@@ -28,7 +28,7 @@
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) |
-| fault_type | query | string | 아니오 | — | |
+| fault_type | query | string | 아니오 | `customer`, `seller`, `carrier` | 귀책 구분 필터 (ClaimReasonFaultTypeEnum — 고객/판매자/배송사 귀책). 미전달 시 전체 |
| is_active | query | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) |
| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) |
@@ -313,7 +313,7 @@ HTTP/1.1 200
-**설명** 관리자가 클래임 사유 1건을 삭제합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 대상이 없으면 404 를 반환하고 존재하면 `ClaimReasonService::deleteReason()`이 삭제합니다. 이미 사용 중인 사유 등 삭제 불가 상황에서는 서비스가 던진 예외 메시지를 그대로 사용해 400 으로 응답하므로, 관리자에게 삭제 실패 사유가 노출됩니다.
+**설명** 관리자가 클래임 사유 1건을 삭제합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 대상이 없으면 404 를 반환하고 존재하면 `ClaimReasonService::deleteReason()`이 삭제합니다. 이미 사용 중인 사유 등 삭제 불가 상황에서는 그 사유에 대응하는 안내 문구를 400 으로 응답합니다 (예외 메시지 원문이 아니라 전용 다국어 키를 사용). 그 밖의 서버 내부 오류는 500 으로 구분됩니다.
### GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id}
@@ -510,6 +510,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 같은 `type` 내 다른 사유가 동일한 `code` 를 이미 사용 중이면 `code` 필드 검증 실패 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -599,6 +600,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -756,11 +758,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: 삭제 결과 요약 (`data` 객체)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| reason_id | integer | `8` | 삭제된 클레임 사유의 기본 키 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "클레임 사유가 삭제되었습니다.",
+ "data": {
+ "reason_id": 8
+ }
+}
+```
**에러 응답**
@@ -769,10 +787,11 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
-**설명** 관리자가 클레임 사유 1건을 삭제합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 대상이 없으면 404 를 반환하고 존재하면 `ClaimReasonService::deleteReason()`이 삭제합니다. 이미 사용 중인 사유 등 삭제 불가 상황에서는 서비스가 던진 예외 메시지를 그대로 사용해 400 으로 응답하므로, 관리자에게 삭제 실패 사유가 노출됩니다.
+**설명** 관리자가 클레임 사유 1건을 삭제합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, 대상이 없으면 404 를 반환하고 존재하면 `ClaimReasonService::deleteReason()`이 삭제합니다. 이미 사용 중인 사유 등 삭제 불가 상황에서는 그 사유에 대응하는 안내 문구를 400 으로 응답합니다 (예외 메시지 원문이 아니라 전용 다국어 키를 사용). 그 밖의 서버 내부 오류는 500 으로 구분됩니다.
### GET /api/modules/sirsoft-ecommerce/admin/claim-reasons/{id}
@@ -798,11 +817,42 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`). 필드 구성은 이 문서의 **POST /api/modules/sirsoft-ecommerce/admin/claim-reasons (클레임 사유 생성)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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"
+ },
+ "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,
+ "can_delete": true
+ }
+ }
+}
+```
**에러 응답**
@@ -860,11 +910,42 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`). 필드 구성은 이 문서의 **POST /api/modules/sirsoft-ecommerce/admin/claim-reasons (클레임 사유 생성)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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"
+ },
+ "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,
+ "can_delete": true
+ }
+ }
+}
+```
**에러 응답**
@@ -874,6 +955,7 @@ Content-Type: application/json
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -903,11 +985,42 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`). 필드 구성은 이 문서의 **POST /api/modules/sirsoft-ecommerce/admin/claim-reasons (클레임 사유 생성)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
-
+```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"
+ },
+ "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,
+ "can_delete": true
+ }
+ }
+}
+```
**에러 응답**
@@ -916,6 +1029,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md
index e6d58135..74d0f505 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/coupons.md
@@ -580,12 +580,14 @@ HTTP/1.1 201
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 400 | 쿠폰 다운로드 제한 횟수를 초과했습니다. | 회원 1인당 발급 제한(`per_user_limit`) 을 이미 채운 경우 |
+| 400 | Bad Request | 발급 불가 사유(한도 초과·발급기간 종료·중복 발급 등). 사유 식별자에 대응하는 `messages.coupon.*` 문구가 `message` 에 담긴다 |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | `coupon_id` 가 정수가 아니거나 존재하지 않는 쿠폰인 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
-**설명** 로그인한 회원이 다운로드형 쿠폰 1건을 실제로 발급받습니다. `auth:sanctum` 인증이 필요하고 대상 쿠폰은 `couponId`(경로)로 지정하며, `UserCouponService::downloadCoupon()`이 발급기간·수량·회원당 한도·중복 발급 여부를 검증한 뒤 발급 내역을 생성해 201로 반환합니다. 한도 초과·발급기간 종료 등 발급 불가 사유는 서비스 예외의 메시지와 코드가 그대로 오류 응답에 전달됩니다.
+**설명** 로그인한 회원이 다운로드형 쿠폰 1건을 실제로 발급받습니다. `auth:sanctum` 인증이 필요하고 대상 쿠폰은 `couponId`(경로)로 지정하며, `UserCouponService::downloadCoupon()`이 발급기간·수량·회원당 한도·중복 발급 여부를 검증한 뒤 발급 내역을 생성해 201로 반환합니다. 한도 초과·발급기간 종료 등 발급 불가 사유는 사유 식별자에 대응하는 다국어 키(`messages.coupon.*`)로 해석되어 `400` 으로 응답합니다.
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 d114266a..32787321 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/extra-fee-templates.md
@@ -259,6 +259,7 @@ HTTP/1.1 201
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `zipcode` 는 숫자 또는 `숫자-숫자` 범위 형식이어야 하며 이미 등록된 우편번호면 중복 오류 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -554,6 +555,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `ids` 는 1건 이상이어야 하며 각 값은 존재하는 템플릿 ID 여야 함 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -622,6 +624,7 @@ HTTP/1.1 201
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 항목별로 `items.*.zipcode` (필수, 숫자 또는 `숫자-숫자` 범위, 최대 20자) 와 `items.*.fee` (필수, 0 이상) 가 검증됨 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -692,6 +695,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `ids` 는 1건 이상이며 각 값은 존재하는 템플릿 ID, `is_active` 는 필수 boolean |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -725,7 +729,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "추가배송비 템플릿이 삭제되었습니다.",
+ "data": true
+}
+```
**에러 응답**
@@ -734,6 +748,7 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -763,11 +778,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ExtraFeeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -876,6 +918,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). `zipcode` 는 숫자 또는 `숫자-숫자` 범위 형식이어야 하며 자신을 제외한 다른 템플릿과 중복될 수 없음 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -905,11 +948,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ExtraFeeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -918,6 +988,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md b/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md
index 7c7b7d0d..82ca21d1 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/guest.md
@@ -261,7 +261,8 @@ _단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 취소 후 갱
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 (토큰이 지목한 주문 외에는 접근 불가) |
-| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 (`error.errors` 에 필드별 메시지), 또는 취소 처리 실패 (`주문 취소에 실패했습니다.`) |
+| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 (`error.errors` 에 필드별 메시지), 또는 취소 도메인 규칙 위반 (`주문 취소 처리에 실패했습니다.`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`작업 처리 중 오류가 발생했습니다.`) |
@@ -560,7 +561,7 @@ _단건 응답: `data` 객체의 필드 (`AdjustmentResult::toPreviewArray()`
| --- | --- | --- |
| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
-| 500 | Internal Server Error | 환불 예상금액 계산 실패 (`환불 예상금액 조회에 실패했습니다.`) |
+| 500 | Internal Server Error | 환불 예상금액 계산 실패 (`환불 예상금액 계산에 실패했습니다.`) |
@@ -634,11 +635,11 @@ _단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 구매확정
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치, 또는 `optionId` 가 해당 주문에 속하지 않는 경우 |
-| 422 | Unprocessable Entity | 구매확정 불가 상태 (배송 미완료 등 — `현재 상태에서는 구매확정할 수 없습니다.`) |
+| 500 | Internal Server Error | 구매확정 처리 실패 (`작업 처리 중 오류가 발생했습니다.`) |
-**설명** 비회원이 배송 완료된 주문의 개별 옵션(`optionId`)을 구매확정합니다. 조회 토큰(`X-Guest-Order-Token`)으로 주문 소유권이 검증되며, `OrderController@confirmOption`이 토큰으로 검증된 주문에 실제 속한 옵션인지 다시 확인한 뒤 `OrderService::confirmOption()`을 호출합니다. 주문에 속하지 않은 옵션 ID면 404, 확정 불가 상태(배송 미완료 등)면 422를 반환합니다. 구매확정 시 적립 포인트 확정 등 후속 처리가 서비스 계층에서 이어집니다.
+**설명** 비회원이 배송 완료된 주문의 개별 옵션(`optionId`)을 구매확정합니다. 조회 토큰(`X-Guest-Order-Token`)으로 주문 소유권이 검증되며, `OrderController@confirmOption`이 토큰으로 검증된 주문에 실제 속한 옵션인지 다시 확인한 뒤 `OrderService::confirmOption()`을 호출합니다. 주문에 속하지 않은 옵션 ID면 404를 반환합니다. `OrderService::confirmOption()` 은 도메인 예외를 던지지 않으므로 그 밖의 실패는 모두 서버 내부 오류(500)로 구분됩니다. 구매확정 시 적립 포인트 확정 등 후속 처리가 서비스 계층에서 이어집니다.
### PUT /api/modules/sirsoft-ecommerce/guest/orders/{orderNumber}/shipping-address
@@ -741,9 +742,21 @@ _단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 배송지 수
| --- | --- | --- |
| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치 |
| 422 | Unprocessable Entity | 요청 파라미터 검증 위반 (`error.errors` 에 필드별 메시지), 또는 배송이 이미 시작되어 수정 불가 (`배송 전 상태에서만 배송지를 변경할 수 있습니다.`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`배송지 변경 처리 중 오류가 발생했습니다.`) |
-**설명** 비회원이 배송 전 상태의 주문 배송지를 수정합니다. 조회 토큰(`X-Guest-Order-Token`)으로 주문 소유권이 검증되며, 비회원은 저장된 회원 주소(`address_id`)를 쓸 수 없으므로 수취인·연락처·주소 필드를 직접 입력받아 `OrderController@updateShippingAddress`가 회원과 동일한 `OrderService::updateShippingAddress()`로 처리합니다. 국내(`zipcode`/`address`)와 해외(`address_line_1`·`intl_city` 등) 주소 필드를 함께 지원합니다. 이미 배송이 시작된 주문 등 수정 불가 상태면 422를 반환합니다.
+**설명** 비회원이 배송 전 상태의 주문 배송지를 수정합니다. 조회 토큰(`X-Guest-Order-Token`)으로 주문 소유권이 검증되며, 비회원은 저장된 회원 주소(`address_id`)를 쓸 수 없으므로 수취인·연락처·주소 필드를 직접 입력받아 `OrderController@updateShippingAddress`가 회원과 동일한 `OrderService::updateShippingAddress()`로 처리합니다.
+
+**국내/해외 주소 조합** `country_code` 가 국내/해외를 가르는 기준축입니다.
+
+| `country_code` | 필수 필드 | 비활성 측 처리 |
+| --- | --- | --- |
+| 미전송 또는 `KR` | `zipcode`, `address` | 해외 컬럼(`address_line_1`·`intl_*`)을 `null` 로 초기화 |
+| 그 외 (예: `US`) | `address_line_1`, `intl_city`, `intl_postal_code` | 국내 컬럼(`zipcode`·`address`)을 빈 문자열로 초기화 |
+
+필수 필드가 비면 422 검증 오류를 반환합니다. 국내↔해외를 전환하면 반대편 컬럼이 초기화되므로 구 주소가 남지 않습니다.
+
+**상태코드 구분** 배송 전 상태가 아닌 등 도메인 규칙 위반은 422, 그 밖의 서버 내부 오류는 500 입니다. 500 응답에는 예외 원문이 실리지 않습니다.
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md
index 4e7810e8..bd8d27cb 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/inquiries.md
@@ -65,12 +65,12 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteInquiry()` 의 `RuntimeException` |
+| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteInquiry()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 삭제 처리 중 예기치 못한 예외 (`문의 삭제에 실패했습니다.`) |
-**설명** 관리자가 상품 1:1 문의 1건을 삭제합니다. `sirsoft-ecommerce.inquiries.delete` 권한이 필요하며, `ProductInquiryService::deleteInquiry()` 가 해당 문의를 제거하고 `{deleted: true}` 를 반환합니다. 문의가 존재하지 않는 등 삭제 불가 상황에서는 서비스가 `RuntimeException` 을 던져 422 로 응답합니다. 관리자 문의 관리 화면에서 부적절하거나 중복된 문의를 정리할 때 사용합니다.
+**설명** 관리자가 상품 1:1 문의 1건을 삭제합니다. `sirsoft-ecommerce.inquiries.delete` 권한이 필요하며, `ProductInquiryService::deleteInquiry()` 가 해당 문의를 제거하고 `{deleted: true}` 를 반환합니다. 문의가 존재하지 않는 등 삭제 불가 상황에서는 서비스가 `ProductInquiryOperationException` 을 던지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 관리자 문의 관리 화면에서 부적절하거나 중복된 문의를 정리할 때 사용합니다.
### DELETE /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply
@@ -121,7 +121,7 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteReply()` 의 `RuntimeException` |
+| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteReply()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 답변 삭제 처리 중 예기치 못한 예외 (`답변 삭제에 실패했습니다.`) |
@@ -185,12 +185,12 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `RuntimeException` |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) |
-**설명** 관리자가 상품 문의에 답변을 등록합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료 상태로 전환한 뒤 `{id, is_answered}` 를 201 로 반환합니다. 이미 답변이 있는 등 등록 불가 상황에서는 `RuntimeException` 이 던져져 422 로 응답합니다. 관리자가 고객 문의에 응대할 때 사용합니다.
+**설명** 관리자가 상품 문의에 답변을 등록합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::createReply()` 가 `content`(1~5000자)로 답변을 저장하고 문의를 답변완료 상태로 전환한 뒤 `{id, is_answered}` 를 201 로 반환합니다. 이미 답변이 있는 등 등록 불가 상황에서는 `ProductInquiryOperationException` 이 던져지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 관리자가 고객 문의에 응대할 때 사용합니다.
### PUT /api/modules/sirsoft-ecommerce/admin/inquiries/{inquiryId}/reply
@@ -247,12 +247,12 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateReply()` 의 `RuntimeException` |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateReply()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 답변 수정 처리 중 예기치 못한 예외 (`답변 수정에 실패했습니다.`) |
-**설명** 관리자가 문의에 이미 등록된 답변 내용을 수정합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::updateReply()` 가 `content`(1~5000자)로 기존 답변을 갱신하고 `{id}` 를 반환합니다. 답변이 없는 문의 등 수정 불가 상황에서는 `RuntimeException` 이 던져져 422 로 응답합니다. 오탈자 정정 등 답변 내용을 고칠 때 사용합니다.
+**설명** 관리자가 문의에 이미 등록된 답변 내용을 수정합니다. `sirsoft-ecommerce.inquiries.update` 권한이 필요하며, `ProductInquiryService::updateReply()` 가 `content`(1~5000자)로 기존 답변을 갱신하고 `{id}` 를 반환합니다. 답변이 없는 문의 등 수정 불가 상황에서는 `ProductInquiryOperationException` 이 던져지고, 컨트롤러가 그 예외의 다국어 키로 422 응답을 만듭니다. 오탈자 정정 등 답변 내용을 고칠 때 사용합니다.
### GET /api/modules/sirsoft-ecommerce/user/inquiries
@@ -453,7 +453,8 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 문의 작성자(`user_id`)가 로그인 사용자와 다른 경우 (`해당 문의에 대한 권한이 없습니다.`) |
| 404 | Not Found | 해당 ID 의 문의가 존재하지 않는 경우 (`문의를 찾을 수 없습니다.`) |
-| 500 | Internal Server Error | 삭제 처리 중 예기치 못한 예외 (`문의 삭제에 실패했습니다.`) — 문의 게시판 미설정 등 서비스 `RuntimeException` 포함 |
+| 422 | Unprocessable Entity | 삭제할 수 없는 도메인 사유 — 문의 게시판 미설정 등. 응답 `message` 에 사유가 그대로 실린다 (`문의 게시판이 설정되지 않았습니다.`) |
+| 500 | Internal Server Error | 삭제 처리 중 예기치 못한 서버 오류 (`문의 삭제에 실패했습니다.`) — 예외 원문은 응답에 포함되지 않는다 |
@@ -522,7 +523,7 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 문의 작성자(`user_id`)가 로그인 사용자와 다른 경우 (`해당 문의에 대한 권한이 없습니다.`) |
| 404 | Not Found | 해당 ID 의 문의가 존재하지 않는 경우 (`문의를 찾을 수 없습니다.`) |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateInquiry()` 의 `RuntimeException` |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateInquiry()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 수정 처리 중 예기치 못한 예외 (`문의 수정에 실패했습니다.`) |
@@ -578,7 +579,7 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteReply()` 의 `RuntimeException` |
+| 422 | Unprocessable Entity | 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::deleteReply()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 답변 삭제 처리 중 예기치 못한 예외 (`답변 삭제에 실패했습니다.`) |
@@ -642,7 +643,7 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `RuntimeException` |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 답변 게시글 생성에 실패한 경우(`답변 등록에 실패했습니다.`) — `ProductInquiryService::createReply()` 의 `ProductInquiryOperationException` |
| 500 | Internal Server Error | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) |
@@ -704,7 +705,7 @@ _단건 응답: `data` 객체의 필드._
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 답변 권한(`sirsoft-ecommerce.inquiries.update`)이 없는 경우 (`해당 문의에 대한 권한이 없습니다.`) |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateReply()` 의 `RuntimeException` |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) / 문의를 찾을 수 없거나(`문의를 찾을 수 없습니다.`) 문의 게시판이 설정되지 않은 경우(`문의 게시판이 설정되지 않았습니다.`) — `ProductInquiryService::updateReply()` 의 `ProductInquiryOperationException` |
| 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 0ca1fa31..459a7b78 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md
@@ -841,7 +841,8 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 취소 처리 실패 — 취소 불가 상태, PG 취소 실패, 가상계좌 환불계좌 정보 누락 등 (`주문 취소에 실패했습니다.` + `errors.detail` 에 예외 메시지) |
+| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 취소 도메인 규칙 위반 — 취소 불가 상태, PG 취소 실패, 가상계좌 환불계좌 정보 누락 등 (`주문 취소에 실패했습니다.`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`작업 처리 중 오류가 발생했습니다.`) |
@@ -1219,6 +1220,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 입금액 불일치(`입금액이 결제예정금액과 일치하지 않습니다.` + `errors.detail`), 또는 입금확인 처리 실패 — 무통장 결제가 아닌 주문·이미 결제완료된 주문 등 (`입금확인 처리에 실패했습니다.` + `errors.detail`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`작업 처리 중 오류가 발생했습니다.`) |
@@ -1349,7 +1351,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.orders.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
-| 500 | Server Error | 환불 예상금액 계산 중 오류 (`환불 예상금액 조회에 실패했습니다.`) |
+| 500 | Server Error | 환불 예상금액 계산 중 오류 (`환불 예상금액 계산에 실패했습니다.`) |
@@ -2439,7 +2441,8 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.cancel`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터 검증 실패(본인 주문 아님·취소 불가 상태 포함), 또는 취소 처리 실패 (`exceptions.order_cancel_failed`) |
+| 422 | Unprocessable Entity | 요청 파라미터 검증 실패(본인 주문 아님·취소 불가 상태 포함), 또는 취소 도메인 규칙 위반 (`exceptions.order_cancel_failed`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`exceptions.operation_failed`) |
@@ -2815,7 +2818,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-orders.confirm`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 구매확정 불가 — 본인 주문이 아니거나 배송완료 전 등 확정 불가 상태 (`exceptions.order_option_cannot_confirm`) |
+| 500 | Internal Server Error | 구매확정 처리 실패 (`exceptions.operation_failed`) |
@@ -2884,7 +2887,8 @@ HTTP/1.1 200
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 재주문 실패 — 주문이 없거나 본인 주문이 아닌 경우 (`재주문에 실패했습니다.`) |
+| 422 | Unprocessable Entity | 재주문 도메인 규칙 위반 — 장바구니 담기 불가·수량 한도 초과 등 (`재주문에 실패했습니다.`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`exceptions.operation_failed`) |
@@ -2989,6 +2993,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 404 | Not Found | 주문이 없거나 본인 주문이 아닌 경우 (`주문을 찾을 수 없습니다.`) |
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 배송지 변경 불가 상태 — 이미 배송이 시작된 주문 등 (`배송 전 상태에서만 배송지를 변경할 수 있습니다.`) |
+| 500 | Internal Server Error | 서버 내부 오류 (`배송지 변경 처리 중 오류가 발생했습니다.`) |
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 620d67d5..bf302721 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-common-infos.md
@@ -28,8 +28,8 @@
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) |
-| active_only | query | boolean | 아니오 | — | |
-| default_only | query | boolean | 아니오 | — | |
+| active_only | query | boolean | 아니오 | `true`, `false` | 활성 공통정보만 조회 (true 일 때만 필터 적용, false·미전달은 전체). 쿼리 문자열 `"true"`/`"false"` 도 인식 |
+| default_only | query | boolean | 아니오 | `true`, `false` | 기본 공통정보만 조회 (true 일 때만 필터 적용). 쿼리 문자열 `"true"`/`"false"` 도 인식 |
| per_page | query | string | 아니오 | — | 페이지당 항목 수 |
**요청 예시**
@@ -270,6 +270,7 @@ HTTP/1.1 201
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -299,11 +300,29 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: 삭제 결과 요약 (`data` 객체)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| common_info_id | integer | `5` | 삭제된 공통정보의 기본 키 |
+| products_count | integer | `2` | 이 공통정보를 참조하던 상품 수 (삭제 시 해당 상품의 연결이 해제됨) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "공통정보가 삭제되었습니다.",
+ "data": {
+ "common_info_id": 5,
+ "products_count": 2
+ }
+}
+```
**에러 응답**
@@ -312,6 +331,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -341,11 +361,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ProductCommonInfoResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} (공통정보 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -472,6 +527,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -501,11 +557,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ProductCommonInfoResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} (공통정보 수정)** 응답 필드 표와 동일합니다._ 비활성으로 전환하면 `공통정보가 비활성화되었습니다.` 가 반환됩니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -514,6 +605,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-common-infos.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md b/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md
index efb8732b..43d67ba8 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-labels.md
@@ -247,11 +247,29 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: 삭제 결과 요약 (`data` 객체)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| label_id | integer | `7` | 삭제된 라벨의 기본 키 |
+| products_count | integer | `0` | 삭제 시점에 이 라벨을 쓰던 상품 수 (연결 상품이 있으면 삭제가 거부되므로 언제나 0) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "라벨이 삭제되었습니다.",
+ "data": {
+ "label_id": 7,
+ "products_count": 0
+ }
+}
+```
**에러 응답**
@@ -260,6 +278,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -289,11 +308,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ProductLabelResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-labels/{id} (라벨 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -398,6 +444,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -427,11 +474,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ProductLabelResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-labels/{id} (라벨 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -440,6 +514,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-labels.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
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 9b81ea14..4c7ed566 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/product-notice-templates.md
@@ -336,6 +336,7 @@ HTTP/1.1 201
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.create`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -365,11 +366,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: 삭제 결과 요약 (`data` 객체)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| template_id | integer | `3` | 삭제된 템플릿의 기본 키 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "상품정보제공고시가 삭제되었습니다.",
+ "data": {
+ "template_id": 3
+ }
+}
+```
**에러 응답**
@@ -378,6 +395,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -407,11 +425,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -534,6 +587,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -563,11 +617,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._ 원본과 동일한 항목 구성으로 새 기본 키가 발급됩니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -576,6 +665,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.create`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -605,11 +695,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._ 비활성으로 전환하면 `상품정보제공고시가 비활성화되었습니다.` 가 반환됩니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -618,6 +743,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.product-notice-templates.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md
index 7b32ffbe..f2e7f94b 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md
@@ -1342,6 +1342,7 @@ HTTP/1.1 201
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 상품 이미지 개수 상한 초과 (`ProductImageUploadLimitException`) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1404,6 +1405,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1457,6 +1459,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 |
| 404 | Not Found | `id` 에 해당하는 이미지가 없는 경우 (`messages.product_images.not_found`) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1715,6 +1718,7 @@ HTTP/1.1 201
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 상품 이미지 개수 상한 초과 (`ProductImageUploadLimitException`) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1769,6 +1773,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.products.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -3586,11 +3591,11 @@ HTTP/1.1 201
| --- | --- | --- |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-products.read`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 도메인 규칙 위반(비밀글 비허용 등, `RuntimeException` → `messages.inquiries.create_failed`) |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 도메인 규칙 위반(문의 게시판 미설정·게시판 모듈 불가 등, `ProductInquiryOperationException` → `messages.inquiries.operation_failed_reason` 에 사유가 담김) |
-**설명** 상품에 1:1 문의를 작성합니다. `optional.sanctum` + `sirsoft-ecommerce.user-products.read` 권한이 적용되며(선택적 인증 표면이지만 실제 작성은 인증 사용자를 전제), `ProductInquiryService::createInquiry()`가 게시판 모듈과 연동해 문의 글을 생성하고 성공 시 201과 생성된 `id`를 반환합니다. `content`는 필수, `title`/`category`/`is_secret`은 선택이며, 첨부는 사전 업로드한 `temp_key`로 연결됩니다. 도메인 규칙 위반(비밀글 비허용 등)은 `RuntimeException`으로 422를 반환합니다.
+**설명** 상품에 1:1 문의를 작성합니다. `optional.sanctum` + `sirsoft-ecommerce.user-products.read` 권한이 적용되며(선택적 인증 표면이지만 실제 작성은 인증 사용자를 전제), `ProductInquiryService::createInquiry()`가 게시판 모듈과 연동해 문의 글을 생성하고 성공 시 201과 생성된 `id`를 반환합니다. `content`는 필수, `title`/`category`/`is_secret`은 선택이며, 첨부는 사전 업로드한 `temp_key`로 연결됩니다. 도메인 규칙 위반(문의 게시판 미설정·게시판 모듈 불가 등)은 `ProductInquiryOperationException` 으로 422 를 반환하며, 예외가 들고 있는 다국어 키로 해석한 사유가 응답 문구에 포함됩니다. 그 밖의 예외는 서버 결함으로 보아 500 입니다.
### GET /api/modules/sirsoft-ecommerce/products/{product}/reviews
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md
index d639110f..fcb0a535 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md
@@ -542,6 +542,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -850,6 +851,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -929,6 +931,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1038,10 +1041,11 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
+| 400 | Bad Request | `{id}` 에 해당하는 쿠폰이 없는 경우 (`sirsoft-ecommerce::exceptions.coupon_not_found`) |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.read`)이 없는 경우 |
-| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1134,6 +1138,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.promotion-coupon.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md b/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md
index c5ba3335..1b50a11a 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/reviews.md
@@ -259,11 +259,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| deleted | boolean | `true` | 삭제 완료 여부 (컨트롤러가 고정값 `true` 로 내려준다) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "리뷰가 삭제되었습니다.",
+ "data": {
+ "deleted": true
+ }
+}
+```
**에러 응답**
@@ -337,7 +353,71 @@ _단건 응답: `data` 객체의 필드._
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "리뷰를 조회했습니다.",
+ "data": {
+ "id": 12,
+ "product_id": 3,
+ "order_option_id": 45,
+ "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4",
+ "user": {
+ "uuid": "a26219fc-94a0-4f63-9404-04c2a6ac99e4",
+ "name": "실측 예시값",
+ "email": "실측 예시값"
+ },
+ "product": {
+ "id": 3,
+ "product_code": "실측 예시값",
+ "name": "실측 예시값",
+ "thumbnail_url": "실측 예시값"
+ },
+ "option_snapshot": {
+ "option_name": "실측 예시값"
+ },
+ "option_snapshot_label": "실측 예시값",
+ "rating": 5,
+ "content": "실측 예시값",
+ "content_mode": "text",
+ "status": "visible",
+ "status_label": "노출",
+ "status_badge_color": "green",
+ "images": [],
+ "image_count": 0,
+ "orderOption": {
+ "id": 45,
+ "order_id": 4,
+ "order_number": "20260728-0111050215",
+ "quantity": 1,
+ "created_at": "2026-08-05 09:46:56"
+ },
+ "has_reply": true,
+ "has_reply_label": "답변완료",
+ "has_reply_badge_color": "green",
+ "reply_content": "실측 예시값",
+ "reply_content_mode": "text",
+ "reply_admin_uuid": "6d1f0d0e-2a1b-4c3d-8e9f-0a1b2c3d4e5f",
+ "reply_admin": {
+ "uuid": "6d1f0d0e-2a1b-4c3d-8e9f-0a1b2c3d4e5f",
+ "name": "실측 예시값",
+ "email": "실측 예시값"
+ },
+ "replied_at": "2026-08-05 09:46:56",
+ "reply_updated_at": "2026-08-05 09:46:56",
+ "created_at": "2026-08-05 09:46:56",
+ "updated_at": "2026-08-05 09:46:56",
+ "abilities": {
+ "can_update": true,
+ "can_delete": true
+ }
+ }
+}
+```
**에러 응답**
@@ -405,7 +485,71 @@ _단건 응답: `data` 객체의 필드._
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "답변이 삭제되었습니다.",
+ "data": {
+ "id": 12,
+ "product_id": 3,
+ "order_option_id": 45,
+ "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4",
+ "user": {
+ "uuid": "a26219fc-94a0-4f63-9404-04c2a6ac99e4",
+ "name": "실측 예시값",
+ "email": "실측 예시값"
+ },
+ "product": {
+ "id": 3,
+ "product_code": "실측 예시값",
+ "name": "실측 예시값",
+ "thumbnail_url": "실측 예시값"
+ },
+ "option_snapshot": {
+ "option_name": "실측 예시값"
+ },
+ "option_snapshot_label": "실측 예시값",
+ "rating": 5,
+ "content": "실측 예시값",
+ "content_mode": "text",
+ "status": "visible",
+ "status_label": "노출",
+ "status_badge_color": "green",
+ "images": [],
+ "image_count": 0,
+ "orderOption": {
+ "id": 45,
+ "order_id": 4,
+ "order_number": "20260728-0111050215",
+ "quantity": 1,
+ "created_at": "2026-08-05 09:46:56"
+ },
+ "has_reply": true,
+ "has_reply_label": "답변완료",
+ "has_reply_badge_color": "green",
+ "reply_content": "실측 예시값",
+ "reply_content_mode": "text",
+ "reply_admin_uuid": "6d1f0d0e-2a1b-4c3d-8e9f-0a1b2c3d4e5f",
+ "reply_admin": {
+ "uuid": "6d1f0d0e-2a1b-4c3d-8e9f-0a1b2c3d4e5f",
+ "name": "실측 예시값",
+ "email": "실측 예시값"
+ },
+ "replied_at": "2026-08-05 09:46:56",
+ "reply_updated_at": "2026-08-05 09:46:56",
+ "created_at": "2026-08-05 09:46:56",
+ "updated_at": "2026-08-05 09:46:56",
+ "abilities": {
+ "can_update": true,
+ "can_delete": true
+ }
+ }
+}
+```
**에러 응답**
@@ -483,7 +627,71 @@ _단건 응답: `data` 객체의 필드._
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "답변이 저장되었습니다.",
+ "data": {
+ "id": 12,
+ "product_id": 3,
+ "order_option_id": 45,
+ "user_id": "a26219fc-94a0-4f63-9404-04c2a6ac99e4",
+ "user": {
+ "uuid": "a26219fc-94a0-4f63-9404-04c2a6ac99e4",
+ "name": "실측 예시값",
+ "email": "실측 예시값"
+ },
+ "product": {
+ "id": 3,
+ "product_code": "실측 예시값",
+ "name": "실측 예시값",
+ "thumbnail_url": "실측 예시값"
+ },
+ "option_snapshot": {
+ "option_name": "실측 예시값"
+ },
+ "option_snapshot_label": "실측 예시값",
+ "rating": 5,
+ "content": "실측 예시값",
+ "content_mode": "text",
+ "status": "visible",
+ "status_label": "노출",
+ "status_badge_color": "green",
+ "images": [],
+ "image_count": 0,
+ "orderOption": {
+ "id": 45,
+ "order_id": 4,
+ "order_number": "20260728-0111050215",
+ "quantity": 1,
+ "created_at": "2026-08-05 09:46:56"
+ },
+ "has_reply": true,
+ "has_reply_label": "답변완료",
+ "has_reply_badge_color": "green",
+ "reply_content": "실측 예시값",
+ "reply_content_mode": "text",
+ "reply_admin_uuid": "6d1f0d0e-2a1b-4c3d-8e9f-0a1b2c3d4e5f",
+ "reply_admin": {
+ "uuid": "6d1f0d0e-2a1b-4c3d-8e9f-0a1b2c3d4e5f",
+ "name": "실측 예시값",
+ "email": "실측 예시값"
+ },
+ "replied_at": "2026-08-05 09:46:56",
+ "reply_updated_at": "2026-08-05 09:46:56",
+ "created_at": "2026-08-05 09:46:56",
+ "updated_at": "2026-08-05 09:46:56",
+ "abilities": {
+ "can_update": true,
+ "can_delete": true
+ }
+ }
+}
+```
**에러 응답**
@@ -729,12 +937,12 @@ HTTP/1.1 201
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.user-reviews.write`)이 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 작성 자격 미충족 시 `ReviewNotWritableException`(RuntimeException) 이 발생해 사유 코드가 담긴 메시지로 응답 (`order_option_not_found` / `not_own_order` / `not_confirmed` / `deadline_passed` / `already_written`) |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) 또는 작성 자격 미충족 시 `ReviewNotWritableException` 이 발생하고, 그 사유 식별자(`order_option_not_found` / `not_own_order` / `not_confirmed` / `deadline_passed` / `already_written`)에 대응하는 다국어 키로 해석된 문구가 `message` 에 담긴다 |
| 500 | Internal Server Error | 리뷰 생성 중 예외 발생 (`messages.reviews.create_failed` — "리뷰 작성에 실패했습니다.") |
-**설명** 로그인 회원이 구매한 상품에 리뷰를 작성합니다. `sirsoft-ecommerce.user-reviews.write` 권한이 필요하며, `ProductReviewService::createReview()` 가 로그인 사용자(`Auth::id()`)를 작성자로 하여 `product_id`·`order_option_id`·별점(1~5)·내용(10~2000자)으로 리뷰를 생성하고 201 로 반환합니다. 본인 주문이 아니거나 이미 작성했거나 작성 조건을 만족하지 못하면 서비스가 `RuntimeException` 을 던져 422 로 응답합니다. 마이페이지 리뷰 작성 폼에서 사용합니다.
+**설명** 로그인 회원이 구매한 상품에 리뷰를 작성합니다. `sirsoft-ecommerce.user-reviews.write` 권한이 필요하며, `ProductReviewService::createReview()` 가 로그인 사용자(`Auth::id()`)를 작성자로 하여 `product_id`·`order_option_id`·별점(1~5)·내용(10~2000자)으로 리뷰를 생성하고 201 로 반환합니다. 본인 주문이 아니거나 이미 작성했거나 작성 조건을 만족하지 못하면 서비스가 `ReviewNotWritableException` 을 던지고, 컨트롤러가 그 사유에 대응하는 다국어 키로 422 응답을 만듭니다. 마이페이지 리뷰 작성 폼에서 사용합니다.
### GET /api/modules/sirsoft-ecommerce/user/reviews/can-write/{orderOptionId}
@@ -760,11 +968,29 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| can_write | boolean | `false` | 이 주문 옵션에 리뷰를 작성할 수 있는지 |
+| reason | string\|null | `"already_written"` | 작성 불가 사유 식별자. `order_option_not_found` / `not_own_order` / `not_confirmed` / `deadline_passed` / `already_written` 중 하나이며, 작성 가능하면 `null` |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "리뷰 작성 가능 여부를 확인했습니다.",
+ "data": {
+ "can_write": false,
+ "reason": "already_written"
+ }
+}
+```
**에러 응답**
@@ -801,11 +1027,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| deleted | boolean | `true` | 삭제 완료 여부 (컨트롤러가 고정값 `true` 로 내려준다) |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "리뷰가 삭제되었습니다.",
+ "data": {
+ "deleted": true
+ }
+}
+```
**에러 응답**
@@ -909,12 +1151,12 @@ HTTP/1.1 201
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 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) 기준 |
+| 422 | Unprocessable Entity | `image` 파일 누락·허용 형식/용량 위반 (`error.errors` 에 필드별 메시지) 또는 리뷰당 최대 첨부 개수 초과 시 `ReviewImageUploadLimitException` — 최대 개수는 리뷰 설정 `review_settings.max_images`(기본 5) 기준이며, 응답 문구는 그 상한을 파라미터로 받는 다국어 키로 만들어진다 |
| 500 | Internal Server Error | 업로드 처리 중 예외 발생 (`messages.reviews.image_upload_failed` — "리뷰 이미지 업로드에 실패했습니다.") |
-**설명** 로그인 회원이 자신의 리뷰에 이미지를 첨부합니다. `sirsoft-ecommerce.user-reviews.write` 권한이 필요하며, 컨트롤러가 `review->user_id` 로 본인 소유를 확인(불일치 시 403)한 뒤 `ProductReviewImageService::upload()` 가 업로드된 이미지(최대 10MB)를 저장하고 201 로 이미지 리소스를 반환합니다. 파일 형식/크기 등 제약 위반 시 서비스가 `RuntimeException` 을 던져 422 로 응답합니다. 포토 리뷰 작성 시 이미지를 추가할 때 사용합니다.
+**설명** 로그인 회원이 자신의 리뷰에 이미지를 첨부합니다. `sirsoft-ecommerce.user-reviews.write` 권한이 필요하며, 컨트롤러가 `review->user_id` 로 본인 소유를 확인(불일치 시 403)한 뒤 `ProductReviewImageService::upload()` 가 업로드된 이미지(최대 10MB)를 저장하고 201 로 이미지 리소스를 반환합니다. 리뷰당 첨부 개수 상한을 넘기면 서비스가 `ReviewImageUploadLimitException` 을 던지고, 컨트롤러가 상한값을 파라미터로 실은 다국어 키로 422 응답을 만듭니다. 포토 리뷰 작성 시 이미지를 추가할 때 사용합니다.
### DELETE /api/modules/sirsoft-ecommerce/user/reviews/{review}/images/{image}
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md
index 5c3d32de..940e9f07 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md
@@ -1013,12 +1013,16 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
+| 400 | Bad Request | 설정 저장은 통과했으나 DB 동기화가 도메인 규칙에 걸린 경우 — 주문에서 사용 중인 배송유형·배송사를 payload 에서 빼 삭제하려 한 경우가 이에 해당하며, `message` 에 대상 이름과 사용 건수가 담긴다 |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
+**삭제 차단 사유의 구분** `shipping.types`·`shipping.carriers` 는 payload 에서 항목을 빼는 방식으로 삭제합니다. 그 항목이 주문에서 사용 중이면 삭제가 거부되는데, 이때는 **400** 과 함께 사유(대상 이름·사용 건수)가 `message` 로 전달됩니다. 저장 자체가 서버 결함으로 실패한 경우의 500(일반 저장 오류 문구)과 구분되므로, 운영자는 화면 문구만으로 "내가 고칠 수 있는 일인지"를 판별할 수 있습니다.
+
**설명** 관리자가 이커머스 환경설정을 저장합니다. `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` 규칙은 숫자 문자열을 통과시키되 캐스트하지 않으므로, 정규화가 없으면 문자열이 그대로 설정 파일에 영속되어 이후 날짜/수치 연산에서 타입 오류를 유발합니다.
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md
index 85b0b7b8..471d1d79 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-carriers.md
@@ -420,11 +420,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: 삭제 결과 요약 (`data` 객체)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| carrier_id | integer | `4` | 삭제된 배송사의 기본 키 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "배송사가 삭제되었습니다.",
+ "data": {
+ "carrier_id": 4
+ }
+}
+```
**에러 응답**
@@ -433,6 +449,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -462,11 +479,41 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ShippingCarrierResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} (배송사 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -585,6 +632,7 @@ HTTP/1.1 200
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -614,11 +662,41 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
-
+_단건 응답: `data` 객체의 필드 (`ShippingCarrierResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} (배송사 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
-
+```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
+ }
+ }
+}
+```
**에러 응답**
@@ -627,6 +705,7 @@ Authorization: Bearer {YOUR_TOKEN}
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.settings.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md
index b0bf0322..669b5824 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/shipping-policies.md
@@ -583,6 +583,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -655,6 +656,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -834,6 +836,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.delete`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1114,6 +1117,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -1200,6 +1204,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-ecommerce.shipping-policies.update`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md
index bfd0fd17..49e72b9a 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md
@@ -169,6 +169,7 @@ HTTP/1.1 200
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 422 | Unprocessable Entity | `page` < 1, `per_page` 가 1~100 범위를 벗어나거나 정수가 아닌 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -212,11 +213,29 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
-
+_단건 응답: 토글 결과 (`data` 객체)._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| added | boolean | `true` | 토글 후 찜 상태 — `true` 면 이번 호출로 추가됨, `false` 면 제거됨. 하트 버튼의 표시 상태를 이 값으로 갱신합니다 |
**응답 예시**
-
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "상품이 찜 목록에 추가되었습니다.",
+ "data": {
+ "added": true
+ }
+}
+```
+
+> 제거된 경우 `message` 는 `상품이 찜 목록에서 제거되었습니다.`, `data.added` 는 `false` 입니다.
**에러 응답**
@@ -224,6 +243,7 @@ Content-Type: application/json
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 500 | Internal Server Error | 서버 내부 오류 (`찜 처리에 실패했습니다.`) — 도메인 규칙 위반이 아닌 예외는 4xx 로 뭉개지 않고 500 으로 구분한다 |
@@ -275,6 +295,7 @@ HTTP/1.1 200
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/BrandOperationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/BrandOperationException.php
new file mode 100644
index 00000000..ae0a4a47
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/BrandOperationException.php
@@ -0,0 +1,50 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ClaimReasonException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ClaimReasonException.php
index 0bb2bb5b..55471a56 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ClaimReasonException.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ClaimReasonException.php
@@ -12,6 +12,17 @@ use Exception;
*/
class ClaimReasonException extends Exception
{
+ /**
+ * @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
+ * @param array $replace 메시지 치환 파라미터
+ */
+ private function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
/**
* 대상 클레임 사유를 찾을 수 없을 때
*
@@ -19,7 +30,7 @@ class ClaimReasonException extends Exception
*/
public static function notFound(): self
{
- return new self(__('sirsoft-ecommerce::exceptions.claim_reason_not_found'));
+ return new self('sirsoft-ecommerce::exceptions.claim_reason_not_found');
}
/**
@@ -30,8 +41,30 @@ class ClaimReasonException extends Exception
*/
public static function inUse(int $usageCount): self
{
- return new self(__('sirsoft-ecommerce::exceptions.claim_reason_in_use', [
+ return new self('sirsoft-ecommerce::exceptions.claim_reason_in_use', [
'count' => $usageCount,
- ]));
+ ]);
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponOperationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponOperationException.php
new file mode 100644
index 00000000..fbae9c22
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/CouponOperationException.php
@@ -0,0 +1,50 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductCommonInfoOperationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductCommonInfoOperationException.php
new file mode 100644
index 00000000..5d392179
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductCommonInfoOperationException.php
@@ -0,0 +1,50 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductInquiryOperationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductInquiryOperationException.php
new file mode 100644
index 00000000..417d86cf
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductInquiryOperationException.php
@@ -0,0 +1,54 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductLabelOperationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductLabelOperationException.php
new file mode 100644
index 00000000..5be0de4a
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ProductLabelOperationException.php
@@ -0,0 +1,50 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ReviewImageUploadLimitException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ReviewImageUploadLimitException.php
index 5cd992f7..5f6a0497 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ReviewImageUploadLimitException.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ReviewImageUploadLimitException.php
@@ -15,8 +15,9 @@ class ReviewImageUploadLimitException extends RuntimeException
/**
* @param int $maxImages 허용된 최대 이미지 수
*/
- public function __construct(int $maxImages)
- {
+ public function __construct(
+ public readonly int $maxImages
+ ) {
parent::__construct(
__('sirsoft-ecommerce::review.image_upload_limit_exceeded', ['max' => $maxImages])
);
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ShippingCarrierOperationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ShippingCarrierOperationException.php
new file mode 100644
index 00000000..2aecf1ab
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ShippingCarrierOperationException.php
@@ -0,0 +1,50 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ShippingTypeOperationException.php b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ShippingTypeOperationException.php
new file mode 100644
index 00000000..978e6b89
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/src/Exceptions/ShippingTypeOperationException.php
@@ -0,0 +1,50 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private string $messageKey,
+ private array $replace = []
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 컨트롤러가 예외 메시지 원문 대신 이 키로 응답을 구성하기 위한 접근자입니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/BrandController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/BrandController.php
index 45bd9546..05b79f1d 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/BrandController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/BrandController.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\BrandOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BrandListRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreBrandRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateBrandRequest;
@@ -24,7 +25,7 @@ class BrandController extends AdminBaseController
/**
* 브랜드 목록 조회
*
- * @param BrandListRequest $request
+ * @param BrandListRequest $request
* @return JsonResponse
*/
public function index(BrandListRequest $request): JsonResponse
@@ -41,7 +42,7 @@ class BrandController extends AdminBaseController
/**
* 브랜드 생성
*
- * @param StoreBrandRequest $request
+ * @param StoreBrandRequest $request
* @return JsonResponse
*/
public function store(StoreBrandRequest $request): JsonResponse
@@ -59,7 +60,7 @@ class BrandController extends AdminBaseController
/**
* 브랜드 상세 조회
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function show(int $id): JsonResponse
@@ -84,8 +85,8 @@ class BrandController extends AdminBaseController
/**
* 브랜드 수정
*
- * @param UpdateBrandRequest $request
- * @param int $brand
+ * @param UpdateBrandRequest $request
+ * @param int $brand
* @return JsonResponse
*/
public function update(UpdateBrandRequest $request, int $brand): JsonResponse
@@ -98,19 +99,27 @@ class BrandController extends AdminBaseController
'messages.brands.updated',
new BrandResource($updatedBrand)
);
- } catch (\Exception $e) {
+ } catch (BrandOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
/**
* 브랜드 상태 토글
*
- * @param int $brand
+ * @param int $brand
* @return JsonResponse
*/
public function toggleStatus(int $brand): JsonResponse
@@ -123,19 +132,27 @@ class BrandController extends AdminBaseController
'messages.brands.status_changed',
new BrandResource($updatedBrand)
);
- } catch (\Exception $e) {
+ } catch (BrandOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
/**
* 브랜드 삭제
*
- * @param int $brand
+ * @param int $brand
* @return JsonResponse
*/
public function destroy(int $brand): JsonResponse
@@ -148,12 +165,20 @@ class BrandController extends AdminBaseController
'messages.brands.deleted',
$result
);
- } catch (\Exception $e) {
+ } catch (BrandOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php
index 51602e09..8fbb53c8 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\CategoryOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CategoryListRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CreateCategoryRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ReorderCategoriesRequest;
@@ -125,12 +126,20 @@ class CategoryController extends AdminBaseController
'messages.categories.updated',
new CategoryResource($updatedCategory)
);
- } catch (\Exception $e) {
+ } catch (CategoryOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -150,12 +159,20 @@ class CategoryController extends AdminBaseController
'messages.categories.deleted',
$result
);
- } catch (\Exception $e) {
+ } catch (CategoryOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -199,12 +216,20 @@ class CategoryController extends AdminBaseController
],
201
);
- } catch (\Exception $e) {
+ } catch (CategoryOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -231,12 +256,20 @@ class CategoryController extends AdminBaseController
'sirsoft-ecommerce',
'messages.category_images.deleted'
);
- } catch (\Exception $e) {
+ } catch (CategoryOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -257,12 +290,20 @@ class CategoryController extends AdminBaseController
'sirsoft-ecommerce',
'messages.category_images.reordered'
);
- } catch (\Exception $e) {
+ } catch (CategoryOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -313,12 +354,20 @@ class CategoryController extends AdminBaseController
'messages.categories.status_changed',
new CategoryResource($category)
);
- } catch (\Exception $e) {
+ } catch (CategoryOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -390,12 +439,20 @@ class CategoryController extends AdminBaseController
'sirsoft-ecommerce',
'messages.categories.order_updated'
);
- } catch (\Exception $e) {
+ } catch (CategoryOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ClaimReasonController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ClaimReasonController.php
index d2cd712a..68145fa6 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ClaimReasonController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ClaimReasonController.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\ClaimReasonException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ActiveClaimReasonRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\IndexClaimReasonRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreClaimReasonRequest;
@@ -119,12 +120,20 @@ class ClaimReasonController extends AdminBaseController
'messages.claim_reasons.updated',
new ClaimReasonResource($reason)
);
- } catch (\Exception $e) {
+ } catch (ClaimReasonException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -154,11 +163,23 @@ class ClaimReasonController extends AdminBaseController
'messages.claim_reasons.deleted',
$result
);
+ } catch (ClaimReasonException $e) {
+ // 삭제 불가 사유(미존재/사용 중)를 전용 키로 안내 — 예외 메시지 원문을
+ // 키 자리에 넘기면(종전 동작) 키 해석에 실패해 원문이 그대로 노출된다.
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error(
+ $messageKey,
+ 400,
+ null,
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- $e->getMessage(),
- 400
+ 'exceptions.operation_failed',
+ 500
);
}
}
@@ -179,12 +200,20 @@ class ClaimReasonController extends AdminBaseController
'messages.claim_reasons.toggled',
new ClaimReasonResource($reason)
);
- } catch (\Exception $e) {
+ } catch (ClaimReasonException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CouponController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CouponController.php
index ad9d0f27..b3f8eee2 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CouponController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CouponController.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\CouponOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateCouponStatusRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CouponIssuesListRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CouponListRequest;
@@ -106,12 +107,20 @@ class CouponController extends AdminBaseController
'messages.coupons.updated',
new CouponResource($coupon)
);
- } catch (\Exception $e) {
+ } catch (CouponOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -131,12 +140,20 @@ class CouponController extends AdminBaseController
'messages.coupons.deleted',
$result
);
- } catch (\Exception $e) {
+ } catch (CouponOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -188,12 +205,20 @@ class CouponController extends AdminBaseController
200,
['issued' => $result['issued'], 'skipped' => count($result['skipped'])]
);
- } catch (\Exception $e) {
+ } catch (CouponOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -214,13 +239,23 @@ class CouponController extends AdminBaseController
'messages.coupons.issue_cancelled',
new CouponIssueResource($issue)
);
+ } catch (CouponOperationException $e) {
+ // 취소 불가 사유(미존재/미사용 아님)를 전용 키로 안내 — 예외 메시지 원문 대신
+ // 키로 응답을 구성한다.
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error(
+ $messageKey,
+ 400,
+ null,
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
- // 취소 불가 사유(미사용 아님 등)를 관리자에게 그대로 노출
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400,
- ['detail' => $e->getMessage()]
+ 500
);
}
}
@@ -249,11 +284,22 @@ class CouponController extends AdminBaseController
'messages.coupons.issues_retrieved',
new CouponIssueCollection($issues)
);
+ } catch (CouponOperationException $e) {
+ // 쿠폰 미존재 등 도메인 사유 — 운영자에게 안내 가능하므로 기존 400 유지
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error(
+ $messageKey,
+ 400,
+ null,
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php
index d08835cd..c304aeb5 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php
@@ -13,10 +13,14 @@ use App\Services\NotificationDefinitionService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiAuthType;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiHttpMethod;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiRequestField;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiResponseType;
+use Modules\Sirsoft\Ecommerce\Exceptions\ShippingCarrierOperationException;
+use Modules\Sirsoft\Ecommerce\Exceptions\ShippingTypeOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\GetSettingRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreBanksRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreEcommerceSettingsRequest;
@@ -198,7 +202,25 @@ class EcommerceSettingsController extends AdminBaseController
400
);
}
+ } catch (ShippingTypeOperationException|ShippingCarrierOperationException $e) {
+ // 도메인 실패(사용 중이라 삭제 불가 등)는 운영자가 조치할 수 있는 사유다.
+ // generic 500 으로 뭉개면 "왜" 저장이 실패했는지 화면에서 알 수 없다.
+ // 키가 이미 `sirsoft-ecommerce::` 로 정규화돼 있어 moduleError 가 아닌 error 를 쓴다
+ // (ShippingCarrierController::destroy 와 동형).
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error(
+ $messageKey,
+ 400,
+ null,
+ $e->getMessageParams()
+ );
} catch (Exception $e) {
+ Log::error('이커머스 설정 저장 실패', [
+ 'user_id' => Auth::id(),
+ 'error' => $e->getMessage(),
+ ]);
+
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.save_error',
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ExtraFeeTemplateController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ExtraFeeTemplateController.php
index 346d6c2e..0933636d 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ExtraFeeTemplateController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ExtraFeeTemplateController.php
@@ -5,7 +5,6 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ExtraFeeTemplateBulkCreateRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ExtraFeeTemplateBulkDeleteRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ExtraFeeTemplateBulkToggleActiveRequest;
@@ -28,7 +27,7 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 추가배송비 템플릿 목록 조회
*
- * @param ExtraFeeTemplateListRequest $request
+ * @param ExtraFeeTemplateListRequest $request
* @return JsonResponse
*/
public function index(ExtraFeeTemplateListRequest $request): JsonResponse
@@ -48,7 +47,7 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 추가배송비 템플릿 상세 조회
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function show(int $id): JsonResponse
@@ -73,7 +72,7 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 추가배송비 템플릿 생성
*
- * @param ExtraFeeTemplateStoreRequest $request
+ * @param ExtraFeeTemplateStoreRequest $request
* @return JsonResponse
*/
public function store(ExtraFeeTemplateStoreRequest $request): JsonResponse
@@ -88,10 +87,11 @@ class ExtraFeeTemplateController extends AdminBaseController
201
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -99,8 +99,8 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 추가배송비 템플릿 수정
*
- * @param ExtraFeeTemplateUpdateRequest $request
- * @param int $id
+ * @param ExtraFeeTemplateUpdateRequest $request
+ * @param int $id
* @return JsonResponse
*/
public function update(ExtraFeeTemplateUpdateRequest $request, int $id): JsonResponse
@@ -124,10 +124,11 @@ class ExtraFeeTemplateController extends AdminBaseController
new ExtraFeeTemplateResource($updatedTemplate)
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -135,7 +136,7 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 추가배송비 템플릿 삭제
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function destroy(int $id): JsonResponse
@@ -158,10 +159,11 @@ class ExtraFeeTemplateController extends AdminBaseController
'messages.extra_fee_template.deleted'
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -169,7 +171,7 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 추가배송비 템플릿 사용여부 토글
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function toggleActive(int $id): JsonResponse
@@ -193,10 +195,11 @@ class ExtraFeeTemplateController extends AdminBaseController
new ExtraFeeTemplateResource($updatedTemplate)
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -204,7 +207,7 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 추가배송비 템플릿 일괄 삭제
*
- * @param ExtraFeeTemplateBulkDeleteRequest $request
+ * @param ExtraFeeTemplateBulkDeleteRequest $request
* @return JsonResponse
*/
public function bulkDestroy(ExtraFeeTemplateBulkDeleteRequest $request): JsonResponse
@@ -218,10 +221,11 @@ class ExtraFeeTemplateController extends AdminBaseController
['deleted_count' => $count]
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -229,7 +233,7 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 추가배송비 템플릿 일괄 사용여부 변경
*
- * @param ExtraFeeTemplateBulkToggleActiveRequest $request
+ * @param ExtraFeeTemplateBulkToggleActiveRequest $request
* @return JsonResponse
*/
public function bulkToggleActive(ExtraFeeTemplateBulkToggleActiveRequest $request): JsonResponse
@@ -247,10 +251,11 @@ class ExtraFeeTemplateController extends AdminBaseController
['updated_count' => $count]
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -258,7 +263,7 @@ class ExtraFeeTemplateController extends AdminBaseController
/**
* 일괄 등록 (CSV/엑셀 업로드용)
*
- * @param ExtraFeeTemplateBulkCreateRequest $request
+ * @param ExtraFeeTemplateBulkCreateRequest $request
* @return JsonResponse
*/
public function bulkStore(ExtraFeeTemplateBulkCreateRequest $request): JsonResponse
@@ -273,10 +278,11 @@ class ExtraFeeTemplateController extends AdminBaseController
201
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/OrderController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/OrderController.php
index d57f8371..d0a6339c 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/OrderController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/OrderController.php
@@ -12,6 +12,9 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderAmountChangedException;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderCancellationException;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkChangeOrderOptionStatusRequest;
@@ -361,6 +364,13 @@ class OrderController extends AdminBaseController
'messages.orders.cancelled',
new OrderResource($updatedOrder)
);
+ } catch (OrderCancellationException $e) {
+ // 취소 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 422
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'messages.orders.cancel_failed',
+ 422
+ );
} catch (Exception $e) {
Log::error('주문 취소 실패 (관리자)', [
'order_id' => $order->id,
@@ -370,9 +380,8 @@ class OrderController extends AdminBaseController
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- 'messages.orders.cancel_failed',
- 422,
- ['detail' => $e->getMessage()]
+ 'exceptions.operation_failed',
+ 500
);
}
}
@@ -412,6 +421,13 @@ class OrderController extends AdminBaseController
422,
['detail' => $e->getMessage()]
);
+ } catch (OrderProcessingException|OrderAmountChangedException $e) {
+ // 입금확인 도메인 규칙 위반(전이 불가 상태/결제예정금액 변동) — 422
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'messages.orders.deposit_confirm_failed',
+ 422
+ );
} catch (Exception $e) {
Log::error('무통장 입금확인 실패 (관리자)', [
'order_id' => $order->id,
@@ -420,9 +436,8 @@ class OrderController extends AdminBaseController
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- 'messages.orders.deposit_confirm_failed',
- 422,
- ['detail' => $e->getMessage()]
+ 'exceptions.operation_failed',
+ 500
);
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductCommonInfoController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductCommonInfoController.php
index 821b81fe..276bbd07 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductCommonInfoController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductCommonInfoController.php
@@ -6,6 +6,7 @@ use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductCommonInfoOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\IndexProductCommonInfoRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreProductCommonInfoRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateProductCommonInfoRequest;
@@ -97,12 +98,20 @@ class ProductCommonInfoController extends AdminBaseController
new ProductCommonInfoResource($commonInfo),
201
);
- } catch (Exception $e) {
+ } catch (ProductCommonInfoOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -123,12 +132,20 @@ class ProductCommonInfoController extends AdminBaseController
'messages.common_infos.updated',
new ProductCommonInfoResource($commonInfo)
);
- } catch (Exception $e) {
+ } catch (ProductCommonInfoOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -148,12 +165,20 @@ class ProductCommonInfoController extends AdminBaseController
'messages.common_infos.deleted',
$result
);
- } catch (Exception $e) {
+ } catch (ProductCommonInfoOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -175,12 +200,20 @@ class ProductCommonInfoController extends AdminBaseController
: 'messages.common_infos.deactivated',
new ProductCommonInfoResource($commonInfo)
);
- } catch (Exception $e) {
+ } catch (ProductCommonInfoOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php
index 84ca4a18..65314598 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductController.php
@@ -12,6 +12,7 @@ use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Exceptions\ProductHasOrderHistoryException;
use Modules\Sirsoft\Ecommerce\Exceptions\ProductImageUploadLimitException;
use Modules\Sirsoft\Ecommerce\Exceptions\ProductPriceRelationException;
+use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdatePriceRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateProductsRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\BulkUpdateStatusRequest;
@@ -731,6 +732,7 @@ class ProductController extends AdminBaseController
'sirsoft-ecommerce'
);
} catch (Exception $e) {
+ // 개수 상한 초과는 위에서 typed 로 분기된다 — 여기 도달하면 서버 결함/인프라 장애다
Log::error('상품 이미지 업로드 실패', [
'product_id' => $productId,
'error' => $e->getMessage(),
@@ -740,7 +742,7 @@ class ProductController extends AdminBaseController
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -768,12 +770,20 @@ class ProductController extends AdminBaseController
'sirsoft-ecommerce',
'messages.product_images.deleted'
);
- } catch (Exception $e) {
+ } catch (ResourceScopeMismatchException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -793,12 +803,20 @@ class ProductController extends AdminBaseController
'sirsoft-ecommerce',
'messages.product_images.reordered'
);
- } catch (Exception $e) {
+ } catch (ResourceScopeMismatchException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -818,12 +836,20 @@ class ProductController extends AdminBaseController
'sirsoft-ecommerce',
'messages.product_images.thumbnail_set'
);
- } catch (Exception $e) {
+ } catch (ResourceScopeMismatchException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductInquiryController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductInquiryController.php
index 8775daae..1c60cb3d 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductInquiryController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductInquiryController.php
@@ -6,6 +6,7 @@ use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductInquiryOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreInquiryReplyRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateInquiryReplyRequest;
use Modules\Sirsoft\Ecommerce\Services\ProductInquiryService;
@@ -39,8 +40,12 @@ class ProductInquiryController extends AdminBaseController
['id' => $inquiry->id, 'is_answered' => $inquiry->is_answered],
201
);
- } catch (\RuntimeException $e) {
- return ResponseHelper::error($e->getMessage(), 422);
+ } catch (ProductInquiryOperationException $e) {
+ // 도메인 규칙 위반 — 예외가 보관한 메시지 키로 응답을 구성한다.
+ // 종전처럼 번역된 원문을 키 자리에 넘기면 키 해석에 실패해 원문이 그대로 노출된다.
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error($messageKey, 422, null, $e->getMessageParams());
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.reply_failed', 500);
}
@@ -58,8 +63,12 @@ class ProductInquiryController extends AdminBaseController
$this->inquiryService->deleteInquiry($inquiryId);
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.inquiries.deleted', ['deleted' => true]);
- } catch (\RuntimeException $e) {
- return ResponseHelper::error($e->getMessage(), 422);
+ } catch (ProductInquiryOperationException $e) {
+ // 도메인 규칙 위반 — 예외가 보관한 메시지 키로 응답을 구성한다.
+ // 종전처럼 번역된 원문을 키 자리에 넘기면 키 해석에 실패해 원문이 그대로 노출된다.
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error($messageKey, 422, null, $e->getMessageParams());
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.delete_failed', 500);
}
@@ -78,8 +87,12 @@ class ProductInquiryController extends AdminBaseController
$this->inquiryService->updateReply($inquiryId, $request->validated());
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.inquiries.reply_updated', ['id' => $inquiryId]);
- } catch (\RuntimeException $e) {
- return ResponseHelper::error($e->getMessage(), 422);
+ } catch (ProductInquiryOperationException $e) {
+ // 도메인 규칙 위반 — 예외가 보관한 메시지 키로 응답을 구성한다.
+ // 종전처럼 번역된 원문을 키 자리에 넘기면 키 해석에 실패해 원문이 그대로 노출된다.
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error($messageKey, 422, null, $e->getMessageParams());
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.reply_update_failed', 500);
}
@@ -97,8 +110,12 @@ class ProductInquiryController extends AdminBaseController
$this->inquiryService->deleteReply($inquiryId);
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.inquiries.reply_deleted', ['deleted' => true]);
- } catch (\RuntimeException $e) {
- return ResponseHelper::error($e->getMessage(), 422);
+ } catch (ProductInquiryOperationException $e) {
+ // 도메인 규칙 위반 — 예외가 보관한 메시지 키로 응답을 구성한다.
+ // 종전처럼 번역된 원문을 키 자리에 넘기면 키 해석에 실패해 원문이 그대로 노출된다.
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error($messageKey, 422, null, $e->getMessageParams());
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.reply_delete_failed', 500);
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductLabelController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductLabelController.php
index d981ac4d..87bb9aff 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductLabelController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductLabelController.php
@@ -6,6 +6,7 @@ use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductLabelOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductLabelListRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductLabelStoreRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductLabelUpdateRequest;
@@ -25,7 +26,7 @@ class ProductLabelController extends AdminBaseController
/**
* 라벨 목록 조회
*
- * @param ProductLabelListRequest $request
+ * @param ProductLabelListRequest $request
* @return JsonResponse
*/
public function index(ProductLabelListRequest $request): JsonResponse
@@ -33,7 +34,7 @@ class ProductLabelController extends AdminBaseController
$filters = $request->validated();
// active_only 필터 처리 (기존 호환성 유지)
- if (!empty($filters['active_only'])) {
+ if (! empty($filters['active_only'])) {
$filters['is_active'] = true;
}
@@ -49,14 +50,14 @@ class ProductLabelController extends AdminBaseController
/**
* 라벨 상세 조회
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function show(int $id): JsonResponse
{
$label = $this->service->getLabel($id);
- if (!$label) {
+ if (! $label) {
return ResponseHelper::notFound(
'messages.labels.not_found',
[],
@@ -74,7 +75,7 @@ class ProductLabelController extends AdminBaseController
/**
* 라벨 생성
*
- * @param ProductLabelStoreRequest $request
+ * @param ProductLabelStoreRequest $request
* @return JsonResponse
*/
public function store(ProductLabelStoreRequest $request): JsonResponse
@@ -92,8 +93,8 @@ class ProductLabelController extends AdminBaseController
/**
* 라벨 수정
*
- * @param ProductLabelUpdateRequest $request
- * @param int $id
+ * @param ProductLabelUpdateRequest $request
+ * @param int $id
* @return JsonResponse
*/
public function update(ProductLabelUpdateRequest $request, int $id): JsonResponse
@@ -113,10 +114,11 @@ class ProductLabelController extends AdminBaseController
'sirsoft-ecommerce'
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.labels.update_failed',
- 400
+ 500
);
}
}
@@ -124,7 +126,7 @@ class ProductLabelController extends AdminBaseController
/**
* 라벨 상태 토글
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function toggleStatus(int $id): JsonResponse
@@ -144,10 +146,11 @@ class ProductLabelController extends AdminBaseController
'sirsoft-ecommerce'
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.labels.update_failed',
- 400
+ 500
);
}
}
@@ -155,7 +158,7 @@ class ProductLabelController extends AdminBaseController
/**
* 라벨 삭제
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function destroy(int $id): JsonResponse
@@ -174,11 +177,22 @@ class ProductLabelController extends AdminBaseController
[],
'sirsoft-ecommerce'
);
+ } catch (ProductLabelOperationException $e) {
+ // 연결된 상품이 있어 삭제 불가 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error(
+ $messageKey,
+ 400,
+ null,
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.labels.delete_failed',
- 400
+ 500
);
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductNoticeTemplateController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductNoticeTemplateController.php
index 8380eb38..aba8b15c 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductNoticeTemplateController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ProductNoticeTemplateController.php
@@ -6,6 +6,7 @@ use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductNoticeTemplateNotFoundException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ProductNoticeTemplateListRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreProductNoticeTemplateRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateProductNoticeTemplateRequest;
@@ -106,12 +107,20 @@ class ProductNoticeTemplateController extends AdminBaseController
new ProductNoticeTemplateResource($template),
201
);
- } catch (Exception $e) {
+ } catch (ProductNoticeTemplateNotFoundException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -132,12 +141,20 @@ class ProductNoticeTemplateController extends AdminBaseController
'messages.notice_templates.updated',
new ProductNoticeTemplateResource($template)
);
- } catch (Exception $e) {
+ } catch (ProductNoticeTemplateNotFoundException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -157,12 +174,20 @@ class ProductNoticeTemplateController extends AdminBaseController
'messages.notice_templates.deleted',
$result
);
- } catch (Exception $e) {
+ } catch (ProductNoticeTemplateNotFoundException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -183,12 +208,20 @@ class ProductNoticeTemplateController extends AdminBaseController
new ProductNoticeTemplateResource($template),
201
);
- } catch (Exception $e) {
+ } catch (ProductNoticeTemplateNotFoundException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -210,12 +243,20 @@ class ProductNoticeTemplateController extends AdminBaseController
: 'messages.notice_templates.deactivated',
new ProductNoticeTemplateResource($template)
);
- } catch (Exception $e) {
+ } catch (ProductNoticeTemplateNotFoundException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ShippingCarrierController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ShippingCarrierController.php
index 0eae9b7e..71d1cfad 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ShippingCarrierController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ShippingCarrierController.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\ShippingCarrierOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ActiveShippingCarrierRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\IndexShippingCarrierRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreShippingCarrierRequest;
@@ -126,12 +127,20 @@ class ShippingCarrierController extends AdminBaseController
'messages.shipping_carriers.updated',
new ShippingCarrierResource($updatedCarrier)
);
- } catch (\Exception $e) {
+ } catch (ShippingCarrierOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
@@ -161,11 +170,23 @@ class ShippingCarrierController extends AdminBaseController
'messages.shipping_carriers.deleted',
$result
);
+ } catch (ShippingCarrierOperationException $e) {
+ // 삭제 불가 사유(미존재/사용 중)를 전용 키로 안내 — 예외 메시지 원문을
+ // 키 자리에 넘기면(종전 동작) 키 해석에 실패해 원문이 그대로 노출된다.
+ $messageKey = $e->getMessageKey();
+
+ return ResponseHelper::error(
+ $messageKey,
+ 400,
+ null,
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- $e->getMessage(),
- 400
+ 'exceptions.operation_failed',
+ 500
);
}
}
@@ -186,12 +207,20 @@ class ShippingCarrierController extends AdminBaseController
'messages.shipping_carriers.status_changed',
new ShippingCarrierResource($updatedCarrier)
);
- } catch (\Exception $e) {
+ } catch (ShippingCarrierOperationException $e) {
+ // 도메인 규칙 위반 — 운영자에게 안내 가능한 상황이므로 기존 400 유지
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
);
+ } catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
+ );
}
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ShippingPolicyController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ShippingPolicyController.php
index 00eda019..93c0fef9 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ShippingPolicyController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/ShippingPolicyController.php
@@ -29,7 +29,7 @@ class ShippingPolicyController extends AdminBaseController
/**
* 배송정책 목록 조회
*
- * @param ShippingPolicyListRequest $request
+ * @param ShippingPolicyListRequest $request
* @return JsonResponse
*/
public function index(ShippingPolicyListRequest $request): JsonResponse
@@ -49,7 +49,7 @@ class ShippingPolicyController extends AdminBaseController
/**
* 배송정책 생성
*
- * @param StoreShippingPolicyRequest $request
+ * @param StoreShippingPolicyRequest $request
* @return JsonResponse
*/
public function store(StoreShippingPolicyRequest $request): JsonResponse
@@ -102,8 +102,8 @@ class ShippingPolicyController extends AdminBaseController
/**
* 배송정책 수정
*
- * @param UpdateShippingPolicyRequest $request
- * @param int $id
+ * @param UpdateShippingPolicyRequest $request
+ * @param int $id
* @return JsonResponse
*/
public function update(UpdateShippingPolicyRequest $request, int $id): JsonResponse
@@ -138,7 +138,7 @@ class ShippingPolicyController extends AdminBaseController
/**
* 배송정책 상세 조회
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function show(int $id): JsonResponse
@@ -163,7 +163,7 @@ class ShippingPolicyController extends AdminBaseController
/**
* 배송정책 삭제
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function destroy(int $id): JsonResponse
@@ -186,10 +186,11 @@ class ShippingPolicyController extends AdminBaseController
'messages.shipping_policy.deleted'
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -197,7 +198,7 @@ class ShippingPolicyController extends AdminBaseController
/**
* 배송정책 사용여부 토글
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function toggleActive(int $id): JsonResponse
@@ -221,10 +222,11 @@ class ShippingPolicyController extends AdminBaseController
new ShippingPolicyResource($updatedPolicy)
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -232,7 +234,7 @@ class ShippingPolicyController extends AdminBaseController
/**
* 배송정책 일괄 삭제
*
- * @param ShippingPolicyBulkDeleteRequest $request
+ * @param ShippingPolicyBulkDeleteRequest $request
* @return JsonResponse
*/
public function bulkDestroy(ShippingPolicyBulkDeleteRequest $request): JsonResponse
@@ -246,10 +248,11 @@ class ShippingPolicyController extends AdminBaseController
['deleted_count' => $count]
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -257,7 +260,7 @@ class ShippingPolicyController extends AdminBaseController
/**
* 배송정책 일괄 사용여부 변경
*
- * @param ShippingPolicyBulkToggleActiveRequest $request
+ * @param ShippingPolicyBulkToggleActiveRequest $request
* @return JsonResponse
*/
public function bulkToggleActive(ShippingPolicyBulkToggleActiveRequest $request): JsonResponse
@@ -275,10 +278,11 @@ class ShippingPolicyController extends AdminBaseController
['updated_count' => $count]
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
@@ -310,7 +314,7 @@ class ShippingPolicyController extends AdminBaseController
/**
* 기본 배송정책 설정
*
- * @param int $id
+ * @param int $id
* @return JsonResponse
*/
public function setDefault(int $id): JsonResponse
@@ -334,10 +338,11 @@ class ShippingPolicyController extends AdminBaseController
new ShippingPolicyResource($updatedPolicy)
);
} catch (\Exception $e) {
+ // 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
- 400
+ 500
);
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/OrderController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/OrderController.php
index e26f2e03..17106433 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/OrderController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/OrderController.php
@@ -8,6 +8,8 @@ use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderCancellationException;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderModificationException;
use Modules\Sirsoft\Ecommerce\Http\Controllers\Traits\HandlesOrderCreation;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\CreateOrderRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\GuestCancelOrderRequest;
@@ -310,6 +312,12 @@ class OrderController extends PublicBaseController
'sirsoft-ecommerce::messages.order.cancelled',
new GuestOrderResource($updatedOrder)
);
+ } catch (OrderCancellationException $e) {
+ // 취소 도메인 규칙 위반 — 사용자에게 안내 가능한 상황이므로 422
+ return ResponseHelper::error(
+ 'sirsoft-ecommerce::exceptions.order_cancel_failed',
+ 422
+ );
} catch (Exception $e) {
Log::error('비회원 주문 취소 실패', [
'order_number' => $request->getOrder()->order_number,
@@ -317,8 +325,8 @@ class OrderController extends PublicBaseController
]);
return ResponseHelper::error(
- 'sirsoft-ecommerce::exceptions.order_cancel_failed',
- 422
+ 'sirsoft-ecommerce::exceptions.operation_failed',
+ 500
);
}
}
@@ -376,7 +384,15 @@ class OrderController extends PublicBaseController
'sirsoft-ecommerce::messages.orders.shipping_address_updated',
new GuestOrderResource($updatedOrder)
);
+ } catch (OrderModificationException $e) {
+ // 도메인 규칙 위반(배송 전 상태 아님 등) — 사용자가 고칠 수 있는 상황이므로 422
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'messages.orders.cannot_modify_address',
+ 422
+ );
} catch (Exception $e) {
+ // 그 외 예외는 서버 결함/인프라 장애다. 422 로 뭉개면 장애가 입력 오류로 위장된다.
Log::error('비회원 배송지 수정 실패', [
'order_number' => $request->getOrder()->order_number,
'error' => $e->getMessage(),
@@ -384,8 +400,8 @@ class OrderController extends PublicBaseController
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- 'messages.orders.cannot_modify_address',
- 422
+ 'exceptions.order_shipping_address_update_failed',
+ 500
);
}
}
@@ -398,6 +414,9 @@ class OrderController extends PublicBaseController
* @param int $optionId 주문 옵션 ID (라우트 파라미터)
* @return JsonResponse 구매확정 후 주문 상세
*/
+ // route-param:unused $orderNumber 는 VerifyGuestOrderToken 미들웨어가 토큰↔주문 일치 검증에
+ // 사용하고, 본문은 미들웨어가 확정한 주문($request->getOrder())만 쓴다. 본문에서 재사용하면
+ // 검증되지 않은 라우트 값으로 주문을 다시 찾는 우회로가 생긴다.
public function confirmOption(GuestOrderTokenRequest $request, string $orderNumber, int $optionId): JsonResponse
{
$order = $request->getOrder();
@@ -418,6 +437,8 @@ class OrderController extends PublicBaseController
new GuestOrderResource($updatedOrder)
);
} catch (Exception $e) {
+ // OrderService::confirmOption 은 도메인 예외를 던지지 않는다 —
+ // 여기에 걸리는 건 전부 서버 결함/인프라 장애다.
Log::error('비회원 구매확정 실패', [
'order_number' => $order->order_number,
'option_id' => $optionId,
@@ -426,8 +447,8 @@ class OrderController extends PublicBaseController
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- 'exceptions.order_option_cannot_confirm',
- 422
+ 'exceptions.operation_failed',
+ 500
);
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/ProductInquiryController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/ProductInquiryController.php
index 48395305..5805127a 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/ProductInquiryController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/ProductInquiryController.php
@@ -6,6 +6,7 @@ use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductInquiryOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\ProductInquiryListRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\StoreInquiryRequest;
use Modules\Sirsoft\Ecommerce\Models\Product;
@@ -74,16 +75,19 @@ class ProductInquiryController extends PublicBaseController
['id' => $inquiry->id],
201
);
- } catch (\RuntimeException $e) {
+ } catch (ProductInquiryOperationException $e) {
// 실패 사유(문의 게시판 미설정·게시판 모듈 불가 등)를 그대로 보여준다.
// 일반 문구만 남기면 서버 기록을 봐야만 원인을 알 수 있다 — 같은 기능의
// 수정·답변 경로는 이미 사유를 노출하고 있어 안내 수준을 맞춘다.
+ //
+ // 종전에는 `\RuntimeException` 을 잡아서, 도메인 사유가 아닌 인프라 예외까지
+ // 422 로 뭉개고 그 원문을 사유 자리에 실어 보냈다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.inquiries.operation_failed_reason',
422,
null,
- ['reason' => $e->getMessage()]
+ ['reason' => __($e->getMessageKey(), $e->getMessageParams())]
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/WishlistController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/WishlistController.php
index 94d103a5..6d3895eb 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/WishlistController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/WishlistController.php
@@ -47,7 +47,9 @@ class WishlistController extends PublicBaseController
'added' => $result['added'],
]);
} catch (Exception $e) {
- return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.wishlist.toggle_failed');
+ // 상태코드를 생략하면 ResponseHelper 기본값 400 이 되어 서버 오류가
+ // "입력 오류"로 위장된다 — 찜 토글에 도메인 예외는 없다.
+ return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.wishlist.toggle_failed', 500);
}
}
@@ -73,7 +75,9 @@ class WishlistController extends PublicBaseController
new WishlistCollection($wishlists)
);
} catch (Exception $e) {
- return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.wishlist.retrieve_failed');
+ // 상태코드를 생략하면 ResponseHelper 기본값 400 이 되어 조회 실패가
+ // "입력 오류"로 위장된다 — 목록 조회에 도메인 예외는 없다.
+ return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.wishlist.retrieve_failed', 500);
}
}
@@ -97,7 +101,8 @@ class WishlistController extends PublicBaseController
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.wishlist.removed');
} catch (Exception $e) {
- return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.wishlist.delete_failed');
+ // 상태코드 생략 시 기본값 400 — 삭제 실패는 서버 오류다 (미존재는 위에서 404 로 분기)
+ return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.wishlist.delete_failed', 500);
}
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/OrderController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/OrderController.php
index 837a4510..45363121 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/OrderController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/OrderController.php
@@ -8,6 +8,11 @@ use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Ecommerce\Exceptions\CartOperationException;
+use Modules\Sirsoft\Ecommerce\Exceptions\CartQuantityLimitException;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderCancellationException;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderModificationException;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\CancelOrderRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\ConfirmOrderOptionRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\EstimateRefundRequest;
@@ -136,6 +141,12 @@ class OrderController extends AuthBaseController
'sirsoft-ecommerce::messages.order.cancelled',
new OrderResource($updatedOrder)
);
+ } catch (OrderCancellationException $e) {
+ // 취소 도메인 규칙 위반 — 사용자에게 안내 가능한 상황이므로 422
+ return ResponseHelper::error(
+ 'sirsoft-ecommerce::exceptions.order_cancel_failed',
+ 422
+ );
} catch (Exception $e) {
Log::error('주문 취소 실패', [
'order_id' => $request->getOrder()->id,
@@ -143,8 +154,8 @@ class OrderController extends AuthBaseController
]);
return ResponseHelper::error(
- 'sirsoft-ecommerce::exceptions.order_cancel_failed',
- 422
+ 'sirsoft-ecommerce::exceptions.operation_failed',
+ 500
);
}
}
@@ -211,7 +222,15 @@ class OrderController extends AuthBaseController
return ResponseHelper::success('sirsoft-ecommerce::messages.orders.shipping_address_updated', [
'order' => new OrderResource($order),
]);
+ } catch (OrderModificationException $e) {
+ // 도메인 규칙 위반(배송 전 상태 아님, 타인 주소 지정 등) — 422
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'messages.orders.cannot_modify_address',
+ 422
+ );
} catch (Exception $e) {
+ // 그 외 예외는 서버 결함/인프라 장애다. 422 로 뭉개면 장애가 입력 오류로 위장된다.
Log::error('Order shipping address update failed', [
'message' => $e->getMessage(),
'order_id' => $id,
@@ -220,8 +239,8 @@ class OrderController extends AuthBaseController
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- 'messages.orders.cannot_modify_address',
- 422
+ 'exceptions.order_shipping_address_update_failed',
+ 500
);
}
}
@@ -250,6 +269,8 @@ class OrderController extends AuthBaseController
['order' => new OrderResource($updatedOrder)]
);
} catch (Exception $e) {
+ // OrderService::confirmOption 은 도메인 예외를 던지지 않는다 —
+ // 여기에 걸리는 건 전부 서버 결함/인프라 장애다.
Log::error('Order option confirm failed', [
'message' => $e->getMessage(),
'order_id' => $request->route('id'),
@@ -259,8 +280,8 @@ class OrderController extends AuthBaseController
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- 'exceptions.order_option_cannot_confirm',
- 422
+ 'exceptions.operation_failed',
+ 500
);
}
}
@@ -286,6 +307,13 @@ class OrderController extends AuthBaseController
'sirsoft-ecommerce::messages.cart.reorder_added',
$result
);
+ } catch (OrderProcessingException|CartOperationException|CartQuantityLimitException $e) {
+ // 재주문 도메인 규칙 위반(장바구니 담기 불가/수량 한도 등) — 422
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'messages.cart.reorder_failed',
+ 422
+ );
} catch (Exception $e) {
Log::error('Order reorder failed', [
'message' => $e->getMessage(),
@@ -295,8 +323,8 @@ class OrderController extends AuthBaseController
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- 'messages.cart.reorder_failed',
- 422
+ 'exceptions.operation_failed',
+ 500
);
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ProductInquiryController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ProductInquiryController.php
index 72ac9ab5..901f1140 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ProductInquiryController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ProductInquiryController.php
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Base\AuthBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductInquiryOperationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\UpdateInquiryReplyRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\UpdateInquiryRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\UserInquiryListRequest;
@@ -76,15 +77,19 @@ class ProductInquiryController extends AuthBaseController
$this->inquiryService->updateInquiry($inquiryId, $request->validated());
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.inquiries.updated', ['id' => $inquiryId]);
- } catch (\RuntimeException $e) {
- // 서비스가 던지는 RuntimeException 은 생성 시점에 __() 로 번역된 사용자 문구다.
- // 사유를 파라미터로 넘겨 실패 원인이 화면에 그대로 남게 한다.
+ } catch (ProductInquiryOperationException $e) {
+ // 실패 사유(문의 게시판 미설정 등)를 그대로 보여준다 — 일반 문구만 남기면
+ // 서버 기록을 봐야만 원인을 알 수 있다.
+ //
+ // 종전에는 `\RuntimeException` 을 잡았다. 서비스가 도메인 실패를 typed 로
+ // 승격한 뒤로 그 catch 에 남는 것은 인프라 예외뿐이었고, 그것까지 422 +
+ // 예외 원문으로 뭉갰다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.inquiries.operation_failed_reason',
422,
null,
- ['reason' => $e->getMessage()]
+ ['reason' => __($e->getMessageKey(), $e->getMessageParams())]
);
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.update_failed', 500);
@@ -114,6 +119,17 @@ class ProductInquiryController extends AuthBaseController
$this->inquiryService->deleteInquiry($inquiryId);
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.inquiries.deleted', ['deleted' => true]);
+ } catch (ProductInquiryOperationException $e) {
+ // `deleteInquiry` 도 도메인 실패를 typed 로 던지는데 여기에는 그것을 구분하는
+ // catch 가 없어, 운영자가 고칠 수 있는 사유(문의 게시판 미설정)가 일반 500
+ // 문구로 나갔다. 형제 메서드(수정·답변)와 같은 폭으로 맞춘다.
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'messages.inquiries.operation_failed_reason',
+ 422,
+ null,
+ ['reason' => __($e->getMessageKey(), $e->getMessageParams())]
+ );
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.delete_failed', 500);
}
@@ -144,15 +160,19 @@ class ProductInquiryController extends AuthBaseController
['id' => $inquiry->id, 'is_answered' => $inquiry->is_answered],
201
);
- } catch (\RuntimeException $e) {
- // 서비스가 던지는 RuntimeException 은 생성 시점에 __() 로 번역된 사용자 문구다.
- // 사유를 파라미터로 넘겨 실패 원인이 화면에 그대로 남게 한다.
+ } catch (ProductInquiryOperationException $e) {
+ // 실패 사유(문의 게시판 미설정 등)를 그대로 보여준다 — 일반 문구만 남기면
+ // 서버 기록을 봐야만 원인을 알 수 있다.
+ //
+ // 종전에는 `\RuntimeException` 을 잡았다. 서비스가 도메인 실패를 typed 로
+ // 승격한 뒤로 그 catch 에 남는 것은 인프라 예외뿐이었고, 그것까지 422 +
+ // 예외 원문으로 뭉갰다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.inquiries.operation_failed_reason',
422,
null,
- ['reason' => $e->getMessage()]
+ ['reason' => __($e->getMessageKey(), $e->getMessageParams())]
);
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.reply_failed', 500);
@@ -179,15 +199,19 @@ class ProductInquiryController extends AuthBaseController
$this->inquiryService->updateReply($inquiryId, $request->validated());
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.inquiries.reply_updated', ['id' => $inquiryId]);
- } catch (\RuntimeException $e) {
- // 서비스가 던지는 RuntimeException 은 생성 시점에 __() 로 번역된 사용자 문구다.
- // 사유를 파라미터로 넘겨 실패 원인이 화면에 그대로 남게 한다.
+ } catch (ProductInquiryOperationException $e) {
+ // 실패 사유(문의 게시판 미설정 등)를 그대로 보여준다 — 일반 문구만 남기면
+ // 서버 기록을 봐야만 원인을 알 수 있다.
+ //
+ // 종전에는 `\RuntimeException` 을 잡았다. 서비스가 도메인 실패를 typed 로
+ // 승격한 뒤로 그 catch 에 남는 것은 인프라 예외뿐이었고, 그것까지 422 +
+ // 예외 원문으로 뭉갰다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.inquiries.operation_failed_reason',
422,
null,
- ['reason' => $e->getMessage()]
+ ['reason' => __($e->getMessageKey(), $e->getMessageParams())]
);
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.reply_update_failed', 500);
@@ -213,15 +237,19 @@ class ProductInquiryController extends AuthBaseController
$this->inquiryService->deleteReply($inquiryId);
return ResponseHelper::moduleSuccess('sirsoft-ecommerce', 'messages.inquiries.reply_deleted', ['deleted' => true]);
- } catch (\RuntimeException $e) {
- // 서비스가 던지는 RuntimeException 은 생성 시점에 __() 로 번역된 사용자 문구다.
- // 사유를 파라미터로 넘겨 실패 원인이 화면에 그대로 남게 한다.
+ } catch (ProductInquiryOperationException $e) {
+ // 실패 사유(문의 게시판 미설정 등)를 그대로 보여준다 — 일반 문구만 남기면
+ // 서버 기록을 봐야만 원인을 알 수 있다.
+ //
+ // 종전에는 `\RuntimeException` 을 잡았다. 서비스가 도메인 실패를 typed 로
+ // 승격한 뒤로 그 catch 에 남는 것은 인프라 예외뿐이었고, 그것까지 422 +
+ // 예외 원문으로 뭉갰다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.inquiries.operation_failed_reason',
422,
null,
- ['reason' => $e->getMessage()]
+ ['reason' => __($e->getMessageKey(), $e->getMessageParams())]
);
} catch (Exception $e) {
return ResponseHelper::moduleError('sirsoft-ecommerce', 'messages.inquiries.reply_delete_failed', 500);
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ProductReviewController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ProductReviewController.php
index 5c8944dd..3fb2be5d 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ProductReviewController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ProductReviewController.php
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Base\AuthBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
+use Modules\Sirsoft\Ecommerce\Exceptions\ReviewNotWritableException;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\StoreReviewRequest;
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductReviewResource;
use Modules\Sirsoft\Ecommerce\Models\ProductReview;
@@ -69,8 +70,16 @@ class ProductReviewController extends AuthBaseController
new ProductReviewResource($review),
201
);
- } catch (\RuntimeException $e) {
- return ResponseHelper::error($e->getMessage(), 422);
+ } catch (ReviewNotWritableException $e) {
+ // 작성 자격 미충족은 도메인 검증 실패 → 422 + 전용 메시지 (메시지 키 + 사유 파라미터).
+ // 예외 메시지 원문을 키 자리에 넘기면 키 해석에 실패해 원문이 그대로 노출된다.
+ return ResponseHelper::error(
+ 'messages.reviews.cannot_write',
+ 422,
+ null,
+ ['reason' => $e->getReason()],
+ 'sirsoft-ecommerce'
+ );
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ReviewImageController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ReviewImageController.php
index 97bcd2af..b561a3f5 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ReviewImageController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/ReviewImageController.php
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Base\AuthBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
+use Modules\Sirsoft\Ecommerce\Exceptions\ReviewImageUploadLimitException;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\UploadReviewImageRequest;
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductReviewImageResource;
use Modules\Sirsoft\Ecommerce\Models\ProductReview;
@@ -54,8 +55,16 @@ class ReviewImageController extends AuthBaseController
new ProductReviewImageResource($image),
201
);
- } catch (\RuntimeException $e) {
- return ResponseHelper::error($e->getMessage(), 422);
+ } catch (ReviewImageUploadLimitException $e) {
+ // 개수 상한 초과는 도메인 검증 실패 → 422 + 전용 메시지 (메시지 키 + 파라미터).
+ // 예외 메시지 원문을 키 자리에 넘기면 키 해석에 실패해 원문이 그대로 노출된다.
+ return ResponseHelper::error(
+ 'review.image_upload_limit_exceeded',
+ 422,
+ null,
+ ['max' => $e->maxImages],
+ 'sirsoft-ecommerce'
+ );
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/UserAddressController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/UserAddressController.php
index d3b4daaf..420930f2 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/UserAddressController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/UserAddressController.php
@@ -34,7 +34,7 @@ class UserAddressController extends AuthBaseController
public function index(): JsonResponse
{
try {
-
+
$userId = Auth::id();
$addresses = $this->userAddressService->getUserAddresses($userId);
@@ -58,13 +58,13 @@ class UserAddressController extends AuthBaseController
/**
* 사용자 배송지 상세 조회
*
- * @param int $id 배송지 ID
+ * @param int $id 배송지 ID
* @return JsonResponse 배송지 정보를 포함한 JSON 응답
*/
public function show(int $id): JsonResponse
{
try {
-
+
$userId = Auth::id();
$address = $this->userAddressService->getAddress($userId, $id);
@@ -97,13 +97,13 @@ class UserAddressController extends AuthBaseController
/**
* 사용자 배송지 등록
*
- * @param StoreUserAddressRequest $request 검증된 요청 데이터
+ * @param StoreUserAddressRequest $request 검증된 요청 데이터
* @return JsonResponse 생성된 배송지 정보를 포함한 JSON 응답
*/
public function store(StoreUserAddressRequest $request): JsonResponse
{
try {
-
+
$userId = Auth::id();
$data = $request->validated();
$data['user_id'] = $userId;
@@ -126,15 +126,9 @@ class UserAddressController extends AuthBaseController
'user_id' => Auth::id(),
]);
- // 최대 배송지 개수 초과 예외 처리
- if (str_contains($e->getMessage(), 'max_addresses_exceeded')) {
- return ResponseHelper::moduleError(
- 'sirsoft-ecommerce',
- 'exceptions.max_addresses_exceeded',
- 422
- );
- }
-
+ // 종전에는 예외 메시지 문자열을 검사해 422 로 분기했으나, 그 문자열을 던지는
+ // 지점도 대응 다국어 키도 존재하지 않아 도달 불가능한 분기였다. 개수 상한이
+ // 도입되면 전용 예외 타입으로 분기한다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.address.create_failed',
@@ -146,14 +140,14 @@ class UserAddressController extends AuthBaseController
/**
* 사용자 배송지 수정
*
- * @param UpdateUserAddressRequest $request 검증된 요청 데이터
- * @param int $id 배송지 ID
+ * @param UpdateUserAddressRequest $request 검증된 요청 데이터
+ * @param int $id 배송지 ID
* @return JsonResponse 수정된 배송지 정보를 포함한 JSON 응답
*/
public function update(UpdateUserAddressRequest $request, int $id): JsonResponse
{
try {
-
+
$userId = Auth::id();
$data = $request->validated();
@@ -188,13 +182,13 @@ class UserAddressController extends AuthBaseController
/**
* 사용자 배송지 삭제
*
- * @param int $id 배송지 ID
+ * @param int $id 배송지 ID
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function destroy(int $id): JsonResponse
{
try {
-
+
$userId = Auth::id();
$deleted = $this->userAddressService->deleteAddress($userId, $id);
@@ -225,13 +219,13 @@ class UserAddressController extends AuthBaseController
/**
* 기본 배송지 설정
*
- * @param int $id 배송지 ID
+ * @param int $id 배송지 ID
* @return JsonResponse 설정 결과를 포함한 JSON 응답
*/
public function setDefault(int $id): JsonResponse
{
try {
-
+
$userId = Auth::id();
$result = $this->userAddressService->setDefaultAddress($userId, $id);
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/UserCouponController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/UserCouponController.php
index fe145c1e..cb23f6c6 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/UserCouponController.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/User/UserCouponController.php
@@ -7,6 +7,8 @@ use App\Http\Controllers\Api\Base\AuthBaseController;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Ecommerce\Exceptions\CouponNotIssuableException;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\DownloadCouponRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\UserCouponAvailableRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\User\UserCouponDownloadableRequest;
@@ -28,7 +30,7 @@ class UserCouponController extends AuthBaseController
/**
* 사용자 쿠폰함 목록 조회
*
- * @param Request $request 요청 데이터
+ * @param Request $request 요청 데이터
* @return JsonResponse 쿠폰 목록을 포함한 JSON 응답
*/
public function index(UserCouponListRequest $request): JsonResponse
@@ -58,7 +60,7 @@ class UserCouponController extends AuthBaseController
/**
* 체크아웃에서 사용 가능한 쿠폰 목록 조회
*
- * @param Request $request 요청 데이터
+ * @param Request $request 요청 데이터
* @return JsonResponse 사용 가능한 쿠폰 목록을 포함한 JSON 응답
*/
public function available(UserCouponAvailableRequest $request): JsonResponse
@@ -86,7 +88,7 @@ class UserCouponController extends AuthBaseController
/**
* 다운로드 가능한 쿠폰 목록 조회
*
- * @param Request $request 요청 데이터
+ * @param Request $request 요청 데이터
* @return JsonResponse 다운로드 가능 쿠폰 목록
*/
public function downloadable(UserCouponDownloadableRequest $request): JsonResponse
@@ -115,8 +117,8 @@ class UserCouponController extends AuthBaseController
/**
* 쿠폰 다운로드 (발급)
*
- * @param DownloadCouponRequest $request 검증된 요청
- * @param int $couponId 쿠폰 ID
+ * @param DownloadCouponRequest $request 검증된 요청
+ * @param int $couponId 쿠폰 ID
* @return JsonResponse 생성된 발급 정보
*/
public function download(DownloadCouponRequest $request, int $couponId): JsonResponse
@@ -133,11 +135,25 @@ class UserCouponController extends AuthBaseController
$couponIssue,
201
);
- } catch (Exception $e) {
+ } catch (CouponNotIssuableException $e) {
+ // 발급 불가 사유는 예외가 식별자로 들고 있다 — 번역된 메시지를 다시 키로
+ // 넘기면(종전 동작) 키 해석에 실패해 원문이 그대로 노출된다.
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
- $e->getMessage(),
- $e->getCode() ?: 400
+ 'messages.coupon.'.$e->getReason(),
+ 400
+ );
+ } catch (Exception $e) {
+ Log::error('쿠폰 다운로드 실패', [
+ 'coupon_id' => $couponId,
+ 'user_id' => Auth::id(),
+ 'error' => $e->getMessage(),
+ ]);
+
+ return ResponseHelper::moduleError(
+ 'sirsoft-ecommerce',
+ 'exceptions.operation_failed',
+ 500
);
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/GuestUpdateShippingAddressRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/GuestUpdateShippingAddressRequest.php
index 9df3a40d..3426cd78 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/GuestUpdateShippingAddressRequest.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/GuestUpdateShippingAddressRequest.php
@@ -31,6 +31,12 @@ class GuestUpdateShippingAddressRequest extends FormRequest
*/
public function rules(): array
{
+ // country_code 가 국내/해외를 가르는 기준축이다. 종전에는 zipcode/address 를
+ // address_line_1 유무로만 강제해, 해외 필드만 채우고 country_code 를 생략하면
+ // 검증을 통과한 뒤 서비스가 국내 분기로 떨어져 NOT NULL 컬럼에 null 을 쓰려다
+ // SQL 무결성 위반이 났다. 서비스의 폴백(?? 'KR')과 동일한 기준으로 판정한다.
+ $isDomestic = $this->isDomesticShipping();
+
return [
'recipient_name' => ['required', 'string', 'max:50'],
'recipient_phone' => ['required', 'string', 'max:20'],
@@ -38,21 +44,35 @@ class GuestUpdateShippingAddressRequest extends FormRequest
'country_code' => ['nullable', 'string', 'size:2'],
// 국내 배송 주소
- 'zipcode' => ['required_without:address_line_1', 'nullable', 'string', 'max:10'],
- 'address' => ['required_without:address_line_1', 'nullable', 'string', 'max:255'],
+ 'zipcode' => [$isDomestic ? 'required' : 'nullable', 'string', 'max:10'],
+ 'address' => [$isDomestic ? 'required' : 'nullable', 'string', 'max:255'],
'address_detail' => ['nullable', 'string', 'max:255'],
// 해외 배송 주소
- 'address_line_1' => ['required_without:address', 'nullable', 'string', 'max:255'],
+ 'address_line_1' => [$isDomestic ? 'nullable' : 'required', 'string', 'max:255'],
'address_line_2' => ['nullable', 'string', 'max:255'],
- 'intl_city' => ['required_with:address_line_1', 'nullable', 'string', 'max:100'],
+ 'intl_city' => [$isDomestic ? 'nullable' : 'required', 'string', 'max:100'],
'intl_state' => ['nullable', 'string', 'max:100'],
- 'intl_postal_code' => ['required_with:address_line_1', 'nullable', 'string', 'max:20'],
+ 'intl_postal_code' => [$isDomestic ? 'nullable' : 'required', 'string', 'max:20'],
'delivery_memo' => ['nullable', 'string', 'max:255'],
];
}
+ /**
+ * 국내 배송 여부를 판정합니다.
+ *
+ * country_code 미전송/빈값은 국내로 간주한다 (OrderService 의 'KR' 폴백과 동일 기준).
+ *
+ * @return bool 국내 배송이면 true
+ */
+ private function isDomesticShipping(): bool
+ {
+ $country = strtoupper(trim((string) $this->input('country_code', '')));
+
+ return $country === '' || $country === 'KR';
+ }
+
/**
* 검증 오류 메시지 커스터마이징
*
@@ -63,10 +83,11 @@ class GuestUpdateShippingAddressRequest extends FormRequest
return [
'recipient_name.required' => __('sirsoft-ecommerce::validation.user_address.recipient_name_required'),
'recipient_phone.required' => __('sirsoft-ecommerce::validation.user_address.recipient_phone_required'),
- 'zipcode.required_without' => __('sirsoft-ecommerce::validation.user_address.zipcode_required'),
- 'address.required_without' => __('sirsoft-ecommerce::validation.user_address.address_required'),
- 'address_line_1.required_without' => __('sirsoft-ecommerce::validation.user_address.address_line_1_required'),
- 'intl_city.required_with' => __('sirsoft-ecommerce::validation.user_address.intl_city_required'),
+ 'zipcode.required' => __('sirsoft-ecommerce::validation.user_address.zipcode_required'),
+ 'address.required' => __('sirsoft-ecommerce::validation.user_address.address_required'),
+ 'address_line_1.required' => __('sirsoft-ecommerce::validation.user_address.address_line_1_required'),
+ 'intl_city.required' => __('sirsoft-ecommerce::validation.user_address.intl_city_required'),
+ 'intl_postal_code.required' => __('sirsoft-ecommerce::validation.user_address.intl_postal_code_required'),
];
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UpdateOrderShippingAddressRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UpdateOrderShippingAddressRequest.php
index 039dfae8..186d7079 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UpdateOrderShippingAddressRequest.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UpdateOrderShippingAddressRequest.php
@@ -23,8 +23,24 @@ class UpdateOrderShippingAddressRequest extends FormRequest
return true;
}
+ /**
+ * 요청에 적용할 검증 규칙
+ *
+ * @return array 검증 규칙 배열
+ */
public function rules(): array
{
+ // country_code 가 국내/해외를 가르는 기준축이다. 종전에는 zipcode/address 를
+ // address_line_1 유무로만 강제해, 해외 필드만 채우고 country_code 를 생략하면
+ // 검증을 통과한 뒤 서비스가 국내 분기로 떨어져 NOT NULL 컬럼에 null 을 쓰려다
+ // SQL 무결성 위반이 났다. 서비스의 폴백(?? 'KR')과 동일한 기준으로 판정한다.
+ // 저장된 배송지(address_id) 선택 시에는 직접 입력 필드를 요구하지 않는다.
+ $hasAddressId = $this->filled('address_id');
+ $isDomestic = $this->isDomesticShipping();
+
+ $domesticRule = (! $hasAddressId && $isDomestic) ? 'required' : 'nullable';
+ $intlRule = (! $hasAddressId && ! $isDomestic) ? 'required' : 'nullable';
+
$rules = [
// 저장된 배송지 선택 (address_id가 있으면 다른 필드 불필요)
'address_id' => ['nullable', 'integer', Rule::exists(UserAddress::class, 'id')->where('user_id', Auth::id())],
@@ -37,16 +53,16 @@ class UpdateOrderShippingAddressRequest extends FormRequest
'country_code' => 'nullable|string|size:2',
// 국내 배송 주소
- 'zipcode' => 'required_without_all:address_id,address_line_1|nullable|string|max:10',
- 'address' => 'required_without_all:address_id,address_line_1|nullable|string|max:255',
+ 'zipcode' => $domesticRule.'|string|max:10',
+ 'address' => $domesticRule.'|string|max:255',
'address_detail' => 'nullable|string|max:255',
// 해외 배송 주소
- 'address_line_1' => 'required_without_all:address_id,address|nullable|string|max:255',
+ 'address_line_1' => $intlRule.'|string|max:255',
'address_line_2' => 'nullable|string|max:255',
- 'intl_city' => 'required_with:address_line_1|nullable|string|max:100',
+ 'intl_city' => $intlRule.'|string|max:100',
'intl_state' => 'nullable|string|max:100',
- 'intl_postal_code' => 'required_with:address_line_1|nullable|string|max:20',
+ 'intl_postal_code' => $intlRule.'|string|max:20',
// 배송 메모
'delivery_memo' => 'nullable|string|max:255',
@@ -55,6 +71,20 @@ class UpdateOrderShippingAddressRequest extends FormRequest
return HookManager::applyFilters('sirsoft-ecommerce.order.shipping_address_validation_rules', $rules, $this);
}
+ /**
+ * 국내 배송 여부를 판정합니다.
+ *
+ * country_code 미전송/빈값은 국내로 간주한다 (OrderService 의 'KR' 폴백과 동일 기준).
+ *
+ * @return bool 국내 배송이면 true
+ */
+ private function isDomesticShipping(): bool
+ {
+ $country = strtoupper(trim((string) $this->input('country_code', '')));
+
+ return $country === '' || $country === 'KR';
+ }
+
/**
* 검증 오류 메시지 커스터마이징
*
@@ -65,11 +95,11 @@ class UpdateOrderShippingAddressRequest extends FormRequest
return [
'recipient_name.required' => __('sirsoft-ecommerce::validation.user_address.recipient_name_required'),
'recipient_phone.required' => __('sirsoft-ecommerce::validation.user_address.recipient_phone_required'),
- 'zipcode.required_without' => __('sirsoft-ecommerce::validation.user_address.zipcode_required'),
- 'address.required_without' => __('sirsoft-ecommerce::validation.user_address.address_required'),
- 'address_line_1.required_without' => __('sirsoft-ecommerce::validation.user_address.address_line_1_required'),
- 'intl_city.required_with' => __('sirsoft-ecommerce::validation.user_address.intl_city_required'),
- 'intl_postal_code.required_with' => __('sirsoft-ecommerce::validation.user_address.intl_postal_code_required'),
+ 'zipcode.required' => __('sirsoft-ecommerce::validation.user_address.zipcode_required'),
+ 'address.required' => __('sirsoft-ecommerce::validation.user_address.address_required'),
+ 'address_line_1.required' => __('sirsoft-ecommerce::validation.user_address.address_line_1_required'),
+ 'intl_city.required' => __('sirsoft-ecommerce::validation.user_address.intl_city_required'),
+ 'intl_postal_code.required' => __('sirsoft-ecommerce::validation.user_address.intl_postal_code_required'),
];
}
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ShippingCarrierRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ShippingCarrierRepositoryInterface.php
index 9f49b9b5..55ee99f6 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ShippingCarrierRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ShippingCarrierRepositoryInterface.php
@@ -13,8 +13,8 @@ interface ShippingCarrierRepositoryInterface
/**
* 배송사 목록 조회
*
- * @param array $filters 필터 조건
- * @param array $with Eager loading 관계
+ * @param array $filters 필터 조건
+ * @param array $with Eager loading 관계
* @return Collection
*/
public function getAll(array $filters = [], array $with = []): Collection;
@@ -22,8 +22,8 @@ interface ShippingCarrierRepositoryInterface
/**
* ID로 배송사 조회
*
- * @param int $id 배송사 ID
- * @param array $with Eager loading 관계
+ * @param int $id 배송사 ID
+ * @param array $with Eager loading 관계
* @return ShippingCarrier|null
*/
public function findById(int $id, array $with = []): ?ShippingCarrier;
@@ -31,7 +31,7 @@ interface ShippingCarrierRepositoryInterface
/**
* 배송사 생성
*
- * @param array $data 배송사 데이터
+ * @param array $data 배송사 데이터
* @return ShippingCarrier
*/
public function create(array $data): ShippingCarrier;
@@ -39,8 +39,8 @@ interface ShippingCarrierRepositoryInterface
/**
* 배송사 수정
*
- * @param int $id 배송사 ID
- * @param array $data 수정 데이터
+ * @param int $id 배송사 ID
+ * @param array $data 수정 데이터
* @return ShippingCarrier
*/
public function update(int $id, array $data): ShippingCarrier;
@@ -48,7 +48,7 @@ interface ShippingCarrierRepositoryInterface
/**
* 배송사 삭제
*
- * @param int $id 배송사 ID
+ * @param int $id 배송사 ID
* @return bool
*/
public function delete(int $id): bool;
@@ -56,8 +56,8 @@ interface ShippingCarrierRepositoryInterface
/**
* 코드 중복 확인
*
- * @param string $code 배송사 코드
- * @param int|null $excludeId 제외할 배송사 ID
+ * @param string $code 배송사 코드
+ * @param int|null $excludeId 제외할 배송사 ID
* @return bool
*/
public function existsByCode(string $code, ?int $excludeId = null): bool;
@@ -65,8 +65,18 @@ interface ShippingCarrierRepositoryInterface
/**
* 활성 배송사 목록 조회 (Select 옵션용)
*
- * @param string|null $type 배송사 유형 필터 (domestic, international, null=전체)
+ * @param string|null $type 배송사 유형 필터 (domestic, international, null=전체)
* @return Collection
*/
public function getActiveCarriers(?string $type = null): Collection;
+
+ /**
+ * 전체 배송사 ID 목록 조회
+ *
+ * 일괄 동기화(syncCarriers)에서 payload 에 없는 기존 항목을 가려내는 데 사용합니다.
+ * 모델 전체를 적재하지 않고 기본키만 조회합니다.
+ *
+ * @return array 배송사 ID 배열
+ */
+ public function pluckIds(): array;
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ShippingTypeRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ShippingTypeRepositoryInterface.php
index 8f096076..9affa071 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ShippingTypeRepositoryInterface.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ShippingTypeRepositoryInterface.php
@@ -84,4 +84,14 @@ interface ShippingTypeRepositoryInterface
* @return string|null 배송유형 코드 (없으면 null)
*/
public function getFirstActiveCodeByCategory(string $category): ?string;
+
+ /**
+ * 전체 배송유형의 기본키만 조회합니다.
+ *
+ * 일괄 동기화(syncShippingTypes)에서 payload 에 없는 기존 항목을 가려내는 데 사용합니다.
+ * 모델 전체를 적재하지 않고 기본키만 조회합니다.
+ *
+ * @return array 배송유형 ID 배열
+ */
+ public function pluckIds(): array;
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingCarrierRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingCarrierRepository.php
index 86342e99..729ae281 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingCarrierRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingCarrierRepository.php
@@ -127,4 +127,12 @@ class ShippingCarrierRepository implements ShippingCarrierRepositoryInterface
return $query->get();
}
+
+ /**
+ * {@inheritDoc}
+ */
+ public function pluckIds(): array
+ {
+ return $this->model->newQuery()->pluck('id')->all();
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingTypeRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingTypeRepository.php
index 97f9283a..1a13b3e5 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingTypeRepository.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ShippingTypeRepository.php
@@ -149,4 +149,12 @@ class ShippingTypeRepository implements ShippingTypeRepositoryInterface
->orderBy('sort_order')
->value('code');
}
+
+ /**
+ * {@inheritDoc}
+ */
+ public function pluckIds(): array
+ {
+ return $this->model->newQuery()->pluck('id')->all();
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/BrandService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/BrandService.php
index 6b32fde6..3520c8a3 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/BrandService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/BrandService.php
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
+use Modules\Sirsoft\Ecommerce\Exceptions\BrandOperationException;
use Modules\Sirsoft\Ecommerce\Models\Brand;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\BrandRepositoryInterface;
@@ -21,7 +22,7 @@ class BrandService
/**
* 브랜드 목록 조회
*
- * @param array $filters 필터 조건
+ * @param array $filters 필터 조건
* @return Collection
*/
public function getAllBrands(array $filters = []): Collection
@@ -46,7 +47,7 @@ class BrandService
/**
* 브랜드 상세 조회
*
- * @param int $id 브랜드 ID
+ * @param int $id 브랜드 ID
* @return Brand|null
*/
public function getBrand(int $id): ?Brand
@@ -70,7 +71,7 @@ class BrandService
/**
* 브랜드 생성
*
- * @param array $data 브랜드 데이터
+ * @param array $data 브랜드 데이터
* @return Brand
*/
public function createBrand(array $data): Brand
@@ -101,16 +102,16 @@ class BrandService
/**
* 브랜드 수정
*
- * @param int $id 브랜드 ID
- * @param array $data 수정할 데이터
+ * @param int $id 브랜드 ID
+ * @param array $data 수정할 데이터
* @return Brand
*/
public function updateBrand(int $id, array $data): Brand
{
$brand = $this->repository->findById($id);
- if (!$brand) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.brand_not_found'));
+ if (! $brand) {
+ throw new BrandOperationException('sirsoft-ecommerce::exceptions.brand_not_found');
}
// Before 훅
@@ -141,16 +142,17 @@ class BrandService
/**
* 브랜드 상태 토글
*
- * @param int $id 브랜드 ID
+ * @param int $id 브랜드 ID
* @return Brand
+ *
* @throws \Exception
*/
public function toggleStatus(int $id): Brand
{
$brand = $this->repository->findById($id);
- if (!$brand) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.brand_not_found'));
+ if (! $brand) {
+ throw new BrandOperationException('sirsoft-ecommerce::exceptions.brand_not_found');
}
// Before 훅
@@ -158,7 +160,7 @@ class BrandService
$brand = DB::transaction(function () use ($brand) {
return $this->repository->update($brand->id, [
- 'is_active' => !$brand->is_active,
+ 'is_active' => ! $brand->is_active,
]);
});
@@ -171,16 +173,17 @@ class BrandService
/**
* 브랜드 삭제
*
- * @param int $id 브랜드 ID
+ * @param int $id 브랜드 ID
* @return array 삭제 결과 정보
+ *
* @throws \Exception
*/
public function deleteBrand(int $id): array
{
$brand = $this->repository->findById($id);
- if (!$brand) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.brand_not_found'));
+ if (! $brand) {
+ throw new BrandOperationException('sirsoft-ecommerce::exceptions.brand_not_found');
}
// 연결된 상품 수 확인
@@ -188,9 +191,9 @@ class BrandService
// 연결된 상품이 있으면 삭제 차단
if ($productsCount > 0) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.brand_has_products', [
+ throw new BrandOperationException('sirsoft-ecommerce::exceptions.brand_has_products', [
'count' => $productsCount,
- ]));
+ ]);
}
// Before 훅
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CouponService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CouponService.php
index 4b06c189..68beef60 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/CouponService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CouponService.php
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
+use Modules\Sirsoft\Ecommerce\Exceptions\CouponOperationException;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
@@ -142,7 +143,7 @@ class CouponService
$coupon = $this->repository->findById($id);
if (! $coupon) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.coupon_not_found'));
+ throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_not_found');
}
// Before 훅
@@ -199,7 +200,7 @@ class CouponService
$coupon = $this->repository->findById($id);
if (! $coupon) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.coupon_not_found'));
+ throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_not_found');
}
// Before 훅
@@ -235,7 +236,7 @@ class CouponService
HookManager::doAction('sirsoft-ecommerce.coupon.before_bulk_status', $ids, $issueStatus);
// 수정 전 스냅샷 캡처
- $snapshots = Coupon::whereIn('id', $ids)->get()->keyBy('id')->map->toArray()->all();
+ $snapshots = $this->repository->findByIdsKeyed($ids)->map->toArray()->all();
$count = DB::transaction(function () use ($ids, $issueStatus) {
return $this->repository->bulkUpdateIssueStatus($ids, $issueStatus);
@@ -267,7 +268,7 @@ class CouponService
$coupon = $this->repository->findByIdForUpdate($couponId);
if (! $coupon) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.coupon_not_found'));
+ throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_not_found');
}
// 쿠폰 자체 발급 가능 여부는 일괄 검증(불가 시 전원 차단)
@@ -316,12 +317,12 @@ class CouponService
$issue = $this->issueRepository->findById($issueId);
if (! $issue || $issue->coupon_id !== $couponId) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.coupon_issue_not_found'));
+ throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_issue_not_found');
}
// 미사용(available) 건만 취소 가능 — 사용/만료/취소된 건은 차단
if ($issue->status !== CouponIssueRecordStatus::AVAILABLE) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.coupon_issue_not_cancellable'));
+ throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_issue_not_cancellable');
}
HookManager::doAction('sirsoft-ecommerce.coupon.before_issue_cancel', $issue);
@@ -355,7 +356,7 @@ class CouponService
$coupon = $this->repository->findById($couponId);
if (! $coupon) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.coupon_not_found'));
+ throw new CouponOperationException('sirsoft-ecommerce::exceptions.coupon_not_found');
}
// Before 훅
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php
index f2930e8c..6cba09a1 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderAdjustmentService.php
@@ -10,7 +10,6 @@ use Modules\Sirsoft\Ecommerce\DTO\ItemCalculation;
use Modules\Sirsoft\Ecommerce\DTO\OrderAdjustment;
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
-use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
@@ -72,19 +71,14 @@ class OrderAdjustmentService
return $this->buildFullCancelResult($order, $originalSnapshot, $originalPaidAmount, $originalPointsUsed, $excludedItems, $refundPriority);
}
- // 4. 쿠폰 used_at 일시 리셋 (재계산 시 검증 통과를 위해)
- $couponIssueIds = $recalcInput->couponIssueIds;
- $originalCouponStates = $this->temporarilyResetCouponUsage($couponIssueIds);
+ // 4. 재계산 실행
+ //
+ // buildRecalcInput() 이 항상 snapshot_mode 를 켜므로 재계산은 쿠폰의 사용 상태
+ // (status/used_at)를 읽지 않는다. 따라서 재계산을 위해 쿠폰을 일시적으로 미사용으로
+ // 되돌릴 필요가 없다 — 조회만 수행한다.
+ $recalcResult = $this->calculationService->calculate($recalcInput);
- // 5. 재계산 실행
- try {
- $recalcResult = $this->calculationService->calculate($recalcInput);
- } finally {
- // 쿠폰 상태 복원 (예외 발생 시에도 반드시 복원)
- $this->restoreCouponUsage($originalCouponStates);
- }
-
- // 6. 환불금액 계산
+ // 5. 환불금액 계산
$recalculatedPaidAmount = (float) $recalcResult->summary->finalAmount;
$recalculatedPointsUsed = (float) $recalcResult->summary->pointsUsed;
$additionalCharges = $adjustment->getAdditionalCharges();
@@ -106,7 +100,7 @@ class OrderAdjustmentService
$refundPointsAmount = min($totalRefundable - $refundAmount, max(0, $remainingPoints));
}
- // 7. 배송비/할인 차이
+ // 6. 배송비/할인 차이
$originalShipping = (float) $order->total_shipping_amount;
$recalculatedShipping = (float) $recalcResult->summary->totalShipping;
$shippingDifference = $originalShipping - $recalculatedShipping;
@@ -115,28 +109,28 @@ class OrderAdjustmentService
$recalculatedDiscount = (float) $recalcResult->summary->totalDiscount;
$discountDifference = $originalDiscount - $recalculatedDiscount;
- // 8. 재계산 스냅샷
+ // 7. 재계산 스냅샷
$recalculatedSnapshot = $this->captureRecalcSnapshot($recalcResult);
$recalculatedSnapshot['total_list_price_amount'] = $this->calculateListPriceTotal($order, $excludedMap);
- // 9. 옵션별 업데이트 정보 생성
+ // 8. 옵션별 업데이트 정보 생성
$optionUpdates = $this->buildOptionUpdates($order, $excludedMap, $recalcResult);
$shippingUpdates = $this->buildShippingUpdates($order, $recalcResult);
$orderUpdates = $this->buildOrderUpdates($order, $recalcResult);
- // 10. 취소 대상 아이템 정보
+ // 9. 취소 대상 아이템 정보
$adjustedItems = $this->buildAdjustedItems($order, $excludedMap);
- // 11. 쿠폰 복원 대상 확인
+ // 10. 쿠폰 복원 대상 확인
$restoredCouponIssueIds = $this->detectRestoredCoupons($order, $recalcResult);
- // 12. 복원 쿠폰 상세 정보 구성
+ // 11. 복원 쿠폰 상세 정보 구성
$restoredCoupons = $this->buildRestoredCouponsInfo($restoredCouponIssueIds, $order->currency_snapshot);
- // 13. 배송비 정책별 상세
+ // 12. 배송비 정책별 상세
$shippingDetails = $this->buildShippingDetails($order, $recalcResult);
- // 14. mc_* 다통화 환불금 변환
+ // 13. mc_* 다통화 환불금 변환
$currencySnapshot = $order->currency_snapshot;
$mcRefundAmount = null;
$mcRefundPointsAmount = null;
@@ -164,23 +158,23 @@ class OrderAdjustmentService
}
}
- // 15. 환불 후 잔여 잔액
+ // 14. 환불 후 잔여 잔액
$remainingPgBalance = max(0, $remainingPg - $refundAmount);
$remainingPointsBalance = max(0, $remainingPoints - $refundPointsAmount);
- // 16. 다통화 스냅샷 (총 정가금액 + 실결제금액)
+ // 15. 다통화 스냅샷 (총 정가금액 + 실결제금액)
$mcOriginalSnapshot = $this->buildMcOriginalSnapshot($order);
$mcRecalculatedSnapshot = $this->buildMcRecalcSnapshot($recalculatedSnapshot, $order->currency_snapshot);
- // 17. 쿠폰 상세 (전/후)
+ // 16. 쿠폰 상세 (전/후)
$originalCoupons = $this->extractCouponDetails($order->promotions_applied_snapshot ?? [], $order->currency_snapshot);
$recalculatedCoupons = $this->extractCouponDetails($recalcResult->promotions?->toArray() ?? [], $order->currency_snapshot);
- // 18. 스냅샷 각 줄을 base 통화로 포맷(취소 모달 primary 표기 = base 통화 기호 고정)
+ // 17. 스냅샷 각 줄을 base 통화로 포맷(취소 모달 primary 표기 = base 통화 기호 고정)
$originalSnapshot = $this->enrichSnapshotWithBaseFormat($originalSnapshot, $order->currency_snapshot);
$recalculatedSnapshot = $this->enrichSnapshotWithBaseFormat($recalculatedSnapshot, $order->currency_snapshot);
- // 19. 환불 총액/잔액 base 포맷 + 결제 통화 병기
+ // 18. 환불 총액/잔액 base 포맷 + 결제 통화 병기
$refundFormatted = $this->buildRefundFormatted([
'refund_total' => max(0, $refundAmount) + $refundPointsAmount,
'refund_amount' => max(0, $refundAmount),
@@ -866,59 +860,6 @@ class OrderAdjustmentService
return $items;
}
- /**
- * 쿠폰 사용 상태를 일시적으로 리셋합니다.
- *
- * 재계산 시 OrderCalculationService의 validateCoupon()이
- * used_at !== null인 쿠폰을 'alreadyUsed'로 거부하므로,
- * 원 주문에서 사용된 쿠폰을 일시적으로 미사용 상태로 변경합니다.
- *
- * @param int[] $couponIssueIds 쿠폰 발급 ID 배열
- * @return array 원래 상태 [{id, status, used_at}]
- */
- private function temporarilyResetCouponUsage(array $couponIssueIds): array
- {
- if (empty($couponIssueIds)) {
- return [];
- }
-
- $originalStates = [];
- $couponIssues = $this->couponIssueRepository->findByIds($couponIssueIds);
-
- foreach ($couponIssues as $couponIssue) {
- $originalStates[] = [
- 'id' => $couponIssue->id,
- 'status' => $couponIssue->status,
- 'used_at' => $couponIssue->used_at,
- 'order_id' => $couponIssue->order_id,
- ];
-
- $this->couponIssueRepository->update($couponIssue->id, [
- 'status' => CouponIssueRecordStatus::AVAILABLE,
- 'used_at' => null,
- 'order_id' => null,
- ]);
- }
-
- return $originalStates;
- }
-
- /**
- * 쿠폰 사용 상태를 원래대로 복원합니다.
- *
- * @param array $originalStates 원래 상태 배열
- */
- private function restoreCouponUsage(array $originalStates): void
- {
- foreach ($originalStates as $state) {
- $this->couponIssueRepository->update($state['id'], [
- 'status' => $state['status'],
- 'used_at' => $state['used_at'],
- 'order_id' => $state['order_id'],
- ]);
- }
- }
-
/**
* 복원 대상 쿠폰을 감지합니다.
*
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderService.php
index c6ae25d7..df13558e 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/OrderService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/OrderService.php
@@ -643,6 +643,15 @@ class OrderService
throw new OrderModificationException(__('sirsoft-ecommerce::messages.address.not_found'));
}
+ // 저장된 배송지의 국내·해외 필드를 모두 싣는다. 지역 판정은 아래
+ // $isDomestic 한 곳에서만 하고 그 분기가 필요한 쪽만 골라 쓴다.
+ // 국내 6필드만 싣던 종전 형태에서는 해외 배송지를 고르면 해외 컬럼이
+ // 전부 null 로 덮여 주소가 통째로 사라졌다.
+ // 배송 메모는 저장 주소가 아니라 이번 요청의 값이므로 승계한다.
+ $memo = array_key_exists('delivery_memo', $data)
+ ? ['delivery_memo' => $data['delivery_memo']]
+ : [];
+
$data = [
'recipient_name' => $savedAddress->recipient_name,
'recipient_phone' => $savedAddress->recipient_phone,
@@ -650,7 +659,12 @@ class OrderService
'zipcode' => $savedAddress->zipcode,
'address' => $savedAddress->address,
'address_detail' => $savedAddress->address_detail,
- ];
+ 'address_line_1' => $savedAddress->address_line_1,
+ 'address_line_2' => $savedAddress->address_line_2,
+ 'intl_city' => $savedAddress->city,
+ 'intl_state' => $savedAddress->state,
+ 'intl_postal_code' => $savedAddress->postal_code,
+ ] + $memo;
}
// 변경 전 훅
@@ -665,24 +679,43 @@ class OrderService
$addressSnapshot = $shippingAddress ? $shippingAddress->toArray() : null;
if ($shippingAddress) {
+ // 국가 코드 폴백은 UpdateOrderShippingAddressRequest::isDomesticShipping()
+ // 과 반드시 같은 기준이어야 한다 — 게이트가 "미전송 = 국내" 로 보고
+ // zipcode/address 를 필수 검증했는데 서비스가 기존 국가를 승계해 해외
+ // 분기로 떨어지면, 방금 검증한 국내 입력을 버리고 빈 문자열을 쓴다.
+ // 직접 입력 폼은 국내 필드만 제공해 country_code 를 보내지 않으므로,
+ // 해외 주문의 배송지를 국내로 바꾸는 경로가 통째로 사라진다(응답은 200).
+ $country = $data['country_code'] ?? 'KR';
+ $isDomestic = strtoupper(trim((string) $country)) === 'KR';
+
$addressData = [
'recipient_name' => $data['recipient_name'],
'recipient_phone' => $data['recipient_phone'],
- 'recipient_country_code' => $data['country_code'] ?? 'KR',
+ 'recipient_country_code' => $country,
];
- $isDomestic = ($data['country_code'] ?? 'KR') === 'KR';
-
+ // 주의: zipcode/address 컬럼은 NOT NULL 이므로 비활성 측은 null 이 아닌 빈 문자열로 초기화.
+ // 반대편 컬럼을 비우지 않으면 국내↔해외 전환 시 구 주소가 잔존해 배송지가 두 벌이 된다.
if ($isDomestic) {
- $addressData['zipcode'] = $data['zipcode'] ?? null;
- $addressData['address'] = $data['address'] ?? null;
+ $addressData['zipcode'] = $data['zipcode'] ?? '';
+ $addressData['address'] = $data['address'] ?? '';
$addressData['address_detail'] = $data['address_detail'] ?? null;
+ // 해외 컬럼 초기화 (nullable)
+ $addressData['address_line_1'] = null;
+ $addressData['address_line_2'] = null;
+ $addressData['intl_city'] = null;
+ $addressData['intl_state'] = null;
+ $addressData['intl_postal_code'] = null;
} else {
$addressData['address_line_1'] = $data['address_line_1'] ?? null;
$addressData['address_line_2'] = $data['address_line_2'] ?? null;
$addressData['intl_city'] = $data['intl_city'] ?? null;
$addressData['intl_state'] = $data['intl_state'] ?? null;
$addressData['intl_postal_code'] = $data['intl_postal_code'] ?? null;
+ // 국내 컬럼 초기화 (zipcode/address 는 NOT NULL → 빈 문자열)
+ $addressData['zipcode'] = '';
+ $addressData['address'] = '';
+ $addressData['address_detail'] = null;
}
// 배송 메모 (항상 업데이트)
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductCommonInfoService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductCommonInfoService.php
index aa850838..f7b84cde 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductCommonInfoService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductCommonInfoService.php
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductCommonInfoOperationException;
use Modules\Sirsoft\Ecommerce\Models\ProductCommonInfo;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductCommonInfoRepositoryInterface;
@@ -21,7 +22,7 @@ class ProductCommonInfoService
/**
* 공통정보 목록 조회
*
- * @param array $filters 필터 조건
+ * @param array $filters 필터 조건
* @return Collection
*/
public function getAllCommonInfos(array $filters = []): Collection
@@ -46,8 +47,8 @@ class ProductCommonInfoService
/**
* 공통정보 목록 페이지네이션 조회
*
- * @param array $filters 필터 조건
- * @param int $perPage 페이지당 항목 수
+ * @param array $filters 필터 조건
+ * @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator
*/
public function getPaginatedCommonInfos(array $filters = [], int $perPage = 20): LengthAwarePaginator
@@ -69,7 +70,7 @@ class ProductCommonInfoService
/**
* 공통정보 상세 조회
*
- * @param int $id 공통정보 ID
+ * @param int $id 공통정보 ID
* @return ProductCommonInfo|null
*/
public function getCommonInfo(int $id): ?ProductCommonInfo
@@ -93,7 +94,7 @@ class ProductCommonInfoService
/**
* 공통정보 생성
*
- * @param array $data 공통정보 데이터
+ * @param array $data 공통정보 데이터
* @return ProductCommonInfo
*/
public function createCommonInfo(array $data): ProductCommonInfo
@@ -129,8 +130,8 @@ class ProductCommonInfoService
/**
* 공통정보 수정
*
- * @param int $id 공통정보 ID
- * @param array $data 수정할 데이터
+ * @param int $id 공통정보 ID
+ * @param array $data 수정할 데이터
* @return ProductCommonInfo
*
* @throws \Exception
@@ -140,7 +141,7 @@ class ProductCommonInfoService
$commonInfo = $this->repository->findById($id);
if (! $commonInfo) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.product_common_info_not_found'));
+ throw new ProductCommonInfoOperationException('sirsoft-ecommerce::exceptions.product_common_info_not_found');
}
// Before 훅
@@ -172,7 +173,7 @@ class ProductCommonInfoService
/**
* 공통정보 삭제
*
- * @param int $id 공통정보 ID
+ * @param int $id 공통정보 ID
* @return array 삭제 결과 정보
*
* @throws \Exception
@@ -182,7 +183,7 @@ class ProductCommonInfoService
$commonInfo = $this->repository->findById($id);
if (! $commonInfo) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.product_common_info_not_found'));
+ throw new ProductCommonInfoOperationException('sirsoft-ecommerce::exceptions.product_common_info_not_found');
}
// 연결된 상품 수 확인
@@ -211,7 +212,7 @@ class ProductCommonInfoService
/**
* 사용 여부 토글
*
- * @param int $id 공통정보 ID
+ * @param int $id 공통정보 ID
* @return ProductCommonInfo
*
* @throws \Exception
@@ -221,7 +222,7 @@ class ProductCommonInfoService
$commonInfo = $this->repository->findById($id);
if (! $commonInfo) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.product_common_info_not_found'));
+ throw new ProductCommonInfoOperationException('sirsoft-ecommerce::exceptions.product_common_info_not_found');
}
return $this->updateCommonInfo($id, [
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php
index 922db4c8..7d5ca46f 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductInquiryService.php
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductInquiryOperationException;
use Modules\Sirsoft\Ecommerce\Models\ProductInquiry;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductInquiryRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
@@ -205,9 +206,7 @@ class ProductInquiryService
$boardSlug = $this->getInquiryBoardSlug();
if (! $boardSlug) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.board_not_configured')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_not_configured');
}
$product = $this->productRepository->find($productId);
@@ -233,9 +232,7 @@ class ProductInquiryService
);
if (! $postResult || empty($postResult['post_id'])) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.board_unavailable')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_unavailable');
}
// 상품명 스냅샷 (다국어) — name은 array cast
@@ -376,17 +373,13 @@ class ProductInquiryService
$inquiry = $this->repository->findById($inquiryId);
if (! $inquiry) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.not_found')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.not_found');
}
$boardSlug = $this->getInquiryBoardSlug();
if (! $boardSlug) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.board_not_configured')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_not_configured');
}
HookManager::applyFilters(
@@ -421,17 +414,13 @@ class ProductInquiryService
$inquiry = $this->repository->findById($inquiryId);
if (! $inquiry) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.not_found')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.not_found');
}
$boardSlug = $this->getInquiryBoardSlug();
if (! $boardSlug) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.board_not_configured')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_not_configured');
}
// ① Post 삭제 — after_delete Action 훅 포함, 트랜잭션 외부에서 실행
@@ -467,17 +456,13 @@ class ProductInquiryService
$inquiry = $this->repository->findById($inquiryId);
if (! $inquiry) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.not_found')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.not_found');
}
$boardSlug = $this->getInquiryBoardSlug();
if (! $boardSlug) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.board_not_configured')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_not_configured');
}
HookManager::applyFilters(
@@ -511,17 +496,13 @@ class ProductInquiryService
$inquiry = $this->repository->findById($inquiryId);
if (! $inquiry) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.not_found')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.not_found');
}
$boardSlug = $this->getInquiryBoardSlug();
if (! $boardSlug) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.board_not_configured')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_not_configured');
}
// ① Reply Post 삭제 — after_delete Action 훅 포함, 트랜잭션 외부에서 실행
@@ -614,9 +595,7 @@ class ProductInquiryService
$inquiry = $this->repository->findById($inquiryId);
if (! $inquiry) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.not_found')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.not_found');
}
$boardSlug = $this->getInquiryBoardSlug();
@@ -634,9 +613,7 @@ class ProductInquiryService
);
if (! $postResult || empty($postResult['post_id'])) {
- throw new \RuntimeException(
- __('sirsoft-ecommerce::messages.inquiries.reply_failed')
- );
+ throw new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.reply_failed');
}
// 피벗 is_answered 업데이트
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductLabelService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductLabelService.php
index 59e90d4a..e298b9db 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductLabelService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductLabelService.php
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
+use Modules\Sirsoft\Ecommerce\Exceptions\ProductLabelOperationException;
use Modules\Sirsoft\Ecommerce\Models\ProductLabel;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductLabelRepositoryInterface;
@@ -21,7 +22,7 @@ class ProductLabelService
/**
* 라벨 목록 조회
*
- * @param array $filters 필터 조건
+ * @param array $filters 필터 조건
* @return Collection
*/
public function getAllLabels(array $filters = []): Collection
@@ -46,7 +47,7 @@ class ProductLabelService
/**
* 라벨 상세 조회
*
- * @param int $id 라벨 ID
+ * @param int $id 라벨 ID
* @return ProductLabel|null
*/
public function getLabel(int $id): ?ProductLabel
@@ -80,7 +81,7 @@ class ProductLabelService
/**
* 라벨 생성
*
- * @param array $data 라벨 데이터
+ * @param array $data 라벨 데이터
* @return ProductLabel
*/
public function createLabel(array $data): ProductLabel
@@ -107,16 +108,17 @@ class ProductLabelService
/**
* 라벨 수정
*
- * @param int $id 라벨 ID
- * @param array $data 수정할 데이터
+ * @param int $id 라벨 ID
+ * @param array $data 수정할 데이터
* @return ProductLabel
+ *
* @throws \Exception
*/
public function updateLabel(int $id, array $data): ProductLabel
{
$label = $this->repository->findById($id);
- if (!$label) {
+ if (! $label) {
throw (new ModelNotFoundException)->setModel(ProductLabel::class, $id);
}
@@ -145,15 +147,16 @@ class ProductLabelService
/**
* 라벨 상태 토글
*
- * @param int $id 라벨 ID
+ * @param int $id 라벨 ID
* @return ProductLabel
+ *
* @throws \Exception
*/
public function toggleStatus(int $id): ProductLabel
{
$label = $this->repository->findById($id);
- if (!$label) {
+ if (! $label) {
throw (new ModelNotFoundException)->setModel(ProductLabel::class, $id);
}
@@ -162,7 +165,7 @@ class ProductLabelService
$label = DB::transaction(function () use ($label) {
return $this->repository->update($label->id, [
- 'is_active' => !$label->is_active,
+ 'is_active' => ! $label->is_active,
]);
});
@@ -175,15 +178,16 @@ class ProductLabelService
/**
* 라벨 삭제
*
- * @param int $id 라벨 ID
+ * @param int $id 라벨 ID
* @return array 삭제 결과 정보
+ *
* @throws \Exception
*/
public function deleteLabel(int $id): array
{
$label = $this->repository->findById($id);
- if (!$label) {
+ if (! $label) {
throw (new ModelNotFoundException)->setModel(ProductLabel::class, $id);
}
@@ -192,9 +196,9 @@ class ProductLabelService
// 연결된 상품이 있으면 삭제 차단
if ($productsCount > 0) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.label_has_products', [
+ throw new ProductLabelOperationException('sirsoft-ecommerce::exceptions.label_has_products', [
'count' => $productsCount,
- ]));
+ ]);
}
// Before 훅
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ShippingCarrierService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ShippingCarrierService.php
index 5a7accfe..278ef2f9 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/ShippingCarrierService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ShippingCarrierService.php
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
+use Modules\Sirsoft\Ecommerce\Exceptions\ShippingCarrierOperationException;
use Modules\Sirsoft\Ecommerce\Models\ShippingCarrier;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingCarrierRepositoryInterface;
@@ -27,7 +28,7 @@ class ShippingCarrierService
/**
* 배송사 목록 조회
*
- * @param array $filters 필터 조건
+ * @param array $filters 필터 조건
* @return Collection
*/
public function getAllCarriers(array $filters = []): Collection
@@ -48,7 +49,7 @@ class ShippingCarrierService
/**
* 배송사 상세 조회
*
- * @param int $id 배송사 ID
+ * @param int $id 배송사 ID
* @return ShippingCarrier|null
*/
public function getCarrier(int $id): ?ShippingCarrier
@@ -68,7 +69,7 @@ class ShippingCarrierService
/**
* 활성 배송사 목록 조회 (Select 옵션용)
*
- * @param string|null $type 배송사 유형 필터
+ * @param string|null $type 배송사 유형 필터
* @return Collection
*/
public function getActiveCarriers(?string $type = null): Collection
@@ -79,7 +80,7 @@ class ShippingCarrierService
/**
* 배송사 생성
*
- * @param array $data 배송사 데이터
+ * @param array $data 배송사 데이터
* @return ShippingCarrier
*/
public function createCarrier(array $data): ShippingCarrier
@@ -105,9 +106,10 @@ class ShippingCarrierService
/**
* 배송사 수정
*
- * @param int $id 배송사 ID
- * @param array $data 수정할 데이터
+ * @param int $id 배송사 ID
+ * @param array $data 수정할 데이터
* @return ShippingCarrier
+ *
* @throws \Exception
*/
public function updateCarrier(int $id, array $data): ShippingCarrier
@@ -115,7 +117,7 @@ class ShippingCarrierService
$carrier = $this->repository->findById($id);
if (! $carrier) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_carrier_not_found'));
+ throw new ShippingCarrierOperationException('sirsoft-ecommerce::exceptions.shipping_carrier_not_found');
}
HookManager::doAction('sirsoft-ecommerce.shipping_carrier.before_update', $id, $data);
@@ -141,8 +143,9 @@ class ShippingCarrierService
/**
* 배송사 상태 토글
*
- * @param int $id 배송사 ID
+ * @param int $id 배송사 ID
* @return ShippingCarrier
+ *
* @throws \Exception
*/
public function toggleStatus(int $id): ShippingCarrier
@@ -150,7 +153,7 @@ class ShippingCarrierService
$carrier = $this->repository->findById($id);
if (! $carrier) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_carrier_not_found'));
+ throw new ShippingCarrierOperationException('sirsoft-ecommerce::exceptions.shipping_carrier_not_found');
}
HookManager::doAction('sirsoft-ecommerce.shipping_carrier.before_toggle_status', $carrier);
@@ -174,14 +177,15 @@ class ShippingCarrierService
* - id 없음 → 새 carrier 생성
* - DB에 있지만 payload에 없음 → 삭제 (주문에서 사용 중이면 예외)
*
- * @param array $carriersData carriers 배열
+ * @param array $carriersData carriers 배열
* @return void
+ *
* @throws \Exception 사용 중인 배송사 삭제 시도 시
*/
public function syncCarriers(array $carriersData): void
{
DB::transaction(function () use ($carriersData) {
- $existingIds = ShippingCarrier::pluck('id')->toArray();
+ $existingIds = $this->repository->pluckIds();
$incomingIds = array_filter(array_column($carriersData, 'id'));
// 삭제: DB에 있지만 payload에 없는 항목
@@ -189,11 +193,11 @@ class ShippingCarrierService
foreach ($toDeleteIds as $id) {
$usageCount = $this->orderShippingRepository->countByCarrierId($id);
if ($usageCount > 0) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_carrier_in_use', [
+ throw new ShippingCarrierOperationException('sirsoft-ecommerce::exceptions.shipping_carrier_in_use', [
'count' => $usageCount,
- ]));
+ ]);
}
- ShippingCarrier::destroy($id);
+ $this->repository->delete($id);
}
// 생성/수정
@@ -209,10 +213,10 @@ class ShippingCarrierService
];
if (! empty($data['id']) && in_array($data['id'], $existingIds)) {
- ShippingCarrier::where('id', $data['id'])->update($carrierData);
+ $this->repository->update((int) $data['id'], $carrierData);
} else {
$carrierData['created_by'] = Auth::id();
- ShippingCarrier::create($carrierData);
+ $this->repository->create($carrierData);
}
}
});
@@ -221,8 +225,9 @@ class ShippingCarrierService
/**
* 배송사 삭제
*
- * @param int $id 배송사 ID
+ * @param int $id 배송사 ID
* @return array 삭제 결과 정보
+ *
* @throws \Exception
*/
public function deleteCarrier(int $id): array
@@ -230,16 +235,16 @@ class ShippingCarrierService
$carrier = $this->repository->findById($id);
if (! $carrier) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_carrier_not_found'));
+ throw new ShippingCarrierOperationException('sirsoft-ecommerce::exceptions.shipping_carrier_not_found');
}
// 주문에서 사용 중인지 확인
$usageCount = $this->orderShippingRepository->countByCarrierId($id);
if ($usageCount > 0) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_carrier_in_use', [
+ throw new ShippingCarrierOperationException('sirsoft-ecommerce::exceptions.shipping_carrier_in_use', [
'count' => $usageCount,
- ]));
+ ]);
}
HookManager::doAction('sirsoft-ecommerce.shipping_carrier.before_delete', $carrier);
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ShippingTypeService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ShippingTypeService.php
index c409cb05..fbaa3432 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Services/ShippingTypeService.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ShippingTypeService.php
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
+use Modules\Sirsoft\Ecommerce\Exceptions\ShippingTypeOperationException;
use Modules\Sirsoft\Ecommerce\Models\ShippingType;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingTypeRepositoryInterface;
@@ -16,8 +17,8 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingTypeRepositoryInter
class ShippingTypeService
{
/**
- * @param ShippingTypeRepositoryInterface $repository 배송유형 Repository
- * @param OrderShippingRepositoryInterface $orderShippingRepository 주문 배송 Repository
+ * @param ShippingTypeRepositoryInterface $repository 배송유형 Repository
+ * @param OrderShippingRepositoryInterface $orderShippingRepository 주문 배송 Repository
*/
public function __construct(
protected ShippingTypeRepositoryInterface $repository,
@@ -27,7 +28,7 @@ class ShippingTypeService
/**
* 배송유형 목록 조회
*
- * @param array $filters 필터 조건
+ * @param array $filters 필터 조건
* @return Collection
*/
public function getAllTypes(array $filters = []): Collection
@@ -48,7 +49,7 @@ class ShippingTypeService
/**
* 배송유형 상세 조회
*
- * @param int $id 배송유형 ID
+ * @param int $id 배송유형 ID
* @return ShippingType|null
*/
public function getType(int $id): ?ShippingType
@@ -68,7 +69,7 @@ class ShippingTypeService
/**
* 활성 배송유형 목록 조회
*
- * @param string|null $category 카테고리 필터
+ * @param string|null $category 카테고리 필터
* @return Collection
*/
public function getActiveTypes(?string $category = null): Collection
@@ -98,7 +99,7 @@ class ShippingTypeService
/**
* 배송유형 생성
*
- * @param array $data 배송유형 데이터
+ * @param array $data 배송유형 데이터
* @return ShippingType
*/
public function createType(array $data): ShippingType
@@ -127,17 +128,18 @@ class ShippingTypeService
/**
* 배송유형 수정
*
- * @param int $id 배송유형 ID
- * @param array $data 수정할 데이터
+ * @param int $id 배송유형 ID
+ * @param array $data 수정할 데이터
* @return ShippingType
- * @throws \Exception
+ *
+ * @throws ShippingTypeOperationException 대상 배송유형이 없을 때
*/
public function updateType(int $id, array $data): ShippingType
{
$type = $this->repository->findById($id);
if (! $type) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_type_not_found'));
+ throw new ShippingTypeOperationException('sirsoft-ecommerce::exceptions.shipping_type_not_found');
}
HookManager::doAction('sirsoft-ecommerce.shipping_type.before_update', $id, $data);
@@ -170,29 +172,30 @@ class ShippingTypeService
* - id 없음 → 새 type 생성
* - DB에 있지만 payload에 없음 → 삭제 (주문에서 사용 중이면 예외)
*
- * @param array $typesData types 배열
+ * @param array $typesData types 배열
* @return void
- * @throws \Exception 사용 중인 배송유형 삭제 시도 시
+ *
+ * @throws ShippingTypeOperationException 사용 중인 배송유형 삭제 시도 시
*/
public function syncShippingTypes(array $typesData): void
{
DB::transaction(function () use ($typesData) {
- $existingIds = ShippingType::pluck('id')->toArray();
+ $existingIds = $this->repository->pluckIds();
$incomingIds = array_filter(array_column($typesData, 'id'));
// 삭제: DB에 있지만 payload에 없는 항목
$toDeleteIds = array_diff($existingIds, $incomingIds);
foreach ($toDeleteIds as $id) {
- $type = ShippingType::find($id);
+ $type = $this->repository->findById((int) $id);
if ($type) {
$usageCount = $this->orderShippingRepository->countByShippingType($type->code);
if ($usageCount > 0) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_type_in_use', [
+ throw new ShippingTypeOperationException('sirsoft-ecommerce::exceptions.shipping_type_in_use', [
'name' => $type->getLocalizedName(),
'count' => $usageCount,
- ]));
+ ]);
}
- $type->delete();
+ $this->repository->delete((int) $type->id);
}
}
@@ -208,10 +211,10 @@ class ShippingTypeService
];
if (! empty($data['id']) && in_array($data['id'], $existingIds)) {
- ShippingType::where('id', $data['id'])->update($typeData);
+ $this->repository->update((int) $data['id'], $typeData);
} else {
$typeData['created_by'] = Auth::id();
- ShippingType::create($typeData);
+ $this->repository->create($typeData);
}
}
});
@@ -223,26 +226,27 @@ class ShippingTypeService
/**
* 배송유형 삭제
*
- * @param int $id 배송유형 ID
+ * @param int $id 배송유형 ID
* @return array 삭제 결과 정보
- * @throws \Exception
+ *
+ * @throws ShippingTypeOperationException 대상이 없거나 주문에서 사용 중일 때
*/
public function deleteType(int $id): array
{
$type = $this->repository->findById($id);
if (! $type) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_type_not_found'));
+ throw new ShippingTypeOperationException('sirsoft-ecommerce::exceptions.shipping_type_not_found');
}
// 주문에서 사용 중인지 확인
$usageCount = $this->orderShippingRepository->countByShippingType($type->code);
if ($usageCount > 0) {
- throw new \Exception(__('sirsoft-ecommerce::exceptions.shipping_type_in_use', [
+ throw new ShippingTypeOperationException('sirsoft-ecommerce::exceptions.shipping_type_in_use', [
'name' => $type->getLocalizedName(),
'count' => $usageCount,
- ]));
+ ]);
}
HookManager::doAction('sirsoft-ecommerce.shipping_type.before_delete', $type);
diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php
index fa15010d..643dbacd 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/exceptions.php
@@ -85,6 +85,7 @@ return [
'order_cancel_failed' => 'Failed to cancel the order.',
'order_estimate_refund_failed' => 'Failed to calculate the estimated refund amount.',
'order_create_failed' => 'Failed to create the order.',
+ 'order_shipping_address_update_failed' => 'Failed to update the shipping address.',
// Currency related
'unknown_currency' => 'Unsupported currency: :currency',
diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php
index bb467cd1..422d13b5 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/exceptions.php
@@ -85,6 +85,7 @@ return [
'order_cancel_failed' => '주문 취소 처리에 실패했습니다.',
'order_estimate_refund_failed' => '환불 예상금액 계산에 실패했습니다.',
'order_create_failed' => '주문 생성에 실패했습니다.',
+ 'order_shipping_address_update_failed' => '배송지 변경 처리 중 오류가 발생했습니다.',
// 통화 관련
'unknown_currency' => '지원하지 않는 통화입니다: :currency',
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsInUseDeletionTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsInUseDeletionTest.php
new file mode 100644
index 00000000..0aaaf84c
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsInUseDeletionTest.php
@@ -0,0 +1,215 @@
+adminUser = $this->createAdminUser([
+ 'sirsoft-ecommerce.settings.read',
+ 'sirsoft-ecommerce.settings.update',
+ ]);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+
+ parent::tearDown();
+ }
+
+ /**
+ * 주문에서 사용 중인 것으로 보이도록 주문배송 Repository 를 대체합니다.
+ *
+ * 실제 주문 그래프를 세우지 않고 "사용 중" 조건만 만든다 — 이 테스트가 고정하려는
+ * 것은 사용량 집계가 아니라 그 결과가 어떤 응답이 되는가이기 때문이다.
+ *
+ * @param int $count 사용 건수
+ */
+ private function pretendInUse(int $count): void
+ {
+ $repository = Mockery::mock(OrderShippingRepositoryInterface::class);
+ $repository->shouldReceive('countByShippingType')->andReturn($count);
+ $repository->shouldReceive('countByCarrierId')->andReturn($count);
+
+ $this->app->instance(OrderShippingRepositoryInterface::class, $repository);
+ }
+
+ /**
+ * 사용 중인 배송유형을 payload 에서 빼 저장하면 사유가 담긴 400 이 돌아온다.
+ *
+ * @scenario resource=shipping_type, error_class=domain
+ *
+ * @effects in_use_shipping_type_deletion_returns_400_with_reason
+ */
+ public function test_deleting_in_use_shipping_type_returns_400_with_reason(): void
+ {
+ $type = ShippingType::create([
+ 'code' => 'inuse_type',
+ 'name' => ['ko' => '사용중배송유형', 'en' => 'In-use type'],
+ 'category' => 'delivery',
+ 'is_active' => true,
+ 'sort_order' => 0,
+ ]);
+
+ $this->pretendInUse(3);
+
+ // payload 에서 이 유형을 빼면 syncShippingTypes 가 삭제 대상으로 판정한다.
+ $response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
+ '_tab' => 'shipping',
+ 'shipping' => [
+ 'types' => [],
+ ],
+ ]);
+
+ $response->assertStatus(400);
+ $response->assertJsonPath(
+ 'message',
+ __('sirsoft-ecommerce::exceptions.shipping_type_in_use', [
+ 'name' => $type->getLocalizedName(),
+ 'count' => 3,
+ ])
+ );
+
+ // 사유가 사라지지 않았는지 — 일반 저장 실패 문구로 대체되면 안 된다.
+ $this->assertNotSame(
+ __('sirsoft-ecommerce::messages.settings.save_error'),
+ $response->json('message')
+ );
+
+ // 삭제는 롤백되어야 한다.
+ $this->assertDatabaseHas($type->getTable(), ['id' => $type->id]);
+ }
+
+ /**
+ * 사용 중인 택배사도 같은 계약을 따른다 (같은 저장 경로의 형제 결함).
+ *
+ * @scenario resource=shipping_carrier, error_class=domain
+ *
+ * @effects in_use_shipping_carrier_deletion_returns_400_with_reason
+ */
+ public function test_deleting_in_use_shipping_carrier_returns_400_with_reason(): void
+ {
+ $carrier = ShippingCarrier::create([
+ 'code' => 'inuse_carrier',
+ 'name' => ['ko' => '사용중택배사', 'en' => 'In-use carrier'],
+ 'type' => 'domestic',
+ 'is_active' => true,
+ 'sort_order' => 0,
+ ]);
+
+ $this->pretendInUse(2);
+
+ $response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
+ '_tab' => 'shipping',
+ 'shipping' => [
+ 'carriers' => [],
+ ],
+ ]);
+
+ $response->assertStatus(400);
+ $response->assertJsonPath(
+ 'message',
+ __('sirsoft-ecommerce::exceptions.shipping_carrier_in_use', ['count' => 2])
+ );
+ $this->assertNotSame(
+ __('sirsoft-ecommerce::messages.settings.save_error'),
+ $response->json('message')
+ );
+
+ $this->assertDatabaseHas($carrier->getTable(), ['id' => $carrier->id]);
+ }
+
+ /**
+ * 도메인 실패가 아닌 예외는 그대로 500 이어야 한다 (구분의 반대편).
+ *
+ * @scenario resource=shipping_type, error_class=infrastructure
+ *
+ * @effects settings_save_infrastructure_exception_returns_500
+ */
+ public function test_infrastructure_exception_on_settings_save_returns_500(): void
+ {
+ $repository = Mockery::mock(OrderShippingRepositoryInterface::class);
+ $repository->shouldReceive('countByShippingType')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: connection lost'));
+ $this->app->instance(OrderShippingRepositoryInterface::class, $repository);
+
+ ShippingType::create([
+ 'code' => 'infra_type',
+ 'name' => ['ko' => '인프라', 'en' => 'Infra'],
+ 'category' => 'delivery',
+ 'is_active' => true,
+ 'sort_order' => 0,
+ ]);
+
+ $response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
+ '_tab' => 'shipping',
+ 'shipping' => [
+ 'types' => [],
+ ],
+ ]);
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', (string) $response->json('message'));
+ }
+
+ /**
+ * 택배사 경로의 인프라 예외도 같은 구분을 따른다.
+ *
+ * 배송유형과 택배사는 사용량을 각각 `countByShippingType`·`countByCarrierId` 로 세므로
+ * 호출 지점이 다르다. 한쪽만 단언하면 나머지 경로의 뭉개기가 그대로 남는다.
+ *
+ * @scenario resource=shipping_carrier, error_class=infrastructure
+ *
+ * @effects settings_save_carrier_infrastructure_exception_returns_500
+ */
+ public function test_infrastructure_exception_on_carrier_settings_save_returns_500(): void
+ {
+ $repository = Mockery::mock(OrderShippingRepositoryInterface::class);
+ $repository->shouldReceive('countByCarrierId')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: connection lost'));
+ $this->app->instance(OrderShippingRepositoryInterface::class, $repository);
+
+ ShippingCarrier::create([
+ 'code' => 'infra_carrier',
+ 'name' => ['ko' => '인프라택배사', 'en' => 'Infra carrier'],
+ 'type' => 'domestic',
+ 'is_active' => true,
+ 'sort_order' => 0,
+ ]);
+
+ $response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
+ '_tab' => 'shipping',
+ 'shipping' => [
+ 'carriers' => [],
+ ],
+ ]);
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', (string) $response->json('message'));
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ExceptionStatusMappingTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ExceptionStatusMappingTest.php
new file mode 100644
index 00000000..195a48a3
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ExceptionStatusMappingTest.php
@@ -0,0 +1,164 @@
+adminUser = $this->createAdminUser([
+ 'sirsoft-ecommerce.brands.read',
+ 'sirsoft-ecommerce.brands.update',
+ 'sirsoft-ecommerce.brands.delete',
+ 'sirsoft-ecommerce.promotion-coupon.read',
+ 'sirsoft-ecommerce.promotion-coupon.update',
+ 'sirsoft-ecommerce.promotion-coupon.delete',
+ 'sirsoft-ecommerce.settings.read',
+ 'sirsoft-ecommerce.settings.update',
+ 'sirsoft-ecommerce.product-common-infos.read',
+ 'sirsoft-ecommerce.product-common-infos.update',
+ 'sirsoft-ecommerce.product-common-infos.delete',
+ ]);
+ }
+
+ /**
+ * 대표 엔드포인트 정의.
+ *
+ * [라벨, HTTP 메서드, URI, 서비스 클래스, 서비스 메서드, typed 예외 클래스|null, typed 기대 상태코드]
+ *
+ * @return array
+ */
+ public static function endpointProvider(): array
+ {
+ $base = '/api/modules/sirsoft-ecommerce/admin';
+
+ return [
+ '브랜드 삭제' => [
+ 'delete', $base.'/brands/1',
+ BrandService::class, 'deleteBrand',
+ BrandOperationException::class, 400,
+ ],
+ '브랜드 상태토글' => [
+ 'patch', $base.'/brands/1/toggle-status',
+ BrandService::class, 'toggleStatus',
+ BrandOperationException::class, 400,
+ ],
+ '쿠폰 삭제' => [
+ 'delete', $base.'/promotion-coupons/1',
+ CouponService::class, 'deleteCoupon',
+ CouponOperationException::class, 400,
+ ],
+ '배송사 상태토글' => [
+ 'patch', $base.'/shipping-carriers/1/toggle-status',
+ ShippingCarrierService::class, 'toggleStatus',
+ ShippingCarrierOperationException::class, 400,
+ ],
+ '상품 공통정보 삭제' => [
+ 'delete', $base.'/product-common-infos/1',
+ ProductCommonInfoService::class, 'deleteCommonInfo',
+ ProductCommonInfoOperationException::class, 400,
+ ],
+ '상품 공통정보 사용토글' => [
+ 'patch', $base.'/product-common-infos/1/toggle-active',
+ ProductCommonInfoService::class, 'toggleActive',
+ ProductCommonInfoOperationException::class, 400,
+ ],
+ ];
+ }
+
+ /**
+ * 도메인 예외가 아닌 예외는 500 으로 구분되어야 한다.
+ *
+ * @scenario error_class=infrastructure
+ *
+ * @effects admin_brand_delete_infrastructure_exception_returns_500,
+ * admin_brand_toggle_status_infrastructure_exception_returns_500,
+ * admin_coupon_delete_infrastructure_exception_returns_500,
+ * admin_shipping_carrier_toggle_status_infrastructure_exception_returns_500,
+ * admin_product_common_info_delete_infrastructure_exception_returns_500,
+ * admin_product_common_info_toggle_active_infrastructure_exception_returns_500,
+ * admin_crud_infrastructure_response_omits_exception_text
+ */
+ #[DataProvider('endpointProvider')]
+ public function test_infrastructure_exception_returns_500(
+ string $verb,
+ string $uri,
+ string $serviceClass,
+ string $serviceMethod,
+ ?string $typedException,
+ ?int $typedStatus
+ ): void {
+ $this->mock($serviceClass, function ($mock) use ($serviceMethod) {
+ $mock->shouldReceive($serviceMethod)
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->adminUser)->{$verb.'Json'}($uri, []);
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ /**
+ * 도메인 예외는 기존 상태코드를 유지해야 한다 (계약 변경 없음).
+ *
+ * @scenario error_class=domain
+ *
+ * @effects admin_brand_delete_domain_exception_keeps_400,
+ * admin_brand_toggle_status_domain_exception_keeps_400,
+ * admin_coupon_delete_domain_exception_keeps_400,
+ * admin_shipping_carrier_toggle_status_domain_exception_keeps_400,
+ * admin_product_common_info_delete_domain_exception_keeps_400,
+ * admin_product_common_info_toggle_active_domain_exception_keeps_400
+ */
+ #[DataProvider('endpointProvider')]
+ public function test_domain_exception_keeps_original_status(
+ string $verb,
+ string $uri,
+ string $serviceClass,
+ string $serviceMethod,
+ ?string $typedException,
+ ?int $typedStatus
+ ): void {
+ if ($typedException === null) {
+ $this->markTestSkipped('typed 도메인 예외가 없는 엔드포인트');
+ }
+
+ $this->mock($serviceClass, function ($mock) use ($serviceMethod, $typedException) {
+ $mock->shouldReceive($serviceMethod)
+ ->andThrow(new $typedException('sirsoft-ecommerce::exceptions.operation_failed'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->adminUser)->{$verb.'Json'}($uri, []);
+
+ $response->assertStatus($typedStatus);
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/InquiryDomainExceptionStatusPairTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/InquiryDomainExceptionStatusPairTest.php
new file mode 100644
index 00000000..c6fa4856
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/InquiryDomainExceptionStatusPairTest.php
@@ -0,0 +1,327 @@
+user = $this->createUser();
+ $this->manager = $this->createAdminUser(['sirsoft-ecommerce.inquiries.update']);
+
+ $product = Product::factory()->create();
+ $this->inquiry = ProductInquiry::factory()->create([
+ 'user_id' => $this->user->id,
+ 'product_id' => $product->id,
+ 'is_answered' => false,
+ ]);
+
+ app(EcommerceSettingsService::class)->setSetting('inquiry.board_slug', 'test-inquiry-board');
+
+ // 다른 모듈이 등록한 inquiry.* 필터가 스냅샷으로 잔존해 서비스 mock 과 충돌하는
+ // cross-module contamination 차단 (UserProductInquiryControllerTest 와 동형).
+ foreach ([
+ 'sirsoft-ecommerce.inquiry.update',
+ 'sirsoft-ecommerce.inquiry.delete',
+ 'sirsoft-ecommerce.inquiry.create',
+ 'sirsoft-ecommerce.inquiry.update_reply',
+ 'sirsoft-ecommerce.inquiry.delete_reply',
+ 'sirsoft-ecommerce.inquiry.update_validation_rules',
+ 'sirsoft-board.post.get_by_ids',
+ ] as $hook) {
+ HookManager::clearFilter($hook);
+ }
+ }
+
+ /**
+ * 도메인 실패를 던지는 서비스 mock (본인 문의 조회는 성공).
+ */
+ private function mockServiceThrowing(string $method, \Throwable $e): void
+ {
+ $inquiry = $this->inquiry;
+
+ $this->mock(ProductInquiryService::class, function ($mock) use ($method, $e, $inquiry) {
+ $mock->shouldReceive('findById')->andReturn($inquiry);
+ $mock->shouldReceive($method)->andThrow($e);
+ $mock->shouldReceive()->andReturnNull();
+ });
+ }
+
+ /**
+ * 도메인 예외 — 게시판 미설정.
+ */
+ private function domainException(): ProductInquiryOperationException
+ {
+ return new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_not_configured');
+ }
+
+ /**
+ * 인프라 예외 — DB 장애. `\RuntimeException` 이라 종전 광역 catch 에 걸렸다.
+ */
+ private function infraException(): \RuntimeException
+ {
+ return new \RuntimeException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away');
+ }
+
+ /**
+ * 응답 메시지가 "해석된 도메인 사유" 인지 단언한다.
+ *
+ * 응답 로케일은 요청 처리 중에 결정되어 테스트 프로세스의 로케일과 다를 수 있으므로
+ * 특정 언어로 못박지 않고 지원 로케일의 해석 결과 집합과 대조한다. 고정하려는 것은
+ * ① 메시지 키 원문이 새지 않을 것 ② 일반 실패 문구로 뭉개지지 않을 것 두 가지다.
+ *
+ * @param string|null $message 응답 message 필드
+ * @param string $context 실패 시 표시할 맥락 설명
+ */
+ private function assertResolvedReason(?string $message, string $context = '도메인 실패 사유가 해석된 다국어 문구로 나가야 합니다.'): void
+ {
+ $this->assertNotNull($message, $context);
+ $this->assertStringNotContainsString(
+ 'sirsoft-ecommerce::',
+ $message,
+ '메시지 키 원문이 응답에 노출되면 안 됩니다.'
+ );
+
+ $key = 'sirsoft-ecommerce::messages.inquiries.board_not_configured';
+ $resolved = array_map(
+ fn (string $locale) => trans($key, [], $locale),
+ config('app.supported_locales')
+ );
+
+ $this->assertContains($message, $resolved, $context);
+ }
+
+ // ------------------------------------------------------------------
+ // 문의 수정
+ // ------------------------------------------------------------------
+
+ /**
+ * @scenario endpoint=update_inquiry, error_class=domain
+ *
+ * @effects inquiry_update_domain_exception_returns_422
+ */
+ public function test_update_domain_exception_returns_422_with_resolved_reason(): void
+ {
+ $this->mockServiceThrowing('updateInquiry', $this->domainException());
+
+ $response = $this->actingAs($this->user)->putJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}",
+ ['content' => '수정된 문의 내용입니다.']
+ );
+
+ $response->assertStatus(422);
+ $this->assertResolvedReason($response->json('message'));
+ }
+
+ /**
+ * @scenario endpoint=update_inquiry, error_class=infrastructure
+ *
+ * @effects inquiry_update_infrastructure_exception_returns_500_not_422
+ */
+ public function test_update_infrastructure_exception_returns_500_not_422(): void
+ {
+ $this->mockServiceThrowing('updateInquiry', $this->infraException());
+
+ $response = $this->actingAs($this->user)->putJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}",
+ ['content' => '수정된 문의 내용입니다.']
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ------------------------------------------------------------------
+ // 문의 삭제 (도메인 예외가 500 으로 나가던 역방향 짝)
+ // ------------------------------------------------------------------
+
+ /**
+ * @scenario endpoint=destroy_inquiry, error_class=domain
+ *
+ * @effects inquiry_destroy_domain_exception_returns_422
+ */
+ public function test_destroy_domain_exception_returns_422_not_500(): void
+ {
+ $this->mockServiceThrowing('deleteInquiry', $this->domainException());
+
+ $response = $this->actingAs($this->user)->deleteJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}"
+ );
+
+ $response->assertStatus(422);
+ $this->assertResolvedReason($response->json('message'), '삭제 실패 사유가 운영자에게 도달해야 합니다 (종전에는 500 일반 문구).');
+ }
+
+ /**
+ * @scenario endpoint=destroy_inquiry, error_class=infrastructure
+ *
+ * @effects inquiry_destroy_infrastructure_exception_returns_500_not_422
+ */
+ public function test_destroy_infrastructure_exception_returns_500(): void
+ {
+ $this->mockServiceThrowing('deleteInquiry', $this->infraException());
+
+ $response = $this->actingAs($this->user)->deleteJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}"
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ------------------------------------------------------------------
+ // 답변 등록
+ // ------------------------------------------------------------------
+
+ /**
+ * @scenario endpoint=create_reply, error_class=domain
+ *
+ * @effects inquiry_reply_domain_exception_returns_422
+ */
+ public function test_reply_domain_exception_returns_422(): void
+ {
+ $this->mockServiceThrowing('createReply', $this->domainException());
+
+ $response = $this->actingAs($this->manager)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply",
+ ['content' => '답변 내용입니다.']
+ );
+
+ $response->assertStatus(422);
+ $this->assertResolvedReason($response->json('message'));
+ }
+
+ /**
+ * @scenario endpoint=create_reply, error_class=infrastructure
+ *
+ * @effects inquiry_reply_infrastructure_exception_returns_500_not_422
+ */
+ public function test_reply_infrastructure_exception_returns_500_not_422(): void
+ {
+ $this->mockServiceThrowing('createReply', $this->infraException());
+
+ $response = $this->actingAs($this->manager)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply",
+ ['content' => '답변 내용입니다.']
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ------------------------------------------------------------------
+ // 답변 수정
+ // ------------------------------------------------------------------
+
+ /**
+ * @scenario endpoint=update_reply, error_class=domain
+ *
+ * @effects inquiry_update_reply_domain_exception_returns_422
+ */
+ public function test_update_reply_domain_exception_returns_422(): void
+ {
+ $this->mockServiceThrowing('updateReply', $this->domainException());
+
+ $response = $this->actingAs($this->manager)->putJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply",
+ ['content' => '수정된 답변 내용입니다.']
+ );
+
+ $response->assertStatus(422);
+ $this->assertResolvedReason($response->json('message'));
+ }
+
+ /**
+ * @scenario endpoint=update_reply, error_class=infrastructure
+ *
+ * @effects inquiry_update_reply_infrastructure_exception_returns_500_not_422
+ */
+ public function test_update_reply_infrastructure_exception_returns_500_not_422(): void
+ {
+ $this->mockServiceThrowing('updateReply', $this->infraException());
+
+ $response = $this->actingAs($this->manager)->putJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply",
+ ['content' => '수정된 답변 내용입니다.']
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ------------------------------------------------------------------
+ // 답변 삭제
+ // ------------------------------------------------------------------
+
+ /**
+ * @scenario endpoint=destroy_reply, error_class=domain
+ *
+ * @effects inquiry_destroy_reply_domain_exception_returns_422
+ */
+ public function test_destroy_reply_domain_exception_returns_422(): void
+ {
+ $this->mockServiceThrowing('deleteReply', $this->domainException());
+
+ $response = $this->actingAs($this->manager)->deleteJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply"
+ );
+
+ $response->assertStatus(422);
+ $this->assertResolvedReason($response->json('message'));
+ }
+
+ /**
+ * @scenario endpoint=destroy_reply, error_class=infrastructure
+ *
+ * @effects inquiry_destroy_reply_infrastructure_exception_returns_500_not_422
+ */
+ public function test_destroy_reply_infrastructure_exception_returns_500_not_422(): void
+ {
+ $this->mockServiceThrowing('deleteReply', $this->infraException());
+
+ $response = $this->actingAs($this->manager)->deleteJson(
+ "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply"
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/OrderDomainExceptionStatusPairTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/OrderDomainExceptionStatusPairTest.php
new file mode 100644
index 00000000..fe7bc38f
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/OrderDomainExceptionStatusPairTest.php
@@ -0,0 +1,398 @@
+user = $this->createUser();
+
+ // 사용자 취소는 PG 환불 훅을 타므로 성공 응답으로 고정 (본 테스트의 관심사가 아님)
+ HookManager::addFilter('sirsoft-ecommerce.payment.refund', fn () => [
+ 'success' => true,
+ 'transaction_id' => 'TEST_PAIR_TXN',
+ 'error_code' => null,
+ 'error_message' => null,
+ ], 10);
+ }
+
+ /**
+ * 취소 가능한 결제완료 주문(옵션 1개)을 만든다.
+ */
+ private function makeCancellableOrder(): Order
+ {
+ $order = Order::factory()->create([
+ 'user_id' => $this->user->id,
+ 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
+ 'subtotal_amount' => 20000,
+ 'total_amount' => 20000,
+ 'total_paid_amount' => 20000,
+ 'total_cancelled_amount' => 0,
+ 'cancellation_count' => 0,
+ 'promotions_applied_snapshot' => [],
+ 'shipping_policy_applied_snapshot' => [],
+ ]);
+
+ OrderOption::factory()->forOrder($order)->create([
+ 'quantity' => 1,
+ 'unit_price' => 20000,
+ 'subtotal_price' => 20000,
+ 'subtotal_paid_amount' => 20000,
+ 'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
+ ]);
+
+ return $order->fresh(['options']);
+ }
+
+ /**
+ * 사용자 선택 가능한 환불 사유 코드를 반환한다.
+ */
+ private function refundReasonCode(): string
+ {
+ $code = ClaimReason::where('type', 'refund')
+ ->where('is_active', true)
+ ->where('is_user_selectable', true)
+ ->value('code');
+
+ if ($code === null) {
+ $this->markTestSkipped('사용자 선택 가능한 환불 사유(ClaimReason) 시드가 없다');
+ }
+
+ return $code;
+ }
+
+ // ------------------------------------------------------------------
+ // 회원 주문 취소
+ // ------------------------------------------------------------------
+
+ /**
+ * @scenario endpoint=cancel, error_class=domain
+ *
+ * @effects order_cancel_domain_exception_returns_422
+ */
+ public function test_user_cancel_domain_exception_returns_422(): void
+ {
+ $order = $this->makeCancellableOrder();
+
+ $this->mock(OrderCancellationService::class, function ($mock) {
+ $mock->shouldReceive('cancelOrder')
+ ->andThrow(new OrderCancellationException('취소 불가'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->user)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/orders/{$order->id}/cancel",
+ ['reason' => $this->refundReasonCode()]
+ );
+
+ $response->assertStatus(422);
+ }
+
+ /**
+ * @scenario endpoint=cancel, error_class=infrastructure
+ *
+ * @effects order_cancel_infrastructure_exception_returns_500_not_422
+ */
+ public function test_user_cancel_infrastructure_exception_returns_500(): void
+ {
+ $order = $this->makeCancellableOrder();
+
+ $this->mock(OrderCancellationService::class, function ($mock) {
+ $mock->shouldReceive('cancelOrder')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->user)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/orders/{$order->id}/cancel",
+ ['reason' => $this->refundReasonCode()]
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ------------------------------------------------------------------
+ // 회원 구매확정 (typed 예외 없음 — 전부 인프라 축)
+ // ------------------------------------------------------------------
+
+ /**
+ * @scenario endpoint=confirm_option, error_class=infrastructure
+ *
+ * @effects order_confirm_option_infrastructure_exception_returns_500_not_422
+ */
+ public function test_user_confirm_option_infrastructure_exception_returns_500(): void
+ {
+ $order = $this->makeCancellableOrder();
+ $option = $order->options->first();
+ // 구매확정 가능 상태(기본값 shipping/delivered)여야 FormRequest 를 통과해
+ // 컨트롤러의 catch 까지 도달한다.
+ $option->update(['option_status' => OrderStatusEnum::DELIVERED]);
+
+ $this->mock(OrderService::class, function ($mock) {
+ $mock->shouldReceive('confirmOption')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->user)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/orders/{$order->id}/options/{$option->id}/confirm"
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ------------------------------------------------------------------
+ // 회원 재주문
+ // ------------------------------------------------------------------
+
+ /**
+ * @scenario endpoint=reorder, error_class=domain
+ *
+ * @effects order_reorder_domain_exception_returns_422
+ */
+ public function test_user_reorder_domain_exception_returns_422(): void
+ {
+ $order = $this->makeCancellableOrder();
+
+ $this->mock(CartService::class, function ($mock) {
+ $mock->shouldReceive('reorderFromOrder')
+ ->andThrow(new OrderProcessingException('장바구니 담기 불가'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->user)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/orders/{$order->id}/reorder"
+ );
+
+ $response->assertStatus(422);
+ }
+
+ /**
+ * @scenario endpoint=reorder, error_class=infrastructure
+ *
+ * @effects order_reorder_infrastructure_exception_returns_500_not_422
+ */
+ public function test_user_reorder_infrastructure_exception_returns_500(): void
+ {
+ $order = $this->makeCancellableOrder();
+
+ $this->mock(CartService::class, function ($mock) {
+ $mock->shouldReceive('reorderFromOrder')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->user)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/orders/{$order->id}/reorder"
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ------------------------------------------------------------------
+ // 회원 쿠폰 다운로드
+ // ------------------------------------------------------------------
+
+ /**
+ * 다운로드 대상 쿠폰 1건을 만든다 (발급 로직은 mock 이 대체하므로 존재만 하면 된다).
+ */
+ private function makeDownloadableCoupon(): Coupon
+ {
+ return Coupon::create([
+ 'name' => ['ko' => '다운로드 쿠폰', 'en' => 'Downloadable Coupon'],
+ 'target_type' => CouponTargetType::PRODUCT_AMOUNT->value,
+ 'discount_type' => CouponDiscountType::FIXED->value,
+ 'discount_value' => 1000,
+ 'min_order_amount' => 0,
+ 'issue_method' => CouponIssueMethod::DOWNLOAD->value,
+ 'issue_condition' => CouponIssueCondition::MANUAL->value,
+ 'issue_status' => CouponIssueStatus::ISSUING->value,
+ 'per_user_limit' => 1,
+ 'valid_type' => 'days_from_issue',
+ 'valid_days' => 30,
+ 'is_combinable' => true,
+ 'target_scope' => CouponTargetScope::ALL->value,
+ 'created_by' => $this->user->id,
+ ]);
+ }
+
+ /**
+ * 발급 불가 사유는 400 을 유지하고, 사유 식별자로 만든 키가 응답 메시지가 된다.
+ *
+ * @scenario endpoint=coupon_download, error_class=domain
+ *
+ * @effects coupon_download_domain_exception_returns_400_with_reason_key
+ */
+ public function test_user_coupon_download_domain_exception_returns_400(): void
+ {
+ $coupon = $this->makeDownloadableCoupon();
+
+ $this->mock(UserCouponService::class, function ($mock) {
+ $mock->shouldReceive('downloadCoupon')
+ ->andThrow(new CouponNotIssuableException('already_downloaded'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->user)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/coupons/{$coupon->id}/download"
+ );
+
+ $response->assertStatus(400);
+ // 번역된 예외 원문이 아니라 사유 키로 해석된 문구가 나가야 한다
+ $this->assertSame(
+ __('sirsoft-ecommerce::messages.coupon.already_downloaded'),
+ $response->json('message')
+ );
+ }
+
+ /**
+ * @scenario endpoint=coupon_download, error_class=infrastructure
+ *
+ * @effects coupon_download_infrastructure_exception_returns_500_not_400
+ */
+ public function test_user_coupon_download_infrastructure_exception_returns_500(): void
+ {
+ $coupon = $this->makeDownloadableCoupon();
+
+ $this->mock(UserCouponService::class, function ($mock) {
+ $mock->shouldReceive('downloadCoupon')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($this->user)->postJson(
+ "/api/modules/sirsoft-ecommerce/user/coupons/{$coupon->id}/download"
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ------------------------------------------------------------------
+ // 관리자 무통장 입금확인
+ // ------------------------------------------------------------------
+
+ /**
+ * 입금확인 대상(무통장·미결제) 주문을 만든다.
+ */
+ private function makeDepositPendingOrder(): Order
+ {
+ $order = Order::factory()->create([
+ 'user_id' => $this->user->id,
+ 'order_status' => OrderStatusEnum::PENDING_PAYMENT,
+ 'subtotal_amount' => 20000,
+ 'total_amount' => 20000,
+ 'total_paid_amount' => 0,
+ 'total_cancelled_amount' => 0,
+ 'cancellation_count' => 0,
+ 'promotions_applied_snapshot' => [],
+ 'shipping_policy_applied_snapshot' => [],
+ ]);
+
+ OrderPayment::factory()->forOrder($order)->create([
+ 'payment_method' => PaymentMethodEnum::DBANK,
+ 'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
+ 'paid_amount_local' => 20000,
+ 'paid_amount_base' => 20000,
+ ]);
+
+ return $order->fresh(['payment']);
+ }
+
+ /**
+ * @scenario endpoint=confirm_deposit, error_class=domain
+ *
+ * @effects order_confirm_deposit_domain_exception_returns_422
+ */
+ public function test_admin_confirm_deposit_domain_exception_returns_422(): void
+ {
+ $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
+ $order = $this->makeDepositPendingOrder();
+
+ $this->mock(OrderProcessingService::class, function ($mock) {
+ $mock->shouldReceive('confirmManualDeposit')
+ ->andThrow(new OrderProcessingException('전이 불가 상태'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($admin)->patchJson(
+ "/api/modules/sirsoft-ecommerce/admin/orders/{$order->id}/confirm-deposit",
+ ['amount' => 20000]
+ );
+
+ $response->assertStatus(422);
+ }
+
+ /**
+ * @scenario endpoint=confirm_deposit, error_class=infrastructure
+ *
+ * @effects order_confirm_deposit_infrastructure_exception_returns_500_not_422
+ */
+ public function test_admin_confirm_deposit_infrastructure_exception_returns_500(): void
+ {
+ $admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
+ $order = $this->makeDepositPendingOrder();
+
+ $this->mock(OrderProcessingService::class, function ($mock) {
+ $mock->shouldReceive('confirmManualDeposit')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->actingAs($admin)->patchJson(
+ "/api/modules/sirsoft-ecommerce/admin/orders/{$order->id}/confirm-deposit",
+ ['amount' => 20000]
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php
index 6ebe0f52..34512dd1 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php
@@ -10,6 +10,8 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderCancellationException;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderModificationException;
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
use Modules\Sirsoft\Ecommerce\Models\Order;
@@ -17,6 +19,8 @@ use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
+use Modules\Sirsoft\Ecommerce\Services\OrderCancellationService;
+use Modules\Sirsoft\Ecommerce\Services\OrderService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -1286,6 +1290,10 @@ class OrderControllerTest extends ModuleTestCase
*
* 비회원은 저장된 주소(address_id) 없이 직접 입력한 배송지 필드를 전송한다.
* 회원과 동일한 OrderService::updateShippingAddress 로 처리되며, 응답은 GuestOrderResource.
+ *
+ * @scenario actor=guest, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects guest_update_shipping_address_succeeds_with_valid_token
*/
public function test_비회원_배송지_변경_성공(): void
{
@@ -1483,4 +1491,361 @@ class OrderControllerTest extends ModuleTestCase
$this->postJson('/api/modules/sirsoft-ecommerce/guest/orders/verify', $payload)
->assertStatus(429);
}
+
+ // ================================================================
+ // 비회원 배송지 국내/해외 조합 정합성 (F3)
+ // ================================================================
+
+ /**
+ * 비회원 배송지: country_code 없이 해외 주소 조합만 보내면 검증으로 차단된다.
+ *
+ * 수정 전에는 검증을 통과해 서비스가 국내 분기로 떨어졌고, NOT NULL 인 zipcode 에
+ * null 을 쓰다 SQL 23000 이 발생해 "배송 전에만 변경 가능" 메시지로 위장됐다.
+ *
+ * @scenario actor=guest, change_mode=manual, address_region=intl_without_country_code, e2e_browser=chromium
+ *
+ * @effects guest_shipping_address_intl_combination_without_country_code_returns_422_validation_errors
+ */
+ public function test_비회원_배송지_해외조합_country_code_누락시_검증차단(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ Order::where('order_number', $orderNumber)
+ ->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $response = $this->putJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/shipping-address",
+ [
+ 'recipient_name' => '김해외',
+ 'recipient_phone' => '010-9999-8888',
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ 'intl_city' => 'Mountain View',
+ 'intl_postal_code' => '94043',
+ ],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['zipcode', 'address']);
+ }
+
+ /**
+ * 비회원 배송지: country_code 를 해외로 명시하면 해외 컬럼이 기록되고 국내 컬럼은 초기화된다.
+ *
+ * @scenario actor=guest, change_mode=manual, address_region=intl, e2e_browser=chromium
+ *
+ * @effects guest_shipping_address_switching_to_intl_clears_domestic_columns
+ */
+ public function test_비회원_배송지_해외전환시_국내컬럼_초기화(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $response = $this->putJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/shipping-address",
+ [
+ 'recipient_name' => '김해외',
+ 'recipient_phone' => '010-9999-8888',
+ 'country_code' => 'US',
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ 'intl_city' => 'Mountain View',
+ 'intl_state' => 'CA',
+ 'intl_postal_code' => '94043',
+ ],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(200);
+
+ $shipping = $order->refresh()->load('addresses')
+ ->addresses->firstWhere('address_type', 'shipping');
+
+ $this->assertSame('US', $shipping->recipient_country_code);
+ $this->assertSame('1600 Amphitheatre Parkway', $shipping->address_line_1);
+ $this->assertSame('Mountain View', $shipping->intl_city);
+ $this->assertSame('', $shipping->zipcode);
+ $this->assertSame('', $shipping->address);
+ }
+
+ /**
+ * 비회원 배송지: country_code 를 국내로 명시하면 국내 필수값이 강제된다.
+ *
+ * 회원 경로에만 있던 축이다 — F3 의 원 재현 경로가 비회원이었으므로 같은 폭으로 고정한다.
+ *
+ * @scenario actor=guest, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects guest_shipping_address_domestic_country_code_requires_zipcode_and_address
+ */
+ public function test_비회원_배송지_국내_country_code_는_zipcode_와_address_를_요구한다(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $response = $this->putJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/shipping-address",
+ [
+ 'recipient_name' => '김국내',
+ 'recipient_phone' => '010-9999-8888',
+ 'country_code' => 'KR',
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ ],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['zipcode', 'address']);
+ }
+
+ /**
+ * 비회원 배송지: country_code 가 해외인데 해외 필수값이 비면 검증으로 차단된다.
+ *
+ * 국내 방향(위)만 고정하면 게이트가 한쪽으로만 서 있는지 드러나지 않는다.
+ * 회원 경로에는 이 역방향 짝이 있었고 비회원 경로에만 없었다.
+ *
+ * @scenario actor=guest, change_mode=manual, address_region=intl, e2e_browser=chromium
+ *
+ * @effects guest_shipping_address_foreign_country_code_requires_intl_fields
+ */
+ public function test_비회원_배송지_해외_country_code_는_해외_필수값을_요구한다(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $response = $this->putJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/shipping-address",
+ [
+ 'recipient_name' => '김해외',
+ 'recipient_phone' => '010-9999-8888',
+ 'country_code' => 'US',
+ ],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['address_line_1', 'intl_city', 'intl_postal_code']);
+ }
+
+ /**
+ * 비회원 배송지: 국내 → 해외 → 국내 왕복 시 반대편 컬럼이 매번 초기화된다.
+ *
+ * 한 방향만 검사하면 되돌아온 뒤 해외 컬럼이 잔존해 배송지가 두 벌로 남는 결함을 놓친다.
+ *
+ * @scenario actor=guest, change_mode=manual, address_region=intl, e2e_browser=chromium
+ *
+ * @effects guest_shipping_address_round_trip_between_domestic_and_intl_clears_opposite_columns
+ */
+ public function test_비회원_배송지_국내_해외_왕복시_반대편_컬럼_초기화(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $url = "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/shipping-address";
+ $headers = ['X-Guest-Order-Token' => $token];
+
+ // ① 해외로 전환
+ $this->putJson($url, [
+ 'recipient_name' => '김해외',
+ 'recipient_phone' => '010-9999-8888',
+ 'country_code' => 'US',
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ 'intl_city' => 'Mountain View',
+ 'intl_state' => 'CA',
+ 'intl_postal_code' => '94043',
+ ], $headers)->assertStatus(200);
+
+ // ② 다시 국내로 전환
+ $this->putJson($url, [
+ 'recipient_name' => '김국내',
+ 'recipient_phone' => '010-1111-2222',
+ 'country_code' => 'KR',
+ 'zipcode' => '06134',
+ 'address' => '서울특별시 강남구 테헤란로 123',
+ 'address_detail' => '4층',
+ ], $headers)->assertStatus(200);
+
+ $shipping = $order->refresh()->load('addresses')
+ ->addresses->firstWhere('address_type', 'shipping');
+
+ $this->assertSame('KR', $shipping->recipient_country_code);
+ $this->assertSame('06134', $shipping->zipcode);
+ $this->assertSame('서울특별시 강남구 테헤란로 123', $shipping->address);
+
+ $this->assertNull($shipping->address_line_1, '국내로 돌아오면 해외 주소가 남으면 안 됩니다.');
+ $this->assertNull($shipping->intl_city);
+ $this->assertNull($shipping->intl_state);
+ $this->assertNull($shipping->intl_postal_code);
+ }
+
+ /**
+ * 비회원 배송지 변경: 도메인 예외는 기존 422 + 안내 문구를 유지한다.
+ *
+ * @scenario actor=guest, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects guest_update_shipping_address_domain_exception_returns_422_infra_returns_500
+ */
+ public function test_비회원_배송지_도메인예외_422(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $this->mock(OrderService::class, function ($mock) {
+ $mock->shouldReceive('updateShippingAddress')
+ ->andThrow(new OrderModificationException('배송 전 상태에서만 변경 가능'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->putJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/shipping-address",
+ [
+ 'recipient_name' => '변경수령인',
+ 'recipient_phone' => '010-9999-8888',
+ 'zipcode' => '06236',
+ 'address' => '서울 강남구 테헤란로 1',
+ ],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(422);
+ $this->assertSame(
+ __('sirsoft-ecommerce::messages.orders.cannot_modify_address'),
+ $response->json('message')
+ );
+ }
+
+ /**
+ * 비회원 배송지 변경: 인프라 예외는 500 이며 예외 원문을 노출하지 않는다.
+ *
+ * 회원 경로(User\OrderController)와 쌍둥이 코드라 한쪽만 고치면 비회원 화면에서만
+ * 장애가 "배송 전에만 변경 가능" 이라는 엉뚱한 입력 오류로 계속 위장된다.
+ *
+ * @scenario actor=guest, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects guest_update_shipping_address_domain_exception_returns_422_infra_returns_500, guest_action_5xx_responses_exclude_exception_message
+ */
+ public function test_비회원_배송지_인프라예외_500(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $this->mock(OrderService::class, function ($mock) {
+ $mock->shouldReceive('updateShippingAddress')
+ ->andThrow(new \RuntimeException('SQLSTATE[23000]: Integrity constraint violation'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->putJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/shipping-address",
+ [
+ 'recipient_name' => '변경수령인',
+ 'recipient_phone' => '010-9999-8888',
+ 'zipcode' => '06236',
+ 'address' => '서울 강남구 테헤란로 1',
+ ],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ /**
+ * 사용자 선택 가능한 환불 사유 코드를 반환한다.
+ */
+ private function userSelectableRefundReason(): string
+ {
+ $code = ClaimReason::where('type', 'refund')
+ ->where('is_active', true)
+ ->where('is_user_selectable', true)
+ ->value('code');
+
+ if ($code === null) {
+ $this->markTestSkipped('사용자 선택 가능한 환불 사유(ClaimReason) 시드가 없다');
+ }
+
+ return $code;
+ }
+
+ /**
+ * 비회원 주문 취소: 도메인 예외는 기존 422 를 유지한다.
+ *
+ * @effects guest_cancel_domain_exception_returns_422_infra_returns_500
+ */
+ public function test_비회원_주문취소_도메인예외_422(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $this->mock(OrderCancellationService::class, function ($mock) {
+ $mock->shouldReceive('cancelOrder')
+ ->andThrow(new OrderCancellationException('취소 불가'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $this->postJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/cancel",
+ ['reason' => $this->userSelectableRefundReason()],
+ ['X-Guest-Order-Token' => $token]
+ )->assertStatus(422);
+ }
+
+ /**
+ * 비회원 주문 취소: 인프라 예외는 500 이며 예외 원문을 노출하지 않는다.
+ *
+ * @effects guest_cancel_domain_exception_returns_422_infra_returns_500, guest_action_5xx_responses_exclude_exception_message
+ */
+ public function test_비회원_주문취소_인프라예외_500(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+
+ $this->mock(OrderCancellationService::class, function ($mock) {
+ $mock->shouldReceive('cancelOrder')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->postJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/cancel",
+ ['reason' => $this->userSelectableRefundReason()],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ /**
+ * 비회원 구매확정: typed 도메인 예외가 없으므로 모든 예외가 500 이어야 한다.
+ *
+ * @effects guest_confirm_option_has_no_domain_exception_so_generic_catch_returns_500, guest_action_5xx_responses_exclude_exception_message
+ */
+ public function test_비회원_구매확정_인프라예외_500(): void
+ {
+ [$orderNumber, $token] = $this->placeGuestOrderAndToken();
+ $order = Order::where('order_number', $orderNumber)->first();
+ $order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
+ $option = $order->options()->first();
+
+ $this->mock(OrderService::class, function ($mock) {
+ $mock->shouldReceive('confirmOption')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: General error'));
+ $mock->shouldReceive()->andReturnNull();
+ });
+
+ $response = $this->postJson(
+ "/api/modules/sirsoft-ecommerce/guest/orders/{$orderNumber}/options/{$option->id}/confirm",
+ [],
+ ['X-Guest-Order-Token' => $token]
+ );
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/OrderShippingAddressTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/OrderShippingAddressTest.php
index 01142839..9c693a8a 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/OrderShippingAddressTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/OrderShippingAddressTest.php
@@ -3,9 +3,11 @@
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\User;
use App\Models\User;
+use Modules\Sirsoft\Ecommerce\Exceptions\OrderModificationException;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\UserAddress;
+use Modules\Sirsoft\Ecommerce\Services\OrderService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
@@ -31,7 +33,7 @@ class OrderShippingAddressTest extends ModuleTestCase
/**
* 배송지 URL 생성 헬퍼
*
- * @param int $orderId 주문 ID
+ * @param int $orderId 주문 ID
* @return string
*/
private function url(int $orderId): string
@@ -39,6 +41,11 @@ class OrderShippingAddressTest extends ModuleTestCase
return "/api/modules/sirsoft-ecommerce/user/orders/{$orderId}/shipping-address";
}
+ /**
+ * @scenario actor=member, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects change_address_handler_uses_user_endpoint_without_token_header_for_member
+ */
public function test_update_shipping_address_with_manual_input(): void
{
$data = [
@@ -62,6 +69,11 @@ class OrderShippingAddressTest extends ModuleTestCase
$this->assertEquals('서울시 서초구 서초대로 123', $this->shippingAddress->address);
}
+ /**
+ * @scenario actor=member, change_mode=saved, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects change_address_handler_saved_mode_sends_address_id_manual_sends_full_object
+ */
public function test_update_shipping_address_with_saved_address(): void
{
$savedAddress = UserAddress::factory()->create([
@@ -86,6 +98,90 @@ class OrderShippingAddressTest extends ModuleTestCase
$this->assertEquals('부산시 해운대구', $this->shippingAddress->address);
}
+ /**
+ * 저장된 해외 배송지를 선택해도 해외 주소가 보존되어야 한다.
+ *
+ * 종전에는 address_id 분기가 요청 데이터를 국내 6필드로만 재구성해, 해외 주소를
+ * 고르면 해외 컬럼이 전부 null 로 덮이고 국내 컬럼도 빈 문자열이라 배송지가
+ * 통째로 사라졌다. 응답은 200 이고 오류도 남지 않는다.
+ *
+ * @scenario actor=member, change_mode=saved, address_region=intl, e2e_browser=chromium
+ *
+ * @effects shipping_address_saved_intl_address_preserves_intl_columns
+ */
+ public function test_update_shipping_address_with_saved_intl_address_preserves_intl_columns(): void
+ {
+ $savedAddress = UserAddress::factory()->create([
+ 'user_id' => $this->user->id,
+ 'recipient_name' => 'John Doe',
+ 'recipient_phone' => '+1-202-555-0143',
+ 'country_code' => 'US',
+ 'zipcode' => null,
+ 'address' => null,
+ 'address_detail' => null,
+ 'address_line_1' => '1600 Pennsylvania Ave NW',
+ 'address_line_2' => 'Suite 200',
+ 'city' => 'Washington',
+ 'state' => 'DC',
+ 'postal_code' => '20500',
+ ]);
+
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'address_id' => $savedAddress->id,
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('success', true);
+
+ $this->shippingAddress->refresh();
+ $this->assertEquals('US', $this->shippingAddress->recipient_country_code);
+ $this->assertEquals('1600 Pennsylvania Ave NW', $this->shippingAddress->address_line_1);
+ $this->assertEquals('Suite 200', $this->shippingAddress->address_line_2);
+ $this->assertEquals('Washington', $this->shippingAddress->intl_city);
+ $this->assertEquals('DC', $this->shippingAddress->intl_state);
+ $this->assertEquals('20500', $this->shippingAddress->intl_postal_code);
+ // 국내 컬럼은 비활성 측이므로 초기화된다 (NOT NULL → 빈 문자열)
+ $this->assertSame('', $this->shippingAddress->zipcode);
+ $this->assertSame('', $this->shippingAddress->address);
+ }
+
+ /**
+ * 저장된 배송지를 선택하면서 함께 보낸 배송 메모가 반영되어야 한다.
+ *
+ * address_id 분기가 요청 데이터를 통째로 갈아치우면서 delivery_memo 가 조용히
+ * 버려졌다 — 검증은 통과하고 응답도 200 이라 입력이 사라진 것이 드러나지 않는다.
+ *
+ * @scenario actor=member, change_mode=saved, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects shipping_address_saved_mode_preserves_delivery_memo_from_request
+ */
+ public function test_update_shipping_address_with_saved_address_keeps_delivery_memo(): void
+ {
+ $savedAddress = UserAddress::factory()->create([
+ 'user_id' => $this->user->id,
+ 'recipient_name' => '박영희',
+ 'recipient_phone' => '010-1111-2222',
+ 'country_code' => 'KR',
+ 'zipcode' => '11111',
+ 'address' => '부산시 해운대구',
+ 'address_detail' => '마린시티',
+ ]);
+
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'address_id' => $savedAddress->id,
+ 'delivery_memo' => '부재 시 문 앞에 놓아주세요',
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('success', true);
+
+ $this->shippingAddress->refresh();
+ $this->assertEquals('부산시 해운대구', $this->shippingAddress->address);
+ $this->assertEquals('부재 시 문 앞에 놓아주세요', $this->shippingAddress->delivery_memo);
+ }
+
public function test_update_shipping_address_fails_after_shipping(): void
{
$order = Order::factory()->shipping()->forUser($this->user)->create();
@@ -238,4 +334,281 @@ class OrderShippingAddressTest extends ModuleTestCase
'data' => ['order'],
]);
}
+
+ // ================================================================
+ // 예외 → 상태코드 분리 (#104)
+ //
+ // 종전에는 모든 예외를 422 + "배송 전에만 변경 가능" 으로 뭉갰다. 인프라 장애까지
+ // 입력 오류로 위장되어 장애 인지가 늦어진다.
+ // ================================================================
+
+ /**
+ * 도메인 예외(배송 전 상태 아님)는 422 + 기존 안내 메시지를 유지한다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects update_shipping_address_domain_exception_returns_422_with_cannot_modify_address
+ */
+ public function test_domain_exception_returns_422_with_cannot_modify_address(): void
+ {
+ $this->mock(OrderService::class, function ($mock) {
+ $mock->shouldReceive('getDetail')->andReturn($this->order);
+ $mock->shouldReceive('updateShippingAddress')
+ ->andThrow(new OrderModificationException('배송 전 상태에서만 변경할 수 있습니다.'));
+ });
+
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김철수',
+ 'recipient_phone' => '010-9999-8888',
+ 'zipcode' => '54321',
+ 'address' => '서울시 서초구 서초대로 123',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonPath('message', __('sirsoft-ecommerce::messages.orders.cannot_modify_address'));
+ }
+
+ /**
+ * 도메인 예외가 아닌 예외(인프라/코드 결함)는 500 으로 구분되어야 한다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects update_shipping_address_infrastructure_exception_returns_500_not_422, update_shipping_address_500_response_excludes_exception_message
+ */
+ public function test_infrastructure_exception_returns_500_not_422(): void
+ {
+ $this->mock(OrderService::class, function ($mock) {
+ $mock->shouldReceive('getDetail')->andReturn($this->order);
+ $mock->shouldReceive('updateShippingAddress')
+ ->andThrow(new \RuntimeException('SQLSTATE[23000]: Integrity constraint violation'));
+ });
+
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김철수',
+ 'recipient_phone' => '010-9999-8888',
+ 'zipcode' => '54321',
+ 'address' => '서울시 서초구 서초대로 123',
+ ]);
+
+ $response->assertStatus(500);
+
+ // 예외 원문이 응답에 실려서는 안 된다
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
+
+ // ================================================================
+ // 국내/해외 주소 조합 정합성 (F3)
+ //
+ // country_code 가 국내/해외를 가르는 기준축인데 nullable 이라, 해외 필드만 채우고
+ // country_code 를 생략하면 검증을 통과한 뒤 서비스가 국내 분기로 떨어져
+ // NOT NULL 컬럼(zipcode/address)에 null 을 쓰려다 SQL 무결성 위반이 났다.
+ // ================================================================
+
+ /**
+ * country_code 없이 해외 주소 조합만 보내면 국내 필수값 검증으로 차단되어야 한다.
+ *
+ * 수정 전에는 검증을 통과해 SQL 23000(zipcode cannot be null)이 발생했고,
+ * 그 인프라 예외가 "배송 전에만 변경 가능" 이라는 엉뚱한 도메인 메시지로 위장됐다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=intl_without_country_code, e2e_browser=chromium
+ *
+ * @effects shipping_address_country_code_is_the_axis_for_domestic_vs_intl_validation, shipping_address_intl_combination_without_country_code_returns_422_validation_errors
+ */
+ public function test_intl_address_without_country_code_is_blocked_by_validation(): void
+ {
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김해외',
+ 'recipient_phone' => '010-9999-8888',
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ 'intl_city' => 'Mountain View',
+ 'intl_postal_code' => '94043',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['zipcode', 'address']);
+ }
+
+ /**
+ * country_code=KR 을 명시하면서 zipcode 를 생략하면 검증으로 차단되어야 한다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects shipping_address_domestic_country_code_requires_zipcode_and_address
+ */
+ public function test_domestic_country_code_requires_zipcode_and_address(): void
+ {
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김국내',
+ 'recipient_phone' => '010-9999-8888',
+ 'country_code' => 'KR',
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['zipcode', 'address']);
+ }
+
+ /**
+ * country_code 가 해외인데 해외 필수값이 비면 검증으로 차단되어야 한다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=intl, e2e_browser=chromium
+ *
+ * @effects shipping_address_foreign_country_code_requires_address_line_1_intl_city_intl_postal_code
+ */
+ public function test_foreign_country_code_requires_intl_fields(): void
+ {
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김해외',
+ 'recipient_phone' => '010-9999-8888',
+ 'country_code' => 'US',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['address_line_1', 'intl_city', 'intl_postal_code']);
+ }
+
+ /**
+ * 해외 주소로 전환하면 해외 컬럼이 기록되고 국내 컬럼은 초기화되어야 한다.
+ *
+ * 초기화하지 않으면 구 국내 주소가 그대로 남아 배송지가 두 벌이 된다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=intl, e2e_browser=chromium
+ *
+ * @effects shipping_address_switching_to_intl_clears_domestic_columns_with_empty_string
+ */
+ public function test_switching_to_intl_address_clears_domestic_columns(): void
+ {
+ $this->shippingAddress->update([
+ 'recipient_country_code' => 'KR',
+ 'zipcode' => '54321',
+ 'address' => '서울시 서초구 서초대로 123',
+ 'address_detail' => '456동 789호',
+ ]);
+
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김해외',
+ 'recipient_phone' => '010-9999-8888',
+ 'country_code' => 'US',
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ 'intl_city' => 'Mountain View',
+ 'intl_state' => 'CA',
+ 'intl_postal_code' => '94043',
+ ]);
+
+ $response->assertOk();
+
+ $this->shippingAddress->refresh();
+ $this->assertSame('US', $this->shippingAddress->recipient_country_code);
+ $this->assertSame('1600 Amphitheatre Parkway', $this->shippingAddress->address_line_1);
+ $this->assertSame('Mountain View', $this->shippingAddress->intl_city);
+ $this->assertSame('94043', $this->shippingAddress->intl_postal_code);
+
+ // 국내 컬럼은 NOT NULL 이므로 빈 문자열로 초기화
+ $this->assertSame('', $this->shippingAddress->zipcode);
+ $this->assertSame('', $this->shippingAddress->address);
+ $this->assertNull($this->shippingAddress->address_detail);
+ }
+
+ /**
+ * 국내 → 해외 → 국내 왕복 시 매번 반대편 컬럼이 초기화되어야 한다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=intl, e2e_browser=chromium
+ *
+ * @effects shipping_address_switching_to_domestic_clears_intl_columns_with_null
+ */
+ public function test_round_trip_between_domestic_and_intl_clears_opposite_columns(): void
+ {
+ // 1) 해외로 전환
+ $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김해외',
+ 'recipient_phone' => '010-9999-8888',
+ 'country_code' => 'US',
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ 'intl_city' => 'Mountain View',
+ 'intl_postal_code' => '94043',
+ ])->assertOk();
+
+ // 2) 다시 국내로 전환
+ $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김국내',
+ 'recipient_phone' => '010-1111-2222',
+ 'country_code' => 'KR',
+ 'zipcode' => '06236',
+ 'address' => '서울 강남구 테헤란로 1',
+ 'address_detail' => '10층',
+ ])->assertOk();
+
+ $this->shippingAddress->refresh();
+ $this->assertSame('KR', $this->shippingAddress->recipient_country_code);
+ $this->assertSame('06236', $this->shippingAddress->zipcode);
+ $this->assertSame('서울 강남구 테헤란로 1', $this->shippingAddress->address);
+
+ // 해외 컬럼은 nullable 이므로 null 로 초기화
+ $this->assertNull($this->shippingAddress->address_line_1);
+ $this->assertNull($this->shippingAddress->intl_city);
+ $this->assertNull($this->shippingAddress->intl_state);
+ $this->assertNull($this->shippingAddress->intl_postal_code);
+ }
+
+ /**
+ * 해외 배송지 주문에서 국내 주소를 직접 입력하면 그 주소가 저장되어야 한다.
+ *
+ * 배송지 변경 모달의 직접 입력 폼은 국내 필드만 제공하므로 country_code 를 보내지
+ * 않는다. FormRequest 게이트는 "국가 미전송 = 국내" 로 판정해 zipcode/address 를
+ * 필수로 검증하는데, 서비스가 기존 주소의 국가(US)를 승계해 해외 분기로 떨어지면
+ * 방금 검증한 국내 입력을 통째로 버리고 빈 문자열을 쓴다 — 응답은 200 이고
+ * 토스트도 성공이라 주소가 사라진 사실이 화면 재조회 전까지 드러나지 않는다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects shipping_address_manual_domestic_input_on_intl_order_is_saved_not_discarded
+ */
+ public function test_manual_domestic_input_on_intl_order_saves_domestic_address(): void
+ {
+ // 주문 배송지를 해외 상태로 만든다
+ $this->shippingAddress->update([
+ 'recipient_country_code' => 'US',
+ 'zipcode' => '',
+ 'address' => '',
+ 'address_detail' => null,
+ 'address_line_1' => '1600 Amphitheatre Parkway',
+ 'address_line_2' => 'Building 42',
+ 'intl_city' => 'Mountain View',
+ 'intl_state' => 'CA',
+ 'intl_postal_code' => '94043',
+ ]);
+
+ // 직접 입력 폼과 동일한 페이로드 — country_code 를 보내지 않는다
+ $response = $this->actingAs($this->user)
+ ->putJson($this->url($this->order->id), [
+ 'recipient_name' => '김국내',
+ 'recipient_phone' => '010-1111-2222',
+ 'zipcode' => '06134',
+ 'address' => '서울특별시 강남구 테헤란로 152',
+ 'address_detail' => '10층',
+ ]);
+
+ $response->assertOk();
+
+ $this->shippingAddress->refresh();
+ $this->assertSame('KR', $this->shippingAddress->recipient_country_code);
+ $this->assertSame('06134', $this->shippingAddress->zipcode);
+ $this->assertSame('서울특별시 강남구 테헤란로 152', $this->shippingAddress->address);
+ $this->assertSame('10층', $this->shippingAddress->address_detail);
+
+ // 해외 컬럼은 초기화된다
+ $this->assertNull($this->shippingAddress->address_line_1);
+ $this->assertNull($this->shippingAddress->address_line_2);
+ $this->assertNull($this->shippingAddress->intl_city);
+ $this->assertNull($this->shippingAddress->intl_state);
+ $this->assertNull($this->shippingAddress->intl_postal_code);
+ }
}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php
index 66548763..14063df2 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php
@@ -4953,6 +4953,12 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
*
* 50,000×1 + 주문쿠폰(FIXED 5000, minOrder=30000)
* 전체취소 → 잔여 0 < 30000 → 쿠폰 복원
+ *
+ * 재계산 전 쿠폰을 일시적으로 미사용으로 되돌리던 왕복 UPDATE 를 제거한 뒤에도(#108)
+ * 이 복원 판정이 그대로여야 한다 — 판정은 스냅샷의 min_order_amount 로만 이뤄지고
+ * 쿠폰의 사용 상태(status/used_at)를 읽지 않기 때문이다.
+ *
+ * @effects coupon_restore_on_min_amount_shortfall_unchanged_after_reset_removal
*/
public function test_coupon_restore_detected_when_min_order_not_met_after_partial(): void
{
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php
index 4683d5fc..3611a21b 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\HookManager;
use App\Models\User;
+use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
@@ -1238,6 +1239,77 @@ class OrderCancellationServiceTest extends ModuleTestCase
$this->assertEquals(0, OrderRefund::where('order_id', $order->id)->count());
}
+ /**
+ * C-1-6: 부분취소 미리보기가 쿠폰 발급 행을 일절 변이하지 않는지 검증 (#108)
+ *
+ * C-1-5 는 단일 옵션 전량 취소라 buildFullCancelResult 조기 반환 경로를 타서
+ * 재계산 자체가 일어나지 않는다. 잔여 옵션이 남는 부분취소여야 재계산 경로에
+ * 진입하므로, 여기서는 옵션 2개 중 1개만 취소한다.
+ *
+ * 전후 status/used_at 비교만으로는 "변경했다 되돌리는" 왕복을 잡지 못하므로
+ * (1) 실행 중 발생한 UPDATE 쿼리 자체를 세고 (2) updated_at 불변을 함께 단언한다.
+ *
+ * @effects preview_refund_issues_zero_write_queries_on_coupon_issues, preview_refund_keeps_coupon_issue_updated_at_unchanged, preview_refund_keeps_coupon_status_used_at_order_id_unchanged, partial_cancel_preview_reaches_recalculation_path_not_full_cancel_shortcut, recalculation_never_reads_coupon_usage_state_in_snapshot_mode
+ */
+ public function test_partial_cancel_preview_issues_no_write_query_on_coupon_issues(): void
+ {
+ $this->createShippingPolicy();
+
+ $couponIssue = $this->createCouponWithIssue(
+ targetType: CouponTargetType::ORDER_AMOUNT,
+ discountType: CouponDiscountType::FIXED,
+ discountValue: 5000,
+ );
+
+ [$pA, $oA] = $this->createProductWithOption(price: 30000);
+ [$pB, $oB] = $this->createProductWithOption(price: 20000);
+
+ $input = new CalculationInput(
+ items: [
+ new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1),
+ new CalculationItem(productId: $pB->id, productOptionId: $oB->id, quantity: 1),
+ ],
+ couponIssueIds: [$couponIssue->id],
+ );
+ $order = $this->createOrderFromCalculation($input);
+
+ $couponTable = (new CouponIssue)->getTable();
+ $before = CouponIssue::find($couponIssue->id);
+
+ // 미리보기 실행 중 쿠폰 발급 테이블에 발생한 쓰기 쿼리를 수집
+ $writeQueries = [];
+ DB::listen(function ($query) use ($couponTable, &$writeQueries) {
+ $sql = strtolower($query->sql);
+ if (str_contains($sql, $couponTable) && ! str_starts_with(ltrim($sql), 'select')) {
+ $writeQueries[] = $query->sql;
+ }
+ });
+
+ // 옵션 2개 중 1개만 취소 → 잔여 옵션이 있으므로 재계산 경로 진입
+ $cancelOption = $order->options->firstWhere('product_option_id', $oA->id);
+ $this->cancellationService->previewRefund($order, [
+ ['order_option_id' => $cancelOption->id, 'cancel_quantity' => 1],
+ ]);
+
+ $this->assertSame(
+ [],
+ $writeQueries,
+ '환불 예상 미리보기는 조회만 수행해야 하는데 쿠폰 발급 테이블에 쓰기 쿼리가 발생했습니다: '
+ .implode(' | ', $writeQueries)
+ );
+
+ // 왕복 UPDATE 는 값을 되돌려도 updated_at 을 오염시킨다
+ $after = CouponIssue::find($couponIssue->id);
+ $this->assertSame(
+ $before->updated_at?->toDateTimeString(),
+ $after->updated_at?->toDateTimeString(),
+ '미리보기 후 쿠폰 발급 행의 updated_at 이 변경되었습니다.'
+ );
+ $this->assertSame($before->status, $after->status);
+ $this->assertSame($before->used_at?->toDateTimeString(), $after->used_at?->toDateTimeString());
+ $this->assertSame($before->order_id, $after->order_id);
+ }
+
// ================================================================
// C-2. 환불 처리 검증 (5건)
// ================================================================
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/admin-crud-exception-status.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/admin-crud-exception-status.yaml
new file mode 100644
index 00000000..7c737dff
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/admin-crud-exception-status.yaml
@@ -0,0 +1,84 @@
+feature: 관리자 CRUD 의 예외 → 상태코드 매핑 (Admin CRUD Exception Status)
+
+description: |
+ 브랜드·쿠폰·배송사·상품 공통정보의 관리자 CRUD 가 실패했을 때, 그 원인이
+ "운영자가 고칠 수 있는 도메인 사유" 인지 "서버 결함·인프라 장애" 인지를
+ 응답 상태코드로 구분한다.
+
+ 배경:
+ 이 확장의 관리자 CRUD 는 `ResponseHelper::moduleError` 의 기본 상태코드가 400 이라,
+ 광역 catch 에서 상태코드 인자를 생략하거나 400 을 그대로 두면 인프라 장애가
+ "입력 오류" 로 위장된다. 운영자는 고칠 수 없는 안내를 보고, 장애는 늦게 발견된다.
+
+ 핵심 설계:
+ - typed 도메인 예외 → **기존 400 유지** (예외 승격이 사용자 계약을 함께 바꾸면 그것이 회귀다)
+ - 그 외 예외 → 500, 응답 본문에 예외 원문(SQLSTATE 등) 미포함
+
+ 소스 전수 계약(`tests/Feature/Http/GenericCatchStatusCodeContractTest.php`)은
+ "광역 catch 에 500 이라고 적혀 있다" 까지만 보증한다. 그 앞의 FormRequest·미들웨어가
+ 다른 응답을 만들 수 있으므로, 이 매니페스트가 담당하는 축은 **계약이 실제 HTTP 응답까지
+ 도달하는가** 이고 대표 엔드포인트의 런타임 매핑으로 확인한다.
+
+ 인프라 500 축은 서비스 mock 으로만 유발할 수 있어 PHPUnit 이 SSoT 다.
+ 브라우저에서는 도메인 사유나 검증이 먼저 걸려 재현되지 않는다.
+
+axes:
+ error_class: [domain, infrastructure]
+
+exclusions: []
+
+effects:
+ # 도메인 예외 — 기존 400 을 유지한다
+ - admin_brand_delete_domain_exception_keeps_400
+ - admin_brand_toggle_status_domain_exception_keeps_400
+ - admin_coupon_delete_domain_exception_keeps_400
+ - admin_shipping_carrier_toggle_status_domain_exception_keeps_400
+ - admin_product_common_info_delete_domain_exception_keeps_400
+ - admin_product_common_info_toggle_active_domain_exception_keeps_400
+ # 인프라 예외 — 500 으로 구분된다
+ - admin_brand_delete_infrastructure_exception_returns_500
+ - admin_brand_toggle_status_infrastructure_exception_returns_500
+ - admin_coupon_delete_infrastructure_exception_returns_500
+ - admin_shipping_carrier_toggle_status_infrastructure_exception_returns_500
+ - admin_product_common_info_delete_infrastructure_exception_returns_500
+ - admin_product_common_info_toggle_active_infrastructure_exception_returns_500
+ # 공통 — 응답 본문에 예외 원문이 실리지 않는다
+ - admin_crud_infrastructure_response_omits_exception_text
+
+scenarios:
+ - id: admin_crud_domain_failure_keeps_original_status
+ description: 도메인 사유로 실패하면 그 엔드포인트가 원래 쓰던 400 이 그대로 전달된다 (예외 typed 승격이 계약을 바꾸지 않았음)
+ effects:
+ - admin_brand_delete_domain_exception_keeps_400
+ - admin_brand_toggle_status_domain_exception_keeps_400
+ - admin_coupon_delete_domain_exception_keeps_400
+ - admin_shipping_carrier_toggle_status_domain_exception_keeps_400
+ - admin_product_common_info_delete_domain_exception_keeps_400
+ - admin_product_common_info_toggle_active_domain_exception_keeps_400
+
+ - id: admin_crud_infrastructure_failure_is_not_disguised_as_input_error
+ description: DB 장애 등 인프라 예외는 500 으로 구분되고 응답 본문에 예외 원문(SQLSTATE 등)이 포함되지 않는다
+ effects:
+ - admin_brand_delete_infrastructure_exception_returns_500
+ - admin_brand_toggle_status_infrastructure_exception_returns_500
+ - admin_coupon_delete_infrastructure_exception_returns_500
+ - admin_shipping_carrier_toggle_status_infrastructure_exception_returns_500
+ - admin_product_common_info_delete_infrastructure_exception_returns_500
+ - admin_product_common_info_toggle_active_infrastructure_exception_returns_500
+ - admin_crud_infrastructure_response_omits_exception_text
+
+test_files:
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/ExceptionStatusMappingTest.php
+
+notes: |
+ **엔드포인트를 axis 로 두지 않은 이유**: 대상 테스트는 DataProvider 로 6개 엔드포인트를
+ 한 메서드가 쓸어 담는다. `@scenario` 마커는 도크블록당 1개이고 축마다 값 1개만 받으므로,
+ 엔드포인트를 axis 로 세우면 cross product 6×2=12 칸 중 2칸만 마킹되어 나머지 10칸이
+ 영구 미커버로 남는다. 실제로 판정을 가르는 축은 `error_class` 하나이고,
+ 엔드포인트별 커버리지는 effects 12건이 1:1로 담는다.
+
+ 이 매니페스트는 5차 감사에서 신설됐다. `OrderDomainExceptionStatusPairTest` 와
+ `InquiryDomainExceptionStatusPairTest` 는 3차 감사에서 각각 전용 매니페스트를 받았는데,
+ 같은 라운드에 만들어진 이 파일만 `@scenario`/`@effects` 가 하나도 없고 어느 매니페스트
+ `test_files` 에도 등록되지 않은 채였다. effect 선언이 아예 없으면 test-scenario-coverage
+ 의 두 방향 검사(선언→테스트, 테스트→선언) 어느 쪽에도 걸리지 않아 조용히 통과한다.
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp02-order-cancellation.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp02-order-cancellation.yaml
index 5c8c833e..1c00228c 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp02-order-cancellation.yaml
+++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/mp02-order-cancellation.yaml
@@ -89,9 +89,19 @@ effects:
- refund_priority_radio_conditional # 환불 우선순위 라디오 조건부 노출
- pg_checkbox_conditional # PG 함께취소 체크박스 조건부 노출
- resource_exposes_requires_pg_cancellation # OrderPaymentResource requires_pg_cancellation
+ # 공개 #108 — 환불 예상(preview)은 조회만 수행한다
+ - preview_refund_issues_zero_write_queries_on_coupon_issues # 미리보기 중 coupon_issues 대상 UPDATE 0건
+ - preview_refund_keeps_coupon_issue_updated_at_unchanged # 왕복 UPDATE 로 인한 updated_at 오염 없음
+ - preview_refund_keeps_coupon_status_used_at_order_id_unchanged # status/used_at/order_id 불변
+ - partial_cancel_preview_reaches_recalculation_path_not_full_cancel_shortcut # 잔여 옵션이 있어야 재계산 경로 진입(전량취소 조기반환 아님)
+ - recalculation_never_reads_coupon_usage_state_in_snapshot_mode # 스냅샷 모드 재계산은 쿠폰 사용 상태를 읽지 않음
+ - coupon_restore_on_min_amount_shortfall_unchanged_after_reset_removal # 최소주문금액 미달 쿠폰 복원 동작 불변(회귀 가드)
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderCancellationServiceTest.php
+ - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderAdjustmentServiceTest.php
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/OrderCancellationControllerTest.php
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/OrderCancellationControllerTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/DTO/AdjustmentResultTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/OrderTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderRepositoryTest.php
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-action-exception-status.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-action-exception-status.yaml
new file mode 100644
index 00000000..31f305b7
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-action-exception-status.yaml
@@ -0,0 +1,78 @@
+feature: 주문 액션의 예외 → 상태코드 매핑 (Order Action Exception Status)
+
+description: |
+ 주문 취소·구매확정·재주문·무통장 입금확인과 쿠폰 다운로드가 실패했을 때, 그 원인이
+ "사용자·운영자가 고칠 수 있는 도메인 사유" 인지 "서버 결함·인프라 장애" 인지를
+ 응답 상태코드로 구분한다.
+
+ 배경:
+ 공개 #104 는 비회원 배송지 변경에서 모든 예외가 422 로 뭉개지는 것을 지적했지만,
+ 같은 형태가 주문 도메인의 다른 액션에도 그대로 있었다. 인프라 장애가 "배송 전
+ 상태에서만 변경 가능" 같은 도메인 안내로 위장되면 사용자는 고칠 수 없는 안내를
+ 보고, 운영자는 장애를 늦게 안다.
+
+ 핵심 설계:
+ - typed 도메인 예외 → **기존 상태코드 유지** (계약 변경 최소화)
+ · 취소/재주문/입금확인 = 422
+ · 쿠폰 다운로드 = 400 (원래 400이었고, 예외 승격이 사용자 계약을 함께
+ 바꾸면 그것이 회귀다)
+ - 그 외 예외 → 500, 응답 본문에 예외 원문 미포함
+ - 사유는 예외가 들고 다니는 키로 조립한다. 번역문을 메시지 키 자리에 넘기면
+ 해석에 실패해 원문이 그대로 노출된다.
+
+ 인프라 500 축은 서비스 mock 으로만 유발할 수 있어 PHPUnit 이 SSoT 다.
+ 브라우저에서는 도메인 사유가 먼저 걸려 재현되지 않는다.
+
+axes:
+ endpoint: [cancel, confirm_option, reorder, coupon_download, confirm_deposit]
+ error_class: [domain, infrastructure]
+
+exclusions:
+ - { endpoint: confirm_option, error_class: domain, reason: "OrderService::confirmOption 은 throw 지점이 0건이라 typed 도메인 예외가 존재하지 않는다. 도메인 축을 만들려면 없는 예외를 새로 만들어야 하므로 조합이 성립하지 않는다 (계획 §3 B그룹 — typed 예외가 아예 없어 4xx 에 걸리는 건 100% 인프라/코드버그)" }
+
+effects:
+ # 주문 취소
+ - order_cancel_domain_exception_returns_422
+ - order_cancel_infrastructure_exception_returns_500_not_422
+ # 구매확정 (도메인 축 없음 — exclusions 참조)
+ - order_confirm_option_infrastructure_exception_returns_500_not_422
+ # 재주문
+ - order_reorder_domain_exception_returns_422
+ - order_reorder_infrastructure_exception_returns_500_not_422
+ # 쿠폰 다운로드 — typed 라도 기존 400 을 유지한다
+ - coupon_download_domain_exception_returns_400_with_reason_key
+ - coupon_download_infrastructure_exception_returns_500_not_400
+ # 관리자 무통장 입금확인
+ - order_confirm_deposit_domain_exception_returns_422
+ - order_confirm_deposit_infrastructure_exception_returns_500_not_422
+
+scenarios:
+ - id: order_domain_failure_keeps_its_original_status
+ description: 도메인 사유로 실패하면 그 액션이 원래 쓰던 상태코드(취소·재주문·입금확인 422 / 쿠폰 다운로드 400)와 해석된 다국어 문구가 그대로 전달된다
+ effects:
+ - order_cancel_domain_exception_returns_422
+ - order_reorder_domain_exception_returns_422
+ - coupon_download_domain_exception_returns_400_with_reason_key
+ - order_confirm_deposit_domain_exception_returns_422
+
+ - id: order_infrastructure_failure_is_not_disguised_as_input_error
+ description: DB 장애 등 인프라 예외는 500 으로 구분되고 응답 본문에 예외 원문(SQLSTATE 등)이 포함되지 않는다
+ effects:
+ - order_cancel_infrastructure_exception_returns_500_not_422
+ - order_confirm_option_infrastructure_exception_returns_500_not_422
+ - order_reorder_infrastructure_exception_returns_500_not_422
+ - coupon_download_infrastructure_exception_returns_500_not_400
+ - order_confirm_deposit_infrastructure_exception_returns_500_not_422
+
+test_files:
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/OrderDomainExceptionStatusPairTest.php
+
+notes: |
+ 이 매니페스트는 감사에서 뒤늦게 신설됐다. 테스트에는 `@effects` 9건이 처음부터
+ 마킹돼 있었지만 어느 매니페스트에도 선언돼 있지 않았고, 그 상태로 검사를 통과했다 —
+ test-scenario-coverage 는 "선언된 effect 가 어느 테스트에든 언급되는가" 한 방향만
+ 보기 때문에, 선언이 아예 없는 effect 는 셀 대상 자체가 아니었다.
+
+ 파일은 `member-order-change-address.yaml` 의 test_files 에 등록돼 있었으나 그
+ 매니페스트가 다루는 것은 배송지 변경이고, 취소·재주문·입금확인·쿠폰 다운로드는
+ 그 시나리오의 축이 아니다. 등록을 이쪽으로 옮겼다.
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-inquiry-exception-status.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-inquiry-exception-status.yaml
new file mode 100644
index 00000000..efd66935
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-inquiry-exception-status.yaml
@@ -0,0 +1,81 @@
+feature: 상품 문의 액션의 예외 → 상태코드 매핑 (Product Inquiry Exception Status)
+
+description: |
+ 마이페이지의 문의 수정·삭제와 답변 등록·수정·삭제가 실패했을 때, 그 원인이
+ "사용자·운영자가 고칠 수 있는 도메인 사유" 인지 "서버 결함·인프라 장애" 인지를
+ 응답 상태코드로 구분한다.
+
+ 배경:
+ ProductInquiryService 의 도메인 실패 12곳을 ProductInquiryOperationException 으로
+ 승격하면서 Public/ProductInquiryController::store 만 typed catch 로 좁혔고,
+ User/ProductInquiryController 4곳은 `catch (\RuntimeException)` 을 유지했다.
+ 그 예외는 승격된 도메인 예외의 **부모**라서, 승격 후 그 catch 에 남는 것은
+ 인프라 예외뿐인데 그것까지 422 + 예외 원문으로 나갔다 — 공개 #104 가 없애려던
+ 형태 그 자체다.
+
+ 반대 방향의 짝도 있었다. destroy 는 deleteInquiry 가 던지는 도메인 예외를
+ 구분하는 catch 가 없어, 운영자가 고칠 수 있는 사유(문의 게시판 미설정)가
+ 일반 500 문구로 나갔다.
+
+ 핵심 설계:
+ - typed(ProductInquiryOperationException) → 422 + 해석된 다국어 사유
+ - 그 외 예외 → 500, 응답 본문에 예외 원문 미포함
+ - 사유는 예외가 들고 다니는 키(getMessageKey/getMessageParams)로 조립한다.
+ 번역문을 메시지 키 자리에 넘기면 해석에 실패해 원문이 그대로 노출된다.
+
+ 인프라 500 축은 서비스 mock 으로만 유발할 수 있어 PHPUnit 이 SSoT 다.
+ 브라우저로는 재현할 수 없다(도메인 사유가 먼저 걸린다).
+
+axes:
+ endpoint: [update_inquiry, destroy_inquiry, create_reply, update_reply, destroy_reply]
+ error_class: [domain, infrastructure]
+
+exclusions: []
+
+effects:
+ # 문의 수정
+ - inquiry_update_domain_exception_returns_422
+ - inquiry_update_infrastructure_exception_returns_500_not_422
+ # 문의 삭제 — 도메인 사유가 500 으로 나가던 역방향 짝
+ - inquiry_destroy_domain_exception_returns_422
+ - inquiry_destroy_infrastructure_exception_returns_500_not_422
+ # 답변 등록
+ - inquiry_reply_domain_exception_returns_422
+ - inquiry_reply_infrastructure_exception_returns_500_not_422
+ # 답변 수정
+ - inquiry_update_reply_domain_exception_returns_422
+ - inquiry_update_reply_infrastructure_exception_returns_500_not_422
+ # 답변 삭제
+ - inquiry_destroy_reply_domain_exception_returns_422
+ - inquiry_destroy_reply_infrastructure_exception_returns_500_not_422
+
+scenarios:
+ - id: inquiry_domain_failure_reaches_operator
+ description: 문의 게시판 미설정 상태에서 문의 수정·삭제와 답변 관리를 시도하면, 그 사유가 422 로 해석된 다국어 문구와 함께 전달된다 (키 원문 미노출)
+ effects:
+ - inquiry_update_domain_exception_returns_422
+ - inquiry_destroy_domain_exception_returns_422
+ - inquiry_reply_domain_exception_returns_422
+ - inquiry_update_reply_domain_exception_returns_422
+ - inquiry_destroy_reply_domain_exception_returns_422
+
+ - id: inquiry_infrastructure_failure_is_not_disguised_as_input_error
+ description: DB 장애 등 인프라 예외는 500 으로 구분되고 응답 본문에 예외 원문(SQLSTATE 등)이 포함되지 않는다
+ effects:
+ - inquiry_update_infrastructure_exception_returns_500_not_422
+ - inquiry_destroy_infrastructure_exception_returns_500_not_422
+ - inquiry_reply_infrastructure_exception_returns_500_not_422
+ - inquiry_update_reply_infrastructure_exception_returns_500_not_422
+ - inquiry_destroy_reply_infrastructure_exception_returns_500_not_422
+
+test_files:
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/InquiryDomainExceptionStatusPairTest.php
+
+notes: |
+ 소스 전수 판정(tests/Feature/Http/GenericCatchStatusCodeContractTest.php)은 광역 catch 가
+ 4xx 를 반환하지 않는 것까지만 보증한다. 그 계약이 실제 응답까지 도달하는지는
+ FormRequest·미들웨어를 통과해야 알 수 있으므로 이 매니페스트의 런타임 페어가 담당한다.
+
+ 그 판정기는 이 결함을 놓치고 있었다 — 광역 catch 를 \Exception/\Throwable 로만 정의해
+ \RuntimeException 을 통과시켰고, 상태코드 추출 정규식이 error($e->getMessage(), 422) 처럼
+ 첫 인자에 함수 호출이 있는 형태를 괄호 배제 때문에 읽지 못했다. 둘 다 수정했다.
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/settings-in-use-deletion-exception-status.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/settings-in-use-deletion-exception-status.yaml
new file mode 100644
index 00000000..5f507ab7
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/settings-in-use-deletion-exception-status.yaml
@@ -0,0 +1,60 @@
+feature: 사용 중인 배송유형·택배사 삭제의 예외 → 상태코드 매핑 (Settings In-Use Deletion Exception Status)
+
+description: |
+ 관리자 배송설정 화면은 항목을 지우는 전용 엔드포인트를 갖지 않는다 — 저장 payload
+ 에서 그 항목을 빼면 서비스가 삭제 대상으로 판정한다. 그런데 그 항목이 주문에서
+ 사용 중이면 서비스가 도메인 실패를 던지고, 컨트롤러가 그것을 광역 catch 로 받아
+ 500 "설정 저장 중 오류" 로 뭉개면 운영자는 **왜** 못 지우는지 알 수 없다.
+
+ 핵심 설계:
+ - 사용 중 사유 → **400 + 사유 문구**(사용 중인 이름·건수). 일반 저장 실패 문구로
+ 대체되지 않아야 하고, 삭제는 롤백되어야 한다.
+ - 그 외 예외 → 500, 응답 본문에 예외 원문(SQLSTATE 등) 미포함.
+
+ 배송유형과 택배사는 사용량 집계 호출 지점이 각각 `countByShippingType`·`countByCarrierId`
+ 로 다르다. 한쪽만 단언하면 나머지 경로의 뭉개기가 그대로 남으므로 `resource` 를 축으로
+ 세워 네 칸을 모두 덮는다.
+
+ 인프라 500 축은 Repository mock 으로만 유발할 수 있어 PHPUnit 이 SSoT 다.
+ 브라우저에서는 사용 중 사유가 먼저 걸려 재현되지 않는다.
+
+axes:
+ resource: [shipping_type, shipping_carrier]
+ error_class: [domain, infrastructure]
+
+exclusions: []
+
+effects:
+ # 도메인 사유 — 사유가 담긴 400, 롤백
+ - in_use_shipping_type_deletion_returns_400_with_reason
+ - in_use_shipping_carrier_deletion_returns_400_with_reason
+ # 인프라 예외 — 500 으로 구분, 원문 미노출
+ - settings_save_infrastructure_exception_returns_500
+ - settings_save_carrier_infrastructure_exception_returns_500
+
+scenarios:
+ - id: in_use_deletion_tells_the_operator_why
+ description: 사용 중인 항목을 지우려 하면 사용 중이라는 사유가 담긴 400 이 돌아오고, 일반 저장 실패 문구로 대체되지 않으며 삭제는 롤백된다
+ effects:
+ - in_use_shipping_type_deletion_returns_400_with_reason
+ - in_use_shipping_carrier_deletion_returns_400_with_reason
+
+ - id: settings_save_infrastructure_failure_is_not_disguised_as_input_error
+ description: 사용량 집계 중 인프라 예외가 나면 두 자원 경로 모두 500 으로 구분되고 응답 본문에 예외 원문이 실리지 않는다
+ effects:
+ - settings_save_infrastructure_exception_returns_500
+ - settings_save_carrier_infrastructure_exception_returns_500
+
+test_files:
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsInUseDeletionTest.php
+
+notes: |
+ 이 매니페스트는 5차 감사에서 신설됐다. 대상 테스트는 브랜치가 추가하면서 `@scenario`·
+ `@effects` 마커까지 붙였는데, 그 effect 이름을 선언한 매니페스트가 어디에도 없었다.
+ 선언이 아예 없는 effect 는 test-scenario-coverage 의 셀 대상이 되지 않아 조용히
+ 통과한다 — 3차 감사가 `OrderDomainExceptionStatusPairTest` 에서 지적한 것과 같은
+ 사각이 같은 브랜치 안에 하나 더 남아 있었다.
+
+ 같은 라운드에 `resource=shipping_carrier, error_class=infrastructure` 칸의 테스트도
+ 함께 추가했다. 그 칸을 exclusion 으로 처리하지 않은 것은, 두 자원의 집계 호출 지점이
+ 실제로 다르기 때문이다. 여기서 제외 사유를 적었다면 그 사유가 곧 거짓이 된다.
diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md
index 8cbd585c..3684f08e 100644
--- a/modules/_bundled/sirsoft-page/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-page/CHANGELOG.md
@@ -6,6 +6,10 @@
## [1.0.3] - 2026-08-13
+### Changed
+
+- 첨부 개수 상한 초과 안내 문구를 만드는 시점이 응답을 만드는 시점과 어긋나 있던 것을 맞췄습니다. 표시되는 문구와 상태는 종전과 동일합니다.
+
### Fixed
- 페이지 첨부 파일의 최대 개수·용량·허용 형식 설정이 저장해도 반영되지 않던 문제를 수정했습니다. 설정값을 읽어 오는 경로 자체가 없어 항상 내장 기본값이 쓰였습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
diff --git a/modules/_bundled/sirsoft-page/docs/api/attachments.md b/modules/_bundled/sirsoft-page/docs/api/attachments.md
index 683e0cd7..df216b2e 100644
--- a/modules/_bundled/sirsoft-page/docs/api/attachments.md
+++ b/modules/_bundled/sirsoft-page/docs/api/attachments.md
@@ -116,7 +116,7 @@ _단건 응답: FileUploader 컴포넌트 규약에 맞춰 `data.data` 객체에
**설명** 페이지 첨부파일을 업로드합니다. `file`(필수)과 함께 이미 저장된 페이지에 귀속시키려면 `page_id`, 아직 저장 전이면 `temp_key`(임시 귀속 후 페이지 저장 시 `store`/`update` 의 `temp_key` 로 확정)를 보냅니다. `collection` 으로 첨부 그룹을 구분합니다. 응답은 FileUploader 컴포넌트 규약(`data.data`)에 맞춰 `PageAttachmentResource` 를 201 로 반환합니다. 권한은 `pages.create` 를 요구합니다.
-업로드 정책(최대 개수 · 최대 용량 · 허용 형식)은 모듈 환경설정 `attachment.*` 가 SSoT 입니다. 용량은 `attachment.max_size_mb`(MB), 형식은 `attachment.allowed_types`(MIME 목록)로 판정하며, 형식 목록을 비우면 형식을 제한하지 않습니다(설정 키 자체가 없으면 모듈 기본 목록으로 폴백). 개수는 `attachment.max_count` 를 기준으로 **직접 업로드 · 임시 업로드(`temp_key`) 연결 · 이미 연결된 첨부** 를 합산해 판정하며, 초과 시 `422`(`errors.code = attachment_limit_exceeded`)를 반환합니다. 이 판정은 업로드 엔드포인트뿐 아니라 페이지 생성·수정 저장 시점에도 동일하게 적용됩니다. `max_count` 가 0 이면 개수를 제한하지 않습니다.
+업로드 정책(최대 개수 · 최대 용량 · 허용 형식)은 모듈 환경설정 `attachment.*` 가 SSoT 입니다. 용량은 `attachment.max_size_mb`(MB), 형식은 `attachment.allowed_types`(MIME 목록)로 판정하며, 형식 목록을 비우면 형식을 제한하지 않습니다(설정 키 자체가 없으면 모듈 기본 목록으로 폴백). 개수는 `attachment.max_count` 를 기준으로 **직접 업로드 · 임시 업로드(`temp_key`) 연결 · 이미 연결된 첨부** 를 합산해 판정하며, 초과 시 `422`(`errors.code = attachment_limit_exceeded`)를 반환하며, 안내 문구(`message`)는 허용 개수와 시도한 개수를 담아 응답 시점의 언어로 만들어집니다. 이 판정은 업로드 엔드포인트뿐 아니라 페이지 생성·수정 저장 시점에도 동일하게 적용됩니다. `max_count` 가 0 이면 개수를 제한하지 않습니다.
### PATCH /api/modules/sirsoft-page/admin/attachments/reorder
diff --git a/modules/_bundled/sirsoft-page/docs/api/pages.md b/modules/_bundled/sirsoft-page/docs/api/pages.md
index b82bacc2..4c8a12c4 100644
--- a/modules/_bundled/sirsoft-page/docs/api/pages.md
+++ b/modules/_bundled/sirsoft-page/docs/api/pages.md
@@ -270,7 +270,7 @@ HTTP/1.1 201
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.create`)이 없는 경우 |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 대표 케이스: `slug` 중복/형식 위반(소문자·숫자·하이픈만), `title` 누락 또는 기본 로케일 값 누락, `content_mode` 가 html/text 이외 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 대표 케이스: `slug` 중복/형식 위반(소문자·숫자·하이픈만), `title` 누락 또는 기본 로케일 값 누락, `content_mode` 가 html/text 이외. 첨부 개수 상한 초과 시에도 422 이며 `errors.code = attachment_limit_exceeded` 와 함께 허용/시도 개수를 담은 안내 문구가 응답 시점의 언어로 내려갑니다 |
| 500 | Internal Server Error | 생성 처리 중 예외 (`페이지 생성에 실패했습니다.`) |
@@ -715,7 +715,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-page.pages.update`)이 없거나, 스코프 밖 리소스인 경우 (`auth.scope_denied`) |
| 404 | Not Found | `{page}` ID 에 해당하는 페이지가 없는 경우 (`페이지를 찾을 수 없습니다.`) |
-| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 대표 케이스: `title` 누락, `slug` 중복(자기 자신은 제외)/형식 위반, `content_mode` 가 html/text 이외 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지). 대표 케이스: `title` 누락, `slug` 중복(자기 자신은 제외)/형식 위반, `content_mode` 가 html/text 이외. 첨부 개수 상한 초과 시에도 422 이며 `errors.code = attachment_limit_exceeded` 와 함께 허용/시도 개수를 담은 안내 문구가 응답 시점의 언어로 내려갑니다 |
| 500 | Internal Server Error | 수정 처리 중 예외 (`페이지 수정에 실패했습니다.`) |
diff --git a/modules/_bundled/sirsoft-page/src/Exceptions/AttachmentLimitExceededException.php b/modules/_bundled/sirsoft-page/src/Exceptions/AttachmentLimitExceededException.php
index b8e99363..385f2def 100644
--- a/modules/_bundled/sirsoft-page/src/Exceptions/AttachmentLimitExceededException.php
+++ b/modules/_bundled/sirsoft-page/src/Exceptions/AttachmentLimitExceededException.php
@@ -46,4 +46,27 @@ class AttachmentLimitExceededException extends Exception
{
return $this->attempted;
}
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * 응답을 만들 때 이미 번역된 `getMessage()` 대신 이 키를 넘긴다 — 번역문을 키로
+ * 해석하려 하면 실패해 원문이 그대로 노출된다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return 'sirsoft-page::messages.errors.attachment_limit_exceeded';
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return ['limit' => $this->limit, 'attempted' => $this->attempted];
+ }
}
diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php
index 7278af46..b1677264 100644
--- a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php
+++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageAttachmentController.php
@@ -53,7 +53,12 @@ class PageAttachmentController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
return $this->error('sirsoft-page::messages.attachment.upload_failed', 500, $e->getMessage());
}
diff --git a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php
index 1fe8879f..1cde65e8 100644
--- a/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php
+++ b/modules/_bundled/sirsoft-page/src/Http/Controllers/Admin/PageController.php
@@ -80,7 +80,12 @@ class PageController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (\Exception $e) {
return $this->error('sirsoft-page::messages.page.create_failed', 500, $e->getMessage());
}
@@ -131,7 +136,12 @@ class PageController extends AdminBaseController
);
} catch (AttachmentLimitExceededException $e) {
// 첨부 개수 상한 초과 — generic 500 이 아닌 422 명시 차단
- return $this->error($e->getMessage(), 422, ['code' => 'attachment_limit_exceeded']);
+ return $this->error(
+ $e->getMessageKey(),
+ 422,
+ ['code' => 'attachment_limit_exceeded'],
+ $e->getMessageParams()
+ );
} catch (ModelNotFoundException) {
return $this->notFound('sirsoft-page::messages.page.not_found');
} catch (AccessDeniedHttpException) {
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
index 6a6f33d6..33a960c5 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.1] - 2026-08-14
+
+### Fixed
+
+- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
+
## [1.1.0] - 2026-08-10
### Added
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/composer.json b/plugins/_bundled/sirsoft-pay_kginicis/composer.json
index 950d55fb..20bff2ba 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/composer.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/composer.json
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-pay_kginicis",
"description": "KG Inicis PG Plugin for G7 platform",
- "version": "1.1.0",
+ "version": "1.1.1",
"type": "library",
"authors": [
{
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md
index a75bb675..05529161 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md
+++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/payment.md
@@ -1296,4 +1296,16 @@ PC 결제로 발급한 가상계좌의 입금통보를 KG 이니시스 서버로
예시 시나리오: 구매자가 PC 결제창에서 가상계좌를 선택 → 계좌 발급 후 주문은 입금대기 → 구매자가 계좌 이체 → KG 이니시스가 이 주소로 입금통보 → 검증 통과 시 결제완료 전환 후 `OK` 반환.
+---
+## 결제 실패 리다이렉트 규약 (브라우저 콜백)
+
+결제창에서 돌아오는 브라우저 콜백은 JSON 응답이 아니라 상점 실패 페이지로 **리다이렉트**하며, 실패 사유를 쿼리스트링으로 전달합니다.
+
+| 쿼리 | 값 | 설명 |
+| --- | --- | --- |
+| `error` | 실패 코드 | 기계 판독용 고정 식별자 (`authorize_failed` · `approve_failed` 등). 화면 분기·문의 접수의 기준값 |
+| `message` | 안내 문구 | 구매자에게 보여 줄 다국어 문구. 상점 실패 페이지가 그대로 출력합니다 |
+| `orderId` | 주문번호 | 실패한 주문의 식별자 |
+
+`message` 에는 예외 원문(내부 오류 메시지·SQL 상태코드·클래스명·경로)을 싣지 않습니다. 이 값은 브라우저 주소창과 참조 로그에 남고 실패 페이지에 그대로 출력되므로, 내부 정보가 구매자와 중간 경유지에 노출됩니다. 원인 파악에 필요한 원문은 서버 로그(`Log::error`)에만 기록합니다.
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php b/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php
index efbea8fe..3c54a5e2 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/lang/en/messages.php
@@ -7,6 +7,7 @@ return [
'cash_receipt_issue_failed' => 'Failed to issue cash receipt.',
'order_not_found' => 'Order not found.',
'cbt_failed' => 'Failed to process the cross-border payment.',
+ 'payment_failed' => 'Payment could not be completed. Please try again in a moment.',
],
'cash_receipt' => [
'provider_name' => 'KG Inicis',
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php b/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php
index 16dd01e9..9a1099bd 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/lang/ko/messages.php
@@ -7,6 +7,7 @@ return [
'cash_receipt_issue_failed' => '현금영수증 발행에 실패했습니다.',
'order_not_found' => '주문을 찾을 수 없습니다.',
'cbt_failed' => '해외결제 처리에 실패했습니다.',
+ 'payment_failed' => '결제 처리에 실패했습니다. 잠시 후 다시 시도해 주세요.',
],
'cash_receipt' => [
'provider_name' => 'KG이니시스',
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json b/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json
index 32940ea4..25645d99 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-pay_kginicis",
- "version": "1.1.0",
+ "version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-pay_kginicis",
- "version": "1.1.0",
+ "version": "1.1.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/package.json b/plugins/_bundled/sirsoft-pay_kginicis/package.json
index 7458b87c..9823d096 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/package.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-pay_kginicis",
- "version": "1.1.0",
+ "version": "1.1.1",
"type": "module",
"private": true,
"scripts": {
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
index 319a95e6..f0e400de 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
+++ b/plugins/_bundled/sirsoft-pay_kginicis/plugin.json
@@ -5,7 +5,7 @@
"ko": "KG 이니시스",
"en": "KG Inicis"
},
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "MIT",
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis/blob/main/CHANGELOG.md",
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php
index 91ec975f..75344eb8 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/MobileCallbackController.php
@@ -319,7 +319,7 @@ class MobileCallbackController
return redirect($this->resolveFailUrl([
'error' => 'approve_failed',
- 'message' => $e->getMessage(),
+ 'message' => __('sirsoft-pay_kginicis::messages.errors.payment_failed'),
'orderId' => $moid,
]));
} finally {
diff --git a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php
index 8872d5ad..ed1db7da 100644
--- a/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php
+++ b/plugins/_bundled/sirsoft-pay_kginicis/src/Controllers/PaymentCallbackController.php
@@ -371,7 +371,7 @@ class PaymentCallbackController
return redirect($this->resolveFailUrl([
'error' => 'authorize_failed',
- 'message' => $e->getMessage(),
+ 'message' => __('sirsoft-pay_kginicis::messages.errors.payment_failed'),
'orderId' => $moid,
]));
} finally {
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
index d849affe..004a7539 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.2] - 2026-08-14
+
+### Fixed
+
+- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
+
## [1.0.1] - 2026-08-10
### Changed
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json b/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json
index 375d28ae..81920c80 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/composer.json
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-pay_nhnkcp",
"description": "NHN KCP PG Plugin for G7 platform",
- "version": "1.0.1",
+ "version": "1.0.2",
"type": "library",
"authors": [
{
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/vbank.md b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/vbank.md
index 9dd9011f..249c0599 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/vbank.md
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/vbank.md
@@ -30,3 +30,17 @@
이 IP 화이트리스트 검사는 코어의 확장 미들웨어 self-gate(`Plugin::getMiddleware()` 의 `targets` 로 위 두 통보 라우트명에만 정밀 타게팅)로 수행된다. 브라우저가 접속하는 결제 콜백(`payment.callback`)에는 적용되지 않는다 — 콜백은 정상 사용자의 브라우저에서 임의 IP 로 도달하므로 IP 로 제한하면 결제가 끊긴다. 검사 자체의 동작·허용 범위는 라우트 파일에서 직접 부착하던 이전 방식과 동일하다.
상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../../../../../docs/backend/middleware.md).
+
+---
+
+## 결제 실패 리다이렉트 규약 (브라우저 콜백)
+
+결제창에서 돌아오는 브라우저 콜백은 JSON 응답이 아니라 상점 실패 페이지로 **리다이렉트**하며, 실패 사유를 쿼리스트링으로 전달합니다.
+
+| 쿼리 | 값 | 설명 |
+| --- | --- | --- |
+| `error` | 실패 코드 | 기계 판독용 고정 식별자 (`authorize_failed` · `approve_failed` 등). 화면 분기·문의 접수의 기준값 |
+| `message` | 안내 문구 | 구매자에게 보여 줄 다국어 문구. 상점 실패 페이지가 그대로 출력합니다 |
+| `orderId` | 주문번호 | 실패한 주문의 식별자 |
+
+`message` 에는 예외 원문(내부 오류 메시지·SQL 상태코드·클래스명·경로)을 싣지 않습니다. 이 값은 브라우저 주소창과 참조 로그에 남고 실패 페이지에 그대로 출력되므로, 내부 정보가 구매자와 중간 경유지에 노출됩니다. 원인 파악에 필요한 원문은 서버 로그(`Log::error`)에만 기록합니다.
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php b/plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php
index 116e7b99..6f373b00 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/lang/en/messages.php
@@ -9,6 +9,7 @@ return [
'in_progress' => 'NHN KCP refund is already in progress.',
],
'errors' => [
+ 'payment_failed' => 'Payment could not be completed. Please try again in a moment.',
'wsdl_missing' => 'KCP WSDL file not found: :file',
'approval_key_error' => 'KCP approval key error [:code]: :message',
'soap_error' => 'KCP SOAP integration error: :message',
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php b/plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php
index 866a4515..e8594daf 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/lang/ko/messages.php
@@ -9,6 +9,7 @@ return [
'in_progress' => 'NHN KCP 환불이 이미 처리 중입니다.',
],
'errors' => [
+ 'payment_failed' => '결제 처리에 실패했습니다. 잠시 후 다시 시도해 주세요.',
'wsdl_missing' => 'KCP WSDL 파일이 없습니다: :file',
'approval_key_error' => 'KCP 승인키 오류 [:code]: :message',
'soap_error' => 'KCP SOAP 연동 오류: :message',
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json b/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json
index 53516502..c847ad60 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-pay_nhnkcp",
- "version": "1.0.1",
+ "version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-pay_nhnkcp",
- "version": "1.0.1",
+ "version": "1.0.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/package.json b/plugins/_bundled/sirsoft-pay_nhnkcp/package.json
index 9b8a119a..a10bc4a2 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/package.json
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-pay_nhnkcp",
- "version": "1.0.1",
+ "version": "1.0.2",
"type": "module",
"private": true,
"scripts": {
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json
index e84f7a9a..58774553 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/plugin.json
@@ -5,7 +5,7 @@
"ko": "NHN KCP",
"en": "NHN KCP"
},
- "version": "1.0.1",
+ "version": "1.0.2",
"license": "MIT",
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp/blob/main/CHANGELOG.md",
diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php
index 7200253c..0bbf2771 100644
--- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php
+++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php
@@ -429,7 +429,7 @@ class PaymentCallbackController
return redirect($this->resolveFailUrl([
'error' => 'confirm_failed',
- 'message' => $e->getMessage(),
+ 'message' => __('sirsoft-pay_nhnkcp::messages.errors.payment_failed'),
'orderId' => $ordrIdxx,
]));
} finally {
@@ -625,7 +625,7 @@ class PaymentCallbackController
return redirect($this->resolveFailUrl([
'error' => 'cli_exception',
- 'message' => $e->getMessage(),
+ 'message' => __('sirsoft-pay_nhnkcp::messages.errors.payment_failed'),
'orderId' => $ordrIdxx,
]));
}
@@ -745,7 +745,7 @@ class PaymentCallbackController
return redirect($this->resolveFailUrl([
'error' => 'vbank_save_failed',
- 'message' => $e->getMessage(),
+ 'message' => __('sirsoft-pay_nhnkcp::messages.errors.payment_failed'),
'orderId' => $ordrIdxx,
]));
}
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
index 4bb941ce..d11f610f 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.2] - 2026-08-14
+
+### Fixed
+
+- 관리자 주문 상세에서 에스크로 배송 등록이 실패하면 내부 오류 원문이 그대로 안내 문구 자리에 표시되던 문제를 수정했습니다. 이제 상황에 맞는 안내가 표시되고, 원인은 서버 로그에만 기록됩니다.
+
## [1.0.1] - 2026-08-10
### Changed
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/composer.json b/plugins/_bundled/sirsoft-pay_nicepayments/composer.json
index 18a0835b..6e0090a6 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/composer.json
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/composer.json
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-pay_nicepayments",
"description": "NicePayments PG Plugin for G7 platform",
- "version": "1.0.1",
+ "version": "1.0.2",
"type": "library",
"authors": [
{
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md
index 10f6dbff..077ca0f8 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md
@@ -5,3 +5,4 @@
| 문서 | 도메인 | 설명 |
| --- | --- | --- |
| [vbank.md](vbank.md) | `payment` | 가상계좌 입금통보 수신 경로와 발신 서버(IP) 확인 |
+| [escrow.md](escrow.md) | `order` | 관리자 에스크로 결제 조회·배송 등록 (JSON API) |
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/escrow.md b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/escrow.md
new file mode 100644
index 00000000..99c4020b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/docs/api/escrow.md
@@ -0,0 +1,119 @@
+# 에스크로 API 레퍼런스
+
+> **소유**: 플러그인 `sirsoft-pay_nicepayments`. 관리자 주문 상세의 에스크로 배송 등록 화면이 쓰는 JSON API 표면이다. 결제대행사 서버가 직접 호출하는 통보 경로([vbank.md](vbank.md))와는 인증·접근 제어가 다르다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 에스크로(EscrowYN=Y) 결제 건의 TID/상태를 조회하고, 배송 정보를 나이스페이먼츠에 등록한다
+2. 두 경로 모두 Bearer 토큰 + admin 미들웨어 + 이커머스 주문 권한이 필요하다
+3. 배송 등록이 성공하면 구매자에게 구매확정 안내가 결제대행사를 통해 자동 발송된다
+4. 결제대행사 호출 실패는 502 로 구분된다 — 응답 본문에 예외 원문을 싣지 않는다
+5. 대상 TID 를 찾지 못하면 422 (등록 가능한 에스크로 결제가 아님)
+```
+
+---
+
+## 공통 규약
+
+| 항목 | 값 |
+| --- | --- |
+| 인증 | `Authorization: Bearer {token}` (`auth:sanctum` + `admin`) |
+| 응답 봉투 | 코어 `ResponseHelper` 표준 (`success` / `message` / `data`) |
+| 권한 | 조회 `sirsoft-ecommerce.orders.read` · 등록 `sirsoft-ecommerce.orders.update` |
+
+---
+
+## 에스크로 결제 목록 조회
+
+| 항목 | 값 |
+| --- | --- |
+| 라우트명 | `api.plugins.sirsoft-pay_nicepayments.admin.orders.escrow-payments` |
+| 메서드/URI | `GET /api/plugins/sirsoft-pay_nicepayments/admin/orders/{orderNumber}/escrow-payments` |
+| 권한 | `sirsoft-ecommerce.orders.read` |
+
+**경로 파라미터**
+
+| 이름 | 타입 | 필수 | 설명 |
+| --- | --- | --- | --- |
+| `orderNumber` | string | 예 | 주문번호 |
+
+**응답 필드** (`data.escrow_payments[]`)
+
+| 필드 | 타입 | 설명 |
+| --- | --- | --- |
+| `id` | integer | 결제 레코드 ID |
+| `transaction_id` | string | 나이스페이먼츠 TID |
+| `payment_method` | string | 결제수단 (가상계좌·카드 등) |
+| `payment_status` | string | 결제 상태 |
+
+해당 주문에 에스크로 결제가 없으면 `escrow_payments` 는 빈 배열이다.
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "요청이 성공했습니다.",
+ "data": {
+ "escrow_payments": [
+ {
+ "id": 812,
+ "transaction_id": "nicepay20260814012345",
+ "payment_method": "vbank",
+ "payment_status": "paid"
+ }
+ ]
+ }
+}
+```
+
+---
+
+## 에스크로 배송 등록
+
+| 항목 | 값 |
+| --- | --- |
+| 라우트명 | `api.plugins.sirsoft-pay_nicepayments.admin.escrow.register-delivery` |
+| 메서드/URI | `POST /api/plugins/sirsoft-pay_nicepayments/admin/escrow/register-delivery` |
+| 권한 | `sirsoft-ecommerce.orders.update` |
+
+**요청 파라미터**
+
+| 이름 | 타입 | 필수 | 제약 | 설명 |
+| --- | --- | --- | --- | --- |
+| `tid` | string | 예 | — | 대상 에스크로 결제의 나이스페이먼츠 TID |
+| `delivery_name` | string | 예 | 최대 100자 | 택배사명 |
+| `tracking_number` | string | 예 | 최대 100자 | 송장번호 |
+| `buyer_address` | string | 예 | 최대 200자 | 구매자 배송지 |
+| `register_name` | string | 예 | 최대 50자 | 등록자명 |
+
+**동작**
+
+나이스페이먼츠에 배송 정보를 등록한다. 등록이 완료되면 구매자에게 구매확정 안내가 결제대행사를 통해 자동 발송된다.
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "요청이 성공했습니다.",
+ "data": {
+ "ResultCode": "0000",
+ "ResultMsg": "정상처리"
+ }
+}
+```
+
+**에러**
+
+| 상태코드 | 사유 | 응답 |
+| --- | --- | --- |
+| 401 | 토큰 누락·만료 | 코어 표준 |
+| 403 | `sirsoft-ecommerce.orders.update` 권한 없음 | 코어 표준 |
+| 422 | 요청 파라미터 검증 위반, 또는 해당 TID 로 등록 가능한 에스크로 결제를 찾지 못함 | `error.errors.message` 에 사유 |
+| 502 | 결제대행사 호출 실패 (네트워크·응답 파싱·자격증명 오류 등) | `common.failed` |
+
+502 응답 본문에는 예외 원문을 싣지 않는다 — 원인은 서버 로그(`NicePayments: escrow delivery registration failed`)에만 기록된다. 이미 번역된 예외 메시지를 응답의 메시지 키 자리에 넘기면 키 해석에 실패해 내부 오류 원문이 그대로 관리자 화면에 노출되기 때문이다. 상세: [docs/backend/exceptions.md "예외 → 응답 매핑"](../../../../../docs/backend/exceptions.md).
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json b/plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json
index 6d22766b..fee0d3b2 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-pay_nicepayments",
- "version": "1.0.1",
+ "version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-pay_nicepayments",
- "version": "1.0.1",
+ "version": "1.0.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/package.json b/plugins/_bundled/sirsoft-pay_nicepayments/package.json
index b62e7045..65d607bd 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/package.json
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-pay_nicepayments",
- "version": "1.0.1",
+ "version": "1.0.2",
"type": "module",
"private": true,
"scripts": {
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json b/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json
index f5e1e019..e8ac9da2 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/plugin.json
@@ -5,7 +5,7 @@
"ko": "나이스페이먼츠",
"en": "NicePayments"
},
- "version": "1.0.1",
+ "version": "1.0.2",
"license": "MIT",
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nicepayments",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nicepayments/blob/main/CHANGELOG.md",
diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php
index bb42dc48..950154a1 100644
--- a/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php
+++ b/plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php
@@ -105,7 +105,14 @@ class AdminEscrowController extends AdminBaseController
return ResponseHelper::success('common.success', $result);
} catch (\Exception $e) {
- return ResponseHelper::error($e->getMessage(), 502, null);
+ // 원문은 로그로만 — 응답 메시지 키 자리에 넘기면 키 해석에 실패해
+ // 예외 원문(스택 힌트 포함)이 그대로 관리자 화면에 나간다.
+ Log::error('NicePayments: escrow delivery registration failed', [
+ 'tid' => $validated['tid'],
+ 'error' => $e->getMessage(),
+ ]);
+
+ return ResponseHelper::error('common.failed', 502, null);
}
}
diff --git a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md
index 18e77490..59a37ee0 100644
--- a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md
+++ b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md
@@ -9,6 +9,7 @@
### Fixed
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
+- 결제 실패 시 이동하는 페이지 주소에 서버 내부 오류 원문이 그대로 실려 나가던 문제를 수정했습니다. 이제 안내 문구만 전달되며, 원인 파악에 필요한 원문은 서버 로그에만 기록됩니다.
## [1.0.1] - 2026-08-11
diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php b/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php
index 6a8fdb3d..1b924384 100644
--- a/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php
+++ b/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php
@@ -4,6 +4,9 @@
* TossPayments Plugin Messages (English)
*/
return [
+ 'errors' => [
+ 'payment_failed' => 'Payment could not be completed. Please try again in a moment.',
+ ],
'refund' => [
'missing_payment_key' => 'Cannot process refund: TossPayments payment key not found.',
'default_reason' => 'Cancelled by customer request',
diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php b/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php
index 2accaebf..420e1b52 100644
--- a/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php
+++ b/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php
@@ -4,6 +4,9 @@
* 토스페이먼츠 플러그인 메시지 (Korean)
*/
return [
+ 'errors' => [
+ 'payment_failed' => '결제 처리에 실패했습니다. 잠시 후 다시 시도해 주세요.',
+ ],
'refund' => [
'missing_payment_key' => '토스페이먼츠 결제 키가 존재하지 않아 환불 처리할 수 없습니다.',
'default_reason' => '고객 요청에 의한 취소',
diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php
index 7589540e..e41f44a0 100644
--- a/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php
+++ b/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php
@@ -148,7 +148,7 @@ class PaymentCallbackController
return redirect($this->resolveFailUrl([
'error' => 'confirm_failed',
- 'message' => $e->getMessage(),
+ 'message' => __('sirsoft-tosspayments::messages.errors.payment_failed'),
'orderId' => $orderId,
]));
}
diff --git a/tests/Feature/Api/Admin/LanguagePackControllerTest.php b/tests/Feature/Api/Admin/LanguagePackControllerTest.php
index cdd41ff5..899d4af2 100644
--- a/tests/Feature/Api/Admin/LanguagePackControllerTest.php
+++ b/tests/Feature/Api/Admin/LanguagePackControllerTest.php
@@ -8,6 +8,7 @@ use App\Models\LanguagePack;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
+use App\Services\LanguagePackService;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
@@ -889,4 +890,63 @@ class LanguagePackControllerTest extends TestCase
]],
];
}
+
+ /**
+ * 실패 응답에 예외 원문이 실리지 않는다.
+ *
+ * 종전에는 `$e->getMessage()` 를 응답 errors/messageParams 로 그대로 넘겨,
+ * 검증 실패 메시지에 내부 예외 문자열(경로·드라이버 오류 등)이 노출됐다.
+ * 디버그 노출은 ResponseHelper 가 app.debug 에서만 Throwable 을 펼치는
+ * 기존 메커니즘에 위임한다.
+ *
+ * @scenario endpoint=manifest_preview
+ *
+ * @effects language_pack_manifest_preview_failure_omits_exception_text
+ */
+ public function test_manifest_preview_failure_does_not_leak_exception_message(): void
+ {
+ config(['app.debug' => false]);
+
+ Sanctum::actingAs($this->manager);
+
+ // ZIP 이 아닌 파일 → previewManifest 내부에서 예외 발생
+ $response = $this->postJson('/api/admin/language-packs/manifest-preview', [
+ 'file' => UploadedFile::fake()->createWithContent('broken.zip', 'not-a-zip-archive'),
+ ]);
+
+ $response->assertStatus(422);
+
+ $body = $response->getContent();
+ foreach (['ZipArchive', 'SQLSTATE', '.php', 'vendor/', 'Exception'] as $needle) {
+ $this->assertStringNotContainsString(
+ $needle,
+ $body,
+ "실패 응답에 내부 예외 흔적('{$needle}')이 포함되어 있습니다: {$body}"
+ );
+ }
+ }
+
+ /**
+ * 캐시 갱신 실패 응답에도 예외 원문이 실리지 않는다 (500 축).
+ *
+ * @scenario endpoint=refresh_cache
+ *
+ * @effects language_pack_refresh_cache_failure_omits_exception_text
+ */
+ public function test_refresh_cache_failure_does_not_leak_exception_message(): void
+ {
+ config(['app.debug' => false]);
+
+ $this->mock(LanguagePackService::class, function ($mock) {
+ $mock->shouldReceive('refreshCache')
+ ->andThrow(new \RuntimeException('SQLSTATE[HY000]: internal detail'));
+ });
+
+ Sanctum::actingAs($this->manager);
+
+ $response = $this->postJson('/api/admin/language-packs/refresh-cache');
+
+ $response->assertStatus(500);
+ $this->assertStringNotContainsString('SQLSTATE', $response->getContent());
+ }
}
diff --git a/tests/Feature/Http/GenericCatchStatusCodeContractTest.php b/tests/Feature/Http/GenericCatchStatusCodeContractTest.php
new file mode 100644
index 00000000..5c7474a3
--- /dev/null
+++ b/tests/Feature/Http/GenericCatchStatusCodeContractTest.php
@@ -0,0 +1,380 @@
+
+ */
+ private const INTENTIONAL_4XX = [
+ // 업로드된 zip 의 manifest 해석 실패는 사용자가 올린 파일의 문제다 —
+ // 미리보기 단계이므로 설치 부작용이 없고, 운영자는 파일을 고쳐 다시 올리면 된다.
+ 'app/Http/Controllers/Api/Admin/LanguagePackController.php::manifestPreview' => '업로드 파일 해석 실패 = 클라이언트 입력 오류',
+ 'app/Http/Controllers/Api/Admin/ModuleController.php::manifestPreview' => '업로드 파일 해석 실패 = 클라이언트 입력 오류',
+ 'app/Http/Controllers/Api/Admin/PluginController.php::manifestPreview' => '업로드 파일 해석 실패 = 클라이언트 입력 오류',
+ 'app/Http/Controllers/Api/Admin/TemplateController.php::manifestPreview' => '업로드 파일 해석 실패 = 클라이언트 입력 오류',
+ // 카테고리 이미지 서빙은 요청 파라미터 기반 파일 해석(검증성 로직) 비중이 크다.
+ 'modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php::downloadImage' => '검증성 로직(요청 파라미터 기반 파일 해석) 비중이 큼',
+ 'modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/CategoryImageController.php::download' => '검증성 로직(요청 파라미터 기반 파일 해석) 비중이 큼',
+ ];
+
+ /**
+ * 예외 원문을 메시지 키로 넘기는 것이 아직 남아 있는 지점.
+ *
+ * 코어 확장 설치 경로는 `\RuntimeException(__('key', [...]))` 형태로 서비스·헬퍼
+ * 40여 곳에서 던져진다. 키를 들고 다니는 예외로 승격하려면 설치/업데이트 경로 전체를
+ * 건드려야 하므로 별도 작업으로 분리한다 — 여기 남겨 두는 이유는 "판정기가 못 봐서
+ * 통과한 것" 과 "알고 남긴 것" 을 구분하기 위해서다. 목록이 늘어나면 실패한다.
+ *
+ * @var array
+ */
+ private const KNOWN_EXCEPTION_TEXT_AS_KEY = [
+ 'app/Http/Controllers/Api/Admin/ModuleController.php:437',
+ 'app/Http/Controllers/Api/Admin/ModuleController.php:461',
+ 'app/Http/Controllers/Api/Admin/PluginController.php:448',
+ 'app/Http/Controllers/Api/Admin/PluginController.php:472',
+ 'app/Http/Controllers/Api/Admin/TemplateController.php:352',
+ 'app/Http/Controllers/Api/Admin/TemplateController.php:376',
+ ];
+
+ /**
+ * 광역 `\RuntimeException` catch 가 4xx 를 반환하는 것이 아직 남아 있는 지점.
+ *
+ * `\RuntimeException` 은 도메인 예외의 부모가 되기 쉬워, 도메인 실패를 typed 로
+ * 승격한 뒤에도 이 catch 를 남겨 두면 남는 것은 인프라 예외뿐인데 그것까지 4xx 로
+ * 뭉갠다. 위 `KNOWN_EXCEPTION_TEXT_AS_KEY` 와 같은 줄이며, 같은 설치 경로 개편에서
+ * 함께 해소된다.
+ *
+ * @var array
+ */
+ private const KNOWN_BROAD_RUNTIME_CATCH_4XX = [
+ 'app/Http/Controllers/Api/Admin/ModuleController.php::installFromFile' => '설치 경로가 도메인 실패를 키 없는 RuntimeException 으로 던진다 — 예외 승격과 함께 해소',
+ 'app/Http/Controllers/Api/Admin/ModuleController.php::installFromGithub' => '동일',
+ 'app/Http/Controllers/Api/Admin/PluginController.php::installFromFile' => '동일',
+ 'app/Http/Controllers/Api/Admin/PluginController.php::installFromGithub' => '동일',
+ 'app/Http/Controllers/Api/Admin/TemplateController.php::installFromFile' => '동일',
+ 'app/Http/Controllers/Api/Admin/TemplateController.php::installFromGithub' => '동일',
+ ];
+
+ /**
+ * 스캔 대상 컨트롤러 루트 목록 (코어 + 번들 모듈/플러그인).
+ *
+ * @return array 절대 경로 목록
+ */
+ private function controllerRoots(): array
+ {
+ $roots = [base_path('app/Http/Controllers')];
+
+ // 컨트롤러 위치는 확장마다 규약이 갈린다 — 모듈·일부 플러그인은
+ // `src/Http/Controllers`, PG 플러그인 4종은 `src/Controllers` 를 쓴다.
+ // 한 규약만 보면 다른 규약을 쓰는 확장이 통째로 사각이 된다(실제로 PG
+ // 플러그인 44파일이 그랬고, 그 안에 위반 1건이 살아 있었다).
+ $conventions = ['src/Http/Controllers', 'src/Controllers'];
+
+ foreach (['modules', 'plugins'] as $kind) {
+ $bundled = base_path($kind.'/_bundled');
+ if (! is_dir($bundled)) {
+ continue;
+ }
+ foreach (scandir($bundled) as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ foreach ($conventions as $convention) {
+ $dir = $bundled.'/'.$entry.'/'.$convention;
+ if (is_dir($dir)) {
+ $roots[] = $dir;
+ }
+ }
+ }
+ }
+
+ return $roots;
+ }
+
+ /**
+ * 루트 아래 PHP 파일을 저장소 상대 경로로 수집합니다.
+ *
+ * @return array
+ */
+ private function collectFiles(): array
+ {
+ $files = [];
+ $prefix = str_replace('\\', '/', base_path()).'/';
+
+ foreach ($this->controllerRoots() as $root) {
+ $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root));
+ foreach ($it as $f) {
+ if (! $f->isFile() || ! str_ends_with($f->getFilename(), '.php')) {
+ continue;
+ }
+ $full = str_replace('\\', '/', $f->getPathname());
+ $files[] = ['path' => $f->getPathname(), 'rel' => str_replace($prefix, '', $full)];
+ }
+ }
+
+ usort($files, fn ($a, $b) => strcmp($a['rel'], $b['rel']));
+
+ return $files;
+ }
+
+ /**
+ * generic catch 블록의 응답 상태코드를 추출합니다.
+ *
+ * @return array
+ */
+ private function collectGenericCatchStatuses(): array
+ {
+ $found = [];
+
+ foreach ($this->collectFiles() as $file) {
+ $lines = file($file['path'], FILE_IGNORE_NEW_LINES);
+ $method = '?';
+
+ foreach ($lines as $i => $line) {
+ if (preg_match('/public function (\w+)\(/', $line, $m)) {
+ $method = $m[1];
+ }
+
+ // 광역 catch: \Exception / \Throwable / \RuntimeException 단독
+ //
+ // `\RuntimeException` 을 포함하는 이유는 그것이 도메인 예외의 부모가 되기
+ // 때문이다 — 서비스가 도메인 실패를 typed 로 승격하고 나면 이 catch 에
+ // 남는 것은 인프라 예외뿐인데, 4xx 를 돌려주면 그대로 위장된다. 실제로
+ // `User/ProductInquiryController` 4곳이 이 사각에 있었다.
+ if (! preg_match('/\}\s*catch\s*\(\s*\\\\?(Exception|Throwable|RuntimeException)\s*(\$\w+)?\s*\)/', $line)) {
+ continue;
+ }
+
+ $status = null;
+ for ($j = $i + 1; $j < min($i + 25, count($lines)); $j++) {
+ if (preg_match('/\}\s*catch\s*\(/', $lines[$j])) {
+ break;
+ }
+ if (preg_match('/^\s*(\d{3}),?\s*$/', $lines[$j], $sm)) {
+ $status = (int) $sm[1];
+ break;
+ }
+ // ->error('key', 404) 처럼 한 줄에 쓰인 형태.
+ // 첫 인자에 함수 호출이 올 수 있으므로(`error($e->getMessage(), 422)`)
+ // 괄호를 배제하지 않는다 — 배제하면 그 형태를 통째로 놓친다.
+ if (preg_match('/(?:error|moduleError)\(.*?,\s*(\d{3})\s*[,)]/', $lines[$j], $sm)) {
+ $status = (int) $sm[1];
+ break;
+ }
+ // 상태코드 인자를 아예 생략한 한 줄 호출 — ResponseHelper 기본값 400 이 적용된다.
+ // 숫자만 찾는 판정은 이 형태를 통째로 놓친다 (실제 사각이었다: WishlistController).
+ if (preg_match("/moduleError\(\s*'[^']+',\s*'[^']+'\s*\)/", $lines[$j])
+ || preg_match("/[^e]error\(\s*'[^']+'\s*\)/", $lines[$j])) {
+ $status = 400;
+ break;
+ }
+ }
+
+ if ($status !== null) {
+ $found[] = ['file' => $file['rel'], 'method' => $method, 'line' => $i + 1, 'status' => $status];
+ }
+ }
+ }
+
+ return $found;
+ }
+
+ /**
+ * 판정기가 실제로 코어와 확장 양쪽을 보고 있는지 먼저 확인한다.
+ *
+ * 모집단이 비거나 한쪽으로 쏠리면 이후 단언은 초록이어도 아무것도 증명하지 못한다.
+ */
+ public function test_scanner_population_covers_core_and_bundled_extensions(): void
+ {
+ $files = $this->collectFiles();
+ $rels = array_column($files, 'rel');
+
+ $this->assertNotEmpty($rels, '컨트롤러 스캔 결과가 비어 있습니다 — 판정기가 대상을 못 찾고 있습니다.');
+
+ $core = array_filter($rels, fn ($r) => str_starts_with($r, 'app/Http/Controllers/'));
+ $ext = array_filter($rels, fn ($r) => str_starts_with($r, 'modules/_bundled/') || str_starts_with($r, 'plugins/_bundled/'));
+
+ $this->assertNotEmpty($core, '코어 컨트롤러가 모집단에 없습니다.');
+ $this->assertNotEmpty($ext, '번들 확장 컨트롤러가 모집단에 없습니다.');
+
+ // 이 브랜치가 계약을 세운 세 영역이 모두 잡혀야 한다
+ $this->assertContains('modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Public/OrderController.php', $rels);
+ $this->assertContains('modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/BoardTypeController.php', $rels);
+ $this->assertContains('app/Http/Controllers/Api/Admin/LanguagePackController.php', $rels);
+
+ // 두 컨트롤러 규약이 모두 잡혀야 한다. `src/Http/Controllers` 앵커만 두면
+ // `src/Controllers` 를 쓰는 확장이 빠져도 이 테스트가 초록으로 남는다.
+ $this->assertContains('plugins/_bundled/sirsoft-pay_nicepayments/src/Controllers/AdminEscrowController.php', $rels);
+
+ // 규약별 확장 수를 세어, 어느 한쪽 규약이 통째로 빠지는 회귀를 막는다.
+ $byConvention = [];
+ foreach ($rels as $rel) {
+ if (preg_match('#^(modules|plugins)/_bundled/([^/]+)/(src/Http/Controllers|src/Controllers)/#', $rel, $m)) {
+ $byConvention[$m[3]][$m[2]] = true;
+ }
+ }
+
+ $this->assertArrayHasKey('src/Http/Controllers', $byConvention, '`src/Http/Controllers` 규약 확장이 모집단에 없습니다.');
+ $this->assertArrayHasKey('src/Controllers', $byConvention, '`src/Controllers` 규약 확장이 모집단에 없습니다 — PG 플러그인이 사각이 됩니다.');
+ }
+
+ /**
+ * generic catch 는 5xx 를 반환해야 한다 (의도적 예외는 상수에 명시).
+ *
+ * @scenario actor=member, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects update_shipping_address_infrastructure_exception_returns_500_not_422, guest_confirm_option_has_no_domain_exception_so_generic_catch_returns_500
+ */
+ public function test_generic_catch_blocks_never_return_4xx(): void
+ {
+ $sites = $this->collectGenericCatchStatuses();
+
+ $this->assertNotEmpty($sites, 'generic catch 를 하나도 찾지 못했습니다 — 판정기 정규식을 확인하세요.');
+
+ $declared = self::INTENTIONAL_4XX + self::KNOWN_BROAD_RUNTIME_CATCH_4XX;
+
+ $violations = [];
+ $hit = [];
+ foreach ($sites as $s) {
+ if ($s['status'] < 400 || $s['status'] >= 500) {
+ continue;
+ }
+ $key = $s['file'].'::'.$s['method'];
+ if (array_key_exists($key, $declared)) {
+ $hit[$key] = true;
+
+ continue;
+ }
+ $violations[] = "{$s['file']}:{$s['line']} ({$s['method']}) -> {$s['status']}";
+ }
+
+ $this->assertSame(
+ [],
+ $violations,
+ "광역 catch 가 4xx 를 반환합니다 (인프라 장애가 입력 오류로 위장됨):\n".implode("\n", $violations)
+ );
+
+ // 면제 목록도 모집단이다 — 해소된 지점이 남아 있으면 "알고 남긴 것" 과
+ // "이미 고쳐진 것" 이 구분되지 않아, 다음 사람이 그 줄을 근거로 삼는다.
+ // `KNOWN_EXCEPTION_TEXT_AS_KEY` 는 이 검사를 갖고 있었는데 4xx 축에는 없었다.
+ $stale = array_values(array_diff(array_keys($declared), array_keys($hit)));
+
+ $this->assertSame(
+ [],
+ $stale,
+ "면제 목록에 이미 해소된 항목이 남아 있습니다 (목록을 줄이세요):\n".implode("\n", $stale)
+ );
+ }
+
+ /**
+ * `$e->getMessage(),` 한 줄이 응답 헬퍼의 **메시지 키 자리**인지 판정한다.
+ *
+ * 여는 호출을 위로 되짚어 `error(` / `moduleError(` 를 찾고, 그 사이에 놓인
+ * 인자 줄 수로 위치를 센다. `error()` 는 0번째, `moduleError()` 는 확장 식별자
+ * 다음인 1번째가 메시지 키다. 그 밖의 호출(도메인 서비스·리다이렉트 헬퍼)은
+ * 아무리 같은 모양이어도 응답 키가 아니므로 위반이 아니다.
+ *
+ * @param array $lines 파일 전체 줄
+ * @param int $index `$e->getMessage(),` 가 있는 줄의 0-based 인덱스
+ * @return bool 메시지 키 자리면 true
+ */
+ private function isResponseMessageKeyPosition(array $lines, int $index): bool
+ {
+ $argLinesBefore = 0;
+
+ // 여는 줄은 멀지 않다 — 인자 6개를 넘겨 쓰는 응답 헬퍼는 없다.
+ for ($j = $index - 1; $j >= 0 && $j >= $index - 7; $j--) {
+ $candidate = trim($lines[$j]);
+
+ if ($candidate === '') {
+ continue;
+ }
+
+ // 여는 호출 발견 — 위치로 판정한다.
+ if (preg_match('/(?:ResponseHelper::)?(?:->)?\b(error|moduleError)\($/', $candidate, $m)) {
+ $keyPosition = $m[1] === 'moduleError' ? 1 : 0;
+
+ return $argLinesBefore === $keyPosition;
+ }
+
+ // 여는 줄에 도달하기 전의 다른 호출/블록 경계면 응답 헬퍼가 아니다.
+ if (str_ends_with($candidate, '(') || str_ends_with($candidate, '{') || str_ends_with($candidate, ';')) {
+ return false;
+ }
+
+ $argLinesBefore++;
+ }
+
+ return false;
+ }
+
+ /**
+ * 예외 메시지 원문을 응답 메시지 키 자리에 넘기지 않는다.
+ *
+ * `error($e->getMessage(), 422)` 는 이미 번역된 문장을 키로 해석하려다 실패해
+ * 원문을 그대로 노출한다.
+ *
+ * @scenario actor=member, change_mode=manual, address_region=domestic, e2e_browser=chromium
+ *
+ * @effects update_shipping_address_500_response_excludes_exception_message, guest_action_5xx_responses_exclude_exception_message
+ */
+ public function test_exception_message_is_not_passed_as_response_message_key(): void
+ {
+ $violations = [];
+
+ foreach ($this->collectFiles() as $file) {
+ $lines = file($file['path'], FILE_IGNORE_NEW_LINES);
+
+ foreach ($lines as $i => $line) {
+ // 한 줄 형태: error($e->getMessage(), 422) / moduleError('mod', $e->getMessage(), …)
+ if (preg_match('/(?:error|moduleError)\((?:[^,]+,\s*)?\$e->getMessage\(\)/', $line)) {
+ $violations[] = $file['rel'].':'.($i + 1);
+
+ continue;
+ }
+
+ // 여러 줄 형태: 인자가 줄마다 놓인 호출에서 `$e->getMessage(),` 만 있는 줄.
+ // 이 모양 자체는 응답과 무관한 곳에도 흔하다 — 도메인 서비스 호출
+ // (`failPayment($order, 'CODE', $e->getMessage())`)이나 리다이렉트 헬퍼
+ // 인자가 그렇다. 여는 호출을 되짚어 **메시지 키 자리**일 때만 위반이다.
+ if (preg_match('/^\s*\$e->getMessage\(\),\s*$/', $line)
+ && $this->isResponseMessageKeyPosition($lines, $i)) {
+ $violations[] = $file['rel'].':'.($i + 1);
+ }
+ }
+ }
+
+ $unexpected = array_values(array_diff($violations, self::KNOWN_EXCEPTION_TEXT_AS_KEY));
+ $stale = array_values(array_diff(self::KNOWN_EXCEPTION_TEXT_AS_KEY, $violations));
+
+ $this->assertSame(
+ [],
+ $unexpected,
+ "예외 메시지 원문이 응답 메시지 키로 전달됩니다:\n".implode("\n", $unexpected)
+ );
+
+ $this->assertSame(
+ [],
+ $stale,
+ "KNOWN_EXCEPTION_TEXT_AS_KEY 에 이미 해소된 항목이 남아 있습니다 (목록을 줄이세요):\n".implode("\n", $stale)
+ );
+ }
+}
diff --git a/tests/Feature/Http/RedirectQueryExceptionTextContractTest.php b/tests/Feature/Http/RedirectQueryExceptionTextContractTest.php
new file mode 100644
index 00000000..c9cd9f40
--- /dev/null
+++ b/tests/Feature/Http/RedirectQueryExceptionTextContractTest.php
@@ -0,0 +1,156 @@
+getMessage()` 를 실으면 브라우저 주소창과 참조 로그에 내부 오류 원문
+ * (SQL 상태코드·클래스명·경로)이 그대로 남는다. 결제 실패 페이지는 이 값을 화면에
+ * 그대로 출력하므로 구매자에게도 노출된다.
+ *
+ * 사유 식별은 같은 쿼리의 `error` 코드가 담당하고, 사람에게 보일 문구는 다국어 키로
+ * 해석한 문장이어야 한다.
+ */
+class RedirectQueryExceptionTextContractTest extends TestCase
+{
+ /**
+ * 아직 해소하지 못한 지점 (사유와 함께 선언한다).
+ *
+ * 목록이 늘어나면 실패하고, 해소된 항목이 남아 있어도 실패한다.
+ *
+ * @var array
+ */
+ private const KNOWN_REDIRECT_EXCEPTION_TEXT = [];
+
+ /**
+ * 스캔 대상 컨트롤러 루트 목록 (코어 + 번들 모듈/플러그인).
+ *
+ * 컨트롤러 위치 규약은 확장마다 갈린다 — 결제대행사 플러그인은 `src/Controllers`,
+ * 그 밖은 `src/Http/Controllers` 를 쓴다. 한 규약만 보면 다른 규약이 통째로 사각이 된다.
+ *
+ * @return array 절대 경로 목록
+ */
+ private function controllerRoots(): array
+ {
+ $roots = [base_path('app/Http/Controllers')];
+ $conventions = ['src/Http/Controllers', 'src/Controllers'];
+
+ foreach (['modules', 'plugins'] as $kind) {
+ $bundled = base_path($kind.'/_bundled');
+ if (! is_dir($bundled)) {
+ continue;
+ }
+ foreach (scandir($bundled) as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+ foreach ($conventions as $convention) {
+ $dir = $bundled.'/'.$entry.'/'.$convention;
+ if (is_dir($dir)) {
+ $roots[] = $dir;
+ }
+ }
+ }
+ }
+
+ return $roots;
+ }
+
+ /**
+ * 스캔 대상 PHP 파일을 저장소 상대 경로와 함께 수집합니다.
+ *
+ * @return array 파일 목록
+ */
+ private function collectFiles(): array
+ {
+ $files = [];
+ $prefix = str_replace('\\', '/', base_path()).'/';
+
+ foreach ($this->controllerRoots() as $root) {
+ $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root));
+ foreach ($it as $f) {
+ if (! $f->isFile() || ! str_ends_with($f->getFilename(), '.php')) {
+ continue;
+ }
+ $full = str_replace('\\', '/', $f->getPathname());
+ $files[] = ['path' => $f->getPathname(), 'rel' => str_replace($prefix, '', $full)];
+ }
+ }
+
+ return $files;
+ }
+
+ /**
+ * 판정기가 실제로 결제대행사 플러그인을 보고 있는지 스스로 단언합니다.
+ *
+ * 모집단이 비어 있으면 이 계약은 아무것도 증명하지 못한 채 초록이 된다.
+ *
+ * @scenario contract_check=scanner_population
+ *
+ * @effects redirect_contract_scanner_population_covers_payment_gateway_plugins
+ */
+ public function test_scanner_population_covers_payment_gateway_plugins(): void
+ {
+ $rels = array_column($this->collectFiles(), 'rel');
+
+ $this->assertNotEmpty($rels, '스캔 대상 파일이 비어 있다.');
+
+ foreach (['sirsoft-pay_kginicis', 'sirsoft-pay_nhnkcp', 'sirsoft-tosspayments', 'sirsoft-pay_nicepayments'] as $plugin) {
+ $hit = array_filter($rels, fn ($r) => str_contains($r, 'plugins/_bundled/'.$plugin.'/'));
+ $this->assertNotEmpty($hit, $plugin.' 의 컨트롤러가 모집단에 없다.');
+ }
+
+ // 리다이렉트 쿼리를 조립하는 지점 자체가 모집단에 있어야 한다.
+ $withFailUrl = array_filter(
+ $this->collectFiles(),
+ fn ($f) => str_contains((string) file_get_contents($f['path']), 'resolveFailUrl(')
+ );
+ $this->assertNotEmpty($withFailUrl, 'resolveFailUrl 을 쓰는 파일이 모집단에 없다.');
+ }
+
+ /**
+ * 리다이렉트 쿼리의 사람이 볼 값에 예외 원문을 싣지 않는다.
+ *
+ * @scenario contract_check=redirect_query_text
+ *
+ * @effects redirect_query_omits_exception_text_repository_wide
+ */
+ public function test_redirect_query_does_not_carry_exception_text(): void
+ {
+ $violations = [];
+
+ foreach ($this->collectFiles() as $file) {
+ $source = (string) file_get_contents($file['path']);
+ if (! str_contains($source, '$e->getMessage()')) {
+ continue;
+ }
+
+ $lines = preg_split('/\r\n|\r|\n/', $source) ?: [];
+ foreach ($lines as $i => $line) {
+ // `'message' => $e->getMessage(),` 처럼 사람이 볼 쿼리 값 자리에 원문을 넣는 형태.
+ if (! preg_match('/[\'"](message|msg|reason|detail)[\'"]\s*=>\s*\$e->getMessage\(\)/', $line)) {
+ continue;
+ }
+
+ // 리다이렉트 URL 조립 블록 안인지 — 위쪽 12줄에서 조립 호출을 되짚는다.
+ $context = implode("\n", array_slice($lines, max(0, $i - 12), min(12, $i)));
+ if (! preg_match('/resolveFailUrl\(|redirect\(|RedirectResponse|->away\(/', $context)) {
+ continue;
+ }
+
+ $violations[] = $file['rel'].':'.($i + 1);
+ }
+ }
+
+ $unexpected = array_values(array_diff($violations, self::KNOWN_REDIRECT_EXCEPTION_TEXT));
+ $stale = array_values(array_diff(self::KNOWN_REDIRECT_EXCEPTION_TEXT, $violations));
+
+ $this->assertSame([], $unexpected, "리다이렉트 쿼리에 예외 원문을 싣는 지점:\n".implode("\n", $unexpected));
+ $this->assertSame([], $stale, "이미 해소되어 면제 목록에서 지워야 하는 항목:\n".implode("\n", $stale));
+ }
+}
diff --git a/tests/scenarios/exception-response-exposure.yaml b/tests/scenarios/exception-response-exposure.yaml
new file mode 100644
index 00000000..cca22e56
--- /dev/null
+++ b/tests/scenarios/exception-response-exposure.yaml
@@ -0,0 +1,46 @@
+feature: 코어 실패 응답의 예외 원문 비노출 (Exception Response Exposure)
+
+description: |
+ 언어팩 관리 API 가 실패했을 때, 응답의 `message` 자리에 이미 번역된 예외 문구가
+ 넘어가면 키 해석에 실패해 원문이 그대로 관리자 화면에 나간다. 원문에는 압축 라이브러리
+ 오류·파일 경로·SQL 상태코드가 섞여 있다.
+
+ 핵심 설계:
+ - `ResponseHelper::error()` 의 첫 인자는 **다국어 키 전용**이다. 번역문을 그 자리에
+ 넘기지 않는다.
+ - 진단 정보 노출은 `ResponseHelper` 가 `app.debug` 에서만 Throwable 을 펼치는
+ 기존 메커니즘에 위임한다 — 호출부가 직접 원문을 문자열로 조립하지 않는다.
+ - 상태코드는 바꾸지 않는다. manifestPreview 는 업로드 파일 해석 실패가 대부분
+ 클라이언트 입력 오류라 422 를 유지하고, refreshCache 는 500 이다.
+
+ 두 테스트는 `app.debug=false` 를 명시적으로 세운 뒤 응답 본문 전체에 내부 흔적
+ (`ZipArchive` `SQLSTATE` `.php` `vendor/` `Exception`)이 없음을 단언한다. 디버그를
+ 끄지 않으면 헬퍼가 의도적으로 노출하는 경로와 구분되지 않는다.
+
+axes:
+ endpoint: [manifest_preview, refresh_cache]
+
+exclusions: []
+
+effects:
+ - language_pack_manifest_preview_failure_omits_exception_text
+ - language_pack_refresh_cache_failure_omits_exception_text
+
+scenarios:
+ - id: failure_responses_never_carry_exception_text_in_the_message_slot
+ description: 설치 전 검증 실패(422)와 캐시 갱신 실패(500) 모두 안내 문구만 전달하고 예외 원문을 싣지 않는다
+ effects:
+ - language_pack_manifest_preview_failure_omits_exception_text
+ - language_pack_refresh_cache_failure_omits_exception_text
+
+test_files:
+ - tests/Feature/Api/Admin/LanguagePackControllerTest.php
+
+notes: |
+ 이 매니페스트는 5차 감사에서 신설됐다. 두 테스트는 브랜치가 추가했지만
+ `@scenario`/`@effects` 가 없어 어느 매니페스트에도 묶이지 않았고, 선언이 아예 없는
+ effect 는 test-scenario-coverage 의 셀 대상이 아니라 조용히 통과했다.
+
+ 같은 파일이 `language-pack-php-whitelist-and-activation-boundary.yaml` 의 test_files
+ 에도 등록돼 있으나 그쪽이 다루는 축은 PHP 구문 화이트리스트와 활성화 권한 경계다.
+ 실패 응답의 원문 노출은 그 시나리오의 축이 아니므로 별도 매니페스트로 둔다.
diff --git a/tests/scenarios/guest-order.yaml b/tests/scenarios/guest-order.yaml
index 42545648..b0f7388e 100644
--- a/tests/scenarios/guest-order.yaml
+++ b/tests/scenarios/guest-order.yaml
@@ -52,6 +52,20 @@ effects:
- guest_orders_show_response_excludes_admin_memo_user_user_id_promotions_snapshot
- guest_orders_show_response_includes_mc_subtotal_discount_shipping_total_amount # 단계 5 그룹 C 보강
- guest_orders_cancel_estimate_refund_update_shipping_confirm_option_require_token
+ # 실패 원인 구분(도메인 422 / 검증 422 / 인프라 500)은 입력 axis 가 아니라 결과 축이므로
+ # error_class axis 를 신설하지 않고 effects 로 검증한다 (member-order-change-address.yaml 과 동일 판단).
+ # 인프라 500 은 서비스 mock 으로만 유발 가능해 PHPUnit 이 SSoT 이고, 브라우저는 200/422 축만 담당한다.
+ # 예외 → 상태코드 분리 (공개 #104) — 비회원 액션 전 지점
+ - guest_update_shipping_address_domain_exception_returns_422_infra_returns_500
+ - guest_cancel_domain_exception_returns_422_infra_returns_500
+ - guest_confirm_option_has_no_domain_exception_so_generic_catch_returns_500
+ - guest_action_5xx_responses_exclude_exception_message
+ # 국내/해외 배송지 조합 정합성 (부수 발견 F3)
+ - guest_shipping_address_intl_combination_without_country_code_returns_422_validation_errors
+ - guest_shipping_address_switching_to_intl_clears_domestic_columns
+ - guest_shipping_address_domestic_country_code_requires_zipcode_and_address
+ - guest_shipping_address_foreign_country_code_requires_intl_fields
+ - guest_shipping_address_round_trip_between_domestic_and_intl_clears_opposite_columns
# 프론트엔드 진입 (단계 5 그룹 A)
- buy_now_when_guest_creates_temp_order_then_navigates_to_login_with_redirect_query
- cart_order_when_guest_creates_temp_order_then_navigates_to_login_with_redirect_query
diff --git a/tests/scenarios/member-order-change-address.yaml b/tests/scenarios/member-order-change-address.yaml
index 8e1996d5..0fe6273f 100644
--- a/tests/scenarios/member-order-change-address.yaml
+++ b/tests/scenarios/member-order-change-address.yaml
@@ -25,10 +25,19 @@ description: |
axes:
actor: [member, guest]
change_mode: [saved, manual]
+ address_region: [domestic, intl, intl_without_country_code]
e2e_browser: [chromium]
exclusions:
- { actor: guest, change_mode: saved, reason: "비회원은 주소록이 없어 saved(저장된 배송지 선택) 모드 미지원 — 직접입력만" }
+ - { change_mode: saved, address_region: intl_without_country_code, reason: "저장된 주소의 country_code 가 그대로 승계되므로 '국가 코드 미전송' 조합이 성립하지 않음" }
+
+# saved × intl 은 종전에 "저장된 주소를 그대로 승계하므로 조합이 성립하지 않는다" 는 사유로
+# 제외돼 있었다. 그 사유가 사실이 아니었다 — address_id 분기가 국내 6필드만 재구성해
+# 해외 주소를 고르면 주소가 통째로 사라졌고, 제외된 칸이 정확히 결함이 살던 자리였다.
+
+# 실패 원인 구분(도메인 422 / 검증 422 / 인프라 500)은 입력 axis 가 아니라 결과 축이므로
+# effects 로 검증한다. 인프라 500 은 서비스 mock 으로만 유발 가능해 PHPUnit 이 SSoT 다.
effects:
# 모달 컨텍스트 — _global 전달 (회원/비회원 공통)
@@ -55,6 +64,21 @@ effects:
- guest_update_shipping_address_succeeds_with_valid_token
- guest_update_shipping_address_blocked_404_without_or_invalid_token
- guest_update_shipping_address_validation_422_when_recipient_fields_missing
+ # 예외 → 상태코드 분리 (공개 #104) — 회원/비회원 공통
+ - update_shipping_address_domain_exception_returns_422_with_cannot_modify_address
+ - update_shipping_address_infrastructure_exception_returns_500_not_422
+ - update_shipping_address_500_response_excludes_exception_message
+ # 국내/해외 주소 조합 정합성 (부수 발견 F3)
+ - shipping_address_country_code_is_the_axis_for_domestic_vs_intl_validation
+ - shipping_address_intl_combination_without_country_code_returns_422_validation_errors
+ - shipping_address_domestic_country_code_requires_zipcode_and_address
+ - shipping_address_foreign_country_code_requires_address_line_1_intl_city_intl_postal_code
+ - shipping_address_switching_to_intl_clears_domestic_columns_with_empty_string
+ - shipping_address_switching_to_domestic_clears_intl_columns_with_null
+ - shipping_address_manual_domestic_input_on_intl_order_is_saved_not_discarded
+ # 저장된 배송지(address_id) 승계 정합성
+ - shipping_address_saved_intl_address_preserves_intl_columns
+ - shipping_address_saved_mode_preserves_delivery_memo_from_request
scenarios:
- id: member_change_address_saved_e2e
@@ -82,9 +106,51 @@ scenarios:
effects:
- guest_update_shipping_address_blocked_404_without_or_invalid_token
+ - id: change_address_error_class_separation
+ description: |
+ 배송지 변경 실패를 원인별로 구분한다 (공개 #104). 도메인 규칙 위반(배송 전 상태 아님)은
+ 422 + 기존 안내, 그 외 예외(인프라/코드 결함)는 500. 종전에는 모두 422 로 뭉개져
+ 인프라 장애가 "배송 전에만 변경 가능" 이라는 엉뚱한 입력 오류로 위장됐다.
+ 500 축은 서비스 mock 이 필요해 PHPUnit 이 SSoT 이고, 브라우저는 200/422 축만 담당한다.
+ effects:
+ - update_shipping_address_domain_exception_returns_422_with_cannot_modify_address
+ - update_shipping_address_infrastructure_exception_returns_500_not_422
+ - update_shipping_address_500_response_excludes_exception_message
+
+ - id: change_address_domestic_intl_combination
+ description: |
+ country_code 를 국내/해외 판정의 기준축으로 삼는다 (부수 발견 F3). 종전에는 zipcode/address 를
+ address_line_1 유무로만 강제해, 해외 필드만 채우고 country_code 를 생략하면 검증을 통과한 뒤
+ 서비스가 국내 분기로 떨어져 NOT NULL 컬럼에 null 을 쓰다 SQL 무결성 위반이 났다.
+ 전환 시 반대편 컬럼을 초기화해 구 주소 잔존(2차 결함)도 함께 막는다.
+ effects:
+ - shipping_address_country_code_is_the_axis_for_domestic_vs_intl_validation
+ - shipping_address_intl_combination_without_country_code_returns_422_validation_errors
+ - shipping_address_domestic_country_code_requires_zipcode_and_address
+ - shipping_address_foreign_country_code_requires_address_line_1_intl_city_intl_postal_code
+ - shipping_address_switching_to_intl_clears_domestic_columns_with_empty_string
+ - shipping_address_switching_to_domestic_clears_intl_columns_with_null
+ - shipping_address_manual_domestic_input_on_intl_order_is_saved_not_discarded
+
+ - id: change_address_saved_address_carry_over
+ description: |
+ 저장된 배송지(address_id) 선택 시 그 주소를 손실 없이 승계한다. 국내 6필드만
+ 재구성하던 종전 형태에서는 해외 배송지를 고르면 해외 컬럼이 전부 null 로 덮이고
+ 국내 컬럼도 빈 문자열이라 주소가 통째로 사라졌으며, 함께 보낸 배송 메모도 버려졌다.
+ 응답은 200 이고 오류도 남지 않아 저장 시점에는 드러나지 않는다.
+ effects:
+ - shipping_address_saved_intl_address_preserves_intl_columns
+ - shipping_address_saved_mode_preserves_delivery_memo_from_request
+
test_files:
- # 백엔드 (비회원 endpoint — 성공/토큰가드 404/검증 422)
+ # 백엔드 (비회원 endpoint — 성공/토큰가드 404/검증 422/국내·해외 조합)
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php
+ # 백엔드 (회원 endpoint — 예외→상태코드 분리, 국내/해외 조합, 왕복 전환 초기화)
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/User/OrderShippingAddressTest.php
+ # 주문 도메인의 다른 액션(취소·구매확정·재주문·입금확인·쿠폰 다운로드) 페어는
+ # 배송지 변경의 축이 아니다 → modules/_bundled/sirsoft-ecommerce/tests/scenarios/order-action-exception-status.yaml
+ # 백엔드 (generic catch 상태코드 전수 계약 — 코어 + 전 번들 확장 컨트롤러)
+ - tests/Feature/Http/GenericCatchStatusCodeContractTest.php
# 프론트엔드 핸들러 단위 (endpoint/body/토큰 분기)
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/handlers/userChangeShippingAddressHandlers.test.ts
# 프론트엔드 모달 구조 (Vitest — _global 바인딩, 회원/비회원 분기, 핸들러 호출, 스피너)
diff --git a/tests/scenarios/redirect-query-exception-exposure.yaml b/tests/scenarios/redirect-query-exception-exposure.yaml
new file mode 100644
index 00000000..fca13c1f
--- /dev/null
+++ b/tests/scenarios/redirect-query-exception-exposure.yaml
@@ -0,0 +1,53 @@
+feature: 실패 리다이렉트 쿼리의 예외 원문 비노출 (Redirect Query Exception Exposure)
+
+description: |
+ 결제 콜백은 실패 사유를 응답 본문이 아니라 **리다이렉트 쿼리스트링**으로 전달한다.
+ 그래서 응답 메시지 키를 보는 계약(`tests/Feature/Http/GenericCatchStatusCodeContractTest.php`)
+ 의 축 밖에 있다. 그 자리에 예외 원문을 실으면 브라우저 주소창과 참조 로그에 SQL
+ 상태코드·클래스명·경로가 남고, 결제 실패 페이지가 그 값을 그대로 출력하므로 구매자에게도
+ 노출된다.
+
+ 핵심 설계:
+ - 사유 **식별**은 쿼리의 `error` 코드가 담당한다.
+ - 사람에게 보일 **문구**는 다국어 키로 해석한 문장이어야 한다 (`errors.payment_failed`).
+ - 예외 원문은 로그에만 남긴다.
+
+ 판정은 소스 전수 스캔이다. 결제대행사 플러그인은 컨트롤러를 `src/Controllers` 에 두고
+ 그 밖의 확장은 `src/Http/Controllers` 를 쓰므로, 한 규약만 보면 다른 규약이 통째로
+ 사각이 된다. 그래서 모집단이 실제로 그 플러그인들과 리다이렉트 조립 지점을 담고 있는지
+ 판정기가 스스로 단언하는 축을 따로 둔다 — 모집단이 비면 위반 0 은 아무것도 증명하지 않는다.
+
+ 면제 목록은 비어 있고, 늘어나도 줄어도(해소된 항목이 남아도) 실패한다.
+
+axes:
+ contract_check: [scanner_population, redirect_query_text]
+
+exclusions: []
+
+effects:
+ - redirect_contract_scanner_population_covers_payment_gateway_plugins
+ - redirect_query_omits_exception_text_repository_wide
+
+scenarios:
+ - id: scanner_actually_sees_the_payment_gateway_convention
+ description: 판정기 모집단이 결제대행사 플러그인 4종과 리다이렉트 URL 조립 지점을 담고 있다
+ effects:
+ - redirect_contract_scanner_population_covers_payment_gateway_plugins
+
+ - id: no_controller_puts_exception_text_in_a_human_visible_redirect_param
+ description: 코어와 전 번들 확장의 컨트롤러 어디에도 리다이렉트 쿼리의 message/msg/reason/detail 자리에 예외 원문을 싣는 지점이 없다
+ effects:
+ - redirect_query_omits_exception_text_repository_wide
+
+test_files:
+ - tests/Feature/Http/RedirectQueryExceptionTextContractTest.php
+
+notes: |
+ 이 매니페스트는 5차 감사에서 신설됐다. 대상 테스트는 브랜치가 추가했지만 `@scenario`·
+ `@effects` 가 하나도 없어 어느 매니페스트에도 묶이지 않았다. 형제 파일
+ `GenericCatchStatusCodeContractTest.php` 는 매니페스트에 등록돼 있었는데 이 파일만
+ 빠져 있었고, 마커도 선언도 없으면 test-scenario-coverage 의 두 방향 검사 어느 쪽에도
+ 걸리지 않아 조용히 통과한다.
+
+ 응답 본문 축은 `tests/scenarios/exception-response-exposure.yaml` 이 담당한다. 이쪽은
+ 통로가 리다이렉트 쿼리라 노출 대상(구매자)과 판정 방식(소스 전수 스캔)이 달라 분리한다.