fix(core,board,ecommerce,page,pay): 예외→응답 매핑 정비와 저장 배송지 승계 결함 수정

generic catch 가 4xx 를 돌려주면 인프라 장애가 입력 오류로 위장돼 장애 인지가
늦어지고, 이미 번역된 예외 메시지를 응답의 메시지 키 자리에 넘기면 키 해석에
실패해 원문이 그대로 화면에 나간다. 두 형태를 코어와 전 번들 확장에서 함께
정리하고, 판정기를 한 확장이 아닌 코어 모집단에 두어 확장 밖 동형 결함도
red 가 되게 했다 — 그 사각에 실제로 15건이 있었다.

주소록에서 해외 배송지를 고르면 국내 6필드만 옮겨 담아 해외 주소가 통째로
사라지던 결함도 함께 고쳤다. 저장은 200 으로 성공 처리돼 주문 상세를 다시
열기 전에는 드러나지 않았다.
This commit is contained in:
HeuJung
2026-08-16 00:53:21 +09:00
parent b33559e462
commit b16c5c38a5
166 changed files with 6533 additions and 627 deletions
+20
View File
@@ -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 데이터 접근
| 금지 | 올바른 사용 |
+1
View File
@@ -33,6 +33,7 @@
- 설정을 항목 단위로 저장할 때 한 번 입력한 값을 빈 칸으로 되돌릴 수 없던 문제를 수정했습니다. 켜기/끄기 설정과 숫자 설정이 문자열로 저장되던 문제도 함께 고쳤으며, 해당 설정이 받을 수 없는 값은 저장 단계에서 안내와 함께 거부됩니다.
- 삭제된 본인인증 플러그인의 식별자가 정책 조회 응답과 공개 페이지에 그대로 노출되던 문제를 수정했습니다. 조회 응답은 실제 사용 가능한 인증 수단으로 해석해 내려주고, 공개 페이지에는 이 값을 싣지 않습니다.
- 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다.
- 언어팩 관리(설치·활성화·삭제·업데이트·캐시 갱신)가 실패했을 때 응답에 내부 오류 원문이 그대로 실리던 문제를 수정했습니다. 설치 전 파일 검증 실패도 마찬가지로 안내 문구만 표시되며, 상세 내용은 디버그 모드에서만 확인할 수 있습니다.
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
+1 -1
View File
@@ -515,8 +515,8 @@ cp .env.example .env
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
+1 -1
View File
@@ -529,8 +529,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
@@ -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);
}
}
}
+80 -4
View File
@@ -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
<!-- @generated:start:api.admin.language-packs.refresh-cache -->
@@ -1147,11 +1149,55 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `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`) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `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`) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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
}
}
```
**에러 응답**
+94 -3
View File
@@ -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 이 된다)
- [ ] 두 언어 모두에서 테스트 수행
- [ ] 예외 메시지가 사용자에게 노출될 경우 보안 정보 포함 금지
@@ -6,6 +6,10 @@
## [1.0.3] - 2026-08-12
### Added
- 게시판 유형 삭제 등 관리 작업이 서버 오류로 실패했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
### Changed
- 신고 반려 누적 제한 안내 문구의 일본어 번역을 실제 동작(설정 건수 도달 시 차단)에 맞게 정정했습니다.
@@ -181,6 +181,7 @@ return [
'permission_denied' => '権限がありません。',
'validation_failed' => '入力値が正しくありません。',
'duplicate_report' => '既に通報された履歴があります。',
'operation_failed' => '処理中にエラーが発生しました。',
],
'warnings' => [
'category_removal_attempted' => '使用中のカテゴリーの削除が試みられました。',
@@ -10,6 +10,7 @@
- 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`).
- 관리자 주문설정 결제수단 목록의 「지정 PG 삭제됨」 배지 일본어 번역을 추가했습니다.
- 배송지 변경 처리 중 서버 오류가 발생했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
- 배송정책 구간 설정의 새 검증 안내(구간 미등록, 중간 구간 종료값 누락, 수량 구간의 정수 제한, 단위값·무료배송 기준금액 누락, 도서산간 우편번호 형식) 일본어 번역을 추가했습니다.
- 배송정책 편집 화면에서 구간 입력 오류를 안내하는 문구(중간 구간 종료값 누락, 배송비 미입력, 수량 구간 정수 제한, 단위값·무료배송 기준금액 미입력) 일본어 번역을 추가했습니다.
- 배송비 요약에 표시되는 구간 단위(개/kg/L)와 구간 입력란의 단위 라벨 일본어 번역을 추가했습니다.
@@ -83,4 +83,5 @@ return [
'coupon_issue_not_found' => 'クーポン発行履歴が見つかりません。',
'coupon_issue_not_cancellable' => '未使用状態の発行分のみキャンセルできます。',
'country_not_shippable' => '選択された配送先国には配送できない商品です。',
'order_shipping_address_update_failed' => '配送先変更処理中にエラーが発生しました。',
];
@@ -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
@@ -5,6 +5,7 @@ return [
'cash_receipt_issue_failed' => '現金領収書の発行に失敗しました。',
'order_not_found' => '注文が見つかりません。',
'cbt_failed' => '海外決済の処理に失敗しました。',
'payment_failed' => '決済処理に失敗しました。しばらく後にもう一度お試しください。',
],
'refund' => [
'missing_tid' => 'トランザクション ID(TID)がないため、返金を進めることができません。',
@@ -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",
@@ -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
@@ -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',
@@ -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",
@@ -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
@@ -16,4 +16,7 @@ return [
'invalid_order_id' => '現金領収書発行識別子がToss Payments形式(英数字·ハイフン·アンダースコア 6~64文字)に一致していません。',
'cancel_reason' => '注文金額変更に伴う再発行',
],
'errors' => [
'payment_failed' => '決済処理に失敗しました。しばらく後に再度お試しください。',
],
];
@@ -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",
@@ -16,6 +16,8 @@
- 관리자 게시판 설정을 저장해도 SEO 미리 생성 캐시가 갱신되지 않아, 검색엔진에 예전 정보가 계속 노출되던 문제를 수정했습니다. 환경설정 기본값 일괄 적용에도 같은 처리가 적용됩니다.
- 일부 설정 저장 경로가 다른 저장 경로와 다른 처리를 거쳐, 저장 직후 조회가 저장 전 값을 돌려주거나 값의 형식이 정리되지 않은 채 남던 문제를 수정했습니다.
- 게시판 유형 삭제가 실패했을 때 내부 오류 원문이 그대로 표시되던 문제를 수정했습니다. 이제 사용 중인 유형·기본 유형이라 삭제할 수 없는 경우에는 그 사유가 안내되고, 그 밖의 오류는 서버 오류로 구분됩니다.
- 첨부 개수 상한 초과·대댓글 깊이 초과·댓글을 달 수 없는 글·메뉴 중복 안내도 같은 방식으로 정리했습니다. 안내 문구를 사용자의 언어 설정으로 만드는 시점이 응답을 만드는 시점과 어긋나 있던 것을 맞췄습니다. 표시되는 문구와 상태는 종전과 동일합니다.
- 신고 반려 누적 제한 안내 문구를 실제 동작에 맞게 정정했습니다. "설정 건수를 초과하면 차단"으로 적혀 있었지만 실제로는 설정 건수에 도달하는 순간부터 차단됩니다 — 5건으로 설정하면 5번째 반려부터 신고가 막힙니다.
- 관리자가 게시판 설정을 저장해도 백그라운드 작업에는 이전 설정이 계속 적용되던 문제를 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 신고 현황 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다.
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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<string, mixed> 치환 파라미터
*/
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()
);
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Board\Exceptions;
use Exception;
/**
* 게시판 유형 조작 실패 예외 클래스
*
* 사용 중인 유형이거나 기본 유형이어서 삭제할 수 없는 경우에 발생합니다.
* 컨트롤러는 이 예외를 도메인 실패로 구분해 기존 422 응답으로 처리하고,
* 그 외 예외는 서버 결함으로 보아 500 을 반환합니다.
*/
class BoardTypeOperationException extends Exception
{
/**
* 게시판 유형 조작 실패 예외 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-board::messages.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return ['max' => $this->limit];
}
}
@@ -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);
}
/**
* 다국어 메시지 키를 반환합니다.
*
* 호출부가 직접 문구를 넘긴 경우엔 키가 없으므로 기본 키를 돌려준다 — 응답에는
* 언제나 키가 전달되어야 하며, 이미 번역된 문구가 키 자리에 들어가면 해석에
* 실패해 원문이 그대로 노출된다.
*
* @return string 다국어 메시지 키
*/
public function getMessageKey(): string
{
return $this->messageKey ?? self::DEFAULT_MESSAGE_KEY;
}
/**
* 메시지 치환 파라미터를 반환합니다.
*
* @return array<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return [];
}
}
@@ -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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return [];
}
/**
@@ -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) {
@@ -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) {
@@ -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;
@@ -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);
}
}
}
@@ -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());
}
@@ -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) {
@@ -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) {
@@ -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);
}
@@ -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) {
@@ -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;
@@ -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);
@@ -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', [
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);
@@ -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
@@ -225,6 +225,7 @@ return [
'permission_denied' => '권한이 없습니다.',
'validation_failed' => '입력값이 올바르지 않습니다.',
'duplicate_report' => '이미 신고한 내역이 있습니다.',
'operation_failed' => '작업 처리 중 오류가 발생했습니다.',
],
// 경고 메시지 (Warning)
@@ -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 테스트
*/
@@ -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 의 셀 대상이 아니라 조용히 통과했다.
@@ -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 님께서 제보해주셨습니다.)
@@ -199,7 +199,7 @@ HTTP/1.1 201
<!-- @generated:end -->
**설명** 로그인한 회원 본인의 새 배송지를 등록합니다. `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` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "배송지가 삭제되었습니다.",
"data": null
}
```
**에러 응답**
@@ -266,11 +276,57 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| address | object | `{"id":99,"user_id":"a26219fc-94a0-4f63-9404-04c2a6ac99e4"…` | 조회한 배송지 단건 (`UserAddressResource`) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| address | object | `{"id":99,"user_id":"a26219fc-94a0-4f63-9404-04c2a6ac99e4"…` | 수정된 배송지 (`UserAddressResource`) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "기본 배송지가 설정되었습니다.",
"data": null
}
```
**에러 응답**
@@ -283,11 +283,29 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: 삭제 결과 요약 (`data` 객체)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| brand_id | integer | `2` | 삭제된 브랜드의 기본 키 |
| products_count | integer | `0` | 삭제 시점에 이 브랜드를 쓰던 상품 수 (연결 상품이 있으면 삭제가 거부되므로 언제나 0) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -435,11 +455,42 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`BrandResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/brands/{id} (브랜드 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`BrandResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/brands/{id} (브랜드 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -846,11 +851,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: 삭제 결과 요약 (`data` 객체)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| category_id | integer | `12` | 삭제된 카테고리의 기본 키 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -1019,11 +1042,49 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`CategoryResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/categories/{id} (카테고리 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`CategoryResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/categories/{id} (카테고리 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -1215,11 +1315,49 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`CategoryResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/categories/{id} (카테고리 수정)** 응답 필드 표와 동일합니다._ 공개 API 는 비활성 카테고리를 노출하지 않습니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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
}
}
}
```
**에러 응답**
@@ -28,7 +28,7 @@
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| type | query | string | 아니오 | — | 유형 필터 (해당 유형의 항목만 조회) |
| fault_type | query | string | 아니오 | — | <!-- TODO: 용도 --> |
| 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
<!-- @generated:end -->
**설명** 관리자가 클래임 사유 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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -756,11 +758,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: 삭제 결과 요약 (`data` 객체)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| reason_id | integer | `8` | 삭제된 클레임 사유의 기본 키 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
**설명** 관리자가 클레임 사유 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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`). 필드 구성은 이 문서의 **POST /api/modules/sirsoft-ecommerce/admin/claim-reasons (클레임 사유 생성)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`). 필드 구성은 이 문서의 **POST /api/modules/sirsoft-ecommerce/admin/claim-reasons (클레임 사유 생성)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -903,11 +985,42 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ClaimReasonResource`). 필드 구성은 이 문서의 **POST /api/modules/sirsoft-ecommerce/admin/claim-reasons (클레임 사유 생성)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
**설명** 로그인한 회원이 다운로드형 쿠폰 1건을 실제로 발급받습니다. `auth:sanctum` 인증이 필요하고 대상 쿠폰은 `couponId`(경로)로 지정하며, `UserCouponService::downloadCoupon()`이 발급기간·수량·회원당 한도·중복 발급 여부를 검증한 뒤 발급 내역을 생성해 201로 반환합니다. 한도 초과·발급기간 종료 등 발급 불가 사유는 서비스 예외의 메시지와 코드가 그대로 오류 응답에 전달됩니다.
**설명** 로그인한 회원이 다운로드형 쿠폰 1건을 실제로 발급받습니다. `auth:sanctum` 인증이 필요하고 대상 쿠폰은 `couponId`(경로)로 지정하며, `UserCouponService::downloadCoupon()`이 발급기간·수량·회원당 한도·중복 발급 여부를 검증한 뒤 발급 내역을 생성해 201로 반환합니다. 한도 초과·발급기간 종료 등 발급 불가 사유는 사유 식별자에 대응하는 다국어 키(`messages.coupon.*`)로 해석되어 `400` 으로 응답합니다.
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -725,7 +729,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -763,11 +778,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ExtraFeeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -905,11 +948,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ExtraFeeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/extra-fee-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 | 서버 내부 오류 (`작업 처리 중 오류가 발생했습니다.`) |
<!-- @generated:end -->
@@ -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 | 환불 예상금액 계산 실패 (`환불 예상금액 계산에 실패했습니다.`) |
<!-- @generated:end -->
@@ -634,11 +635,11 @@ _단건 응답: `data` 객체의 필드 (`GuestOrderResource` — 구매확정
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 404 | Not Found | `X-Guest-Order-Token` 누락·만료·주문번호 불일치, 또는 `optionId` 가 해당 주문에 속하지 않는 경우 |
| 422 | Unprocessable Entity | 구매확정 불가 상태 (배송 미완료 등 — `현재 상태에서는 구매확정할 수 없습니다.`) |
| 500 | Internal Server Error | 구매확정 처리 실패 (`작업 처리 중 오류가 발생했습니다.`) |
<!-- @generated:end -->
**설명** 비회원이 배송 완료된 주문의 개별 옵션(`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 | 서버 내부 오류 (`배송지 변경 처리 중 오류가 발생했습니다.`) |
<!-- @generated:end -->
**설명** 비회원이 배송 전 상태의 주문 배송지를 수정합니다. 조회 토큰(`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 응답에는 예외 원문이 실리지 않습니다.
@@ -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 | 삭제 처리 중 예기치 못한 예외 (`문의 삭제에 실패했습니다.`) |
<!-- @generated:end -->
**설명** 관리자가 상품 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 | 답변 삭제 처리 중 예기치 못한 예외 (`답변 삭제에 실패했습니다.`) |
<!-- @generated:end -->
@@ -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 | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) |
<!-- @generated:end -->
**설명** 관리자가 상품 문의에 답변을 등록합니다. `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 | 답변 수정 처리 중 예기치 못한 예외 (`답변 수정에 실패했습니다.`) |
<!-- @generated:end -->
**설명** 관리자가 문의에 이미 등록된 답변 내용을 수정합니다. `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 | 삭제 처리 중 예기치 못한 서버 오류 (`문의 삭제에 실패했습니다.`) — 예외 원문은 응답에 포함되지 않는다 |
<!-- @generated:end -->
@@ -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 | 수정 처리 중 예기치 못한 예외 (`문의 수정에 실패했습니다.`) |
<!-- @generated:end -->
@@ -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 | 답변 삭제 처리 중 예기치 못한 예외 (`답변 삭제에 실패했습니다.`) |
<!-- @generated:end -->
@@ -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 | 답변 등록 처리 중 예기치 못한 예외 (`답변 등록에 실패했습니다.`) |
<!-- @generated:end -->
@@ -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 | 답변 수정 처리 중 예기치 못한 예외 (`답변 수정에 실패했습니다.`) |
<!-- @generated:end -->
@@ -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 | 서버 내부 오류 (`작업 처리 중 오류가 발생했습니다.`) |
<!-- @generated:end -->
@@ -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 | 서버 내부 오류 (`작업 처리 중 오류가 발생했습니다.`) |
<!-- @generated:end -->
@@ -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 | 환불 예상금액 계산 중 오류 (`환불 예상금액 계산에 실패했습니다.`) |
<!-- @generated:end -->
@@ -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`) |
<!-- @generated:end -->
@@ -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`) |
<!-- @generated:end -->
@@ -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`) |
<!-- @generated:end -->
@@ -2989,6 +2993,7 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 404 | Not Found | 주문이 없거나 본인 주문이 아닌 경우 (`주문을 찾을 수 없습니다.`) |
| 422 | Unprocessable Entity | 요청 파라미터 검증 실패, 또는 배송지 변경 불가 상태 — 이미 배송이 시작된 주문 등 (`배송 전 상태에서만 배송지를 변경할 수 있습니다.`) |
| 500 | Internal Server Error | 서버 내부 오류 (`배송지 변경 처리 중 오류가 발생했습니다.`) |
<!-- @generated:end -->
@@ -28,8 +28,8 @@
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| search | query | string | 아니오 | max 255 | 검색어 (지정한 검색 대상 필드에서 부분 일치) |
| active_only | query | boolean | 아니오 | — | <!-- TODO: 용도 --> |
| default_only | query | boolean | 아니오 | — | <!-- TODO: 용도 --> |
| 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 으로 구분한다 |
<!-- @generated:end -->
@@ -299,11 +300,29 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: 삭제 결과 요약 (`data` 객체)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| common_info_id | integer | `5` | 삭제된 공통정보의 기본 키 |
| products_count | integer | `2` | 이 공통정보를 참조하던 상품 수 (삭제 시 해당 상품의 연결이 해제됨) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -341,11 +361,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ProductCommonInfoResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} (공통정보 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -501,11 +557,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ProductCommonInfoResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-common-infos/{id} (공통정보 수정)** 응답 필드 표와 동일합니다._ 비활성으로 전환하면 `공통정보가 비활성화되었습니다.` 가 반환됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -247,11 +247,29 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: 삭제 결과 요약 (`data` 객체)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| label_id | integer | `7` | 삭제된 라벨의 기본 키 |
| products_count | integer | `0` | 삭제 시점에 이 라벨을 쓰던 상품 수 (연결 상품이 있으면 삭제가 거부되므로 언제나 0) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -289,11 +308,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ProductLabelResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-labels/{id} (라벨 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -427,11 +474,38 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ProductLabelResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-labels/{id} (라벨 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -365,11 +366,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: 삭제 결과 요약 (`data` 객체)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| template_id | integer | `3` | 삭제된 템플릿의 기본 키 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -407,11 +425,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -563,11 +617,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._ 원본과 동일한 항목 구성으로 새 기본 키가 발급됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -605,11 +695,46 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ProductNoticeTemplateResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/product-notice-templates/{id} (템플릿 수정)** 응답 필드 표와 동일합니다._ 비활성으로 전환하면 `상품정보제공고시가 비활성화되었습니다.` 가 반환됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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` 에 사유가 담김) |
<!-- @generated:end -->
**설명** 상품에 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
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -259,11 +259,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| deleted | boolean | `true` | 삭제 완료 여부 (컨트롤러가 고정값 `true` 로 내려준다) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "리뷰가 삭제되었습니다.",
"data": {
"deleted": true
}
}
```
**에러 응답**
@@ -337,7 +353,71 @@ _단건 응답: `data` 객체의 필드._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 객체의 필드._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 객체의 필드._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` — "리뷰 작성에 실패했습니다.") |
<!-- @generated:end -->
**설명** 로그인 회원이 구매한 상품에 리뷰를 작성합니다. `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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| can_write | boolean | `false` | 이 주문 옵션에 리뷰를 작성할 수 있는지 |
| reason | string\|null | `"already_written"` | 작성 불가 사유 식별자. `order_option_not_found` / `not_own_order` / `not_confirmed` / `deadline_passed` / `already_written` 중 하나이며, 작성 가능하면 `null` |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| deleted | boolean | `true` | 삭제 완료 여부 (컨트롤러가 고정값 `true` 로 내려준다) |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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` — "리뷰 이미지 업로드에 실패했습니다.") |
<!-- @generated:end -->
**설명** 로그인 회원이 자신의 리뷰에 이미지를 첨부합니다. `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}
@@ -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 으로 구분한다 |
<!-- @generated:end -->
**삭제 차단 사유의 구분** `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` 규칙은 숫자 문자열을 통과시키되 캐스트하지 않으므로, 정규화가 없으면 문자열이 그대로 설정 파일에 영속되어 이후 날짜/수치 연산에서 타입 오류를 유발합니다.
@@ -420,11 +420,27 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: 삭제 결과 요약 (`data` 객체)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| carrier_id | integer | `4` | 삭제된 배송사의 기본 키 |
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -462,11 +479,41 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ShippingCarrierResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} (배송사 수정)** 응답 필드 표와 동일합니다._
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -614,11 +662,41 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
_단건 응답: `data` 객체의 필드 (`ShippingCarrierResource`). 필드 구성은 이 문서의 **PUT /api/modules/sirsoft-ecommerce/admin/shipping-carriers/{id} (배송사 수정)** 응답 필드 표와 동일합니다._ `is_active` 가 반전된 상태로 반환됩니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -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 으로 구분한다 |
<!-- @generated:end -->
@@ -212,11 +213,29 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_단건 응답: 토글 결과 (`data` 객체)._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| added | boolean | `true` | 토글 후 찜 상태 — `true` 면 이번 호출로 추가됨, `false` 면 제거됨. 하트 버튼의 표시 상태를 이 값으로 갱신합니다 |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```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 으로 구분한다 |
<!-- @generated:end -->
@@ -275,6 +295,7 @@ HTTP/1.1 200
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
| 500 | Internal Server Error | 서버 내부 오류 — 도메인 규칙 위반이 아닌 예외(인프라 장애·코드 결함)는 4xx 로 뭉개지 않고 500 으로 구분한다 |
<!-- @generated:end -->
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 브랜드 조작 실패 예외
*
* 브랜드를 찾을 수 없거나, 연결된 상품이 있어 삭제할 수 없는 경우에 사용합니다.
* 컨트롤러는 이 예외를 도메인 실패로 구분해 기존 400 응답으로 처리하고,
* 그 외 예외는 서버 결함으로 보아 500 을 반환합니다.
*/
class BrandOperationException extends RuntimeException
{
/**
* BrandOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -12,6 +12,17 @@ use Exception;
*/
class ClaimReasonException extends Exception
{
/**
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 쿠폰 조작 실패 예외
*
* 쿠폰을 찾을 수 없거나, 발급 내역을 취소할 수 없는 경우에 사용합니다.
* 컨트롤러는 이 예외를 도메인 실패로 구분해 기존 400 응답으로 처리하고,
* 그 외 예외는 서버 결함으로 보아 500 을 반환합니다.
*/
class CouponOperationException extends RuntimeException
{
/**
* CouponOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 상품 공통정보 조작 실패 예외
*
* 상품 공통정보를 찾을 수 없는 경우에 사용합니다.
* 컨트롤러는 이 예외를 도메인 실패로 구분해 기존 400 응답으로 처리하고,
* 그 외 예외는 서버 결함으로 보아 500 을 반환합니다.
*/
class ProductCommonInfoOperationException extends RuntimeException
{
/**
* ProductCommonInfoOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -0,0 +1,54 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 상품 문의 조작 실패 예외
*
* 문의 게시판이 설정되지 않았거나, 대상 문의·답변을 찾을 수 없거나,
* 답변 등록·수정·삭제가 도메인 규칙에 어긋날 때 사용합니다.
*
* 컨트롤러는 이 예외를 도메인 실패로 구분해 기존 422 응답으로 처리하고,
* 그 외 예외는 서버 결함으로 보아 500 을 반환합니다. 종전에는 컨트롤러가
* 예외 메시지 원문을 응답 메시지 키 자리에 넘겨, 번역되지 않은 문자열이
* 그대로 노출될 수 있었습니다.
*/
class ProductInquiryOperationException extends RuntimeException
{
/**
* ProductInquiryOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::messages.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 상품 라벨 조작 실패 예외
*
* 연결된 상품이 있어 라벨을 삭제할 수 없는 경우에 사용합니다.
* 컨트롤러는 이 예외를 도메인 실패로 구분해 기존 400 응답으로 처리하고,
* 그 외 예외는 서버 결함으로 보아 500 을 반환합니다.
*/
class ProductLabelOperationException extends RuntimeException
{
/**
* ProductLabelOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -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])
);
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 택배사 조작 실패 예외
*
* 택배사를 찾을 수 없거나, 사용 중이어서 삭제·비활성화할 수 없는 경우에 사용합니다.
* 컨트롤러는 이 예외를 도메인 실패로 구분해 기존 400 응답으로 처리하고,
* 그 외 예외는 서버 결함으로 보아 500 을 반환합니다.
*/
class ShippingCarrierOperationException extends RuntimeException
{
/**
* ShippingCarrierOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 배송유형 조작 실패 예외
*
* 배송유형을 찾을 수 없거나, 주문에서 사용 중이어서 삭제할 수 없는 경우에 사용합니다.
* 컨트롤러는 이 예외를 도메인 실패로 구분해 사유가 담긴 400 응답으로 처리하고,
* 그 외 예외는 서버 결함으로 보아 500 을 반환합니다.
*/
class ShippingTypeOperationException extends RuntimeException
{
/**
* ShippingTypeOperationException 생성자
*
* @param string $messageKey 다국어 메시지 키 (sirsoft-ecommerce::exceptions.*)
* @param array<string, mixed> $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<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -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;
@@ -98,12 +99,20 @@ 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
);
}
}
@@ -123,12 +132,20 @@ 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
);
}
}
@@ -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
);
}
}
}
@@ -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
);
}
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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',
@@ -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;
@@ -88,10 +87,11 @@ class ExtraFeeTemplateController extends AdminBaseController
201
);
} catch (\Exception $e) {
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
500
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -273,10 +278,11 @@ class ExtraFeeTemplateController extends AdminBaseController
201
);
} catch (\Exception $e) {
// 서버 결함/인프라 장애 — 4xx 로 뭉개면 장애가 입력 오류로 위장된다
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'exceptions.operation_failed',
400
500
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
}
@@ -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
);
}
}
@@ -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);
}
@@ -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;
@@ -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;
}
@@ -56,7 +57,7 @@ class ProductLabelController extends AdminBaseController
{
$label = $this->service->getLabel($id);
if (!$label) {
if (! $label) {
return ResponseHelper::notFound(
'messages.labels.not_found',
[],
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
}
@@ -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
);
}
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -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(
@@ -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);
}
}
}
@@ -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
);
}
}
@@ -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);
@@ -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',
@@ -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',
@@ -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',
@@ -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;
@@ -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
);
}
}
@@ -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'),
];
}
@@ -23,8 +23,24 @@ class UpdateOrderShippingAddressRequest extends FormRequest
return true;
}
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, mixed> 검증 규칙 배열
*/
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'),
];
}
}
@@ -69,4 +69,14 @@ interface ShippingCarrierRepositoryInterface
* @return Collection
*/
public function getActiveCarriers(?string $type = null): Collection;
/**
* 전체 배송사 ID 목록 조회
*
* 일괄 동기화(syncCarriers)에서 payload 에 없는 기존 항목을 가려내는 데 사용합니다.
* 모델 전체를 적재하지 않고 기본키만 조회합니다.
*
* @return array<int, int> 배송사 ID 배열
*/
public function pluckIds(): array;
}
@@ -84,4 +84,14 @@ interface ShippingTypeRepositoryInterface
* @return string|null 배송유형 코드 (없으면 null)
*/
public function getFirstActiveCodeByCategory(string $category): ?string;
/**
* 전체 배송유형의 기본키만 조회합니다.
*
* 일괄 동기화(syncShippingTypes)에서 payload 에 없는 기존 항목을 가려내는 데 사용합니다.
* 모델 전체를 적재하지 않고 기본키만 조회합니다.
*
* @return array<int, int> 배송유형 ID 배열
*/
public function pluckIds(): array;
}
@@ -127,4 +127,12 @@ class ShippingCarrierRepository implements ShippingCarrierRepositoryInterface
return $query->get();
}
/**
* {@inheritDoc}
*/
public function pluckIds(): array
{
return $this->model->newQuery()->pluck('id')->all();
}
}
@@ -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();
}
}
@@ -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;
@@ -109,8 +110,8 @@ class BrandService
{
$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 훅
@@ -143,14 +144,15 @@ class BrandService
*
* @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,
]);
});
@@ -173,14 +175,15 @@ class BrandService
*
* @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 훅
@@ -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 훅

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